<!DOCTYPE html> <html> <title>SpaSca : Spatial Scaffolding</title> <head> <!-- Suggestions? https://git.benetou.fr/utopiah/text-code-xr-engine/issues/ --> <!-- 1.6 needed for real-world-meshing <script src='dependencies/aframe.offline.min.js'></script>--> <script src="https://aframe.io/releases/1.6.0/aframe.min.js"></script> <!-- <script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>--> <!-- <script src='dependencies/aframe.offline.min.js'></script>--> <script src="dependencies/a-console.js"></script> <script src='dependencies/aframe-troika-text.min.js'></script> <script src='dependencies/webdav.js'></script> <script src='jxr-core.js?125'></script> <script src='jxr-postitnote.js?13235'></script> </head> <body> <script> // onreleased does not work on 1.6 yet works on 1.5 // also minimalist example on event to emit() then receive on component does work function getFurnitures(){ // from https://aframe.io/docs/1.6.0/components/real-world-meshing.html worldMeshes = Array.from(document.querySelectorAll("[data-world-mesh]") ) // should be done after entering AR worldMeshes.map( el => console.log( el.getAttribute("data-world-mesh") ) ) // should be use in order to snap onreleased to a specific object, e.g } function attachFirstWorldMeshFromLabel(label){ console.log('testing attachFirstWorldMeshFromLabel()') // used as e.g onreleased="attachFirstWorldMeshFromLabel('screen')" let target = document.querySelector("[data-world-mesh="+label+"]") if (!target) { console.log('no entity of semantic label', label, 'found') console.log('(consider doing room setup again)') } else { let latest = selectedElements[selectedElements.length-1].element console.log('putting', latest, 'on', target, 'of label', label) latest.setAttribute( "position", target.getAttribute("position") ) latest.setAttribute( "rotation", target.getAttribute("rotation") ) // note that this will NOT work well on objects with depth, e.g table, but rather on screens, art piece, walls (even arguable) // could add optional parameter to add "on top of" or other positionning // could be based on the limited type of labels too // could stack, or even add random offsets to void overlap } } var forceXaxis // setInterval( _ => console.log(forceXaxis), 1000) var translatingTargets = false var clearRot function toggleTranslateTargets(){ translatingTargets = !translatingTargets let scene = AFRAME.scenes[0].object3D if (translatingTargets){ let anchor = new THREE.Object3D() let latest = selectedElements[selectedElements.length-1].element latest.object3D.add( anchor ) // also inherits rotation, could try cancel it as the opposite of latest rotation // might be easier to copy the position only every few ms instead anchor.position.sub( latest.object3D.position ) //targets.map( t => anchor.attach(t.object3D) ) // should attach all BUT the current moving entity! Array.from(document.querySelectorAll('.mab')).map( t => anchor.attach(t.object3D) ) // they don't move... despite } else { clearInterval( clearRot ) Array.from(document.querySelectorAll('.mab')).map( t => scene.attach(t.object3D) ) //targets.map( t => scene.attach(t.object3D) ) // could delete anchor, cleaner } } var attachToPlayer = false function toggleAttachToSelf(){ attachToPlayer = !attachToPlayer attachToPlayer ? parent=document.querySelector("#player") : parent=AFRAME.scenes[0] targets.map( t => parent.object3D.attach(t.object3D) ) } function checkIntersection(latest, nearby){ //let latest = selectedElements[selectedElements.length-1].element //let nearby = getClosestTargetElements( latest.getAttribute('position') ) // https://threejs.org/docs/?q=box#api/en/math/Box3.containsBox // https://threejs.org/docs/?q=box#api/en/math/Box3.expandByObject let a = new THREE.Box3().expandByObject( latest.object3D ) // consider mesh.geometry.computeBoundingBox() first let b = new THREE.Box3().expandByObject( nearby.object3D ) console.log(a,b, a.containsBox(b)) // testable as checkIntersection( document.querySelector("[color='yellow']"), document.querySelector("[color='purple']") ) // <a-box scale=".1 .1 .1" position=".5 .8 -.3" color="purple" ></a-box> // <a-box scale=".2 .2 .2" position=".5 .8 -.3" color="yellow" ></a-box> } setTimeout( _ => { let newPostIt = addNewNoteAsPostItNote("jxr console.log(222);", "0 1.2 -.5") .setAttribute("onreleased", "grammarBasedSnap()") let otherPostIt = addNewNoteAsPostItNote("jxr console.log(111);", "0 1.4 -.5") .setAttribute("onreleased", "grammarBasedSnap()") let postIt = addNewNoteAsPostItNote("hi this is a post-it note.", "0 1.6 -.5") .setAttribute("onreleased", "runClosestJXR(); grammarBasedSnap()") // dunno how to share the event context back here... // .setAttribute("onreleased", "snapNext()") // does NOT support multiple instances for now // see https://aframe.io/docs/1.5.0/core/component.html#multiple // maybe bind could help //let cloneMe = addNewNote('jxr clone me from corner', '0 0 .1', '1 1 1', 'cmd') // should rebind parent... //setTimeout( _ => { _ => cloneMe.object3D.parent = postIt.object3D }, 1000 ) // should try object3D.attach() instead //.addEventListener('loaded', // entityIndexes( document.querySelector("[color='blue']").object3D.children[0] ) }, 1000 ) // e.g document.querySelector("[color='blue']").object3D.children[0] function entityIndexes(mesh){ // needs a mesh with a geometry, not a group // could also traverse let gp = mesh.geometry.attributes.position; let wPos = []; for(let i = 0;i < gp.count; i++){ let p = new THREE.Vector3().fromBufferAttribute(gp, i); // set p from `position` mesh.localToWorld(p); // p has wordl coords wPos.push(p); } // many are duplicates, i.e a a cube will return 24 indexes (4 per 6 faces), not 8 //let l = [...new Set(wPos)].length; console.log( l ) [...new Set(wPos)].map( p => addNewNote("x", p)) console.log( [...new Set(wPos)].length ) // seems to add the duplicates again // try to "de-dup" via .distanceTo() below a threshold instead } function snapToGrid(gridSize=1){ // default as 1 decimeter let latest = selectedElements[selectedElements.length-1].element latest.setAttribute("rotation", "0 0 0") let pos = latest.getAttribute("position") pos.multiplyScalar(gridSize*10).round().divideScalar(gridSize*10) latest.setAttribute("position", pos ) } // deeper question, making the rules themselves manipulable? JXR? // So the result of the grammar becomes manipulable, but could you make the rules of the grammar itself visual? Even manipulable? // could start by visualizing examples first e.g https://writer.com/wp-content/uploads/2024/03/grammar-1.webp function snapMAB(){ // multibase arithmetic blocks aka MAB cf https://en.wikipedia.org/wiki/Base_ten_block let latest = selectedElements[selectedElements.length-1].element let nearby = getClosestTargetElements( latest.getAttribute('position') ) let linked = [] if (nearby.length>0){ latest.setAttribute("rotation", AFRAME.utils.coordinates.stringify( nearby[0].el.getAttribute("rotation") ) ) latest.setAttribute("position", AFRAME.utils.coordinates.stringify( nearby[0].el.getAttribute("position") ) ) latest.object3D.translateX( 1/10 ) linked.push( latest ) linked.push( nearby[0].el ) let overlap = Array.from( document.querySelectorAll(".mab") ).filter( e => e.object3D.position.distanceTo( latest.object3D.position ) < 0.01 && e!=latest ) while (overlap.length > 0 ){ latest.object3D.translateX( 1/10 ) linked.push( overlap[0] ) overlap = Array.from( document.querySelectorAll(".mab") ).filter( e => e.object3D.position.distanceTo( latest.object3D.position ) < 0.01 && e!=latest ) } // do something special if it becomes 10, e.g become a single line, removing the "ridges" if (linked.length > 3) linked.map( e => Array.from( e.querySelectorAll("a-box") ).setAttribute("color", "orange") ) // also need to go backward too to see if it's the latest added } } function snapRightOf(){ let latest = selectedElements[selectedElements.length-1].element let nearby = getClosestTargetElements( latest.getAttribute('position') ) if (nearby.length>0){ latest.setAttribute("rotation", AFRAME.utils.coordinates.stringify( nearby[0].el.getAttribute("rotation") ) ) latest.setAttribute("position", AFRAME.utils.coordinates.stringify( nearby[0].el.getAttribute("position") ) ) latest.object3D.translateX( 1/10 ) // somehow... works only the 2nd time, not the 1st?! } } function grammarBasedSnap(){ // verify if snappable, e.g of same type (or not) // e.g check if both have .getAttribute('value').match(prefix) or not let latest = selectedElements[selectedElements.length-1].element let nearby = getClosestTargetElements( latest.getAttribute('position') ) if (nearby.length>0){ let closest = nearby[0].el let latestTypeJXR = latest.getAttribute('value').match(prefix) let closestTypeJXR = latest.getAttribute('value').match(prefix) latest.setAttribute("rotation", AFRAME.utils.coordinates.stringify( closest.getAttribute("rotation") ) ) latest.setAttribute("position", AFRAME.utils.coordinates.stringify( closest.getAttribute("position") ) ) if ( latestTypeJXR && closestTypeJXR ) latest.object3D.translateX( 1/10 ) // same JXR type, snap close else latest.object3D.translateX( 2/10 ) // different types, snap away // somehow... works only the 2nd time, not the 1st?! } } function cloneTarget(target){ let el = target.cloneNode(true) if (!el.id) el.id = "clone_" + crypto.randomUUID() else el.id += "_clone_" + crypto.randomUUID() AFRAME.scenes[0].appendChild(el) } function deleteTarget(target){ targets = targets.filter( e => e != target) target.remove() } function runClosestJXR(){ // ideally this would come from event details let latest = selectedElements[selectedElements.length-1].element let nearby = getClosestTargetElements( latest.getAttribute('position') ) // if (nearby.length>0){ interpretJXR( nearby[0].el.getAttribute("value") ) } nearby.map( n => interpretJXR( n.el.getAttribute("value") ) ) } function notesFromArray(data, generatorName="", field="title", offset=1, step=1/10, depth=-.5 ){ data.slice(0,maxItemsFromSources).map( (n,i) => { addNewNote( n[field], "0 "+(offset+i*step)+" "+depth, ".1 .1 .1", null, generatorName ) .setAttribute("onreleased","spreadItemsFromCollection('getcsljson', 1.5)") }) } function spreadItemsFromCollection( generatorName, offset=1, step=1/10, depth=-.5 ){ getArrayFromClass(generatorName).sort((a,b)=>a.getAttribute('position').y-b.getAttribute('position').y).map( (n,i) => { n.setAttribute('position', "0 "+(offset+i*step)+" "+depth) n.setAttribute('rotation', "0 0 0") // could also be based on the average of all items, the first item, last one, etc // see also snap-on-pinchended component }) let items = getArrayFromClass(generatorName).sort((b,a)=>a.getAttribute('position').y-b.getAttribute('position').y).map( n => n.getAttribute('value') ) shareLiveEvent('modified list', items) } AFRAME.registerComponent('onemptypinch', { // changed from ondrop to be coherent with event name init: function(){ AFRAME.scenes[0].addEventListener('enter-vr', e => { console.log('entered vr') document.querySelector("[cursor]").setAttribute("visible", "true") document.querySelector("[camera]").setAttribute("cursor", "") }) }, // could support multi events: { emptypinch: function (e) { // works with AFRAME.scenes[0].emit('emptypinch', {position:"0 0 0"}) let code = this.el.getAttribute('onemptypinch') // if multi, should also look for onreleased__ not just onreleased try { eval( code ) // should be jxr too e.g if (txt.match(prefix)) interpretJXR(txt) } catch (error) { console.error(`Evaluation failed with ${error}`); } } } }) function onHoveredTeleport(){ // iterate over targets // see instead of teleportable https://aframe.io/docs/1.5.0/components/cursor.html#configuring-the-cursor-through-the-raycaster-component Array.from( document.querySelectorAll("[teleporter]") ).map( target => { if ( target.states.includes( "cursor-hovered" ) ){ target.setAttribute("material", "color", "magenta") // visited document.getElementById('rig').setAttribute('position', target.getAttribute("position") ) } }) } AFRAME.registerComponent('teleporter', { init: function(){ this.el.setAttribute("opacity", .5) if (window.location.hash && document.querySelector(window.location.hash+"[teleporter]")) document.getElementById('rig').setAttribute('position', document.querySelector(window.location.hash+"[teleporter]").getAttribute("position") ) }, events: { mouseenter: function (e) { this.el.setAttribute("opacity", .8) }, mouseleave: function (e) { this.el.setAttribute("opacity", .5) }, click: function (e) { let posTarget = new THREE.Vector3() this.el.object3D.getWorldPosition( posTarget ) console.log( posTarget) document.getElementById('rig').setAttribute('position', posTarget) // seems to work, maybe inteference with others teleporters activated unknowingly, e.g in succession } // this.el.getAttribute("position") ) } // does not get proper world position // makes it compatible with mouse on desktop ... but also somehow enable the wrist shortcut?! } }); AFRAME.registerComponent('scaffolding', { init: function(){ console.log(this.el.innerHTML) // should become editable then saved back/replaced (or cloned) // could do a test with switching to wireframe or grey color // BT keyboard does get focus on Vision Pro too // enter key does not seem to work though // it does work on desktop tso should console.log() what keypresses are actually received // Meta+Enter does work though! (on Corne-ish Zen it's Linux key with Enter) // still prepare jxr regexes // e.g el = document.querySelector("[scaffolding]")l el.innerHTML = el.innerHTML.replaceAll("<a-cylinder", "<a-cylinder wireframe=true") // block based too // responsive pedagogical way, as discuss with Adam particularly, metaphor of the electrician and consecutive pannels with limited access // put this.el.innerHTML on a plane, e.g 1x1 black plane at back of current scaffolding // arrow keys (on physical keyboard) move through that, initially changing color or current char // use a font that faciliates positionning // as done before, make the carret, e.g | directly in the content text itself // on save (ESC? to define and test), remove carret then save back to this.el.innerHTML } }); let page = "Wiki.VirtualRealityInterface"; let pageFromParam = AFRAME.utils.getUrlParameter('page') if (pageFromParam) page = pageFromParam setTimeout( _ => { Array.from( document.querySelectorAll("[value='"+page+"']") ).map( n => n.setAttribute("onreleased", "console.log('dropped, should toggle display children,"+n.id+"')")); Array.from( document.querySelectorAll("[value='"+page+"']>a-sphere") ).map( n => n.setAttribute("color", "purple")) }, 5000) </script> <div style='position:fixed;z-index:1; top: 0%; left: 0%; border-bottom: 70px solid transparent; border-left: 70px solid #eee; '> <a href="https://git.benetou.fr/utopiah/text-code-xr-engine/issues/"> <img style='position:fixed;left:10px;' title='code repository' src='gitea_logo.svg'> </a> </div> <a-scene real-world-meshing="meshesEnabled: false; planeMixin: xrplane" xr-mode-ui="XRMode: ar" startfunctions onemptypinch="onHoveredTeleport()"> <a-mixin id="xrplane" material="color: pink" visible="false"> </a-mixin> <a-gltf-model NOhide-on-enter-ar="" id="environment" src="../content/CubeRoom.glb" rotation="0 -90 0" position="0 0 1" scale="" ></a-gltf-model> <!-- Cube Room by Anonymous [CC-BY] via Poly Pizza --> <a-entity id="rig"> <a-entity id="player" networked="template:#avatar-template;attachTemplateToLocal:false;" hud camera look-controls wasd-controls position="0 1.6 0"> <a-entity cursor position="0 0 -1" geometry="primitive: ring; radiusInner: 0.005; radiusOuter: 0.01" material="color: black; shader: flat; opacity:.05;" ></a-entity> </a-entity> <a-entity id="rightHand" pinchprimary hand-tracking-controls="hand: right;"></a-entity> <a-entity id="leftHand" pinchsecondary wristattachsecondary="target: #box" hand-tracking-controls="hand: left;"></a-entity> <a-console position="2 2 0" rotation="0 -45 0" font-size="34" height=1 skip-intro=true></a-console> </a-entity> <a-box pressable start-on-press id="box" scale="0.05 0.05 0.05" color="pink"></a-box> <a-troika-text value="SpaSca : Spatial Scaffolding" anchor="left" outline-width="5%" font="../content/ChakraPetch-Regular.ttf" position="-3 5 -2" scale="3 3 3" rotation="80 0 0" troika-text="outlineWidth: 0.01; strokeColor: #ffffff" material="flatShading: true; blending: additive; emissive: #c061cb"></a-troika-text> <a-sky hide-on-enter-ar color="lightgray"></a-sky> <a-troika-text anchor=left target value="instructions : \n--right pinch to move\n--left pinch to execute" position="0 0.65 -0.2" scale="0.1 0.1 0.1"></a-troika-text> <a-troika-text anchor=left value="jxr onNextPrimaryPinch(deleteTarget)" target position=" -0.3 1.50 0" rotation="0 40 0" scale="0.1 0.1 0.1"></a-troika-text> <a-troika-text anchor=left value="jxr onNextPrimaryPinch(cloneTarget)" target position=" -0.3 1.60 0" rotation="0 40 0" scale="0.1 0.1 0.1"></a-troika-text> <a-troika-text anchor=left value="jxr location.reload()" target position=" -0.3 1.30 0" rotation="0 40 0" scale="0.1 0.1 0.1"></a-troika-text> <a-troika-text anchor=left value="jxr makeAnchorsVisibleOnTargets()" target position=" -0.3 1.20 0" rotation="0 40 0" scale="0.1 0.1 0.1"></a-troika-text> <a-troika-text anchor=left value="forceXaxis toggling" onreleased="console.log('run on released');forceXaxis=!forceXaxis" onpicked="console.log('run on picked');forceXaxis=!forceXaxis" target position=" -0.3 1.45 0" rotation="0 40 0" scale="0.1 0.1 0.1"></a-troika-text> <a-troika-text anchor=left value="translate targets" onreleased="toggleTranslateTargets()" onpicked="toggleTranslateTargets()" target position=" 1 1.45 -.2" rotation="0 -40 0" scale="0.1 0.1 0.1"></a-troika-text> <a-troika-text anchor=left value="jxr setTimeout( _ => toggleAttachToSelf(), 1000); toggleAttachToSelf()" target position=" -0.3 1.25 0" rotation="0 40 0" scale="0.1 0.1 0.1"></a-troika-text> <a-box scale=".07 .07 .07" class="mab" target position=".3 1.6 -.5" color="brown" onreleased="snapMAB()" > <a-box scale="1.5 1.5 1" color="brown"></a-box> <a-box scale="1 1.5 1.5" color="brown"></a-box> <a-box scale="1.5 1 1.5" color="brown"></a-box> </a-box> <a-box scale=".07 .07 .07" class="mab" target position=".1 1.6 -.5" color="brown" onreleased="snapMAB()" > <a-box scale="1.5 1.5 1" color="brown"></a-box> <a-box scale="1 1.5 1.5" color="brown"></a-box> <a-box scale="1.5 1 1.5" color="brown"></a-box> </a-box> <a-box scale=".07 .07 .07" class="mab" target position=".5 1.6 -.5" color="brown" onreleased="snapMAB()" > <a-box scale="1.5 1.5 1" color="brown"></a-box> <a-box scale="1 1.5 1.5" color="brown"></a-box> <a-box scale="1.5 1 1.5" color="brown"></a-box> </a-box> <a-box scale=".07 .07 .07" class="mab" target position="-.5 1.6 -.5" color="brown" onreleased="snapMAB()" > <a-box scale="1.5 1.5 1" color="brown"></a-box> <a-box scale="1 1.5 1.5" color="brown"></a-box> <a-box scale="1.5 1 1.5" color="brown"></a-box> </a-box> <a-box scale=".1 .1 .1" target position=".5 1.6 -.3" color="blue" onreleased="snapToGrid()" annotation="content:could also show/hide grid with gridHelper on pinch started and hide on release" ></a-box> <a-box scale=".1 .1 .1" target position=".5 1.8 -.3" color="blue" onreleased="snapToGrid()" ></a-box> <a-box scale=".1 .1 .1" position=".5 .8 -.3" color="purple" ></a-box> <a-box scale=".2 .2 .2" position=".5 .8 -.3" color="yellow" ></a-box> <a-box teleporter height=".1" class="teleportable" material="color: cyan" position="3.5 0 -3.5" ></a-box> <a-box teleporter height=".1" class="teleportable" material="color: cyan" position="-4 0 4" ></a-box> <a-box teleporter height=".1" class="teleportable" material="color: cyan" position="3 3 4" > <a-troika-text anchor=left value="jxr location.reload()" target position="0 1.30 -.5" rotation="0 0 0" scale="0.1 0.1 0.1"></a-troika-text> <!-- works to execute but not to move, should either reparent or take into account the parent offset while moving --> <!-- see pinchmoved in primary pinch in jxr-core.js as potential solution --> </a-box> <a-box teleporter height=".1" class="teleportable" material="color: cyan" position="0 0 0" ></a-box> <a-box id="namedteleporter" teleporter height=".1" class="teleportable" material="color: cyan" position="2 0 2" ></a-box> <a-box target teleporter height=".1" depth=".1" width=".1" class="teleportable" material="color: red" position="0 1 -.5" ></a-box> <a-troika-text anchor=left value="jxr getFurnitures()" target position=" 0.3 1.25 0" rotation="0 -40 0" scale="0.1 0.1 0.1"></a-troika-text> <a-box target height=".1" depth=".02" width=".1" material="color: purple" position="0 1.2 -.5" onreleased="attachFirstWorldMeshFromLabel('table')"></a-box> </a-scene> </body> </script> </html>