From 58d452447a5847520de9e7140422ecf2116a953c Mon Sep 17 00:00:00 2001 From: Leon van Kammen Date: Wed, 22 May 2024 10:56:43 +0000 Subject: [PATCH] handmenu test --- app/aframe-inspector.js | 75 ++++++++++++ app/launcher.js | 3 + app/paste.js | 95 +++++++++++++-- app/spatialize.js | 2 +- com/control/hand-menu-button.js | 153 ++++++++++++++++++++++++ com/control/hand-menu.js | 198 ++++++++++++++++++++++++++++++++ com/osbutton.js | 86 ++++++++++++++ 7 files changed, 601 insertions(+), 11 deletions(-) create mode 100644 app/aframe-inspector.js create mode 100644 com/control/hand-menu-button.js create mode 100644 com/control/hand-menu.js create mode 100644 com/osbutton.js diff --git a/app/aframe-inspector.js b/app/aframe-inspector.js new file mode 100644 index 0000000..caa8c51 --- /dev/null +++ b/app/aframe-inspector.js @@ -0,0 +1,75 @@ +AFRAME.registerComponent('aframe-inspector', { + schema: { + foo: { type:"string"} + }, + + init: function () { + document.querySelector('a-scene').setAttribute("inspector","url: https://cdn.jsdelivr.net/gh/aframevr/aframe-inspector@master/dist/aframe-inspector.min.js") + }, + + requires:{ }, + + events:{ + + // component events + ready: function(e){ }, + + launcher: function(e){ + $('[inspector]').components.inspector.openInspector() + }, + + }, + + manifest: { // HTML5 manifest to identify app to xrsh + "short_name": "inspector", + "name": "AFRAME inspector", + "icons": [ + { + "src": "https://css.gg/list-tree.svg", + "type": "image/svg+xml", + "sizes": "512x512" + } + ], + "id": "/?source=pwa", + "start_url": "/?source=pwa", + "background_color": "#3367D6", + "display": "standalone", + "scope": "/", + "theme_color": "#3367D6", + "shortcuts": [ + { + "name": "What is the latest news?", + "cli":{ + "usage": "helloworld [options]", + "example": "helloworld news", + "args":{ + "--latest": {type:"string"} + } + }, + "short_name": "Today", + "description": "View weather information for today", + "url": "/today?source=pwa", + "icons": [{ "src": "/images/today.png", "sizes": "192x192" }] + } + ], + "description": "use CTRL+ALT+i to launch inspector", + "screenshots": [ + { + "src": "/images/screenshot1.png", + "type": "image/png", + "sizes": "540x720", + "form_factor": "narrow" + } + ], + "help":` +Helloworld application + +This is a help file which describes the application. +It will be rendered thru troika text, and will contain +headers based on non-punctualized lines separated by linebreaks, +in above's case "\nHelloworld application\n" will qualify as header. + ` + } + +}); + diff --git a/app/launcher.js b/app/launcher.js index 170e2e5..72d166c 100644 --- a/app/launcher.js +++ b/app/launcher.js @@ -31,6 +31,8 @@ AFRAME.registerComponent('launcher', { requires:{ html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME + 'hand-menu': "./com/control/hand-menu.js", + 'hand-menu-button': "./com/control/hand-menu-button.js", }, dom: { @@ -108,6 +110,7 @@ AFRAME.registerComponent('launcher', { ready: function( ){ this.el.dom.children[0].id = this.el.uid // important hint for html-mesh + document.querySelector('#left-hand').setAttribute('hand-menu','') }, }, diff --git a/app/paste.js b/app/paste.js index d4370f1..6eadce9 100644 --- a/app/paste.js +++ b/app/paste.js @@ -5,12 +5,11 @@ AFRAME.registerComponent('paste', { init: function () { this.el.object3D.visible = false - //this.el.innerHTML = ` ` }, requires:{ - // somecomponent: "https://unpkg.com/some-aframe-component/mycom.min.js" + osbutton: "com/osbutton.js" }, events:{ @@ -23,23 +22,99 @@ AFRAME.registerComponent('paste', { navigator.clipboard.readText() .then( (base64) => { let mimetype = base64.replace(/;base64,.*/,'') - console.log(base64.substr(0,100)) let data = base64.replace(/.*;base64,/,'') - if( data.match(/", "href=", "src="]; + const aframeClues = ["", "position="]; + const jsClues = ["var ", "let ", "function ", "setTimeout","console."]; + // Count occurrences of clues for each script type + const bashCount = bashClues.reduce((acc, clue) => acc + (text.includes(clue) ? 1 : 0), 0); + const htmlCount = htmlClues.reduce((acc, clue) => acc + (text.includes(clue) ? 1 : 0), 0); + const aframeCount = aframeClues.reduce((acc, clue) => acc + (text.includes(clue) ? 1 : 0), 0); + const jsCount = jsClues.reduce((acc, clue) => acc + (text.includes(clue) ? 1 : 0), 0); + + // Identify the script with the most clues or return unknown if inconclusive + const maxCount = Math.max(bashCount, htmlCount, jsCount, aframeCount); + if (maxCount === 0) { + return "unknown"; + } else if (bashCount === maxCount) { + return "bash"; + } else if (htmlCount === maxCount) { + return "html"; + } else if (jsCount === maxCount) { + return "javascript"; + } else { + return "aframe"; + } + }, + + insertAFRAME: function(data){ + let scene = document.createElement('a-entity') + scene.id = "embedAframe" + scene.innerHTML = data + let el = document.createElement('a-text') + el.setAttribute("value",data) + el.setAttribute("color","white") + el.setAttribute("align","center") + el.setAttribute("anchor","align") + let osbutton = this.wrapOSButton(el,"aframe",data) + AFRAME.scenes[0].appendChild(osbutton) + console.log(data) + }, + + insertText: function(data){ + let el = document.createElement('a-text') + el.setAttribute("value",data) + el.setAttribute("color","white") + el.setAttribute("align","center") + el.setAttribute("anchor","align") + let osbutton = this.wrapOSButton(el,"text",data) + AFRAME.scenes[0].appendChild(osbutton) + console.log(data) + }, + + wrapOSButton: function(el,type,data){ + let osbutton = document.createElement('a-entity') + let height = type == 'aframe' ? 0.3 : 0.1 + let depth = type == 'aframe' ? 0.3 : 0.05 + osbutton.setAttribute("osbutton",`width:0.3; height: ${height}; depth: ${depth}; color:blue `) + osbutton.appendChild(el) + osbutton.object3D.position.copy( this.getPositionInFrontOfCamera() ) + return osbutton + }, + + getPositionInFrontOfCamera: function(){ + const camera = this.el.sceneEl.camera; + let pos = new THREE.Vector3() + let direction = new THREE.Vector3(); + // Get camera's forward direction (without rotation) + camera.getWorldDirection(direction); + camera.getWorldPosition(pos) + direction.normalize(); + // Scale the direction by 1 meter + direction.multiplyScalar(1.5); + // Add the camera's position to the scaled direction to get the target point + pos.add(direction); + return pos }, manifest: { // HTML5 manifest to identify app to xrsh - "short_name": "Hello world", - "name": "Hello world", + "short_name": "Paste", + "name": "Paste", "icons": [ { "src": "https://css.gg/clipboard.svg", diff --git a/app/spatialize.js b/app/spatialize.js index f45a652..badad40 100644 --- a/app/spatialize.js +++ b/app/spatialize.js @@ -24,7 +24,7 @@ AFRAME.registerComponent('spatialize', { this.el.sceneEl.addEventListener('launched',(e) => { console.dir(e) let appEl = e.detail.el.dom - if( appEl.style && appEl.style.display != 'none' && appEl.innerHTML ){ + if( appEl && appEl.style && appEl.style.display != 'none' && appEl.innerHTML ){ this.btn.style.display = '' // show button } }) diff --git a/com/control/hand-menu-button.js b/com/control/hand-menu-button.js new file mode 100644 index 0000000..80e56c4 --- /dev/null +++ b/com/control/hand-menu-button.js @@ -0,0 +1,153 @@ +/* global AFRAME, THREE */ +AFRAME.registerComponent('hand-menu-button', { + schema: { + src: {default: ''}, + srcHover: {default: ''}, + mixin: {default: ''} + }, + + init: function () { + this.onWatchButtonHovered = this.onWatchButtonHovered.bind(this); + this.onAnimationComplete = this.onAnimationComplete.bind(this); + this.onCollisionStarted = this.onCollisionStarted.bind(this); + this.onCollisionEnded = this.onCollisionEnded.bind(this); + this.onAnimationBegin = this.onAnimationBegin.bind(this); + this.onPinchEnded = this.onPinchEnded.bind(this); + + this.el.addEventListener('obbcollisionstarted', this.onCollisionStarted); + this.el.addEventListener('obbcollisionended', this.onCollisionEnded); + this.el.object3D.renderOrder = 1000; + + this.menuEl = this.el.parentEl; + this.handMenuEl = this.el.sceneEl.querySelector('[hand-menu]'); + + this.menuEl.addEventListener('animationbegin', this.onAnimationBegin); + this.menuEl.addEventListener('animationcomplete', this.onAnimationComplete); + }, + + onAnimationBegin: function (evt) { + // To prevent menu activations while animation is running. + if (evt.detail.name === 'animation__open') { this.menuOpen = false; } + }, + + onAnimationComplete: function (evt) { + if (evt.detail.name === 'animation__open') { this.menuOpen = true; } + if (evt.detail.name === 'animation__close') { this.menuOpen = false; } + }, + + onCollisionStarted: function (evt) { + var withEl = evt.detail.withEl; + if (this.handMenuEl === withEl || + !withEl.components['hand-tracking-controls']) { return; } + if (!this.menuOpen) { return; } + this.handHoveringEl = withEl; + this.el.emit('watchbuttonhoverstarted'); + }, + + onCollisionEnded: function (evt) { + var withEl = evt.detail.withEl; + if (this.handMenuEl === withEl || + !withEl.components['hand-tracking-controls']) { return; } + this.disableHover(); + this.handHoveringEl = undefined; + this.el.emit('watchbuttonhoverended'); + }, + + enableHover: function () { + this.handHoveringEl.addEventListener('pinchended', this.onPinchEnded); + this.el.setAttribute('material', 'src', this.data.srcHover); + }, + + disableHover: function () { + if (!this.handHoveringEl) { return; } + this.handHoveringEl.removeEventListener('pinchended', this.onPinchEnded); + this.el.setAttribute('material', 'src', this.data.src); + }, + + onPinchEnded: (function () { + var spawnPosition = new THREE.Vector3(0, 1, 0); + return function () { + var cubeEl; + var newEntityEl; + if (!this.menuOpen) { return; } + this.menuOpen = false; + if (!this.handHoveringEl || !this.data.mixin) { return; } + // Spawn shape a little above the menu. + spawnPosition.set(0, 1, 0); + // Apply rotation of the menu. + spawnPosition.applyQuaternion(this.el.parentEl.object3D.quaternion); + // 20cm above the menu. + spawnPosition.normalize().multiplyScalar(0.2); + spawnPosition.add(this.el.parentEl.object3D.position); + + newEntityEl = document.createElement('a-entity'); + newEntityEl.setAttribute('mixin', this.data.mixin); + newEntityEl.setAttribute('position', spawnPosition); + this.el.sceneEl.appendChild(newEntityEl); + this.handHoveringEl.removeEventListener('pinchended', this.onPinchEnded); + }; + })(), + + onWatchButtonHovered: function (evt) { + if (evt.target === this.el || !this.handHoveringEl) { return; } + this.disableHover(); + this.handHoveringEl = undefined; + } +}); + +/* + User's hand can collide with multiple buttons simultaneously but only want one in a hovered state. + This system keeps track of all the collided buttons, keeping just the closest to the hand in a hovered state. +*/ +AFRAME.registerSystem('hand-menu-button', { + init: function () { + this.onWatchButtonHovered = this.onWatchButtonHovered.bind(this); + this.el.parentEl.addEventListener('watchbuttonhoverended', this.onWatchButtonHovered); + this.el.parentEl.addEventListener('watchbuttonhoverstarted', this.onWatchButtonHovered); + this.hoveredButtonEls = []; + }, + + tick: function () { + var buttonWorldPosition = new THREE.Vector3(); + var thumbPosition; + var smallestDistance = 1000000; + var currentDistance; + var closestButtonEl; + if (this.hoveredButtonEls.length < 2) { return; } + thumbPosition = this.hoveredButtonEls[0].components['hand-menu-button'].handHoveringEl.components['obb-collider'].trackedObject3D.position; + for (var i = 0; i < this.hoveredButtonEls.length; ++i) { + this.hoveredButtonEls[i].object3D.getWorldPosition(buttonWorldPosition); + currentDistance = buttonWorldPosition.distanceTo(thumbPosition); + if (currentDistance < smallestDistance) { + closestButtonEl = this.hoveredButtonEls[i]; + smallestDistance = currentDistance; + } + } + + if (this.hoveredButtonEl === closestButtonEl) { return; } + + this.hoveredButtonEl = closestButtonEl; + + for (i = 0; i < this.hoveredButtonEls.length; ++i) { + if (!this.hoveredButtonEls[i].components['hand-menu-button'].handHoveringEl) { continue; } + if (this.hoveredButtonEls[i] === closestButtonEl) { + this.hoveredButtonEls[i].components['hand-menu-button'].enableHover(); + continue; + } + this.hoveredButtonEls[i].components['hand-menu-button'].disableHover(); + } + }, + + onWatchButtonHovered: function (evt) { + this.buttonEls = this.el.sceneEl.querySelectorAll('[hand-menu-button]'); + this.hoveredButtonEls = []; + for (var i = 0; i < this.buttonEls.length; ++i) { + if (!this.buttonEls[i].components['hand-menu-button'].handHoveringEl) { continue; } + this.hoveredButtonEls.push(this.buttonEls[i]); + } + if (this.hoveredButtonEls.length === 1) { + this.hoveredButtonEl = this.hoveredButtonEls[0]; + this.hoveredButtonEls[0].components['hand-menu-button'].enableHover(); + } + } +}); diff --git a/com/control/hand-menu.js b/com/control/hand-menu.js new file mode 100644 index 0000000..987cd79 --- /dev/null +++ b/com/control/hand-menu.js @@ -0,0 +1,198 @@ +/* global AFRAME, THREE */ +AFRAME.registerComponent('hand-menu', { + schema: { + location: {default: 'palm', oneOf: ['palm']} + }, + + menuHTML: /* syntax: html */ ` + + + + + + + + + + + + + + + + + + `, + + init: function () { + this.onCollisionStarted = this.onCollisionStarted.bind(this); + this.onCollisionEnded = this.onCollisionEnded.bind(this); + this.onSceneLoaded = this.onSceneLoaded.bind(this); + this.onEnterVR = this.onEnterVR.bind(this); + + this.throttledOnPinchEvent = AFRAME.utils.throttle(this.throttledOnPinchEvent, 50, this); + + //this.el.sceneEl.addEventListener('loaded', this.onSceneLoaded); + this.el.sceneEl.addEventListener('enter-vr', this.onEnterVR); + }, + + onEnterVR: function () { + this.onSceneLoaded() + this.setupMenu(); + }, + + onSceneLoaded: function () { + var handEls = this.el.sceneEl.querySelectorAll('[hand-tracking-controls]'); + for (var i = 0; i < handEls.length; i++) { + if (handEls[i] === this.el) { continue; } + this.handElement = handEls[i]; + } + }, + + setupMenu: function () { + var template = document.createElement('template'); + template.innerHTML = this.menuHTML; + this.menuEl = template.content.children[0]; + this.el.sceneEl.appendChild(this.menuEl); + + if (this.data.location === 'palm') { + this.setupPalm(); + } + }, + + setupPalm: function () { + var el = this.openMenuEl = document.createElement('a-entity'); + el.setAttribute('geometry', 'primitive: circle; radius: 0.025'); + el.setAttribute('material', 'side: double; src: #palmButton; shader: flat'); + el.setAttribute('rotation', '90 0 180'); + el.setAttribute('position', '0 -0.035 -0.07'); + el.setAttribute('obb-collider', ''); + el.addEventListener('obbcollisionstarted', this.onCollisionStarted); + el.addEventListener('obbcollisionended', this.onCollisionEnded); + this.el.appendChild(el); + }, + + throttledOnPinchEvent: function (evt) { + if (evt.type === 'pinchstarted') { this.onPinchStarted(evt); } + if (evt.type === 'pinchended') { this.onPinchEnded(evt); } + }, + + onCollisionStarted: function (evt) { + var withEl = evt.detail.withEl; + if (this.handElement !== withEl) { return; } + withEl.addEventListener('pinchstarted', this.throttledOnPinchEvent); + withEl.addEventListener('pinchended', this.throttledOnPinchEvent); + this.handHoveringEl = withEl; + this.updateUI(); + }, + + onCollisionEnded: function (evt) { + var withEl = evt.detail.withEl; + if (this.handElement !== withEl) { return; } + withEl.removeEventListener('pinchstarted', this.throttledOnPinchEvent); + if (!this.opened) { + withEl.removeEventListener('pinchended', this.throttledOnPinchEvent); + } + this.handHoveringEl = undefined; + this.updateUI(); + }, + + updateUI: function () { + var palmButtonImage; + if (this.data.location === 'palm') { + palmButtonImage = this.handHoveringEl ? '#palmButtonHover' : '#palmButton'; + debugger + this.openMenuEl.setAttribute('material', 'src', palmButtonImage); + return; + } + }, + + onPinchStarted: (function () { + var auxMatrix = new THREE.Matrix4(); + var auxQuaternion = new THREE.Quaternion(); + return function (evt) { + if (!this.handHoveringEl || this.opened) { return; } + this.opened = true; + this.menuEl.object3D.position.copy(evt.detail.position); + this.menuEl.emit('open'); + function lookAtVector (sourcePoint, destPoint) { + return auxQuaternion.setFromRotationMatrix( + auxMatrix.identity() + .lookAt(sourcePoint, destPoint, new THREE.Vector3(0, 1, 0))); + } + + var cameraEl = this.el.sceneEl.querySelector('[camera]'); + var rotationQuaternion = lookAtVector(this.menuEl.object3D.position, cameraEl.object3D.position); + this.menuEl.object3D.quaternion.copy(rotationQuaternion); + this.pinchedEl = this.handHoveringEl; + if (this.data.location === 'palm') { this.openMenuEl.object3D.visible = false; } + }; + })(), + + onPinchEnded: function (evt) { + if (!this.pinchedEl) { return; } + this.opened = false; + this.menuEl.emit('close'); + this.pinchedEl = undefined; + this.openMenuEl.object3D.visible = true; + }, + + lookAtCamera: (function () { + var auxVector = new THREE.Vector3(); + var auxObject3D = new THREE.Object3D(); + return function (el) { + var cameraEl = this.el.sceneEl.querySelector('[camera]'); + auxVector.subVectors(cameraEl.object3D.position, el.object3D.position).add(el.object3D.position); + el.object3D.lookAt(auxVector); + el.object3D.rotation.z = 0; + }; + })() +}); + +/* + +Watch style UI that work both in VR and AR with @aframevr in one line of + +Try now on @Meta Quest Browser + +https://a-watch.glitch.me/ + +Just 400 lines of code: https://glitch.com/edit/#!/a-watch + +Watch-style intuitive but easy to occlude hands ⌚️ +Palm- style less familiar but more robust ✋ + +Enjoy! Wanna see more of this? sponsor me on @github + +https://github.com/sponsors/dmarcos + +*/ diff --git a/com/osbutton.js b/com/osbutton.js new file mode 100644 index 0000000..32b6440 --- /dev/null +++ b/com/osbutton.js @@ -0,0 +1,86 @@ +AFRAME.registerComponent('osbutton',{ + + data:{ + width: {type: 'number', default: 0.4}, + height: {type: 'number', default: 0.2}, + depth: {type: 'number', default: 0.06}, + color: {type: 'color', default: 'blue'}, + distance: {type: 'number'}, + label: {type: 'string'} + }, + + init: function(){ + this + .createBox() + .setupDistanceTick() + }, + + setupDistanceTick: function(){ + // we throttle by distance, to support scenes with loads of clickable objects (far away) + if( !this.data.distance ) this.data.distance = 0.9 + this.distance = -1 + this.worldPosition = new THREE.Vector3() + this.posCam = new THREE.Vector3() + this.tick = this.throttleByDistance( () => this.showSource() ) + }, + + createBox: function(){ + let geometry = this.geometry = new THREE.BoxGeometry(this.data.width, this.data.height, this.data.depth); + this.material = new THREE.MeshStandardMaterial({color: this.data.color }); + this.mesh = new THREE.Mesh(this.geometry, this.material); + this.scaleChildToButton(this.el.object3D, this.mesh) + this.el.object3D.add(this.mesh) + return this + }, + + throttleByDistance: function(f){ + return function(){ + if( this.distance < 0 ) return f() // first call + if( !f.tid ){ + let x = this.distance + let y = x*(x*0.05)*1000 // parabolic curve + f.tid = setTimeout( function(){ + f.tid = null + f() + }, y ) + } + } + }, + + showSource: function(){ + this.el.sceneEl.camera.getWorldPosition(this.posCam) + this.el.object3D.getWorldPosition(this.worldPosition) + this.distance = this.posCam.distanceTo(this.worldPosition) + + if( this.distance < this.data.distance ){ + this.material.side = THREE.BackSide + }else{ + this.material.side = THREE.FrontSide + } + }, + + scaleChildToButton: function(scene, mesh ){ + let cleanScene = scene.clone() + let remove = [] + const notVisible = (n) => !n.visible || (n.material && !n.material.visible) + cleanScene.traverse( (n) => notVisible(n) && n.children.length == 0 && (remove.push(n)) ) + remove.map( (n) => n.removeFromParent() ) + let restrictTo3DBoundingBox = mesh.geometry + if( restrictTo3DBoundingBox ){ + // normalize instanced objectsize to boundingbox + let sizeFrom = new THREE.Vector3() + let sizeTo = new THREE.Vector3() + let empty = new THREE.Object3D() + new THREE.Box3().setFromObject(mesh).getSize(sizeTo) + new THREE.Box3().setFromObject(cleanScene).getSize(sizeFrom) + let ratio = sizeFrom.divide(sizeTo) + scene.children.map( (c) => { + if( c.uuid != mesh.uuid ){ + c.scale.multiplyScalar( 1.0 / Math.max(ratio.x, ratio.y, ratio.z)) + } + }) + } + + }, + +})