diff --git a/index.html b/index.html
index 80049d7..94cbe17 100644
--- a/index.html
+++ b/index.html
@@ -3,95 +3,16 @@
SpaSca : Spatial Scaffolding
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ hud camera look-controls wasd-controls position="0 1.6 0">
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
diff --git a/jxr-core.js b/jxr-core.js
new file mode 100644
index 0000000..941b716
--- /dev/null
+++ b/jxr-core.js
@@ -0,0 +1,841 @@
+const prefix = /^jxr /
+const codeFontColor = "lightgrey"
+const fontColor= "white"
+var selectedElement = null;
+var targets = []
+const zeroVector3 = new THREE.Vector3()
+var bbox = new THREE.Box3()
+bbox.min.copy( zeroVector3 )
+bbox.max.copy( zeroVector3 )
+var selectionBox = new THREE.BoxHelper( bbox.object3D, 0x0000ff);
+var groupHelpers = []
+var primaryPinchStarted = false
+var wristShortcut = "jxr switchToWireframe()"
+var selectionPinchMode = false
+var groupingMode = false
+var hudTextEl // should instead rely on the #typinghud selector in most cases
+const startingText = "[]"
+var added = []
+const maxItemsFromSources = 20
+let alphabet = ['abcdefghijklmnopqrstuvwxyz', '0123456789', '<>'];
+var commandhistory = []
+var groupSelection = []
+var primarySide = 0
+const sides = ["right", "left"]
+var pinches = [] // position, timestamp, primary vs secondary
+var dl2p = null // from distanceLastTwoPinches
+var selectedElements = [];
+
+// ==================================== picking ======================================================
+
+AFRAME.registerComponent('target', {
+ init: function () {
+ targets.push( this.el )
+ this.el.classList.add("collidable")
+ }
+})
+
+function getClosestTargetElements( pos, threshold=0.05 ){
+ // TODO Bbox intersects rather than position
+ return targets.filter( e => e.getAttribute("visible") == true).map( t => { return { el: t, dist : pos.distanceTo(t.getAttribute("position") ) } })
+ .filter( t => t.dist < threshold )
+ .sort( (a,b) => a.dist > b.dist)
+}
+
+function getClosestTargetElement( pos, threshold=0.05 ){ // 10x lower threshold for flight mode
+ var res = null
+ const matches = getClosestTargetElements( pos, threshold)
+ if (matches.length > 0) res = matches[0].el
+ return res
+}
+
+// ==================================== HUD ======================================================
+
+var keyboardInputTarget = 'hud'
+
+AFRAME.registerComponent('hud', {
+ init: function(){
+ var feedbackHUDel= document.createElement("a-troika-text")
+ feedbackHUDel.id = "feedbackhud"
+ feedbackHUDel.setAttribute("value", "")
+ feedbackHUDel.setAttribute("position", "-0.05 0.01 -0.2")
+ feedbackHUDel.setAttribute("scale", "0.05 0.05 0.05")
+ this.el.appendChild( feedbackHUDel )
+ var typingHUDel = document.createElement("a-troika-text")
+ typingHUDel.id = "typinghud"
+ typingHUDel.setAttribute("value", startingText)
+ typingHUDel.setAttribute("position", "-0.05 0 -0.2")
+ typingHUDel.setAttribute("scale", "0.05 0.05 0.05")
+ this.el.appendChild( typingHUDel )
+ hudTextEl = typingHUDel // should rely on the id based selector now
+ document.addEventListener('keyup', function(event) {
+ if (keyboardInputTarget != 'hud') return
+ parseKeys('keyup', event.key)
+ });
+ document.addEventListener('keydown', function(event) {
+ if (keyboardInputTarget != 'hud') return
+ parseKeys('keydown', event.key)
+ });
+ }
+})
+
+
+function appendToFeedbackHUD(txt){
+ setFeedbackHUD( document.querySelector("#feedbackhud").getAttribute("value") + " " + txt )
+}
+
+function setFeedbackHUD(txt){
+ document.querySelector("#feedbackhud").setAttribute("value",txt)
+ setTimeout( _ => document.querySelector("#feedbackhud").setAttribute("value","") , 2000)
+}
+
+function appendToHUD(txt){
+ const textHUD = document.querySelector("#typinghud").getAttribute("value")
+ if ( textHUD == startingText)
+ setHUD( txt )
+ else
+ setHUD( textHUD + txt )
+}
+
+function setHUD(txt){
+ document.querySelector("#typinghud").setAttribute("value",txt)
+}
+
+function showhistory(){
+ setFeedbackHUD("history :\n")
+ commandhistory.map( i => appendToHUD(i.uninterpreted+"\n") )
+}
+
+function saveHistoryAsCompoundSnippet(){
+ addNewNote( commandhistory.map( e => e.uninterpreted ).join("\n") )
+}
+
+// ==================================== pinch primary and secondary ======================================================
+
+AFRAME.registerComponent('pinchsecondary', {
+ init: function () {
+ this.el.addEventListener('pinchended', function (event) {
+ selectedElement = getClosestTargetElement( event.detail.position )
+ selectedElements.push({element:selectedElement, timestamp:Date.now(), primary:false})
+ // if close enough to a target among a list of potential targets, unselect previous target then select new
+ if (selectedElement) interpretJXR( selectedElement.getAttribute("value") )
+ selectedElement = null
+ });
+ this.el.addEventListener('pinchmoved', function (event) {
+ if (selectionPinchMode){
+ bbox.min.copy( event.detail.position )
+ setFeedbackHUD( "selectionPinchMode updated min")
+ if (!bbox.max.equal(zeroVector3))
+ selectionBox.update();
+ }
+ });
+ this.el.addEventListener('pinchstarted', function (event) {
+ if (!selectionPinchMode) bbox.min.copy( zeroVector3 )
+ if (selectionPinchMode) setFeedbackHUD( "selectionPinchMode started")
+ });
+ },
+ remove: function() {
+ // should remove event listeners here. Requires naming them.
+ }
+});
+
+AFRAME.registerComponent('pinchprimary', { // currently only 1 hand, the right one, should be switchable
+
+// consider instead https://github.com/AdaRoseCannon/handy-work/blob/main/README-AFRAME.md for specific poses
+// or https://aframe.io/aframe/examples/showcase/hand-tracking/pinchable.js
+
+ init: function () {
+ var el = this.el
+ this.el.addEventListener('pinchended', function (event) {
+ // if positioned close enough to a target zone, trigger action
+ // see own trigger-box component. Could use dedicated threejs helpers instead.
+ // https://github.com/Utopiah/aframe-triggerbox-component/blob/master/aframe-triggerbox-component.js#L66
+ // could make trigger zones visible as debug mode
+ var closests = getClosestTargetElements( event.detail.position )
+ //if (closests && closests.length > 0) // avoiding self reference
+ // setFeedbackHUD("close enough, could stack with "+ closests[1].el.getAttribute("value") )
+ var dist = event.detail.position.distanceTo( document.querySelector("#box").object3D.position )
+ if (dist < .1){
+ setFeedbackHUD("close enough, replaced shortcut with "+ selectedElement.getAttribute("value") )
+ wristShortcut = selectedElement.getAttribute("value")
+ }
+ if (selectedElement){
+ let content = selectedElement.getAttribute("value")
+ selectedElements.push({element:selectedElement, timestamp:Date.now(), primary:true})
+ selectedElement.emit('released')
+ }
+ // unselect current target if any
+ selectedElement = null;
+ if ( groupingMode ) addToGroup( event.detail.position )
+ selectionPinchMode = false
+ /*
+ setHUD( AFRAME.utils.coordinates.stringify( bbox.min ),
+ AFRAME.utils.coordinates.stringify( bbox.max ) )
+ bbox.min.copy( zeroVector3 )
+ bbox.man.copy( zeroVector3 )
+ */
+ setTimeout( _ => primaryPinchStarted = false, 200) // delay otherwise still activate on release
+
+ var newPinchPos = new THREE.Vector3()
+ newPinchPos.copy(event.detail.position )
+ pinches.push({position:newPinchPos, timestamp:Date.now(), primary:true})
+ dl2p = distanceLastTwoPinches()
+
+ });
+ this.el.addEventListener('pinchmoved', function (event) {
+ // move current target if any
+ if (selectionPinchMode){
+ bbox.max.copy( event.detail.position )
+ if (!bbox.min.equal(zeroVector3))
+ selectionBox.update();
+ }
+ if (selectedElement && !groupingMode) {
+ selectedElement.setAttribute("position", event.detail.position)
+ document.querySelector("#rightHand").object3D.traverse( e => {
+ if (e.name == "ring-finger-tip"){
+ selectedElement.object3D.rotation.copy( e.rotation )
+ }
+ })
+ // rotation isn't ideal with the wrist as tend not have wrist flat as we pinch
+ }
+ if (selectedElement) selectedElement.emit("moved")
+ });
+ this.el.addEventListener('pinchstarted', function (event) {
+ primaryPinchStarted = true
+ if (!selectionPinchMode) bbox.max.copy( zeroVector3 )
+
+ //var clone = getClosestTargetElement( event.detail.position ).cloneNode()
+ // might want to limit cloning to unmoved element and otherwise move the cloned one
+ //AFRAME.scenes[0].appendChild( clone )
+ //targets.push( clone )
+ //selectedElement = clone
+
+ selectedElement = getClosestTargetElement( event.detail.position )
+ if (selectedElement) selectedElement.emit("picked")
+ // is it truly world position? See https://github.com/aframevr/aframe/issues/5182
+ // setFeedbackHUD( AFRAME.utils.coordinates.stringify( event.detail.position ) )
+ // if close enough to a target among a list of potential targets, unselect previous target then select new
+ });
+ },
+ remove: function() {
+ // should remove event listeners here. Requires naming them.
+ }
+});
+
+// avoiding setOnDropFromAttribute() as it is not idiosyncratic and creates timing issues
+AFRAME.registerComponent('onreleased', { // changed from ondrop to be coherent with event name
+ events: {
+ released: function (e) {
+ let code = this.el.getAttribute('onreleased')
+ try {
+ eval( code )
+ } catch (error) {
+ console.error(`Evaluation failed with ${error}`);
+ }
+ }
+ }
+})
+
+function distanceLastTwoPinches(){
+ let dist = null
+ if (pinches.length>1){
+ dist = pinches[pinches.length-1].position.distanceTo( pinches[pinches.length-2].position )
+ }
+ return dist
+}
+
+function groupSelectionToNewNote(){
+ var text = ""
+ groupSelection.map( grpel => {
+ //removeBoundingBoxToTextElement( grpel )
+ // somehow fails...
+ text += grpel.getAttribute("value") + "\n"
+ })
+ groupHelpers.map( e => e.removeFromParent() )
+ groupHelpers = []
+ groupSelection = []
+ addNewNote( text )
+}
+
+// ==================================== keyboard ======================================================
+
+AFRAME.registerComponent('keyboard', {
+ init:function(){
+ let generatorName = this.attrName
+ const horizontaloffset = .7
+ const horizontalratio = 1/20
+ alphabet.map( (line,ln) => {
+ for (var i = 0; i < line.length; i++) {
+ var pos = i * horizontalratio - horizontaloffset
+ addNewNote( line[i], pos+" "+(1.6-ln*.06)+" -.4", ".1 .1 .1", null, generatorName)
+ }
+ })
+ }
+})
+
+
+function parseKeys(status, key){
+ var e = hudTextEl
+ if (status == "keyup"){
+ if (key == "Control"){
+ groupingMode = false
+ groupSelectionToNewNote()
+ }
+ }
+ if (status == "keydown"){
+ var txt = e.getAttribute("value")
+ if (txt == "[]")
+ e.setAttribute("value", "")
+ if (key == "Backspace" && txt.length)
+ e.setAttribute("value", txt.slice(0,-1))
+ if (key == "Control")
+ groupingMode = true
+ if (key == "Shift" && selectedElement)
+ e.setAttribute("value", selectedElement.getAttribute("value") )
+ else if (key == "Enter") {
+ if ( selectedElement ){
+ var clone = selectedElement.cloneNode()
+ clone.setAttribute("scale", "0.1 0.1 0.1") // somehow lost
+ AFRAME.scenes[0].appendChild( clone )
+ targets.push( clone )
+ selectedElement = clone
+ } else {
+ if (txt.match(prefix)) interpretJXR(txt)
+ // check if text starts with jxr, if so, also interpret it.
+ addNewNote(e.getAttribute("value"))
+ e.setAttribute("value", "")
+ }
+ } else {
+ // consider also event.ctrlKey and multicharacter ones, e.g shortcuts like F1, HOME, etc
+ if (key.length == 1)
+ e.setAttribute("value", e.getAttribute("value") + key )
+ }
+ }
+}
+
+// ==================================== note as text and possibly executable snippet ======================================================
+
+function addNewNote( text, position=`-0.2 1.1 -0.1`, scale= "0.1 0.1 0.1", id=null, classes="notes", visible="true", rotation="0 0 0" ){
+ var newnote = document.createElement("a-troika-text")
+ newnote.setAttribute("anchor", "left" )
+ newnote.setAttribute("outline-width", "5%" )
+ newnote.setAttribute("outline-color", "black" )
+ newnote.setAttribute("visible", visible )
+
+ if (id)
+ newnote.id = id
+ else
+ newnote.id = "note_" + crypto.randomUUID() // not particularly descriptive but content might change later on
+ if (classes)
+ newnote.className += classes
+ newnote.setAttribute("side", "double" )
+ var userFontColor = AFRAME.utils.getUrlParameter('fontcolor')
+ if (userFontColor && userFontColor != "")
+ newnote.setAttribute("color", userFontColor )
+ else
+ newnote.setAttribute("color", fontColor )
+ if (text.match(prefix))
+ newnote.setAttribute("color", codeFontColor )
+ newnote.setAttribute("value", text )
+ //newnote.setAttribute("font", "sw-test/Roboto-msdf.json")
+ newnote.setAttribute("position", position)
+ newnote.setAttribute("rotation", rotation)
+ newnote.setAttribute("scale", scale)
+ AFRAME.scenes[0].appendChild( newnote )
+ targets.push(newnote)
+ return newnote
+}
+
+AFRAME.registerComponent('annotation', {
+// consider also multiple annotation but being mindful that it might clutter significantly
+ schema: {
+ content : {type: 'string'}
+ },
+ init: function () {
+ addAnnotation(this.el, this.data.content)
+ },
+ update: function () {
+ this.el.querySelector('.annotation').setAttribute('value', this.data.content )
+ // assuming single annotation
+ },
+ remove: function () {
+ this.el.querySelector('.annotation').removeFromParent()
+ //Array.from( this.el.querySelectorAll('.annotation') ).map( a => a.removeFromParent() )
+ }
+})
+
+function addAnnotation(el, content){
+ // could also appear only when in close proximity or while pinching
+ let annotation = document.createElement( 'a-troika-text' )
+ annotation.classList.add( 'annotation' )
+ annotation.setAttribute('value', content)
+ annotation.setAttribute('position', '0 .1 -.1')
+ annotation.setAttribute('rotation', '-90 0 0')
+ annotation.setAttribute("anchor", "left" )
+ annotation.setAttribute("outline-width", "5%" )
+ annotation.setAttribute("outline-color", "black" )
+ el.appendChild(annotation)
+ return el
+}
+
+
+function interpretAny( code ){
+
+ if (!code.match(/^dxr /)) return
+ var newcode = code
+ newcode = newcode.replace("dxr ", "")
+ //newcode = newcode.replace(/bash ([^\s]+)/ ,`debian '$1'`) // syntax delegated server side
+ fetch("/command?command="+newcode).then( d => d.json() ).then( d => {
+ console.log( d.res )
+ appendToHUD( d.res ) // consider shortcut like in jxr to modify the scene directly
+ // res might return that said language isn't support
+ // commandlistlanguages could return a list of supported languages
+ })
+}
+
+function parseJXR( code ){
+// should make reserved keywords explicit.
+ var newcode = code
+ newcode = newcode.replace("jxr ", "")
+ newcode = newcode.replace(/(\d)s (.*)/ ,`setTimeout( _ => { $2 }, $1*1000)`)
+
+ // qs X => document.querySelector("X")
+ newcode = newcode.replace(/qs ([^\s]+)/ ,`document.querySelector('$1')`)
+
+ // sa X Y => .setAttribute("X", "Y")
+ newcode = newcode.replace(/ sa ([^\s]+) (.*)/,`.setAttribute('$1','$2')`)
+ // problematic for position as they include spaces
+
+ newcode = newcode.replace(/obsv ([^\s]+)/ ,`newNoteFromObservableCell('$1')`)
+
+ // TODO
+ //
+ newcode = newcode.replace(/observe ([^\s]+)/,`bindVariableValueToNewNote('$1')`)
+ // could proxy instead... but for now, the quick and dirty way :
+
+ // e.g qs a-sphere sa color red =>
+ // document.querySelector("a-sphere").setAttribute("color", "red")
+
+ newcode = newcode.replace(/lg ([^\s]+) ([^\s]+)/ ,`addGltfFromURLAsTarget('$1',$2)`)
+ // order matters, here we only process the 2 params if they are there, otherwise 1
+ newcode = newcode.replace(/lg ([^\s]+)/ ,`addGltfFromURLAsTarget('$1')`)
+ return newcode
+}
+
+function interpretJXR( code ){
+ if (!code) return
+ if (code.length == 1) { // special case of being a single character, thus keyboard
+ if (code == ">") { // Enter equivalent
+ content = hudTextEl.getAttribute("value")
+ if (Number.isFinite(Number(content))) {
+ loadPageRange(Number(content));
+ } else {
+ addNewNote( content )
+ }
+ setHUD("")
+ } else if (code == "<") { // Backspace equivalent
+ setHUD( hudTextEl.getAttribute("value").slice(0,-1))
+ } else {
+ appendToHUD( code )
+ }
+ }
+ if (!code.match(prefix)) return
+ var uninterpreted = code
+ var parseCode = ""
+ code.split("\n").map( lineOfCode => parseCode += parseJXR( lineOfCode ) + ";" )
+ // could ignore meta code e.g showhistory / saveHistoryAsCompoundSnippet
+ commandhistory.push( {date: +Date.now(), uninterpreted: uninterpreted, interpreted: parseCode} )
+
+ console.log( parseCode )
+ try {
+ eval( parseCode )
+ } catch (error) {
+ console.error(`Evaluation failed with ${error}`);
+ }
+
+ // unused keyboard shortcuts (e.g BrowserSearch) could be used too
+ // opt re-run it by moving the corresponding text in target volume
+}
+
+function bindVariableValueToNewNote(variableName){
+ // from observe jxr keyword
+ const idName = "bindVariableValueToNewNote"+variableName
+ addNewNote( variableName + ":" + eval(variableName), `-0.15 1.4 -0.1`, "0.1 0.1 0.1", idName, "observers", "true" )
+ // could add to the HUD instead and have a list of these
+ return setInterval( _ => {
+ const value = variableName+";"+eval(variableName)
+ // not ideal for DOM elements, could have shortcuts for at least a-text with properties, e.g value or position
+ document.getElementById(idName).setAttribute("value", value)
+ }, 100 )
+}
+
+AFRAME.registerComponent('gltf-jxr', {
+ events: {
+ "model-loaded": function (evt) {
+ this.el.object3D.traverse( n => { if (n.userData.jxr) {
+ console.log(n.userData)
+ // need to make gltf become a child of a note to be executable on pinch
+ // try reparenting first... otherwise var clone = this.el.cloneNode(true)
+ // might not be great, cf https://github.com/aframevr/aframe/issues/2425
+ let pos = this.el.object3D.position.clone()
+ let rot = this.el.object3D.rotation.clone()
+ this.el.remove()
+
+ let note = addNewNote( n.userData.jxr, pos, "0.1 0.1 0.1", null, "gltf-jxr-source")
+ let clone = this.el.cloneNode(true)
+ clone.setAttribute('position', '0 0 0')
+ clone.setAttribute('scale', '10 10 10') // assuming not scaled until now, surely wrong
+ // need rescaling to current scale by 1/0.1, clone.setAttribute(
+ clone.removeAttribute('gltf-jxr')
+ note.appendChild(clone)
+ }
+ })
+ },
+ },
+
+ /* example of backend code to annotate the glTF
+ import { NodeIO } from '@gltf-transform/core';
+ import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
+ const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
+ const document = await io.read('PopsicleChocolate.glb');
+ const node = document.getRoot() // doesn't seem to work.listNodes().find((node) => node.getName() === 'RootNode');
+ node.setExtras({jxr: "jxr addNewNote('hi')"});
+ await io.write('output.glb', document);
+ */
+});
+
+
+// ==================================== interactions beyond pinch ======================================================
+
+AFRAME.registerComponent('wristattachsecondary',{
+ schema: {
+ target: {type: 'selector'},
+ },
+ init: function () {
+ var el = this.el
+ this.worldPosition=new THREE.Vector3();
+ },
+ tick: function () {
+ // could check if it exists first, or isn't 0 0 0... might re-attach fine, to test
+ // somehow very far away... need to convert to local coordinate probably
+ // localToWorld?
+ (primarySide == 0) ? secondarySide = 1 : secondarySide = 0
+ var worldPosition=this.worldPosition;
+ this.el.object3D.traverse( e => { if (e.name == "wrist") {
+ worldPosition.copy(e.position);e.parent.updateMatrixWorld();e.parent.localToWorld(worldPosition)
+ rotation = e.rotation.x*180/3.14 + " " + e.rotation.y*180/3.14 + " " + e.rotation.z*180/3.14
+ this.data.target.setAttribute("rotation", rotation)
+ this.data.target.setAttribute("position",
+ AFRAME.utils.coordinates.stringify( worldPosition ) )
+ // doesnt work anymore...
+ //this.data.target.setAttribute("rotation", AFRAME.utils.coordinates.stringify( e.getAttribute("rotation") )
+ }
+ })
+ },
+ remove: function() {
+ // should remove event listeners here. Requires naming them.
+ }
+});
+
+function doublePinchToScale(){
+ let initialPositionSecondary
+ let initialScale
+ let elSecondary = document.querySelector('[pinchsecondary]')
+ elSecondary.addEventListener('pinchmoved', movedSecondary );
+ function movedSecondary(event){
+ if (!selectedElement) return
+ let scale = initialScale * initialPositionSecondary.distanceTo(event.detail.position) * 50
+ selectedElement.setAttribute("scale", ""+scale+" "+scale+" "+scale+" ")
+ }
+ elSecondary.addEventListener('pinchstarted', startedSecondary );
+ function startedSecondary(event){
+ initialPositionSecondary = event.detail.position.clone()
+ if (!selectedElement) return
+ initialScale = AFRAME.utils.coordinates.parse( selectedElement.getAttribute("scale") ).x
+ }
+}
+
+// from https://aframe.io/aframe/examples/showcase/hand-tracking/pressable.js
+AFRAME.registerComponent('pressable', {
+ schema:{pressDistance:{default:0.06}},
+ init:function(){this.worldPosition=new THREE.Vector3();this.handEls=document.querySelectorAll('[hand-tracking-controls]');this.pressed=false;},
+ tick:function(){var handEls=this.handEls;var handEl;var distance;for(var i=0;i {
+ // cpnsider getObjectByName() instead
+ p.object3D.traverse( e => { if (e.name == 'index-finger-tip' ) tip = e.position })
+ //index-finger-phalanx-distal
+ //index-finger-phalanx-intermediate
+ p.object3D.traverse( e => { if (e.name == 'index-finger-phalanx-proximal' ) proximal = e.position })
+ p.object3D.traverse( e => { if (e.name == 'thumb-tip' ) thumb = e.position })
+ let touch = {}
+ touch.date = Date.now()
+ touch.thumb2tip = thumb.distanceTo(tip)
+ if (!touch.thumb2tip) return
+ touch.thumb2proximal = thumb.distanceTo(proximal)
+ //console.log( touch.thumb2tip, touch.thumb2proximal )
+ // usually <1cm <4cm (!)
+ //if ((touch.thumb2tip && touch.thumb2tip < threshold_thumb2tip)
+ //|| (touch.thumb2proximal && touch.thumb2proximal < threshold_thumb2proximal))
+ if (touch.thumb2tip < threshold_thumb2tip
+ || touch.thumb2proximal < threshold_thumb2proximal){
+ if (touches.length){
+ let previous = touches[touches.length-1]
+ if (touch.date - previous.date < 300){
+ if (touch.thumb2tip < threshold_thumb2tip &&
+ previous.thumb2proximal < threshold_thumb2proximal){
+ console.log('^')
+ p.emit('thumb2indexpull')
+ }
+ if (touch.thumb2proximal < threshold_thumb2proximal &&
+ previous.thumb2tip < threshold_thumb2tip){
+ console.log('v')
+ p.emit('thumb2indexpush')
+ }
+ }
+ }
+ touches.push(touch)
+ }
+ }, 50)
+ // TODO
+ // Bind thumb2indexpush/thumb2indexpull to zoom in/out "world" i.e all assets that aren't "special" e.g self, lights, UI
+}
+
+let changeovercheck
+AFRAME.registerComponent('changeover', {
+ schema: { color : {type: 'string'} },
+ init: function () {
+ // (this.el, this.data.content)
+ if (changeovercheck) return
+ let player = document.getElementById('player') // assuming single player, non networked
+ console.log('adding timer')
+ changeovercheck = setInterval( _ => {
+ let pos = player.getAttribute('position').clone()
+ pos.y = 0.1 // hard coded but should be from component element
+ let hits = Array.from(document.querySelectorAll('[changeover]'))
+ .filter( e => e.getAttribute("visible") == true)
+ .map( t => { return { el: t, dist : pos.distanceTo(t.getAttribute("position") ) } })
+ .filter( t => t.dist < 0.02 )
+ .sort( (a,b) => a.dist > b.dist)
+ //console.log(hits.length)
+ if (hits.length>0) {
+ setFeedbackHUD('touching cone')
+ console.log('touching cone')
+ hits[hits.length-1].el.setAttribute('color', 'red')
+ }
+ }, 50)
+ }
+})
+
+// to add only on selectable elements, thus already with a target component attached
+AFRAME.registerComponent('pull', {
+ events: {
+ picked: function (evt) {
+ this.startePos = this.el.getAttribute('position').clone()
+ this.starteRot = this.el.getAttribute('rotation')//.clone() not necessary as converted first
+ this.decimtersEl = document.createElement('a-troika-text')
+ AFRAME.scenes[0].appendChild(this.decimtersEl)
+ },
+ moved: function (evt) {
+ let pos = AFRAME.utils.coordinates.stringify( this.startePos )
+ let oldpos = AFRAME.utils.coordinates.stringify( this.el.getAttribute('position') )
+ AFRAME.scenes[0].setAttribute("line__pull", `start: ${oldpos}; end : ${pos};`)
+ let d = this.startePos.distanceTo( this.el.getAttribute('position') )
+ // could show a preview state before release, e.g
+ let decimeters = Math.round(d*10)
+ console.log('pulling '+decimeters+' pages')
+ // update visible value instead, ideally under line but still facing user
+ let textPos = new THREE.Vector3()
+ textPos.lerpVectors(this.startePos, this.el.getAttribute('position'), .7)
+ this.decimtersEl.setAttribute('position', textPos )
+ this.decimtersEl.setAttribute('rotation', this.el.getAttribute('rotation') )
+ this.decimtersEl.setAttribute('value', decimeters )
+ },
+ released: function (evt) {
+ let d = this.startePos.distanceTo( this.el.getAttribute('position') )
+ console.log('This entity was released '+ d + 'm away from picked pos')
+ this.el.setAttribute('position', AFRAME.utils.coordinates.stringify( this.startePos ))
+ this.el.setAttribute('rotation', AFRAME.utils.coordinates.stringify( this.starteRot ))
+ AFRAME.scenes[0].removeAttribute("line__pull")
+ this.decimtersEl.remove()
+ },
+ },
+});
+// ==================================== utils on entities and classes ======================================================
+
+function toggleVisibilityEntitiesFromClass(classname){
+ let entities = Array.from( document.querySelectorAll("."+classname) )
+ if (entities.length == 0) return
+ let state = entities[0].getAttribute("visible") // assume they are all the same
+ if (state)
+ entities.map( e => e.setAttribute("visible", "false"))
+ else
+ entities.map( e => e.setAttribute("visible", "true"))
+}
+
+function pushLeftClass(classname, value=.1){
+ Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.x -= value)
+}
+
+function pushRightClass(classname, value=.1){
+ Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.x += value)
+}
+
+function pushUpClass(classname, value=.1){
+ Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.y += value)
+}
+
+function pushDownClass(classname, value=.1){
+// can be used for accessibiliy, either directly or sampling e.g 10s after entering VR to lower based on the estimated user height
+ Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.y -= value)
+}
+
+function pushBackClass(classname, value=.1){
+ Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.z -= value)
+}
+
+function pushFrontClass(classname, value=.1){
+ Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.z += value)
+}
+
+function toggleVisibilityAllGenerators(){
+ generators.split(" ").map( g => toggleVisibilityEntitiesFromClass(g) )
+ // not hidableassets though
+}
+
+function toggleVisibilityAll(){
+ toggleVisibilityAllGenerators()
+ toggleVisibilityEntitiesFromClass("hidableassets")
+}
+
+function toggleVisibilityAllButClass(classname){
+ generators.split(" ").filter( e => e != classname).map( g => toggleVisibilityEntitiesFromClass(g) )
+ toggleVisibilityEntitiesFromClass("hidableassets")
+}
+
+function switchSide(){
+ // mostly works... but event listeners are not properly removed. Quickly creates a mess, low performance and unpredictable.
+ document.querySelector("#"+sides[primarySide]+"Hand").removeAttribute("pinchprimary")
+ document.querySelector("#"+sides[secondarySide]+"Hand").removeAttribute("pinchsecondary")
+ document.querySelector("#"+sides[secondarySide]+"Hand").removeAttribute("wristattachsecondary")
+ document.querySelector("#"+sides[secondarySide]+"Hand").setAttribute("pinchprimary", "")
+ document.querySelector("#"+sides[primarySide]+"Hand").setAttribute("pinchsecondary", "")
+ document.querySelector("#"+sides[primarySide]+"Hand").setAttribute("wristattachsecondary", "target: #box")
+ if (primarySide == 0) {
+ secondarySide = 0
+ primarySide = 1
+ } else {
+ primarySide = 0
+ secondarySide = 1
+ }
+}
+
+function getIdFromPick(){
+ let id = null
+ let pp = selectedElements.filter( e => e.primary )
+ if (pp && pp[pp.length-1] && pp[pp.length-1].element ){
+ if (!pp[pp.length-1].element.id) pp[pp.length-1].element.id= "missingid_"+Date.now()
+ id = pp[pp.length-1].element.id
+ setFeedbackHUD(id)
+ }
+ return id
+}
+
+function getClassFromPick(){ // should be classes, for now assuming one
+ let classFound = null
+ let pp = selectedElements.filter( e => e.primary )
+ if (pp && pp[pp.length-1] && pp[pp.length-1].element ){
+ //if (!pp[pp.length-1].element.className) pp[pp.length-1].element.className= "missingclass"
+ // arguable
+ classFound = pp[pp.length-1].element.className
+ setFeedbackHUD(classFound)
+ }
+ return classFound
+}
+
+function getArrayFromClass(classname){
+ return Array.from( document.querySelectorAll("."+classname) )
+}
+
+function applyToClass(classname, callback, value){
+// example applyToClass("template_object", (e, val ) => e.setAttribute("scale", val), ".1 .1 .2")
+ getArrayFromClass(classname).map( e => callback(e, value))
+// could instead become a jxr shortcut, namely apply a set attribute to a class of entities
+}
+
+function addDropZone(position="0 1.4 -0.6", callback=setFeedbackHUD, radius=0.11){
+// consider how this behavior could be similar to the wrist watch shortcut
+// namely binding it to a jxr function
+ let el = document.createElement("a-sphere")
+ el.setAttribute("wireframe", true)
+ el.setAttribute("radius", radius)
+ el.setAttribute("position", position)
+ el.id = "dropzone_"+Date.now()
+ AFRAME.scenes[0].appendChild( el )
+ let sphere = new THREE.Sphere( AFRAME.utils.coordinates.parse( position ), radius )
+ // could become movable but would then need to move the matching sphere too
+ // could be a child of that entity
+ let pincher = document.querySelector('[pinchprimary]')
+ pincher.addEventListener('pinchended', function (event) {
+ if (selectedElements.length){
+ let lastDrop = selectedElements[selectedElements.length-1]
+ if ((Date.now() - lastDrop.timestamp) < 1000){
+ if (sphere.containsPoint( lastDrop.element.getAttribute("position"))){
+ // should be a threejs sphere proper, not a mesh
+ console.log("called back" )
+ callback( lastDrop.selectedElement )
+ }
+ }
+ }
+ })
+ // never unregister
+ return el
+}
+
+// ==================================== facilitating debugging ======================================================
+
+function makeAnchorsVisibleOnTargets(){
+ targets.map( t => {
+ let controlSphere = document.createElement("a-sphere")
+ controlSphere.setAttribute("radius", 0.05)
+ controlSphere.setAttribute("color", "blue")
+ controlSphere.setAttribute("wireframe", "true")
+ controlSphere.setAttribute("segments-width", 8)
+ controlSphere.setAttribute("segments-height", 8)
+ t.appendChild( controlSphere )
+ }) // could provide a proxy to be able to monitor efficiently
+}
+
+function switchToWireframe(){
+ let model = document.querySelector("#environment")?.object3D
+ if (model) model.traverse( o => { if (o.material) {
+ let visible = !o.material.wireframe
+ o.material.wireframe = visible;
+ o.material.opacity = visible ? 0.05 : 1;
+ o.material.transparent = visible;
+ } })
+}
diff --git a/jxr-extras.js b/jxr-extras.js
new file mode 100644
index 0000000..a1e82df
--- /dev/null
+++ b/jxr-extras.js
@@ -0,0 +1,3 @@
+console.log('jxr extras to gradually add, by default could refer to branches instead then add as components proper')
+console.log('arguably utils and additional interactions beyond pinche could be extras rather than core')
+console.log('extra could also be empty...')