core-vs-extras
Fabien Benetou 9 months ago
parent cf71940aa1
commit a3042794db
  1. 206
      index.html
  2. 841
      jxr-core.js
  3. 3
      jxr-extras.js

@ -3,95 +3,16 @@
<title>SpaSca : Spatial Scaffolding</title> <title>SpaSca : Spatial Scaffolding</title>
<head> <head>
<!-- Suggestions? https://git.benetou.fr/utopiah/text-code-xr-engine/issues/ --> <!-- Suggestions? https://git.benetou.fr/utopiah/text-code-xr-engine/issues/ -->
<!--<script src='dependencies/aframe.min.js'></script>-->
<script src='dependencies/aframe.offline.min.js'></script> <script src='dependencies/aframe.offline.min.js'></script>
<script src="dependencies/a-console.js"></script> <script src="dependencies/a-console.js"></script>
<script src='dependencies/aframe-html.js'></script>
<script src='dependencies/aframe-mirror.js'></script>
<script src='dependencies/aframe-troika-text.min.js'></script> <script src='dependencies/aframe-troika-text.min.js'></script>
<!--<script type="module" id=immersbundle src='dependencies/immers-client.js?save=true'></script>-->
<!--<script type="module" id=immersbundle src="https://cdn.jsdelivr.net/npm/immers-client/dist/destination.bundle.js?role=modFull"></script>-->
<!--<script src="https://threejs.org/examples/js/exporters/GLTFExporter.js"></script>-->
<script src="https://cdn.jsdelivr.net/gh/c-frame/aframe-extras@7.1.0/dist/aframe-extras.min.js"></script>
<script src="dependencies/shiki0.14.1.js"></script>
<!-- for input sharing -->
<script src='dependencies/peerjs.min.js'></script>
<!-- for content sharing, using NAF
<script src='dependencies/socket.io.slim.js'></script>
<script src="https://naf.benetou.fr/easyrtc/easyrtc.js"></script>
<script src='dependencies/networked-aframe.min.js'></script>
-->
<script src="dependencies/aframe-physics-system.min.js"></script>
<!-- still experimenting, see webdav.html -->
<script src='dependencies/webdav.js'></script> <script src='dependencies/webdav.js'></script>
<script src='jxr.js?123456'></script> <script src='jxr-core.js?132345'></script>
<script src='jxr-extras.js?123456'></script>
<script src="https://unpkg.com/zotero-api-client"></script>
<!-- replacing with local copies as CDNs are like unpkg tend to be slow
<script type="module" src="https://unpkg.com/immers-client/dist/destination.bundle.js"></script>
<script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
<script src="https://unpkg.com/aframe-troika-text/dist/aframe-troika-text.min.js"></script>
<script src="aframe-html.js"></script>
<script src="https://unpkg.com/peerjs@1.4.5/dist/peerjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.4.0/socket.io.slim.js"></script>
<script src="https://naf.benetou.fr/easyrtc/easyrtc.js"></script>
<script src="https://unpkg.com/networked-aframe@^0.10.0/dist/networked-aframe.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/aframe-mirror@latest/index.js"></script>
-->
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
</head> </head>
<body> <body>
<script> <script>
const zoteroReadOnlyKey = 'E3mBRqn1Z0ngndUerrTFnzUA'
// my user, i.e utopiah, for demo purposes only
const zoteroUsedID = 13985178
// from https://www.zotero.org/settings/keys
const { default: api } = ZoteroApiClient;
const myapi = api(zoteroReadOnlyKey).library('user', zoteroUsedID)
//itemsResponse = await myapi.items().get();
//collectionsResponse = await myapi.collections().get();
function loadFile(element){
const file = element.files[0]
const reader = new FileReader();
reader.addEventListener( "load", () => {
writecsljsonback(reader.result)
}, false,);
if (file) {
reader.readAsText(file);
}
}
const libraryURL = 'https://webdav.benetou.fr/fotsave/ExportedItems-FromZoteroAsCSLJSON.json'
AFRAME.registerComponent('getcsljson', {
schema: {
url: {type: 'string', default: libraryURL },
},
init: function(){
let generatorName = this.attrName
fetch(this.data.url).then(res => res.json() ).then(res => { notesFromArray(res, generatorName, "title", 2, -1/10) })
//fetch(this.data.url).then(res => res.json() ).then(res => { notesFromArray(res, generatorName) })
// could use citeproc instead
// see also https://citation.js.org
}
})
function notesFromArray(data, generatorName="", field="title", offset=1, step=1/10, depth=-.5 ){ function notesFromArray(data, generatorName="", field="title", offset=1, step=1/10, depth=-.5 ){
data.slice(0,maxItemsFromSources).map( (n,i) => { data.slice(0,maxItemsFromSources).map( (n,i) => {
@ -100,40 +21,6 @@ function notesFromArray(data, generatorName="", field="title", offset=1, step=1/
}) })
} }
function getDataToSaveBack(){
let dataToSave = []
let unsorted = []
fetch(libraryURL).then(res => res.json() ).then(res => {
// assume unique title always present
getArrayFromClass('getcsljson').map(i=>{
unsorted.push( {data: res.filter(citation=>citation.title==i.getAttribute('value'))[0],
position: i.getAttribute('position')
})
})
dataToSave = unsorted.sort((a,b)=>b.position.y-a.position.y)
.map(i=> { if (!i.data.note) { i.data.note = JSON.stringify(i.position) } else {i.data.note += '\n' + JSON.stringify(i.position) } ; return i.data } )
// this is also where piggy-backing on CSL-JSON could be tested, e.g spatial position (or stringifoied pose) field added
// could consider appending to the .note field instead
// .position does get saved, and does not prevent to be loaded from Zotero, but does get lost after when exported again
// cf https://citeproc-js.readthedocs.io/en/latest/csl-json/markup.html#cheater-syntax-for-odd-fields
writecsljsonback(JSON.stringify(dataToSave))
})
}
// assumption that a step here is 1/10
function bumpItemUp(id, generatorName){ bumpItem(id, generatorName, 1/10*1.1) }
function bumpItemDown(id, generatorName){ bumpItem(id, generatorName, -1/10*1.1) }
function bumpItem(id, generatorName, step){
if (!id || !generatorName) return
// could use annotation symbols with jxr
// sets x and z to 0 though
// requires to exist for each list item
document.getElementById(id).object3D.position.y += step
// assuming here they are already aligned, which is there case if they have onreleased already set with spreadItemsFromCollection()
spreadItemsFromCollection(generatorName, 1.5)
}
function spreadItemsFromCollection( generatorName, offset=1, step=1/10, depth=-.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) => { 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('position', "0 "+(offset+i*step)+" "+depth)
@ -144,112 +31,35 @@ function spreadItemsFromCollection( generatorName, offset=1, step=1/10, depth=-.
shareLiveEvent('modified list', items) shareLiveEvent('modified list', items)
} }
let sessionId = crypto.randomUUID()
const sse = 'https://jxr-backend-collab.glitch.me'
const sseEvents = '/events'
function shareLiveEvent(eventName, eventData, server=sse){
// should make a room based one e.g source URL (even though here with multiple branches on 1 URL it would not work)
// this could be forced via eventData then in turn ignored if the room name does not match
if (!eventName) return
let data = { eventName, sessionId }
if (eventData) data.eventData = eventData
fetch(server+'/newevent/'+JSON.stringify(data))
// using SSE but should use instead NAF broadcasting
}
const source = new EventSource(sse+sseEvents)
source.addEventListener('message', message => {
let json = JSON.parse(message.data)
if (json.eventData) console.log('Got', json.eventData);
// on first connecting getting an array of historical messages then after that an object for each new live event
})
// data could come from parsing back order from getArrayFromClass('getcsljson').map(i=>i.getAttribute('position').y)
// cf https://gist.github.com/Utopiah/26bae9fecc7a921f8bfd38cf5fc91612#file-logo_vr_hubs-js-L44
// yet still needs the actual data itself and adding a comment field for position if to be used back here rather than e.g Zotero
function writecsljsonback(data){
const webdavurl = "https://webdav.benetou.fr";
const client = window.WebDAV.createClient(webdavurl)
async function w(path, data){ return await client.putFileContents(path, data); }
w("/fotsave/ExportedItems-FromZoteroAsCSLJSON.json", data )
setFeedbackHUD( "file saved" )
}
</script> </script>
<input style='position:fixed;z-index:1; top: 0%; left: 20%; display:float'
type="file" name="file-input" accept=".json" id="file-input" onchange="loadFile(this)" />
<div style='position:fixed;z-index:1; top: 0%; left: 0%; border-bottom: 70px solid transparent; border-left: 70px solid #eee; '> <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/"> <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'> <img style='position:fixed;left:10px;' title='code repository' src='gitea_logo.svg'>
</a> </a>
</div> </div>
<button id=mainbutton style="display:none; z-index: 1; position: absolute; width:50%; margin: auto; text-align:center; top:45%; left:30%; height:30%;" onclick="startExperience()">Start the experience (hand tracking recommended)</button> <a-scene startfunctions >
<a-gltf-model hide-on-enter-ar="" id="environment" src="../content/CubeRoom.glb" rotation="0 -90 0" position="0 0 1" scale="" ></a-gltf-model>
<a-scene startfunctions getcsljson> <!-- Cube Room by Anonymous [CC-BY] via Poly Pizza -->
<!-- screenstack dynamic-view selectionboxonpinches glossary timeline issues fot
toolbox commands-from-external-json disable-components-via-url enable-components-via-url
physics="debug:true; friction: 0.01;"
networked-scene="serverURL: https://naf.benetou.fr/; adapter: easyrtc; audio: true;"
refresh-text-content-from-wiki-page="pagename:TestingPairCollaboration"
-->
<a-assets>
<template id="avatar-template"></template>
<template id="left-hand-default-template">
<a-entity networked-hand-controls="hand:left"></a-entity>
</template>
<template id="right-hand-default-template">
<a-entity networked-hand-controls="hand:right"></a-entity>
</template>
</a-assets>
<a-gltf-model hide-on-enter-ar="" src="../content/MinimalisticJapaneseRoom.glb" rotation="0 -90 0" position="2 1 -1" scale="" ></a-gltf-model>
<!-- from https://poly.pizza/m/8cWuXx5BASV -->
<!-- alt https://poly.pizza/m/cA_lcvRC4NA -->
<a-troika-text anchor=left target annotation="content:saves data back to Zotero library over WebDAV backend"
value="jxr getDataToSaveBack()" position=" -0.3 0.60 -.5" rotation="0 0 0" scale="0.1 0.1 0.1"></a-troika-text>
<a-entity id="rig"> <a-entity id="rig">
<a-entity id="player" networked="template:#avatar-template;attachTemplateToLocal:false;" <a-entity id="player" networked="template:#avatar-template;attachTemplateToLocal:false;"
hud camera look-controls wasd-controls waistattach="target: .movebypinch" position="0 1.6 0"> hud camera look-controls wasd-controls position="0 1.6 0">
</a-entity> </a-entity>
<a-entity id="rightHand" pinchprimary hand-tracking-controls="hand: right;"></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-entity id="leftHand" pinchsecondary wristattachsecondary="target: #box" hand-tracking-controls="hand: left;"></a-entity>
</a-entity> </a-entity>
<a-box pressable start-on-press id="box" scale="0.05 0.05 0.05" color="pink"></a-box> <a-box pressable start-on-press id="box" scale="0.05 0.05 0.05" color="pink"></a-box>
<!-- could attach functions here... BUT then they have to be activable with the other hand! -->
<!-- visual reminders of shortcuts, a poster on the far left/right of keyboard shortcuts -->
<a-entity light="type: ambient; color: #BBB; intensity: 0.6"></a-entity> <a-troika-text value="SpaSca : Spatial Scaffolding" anchor="left" outline-width="5%" font="../content/ChakraPetch-Regular.ttf" position="-3 5 -2"
<a-entity light="type: directional; color: #FFF; intensity: 1.4" position="-0.5 1 1"></a-entity> 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-troika-text value="SpaSca : Spatial Scaffolding" anchor="left" outline-width="5%" font="../content/ChakraPetch-Regular.ttf" position="-5.26197 6.54224 -1.81284"
scale="4 4 5" rotation="90 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-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 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 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 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 target id="startdraw2d" annotation="content:dessiner en 2D"
value="jxr startDraw2D()" position="0 1.45 -0.1" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target id="displaypred" value="jxr displayPred()" position="0 1.40 -0.1" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value="jxr tiltUpId('codeditor')" position=" -0.3 1.65 0" rotation="0 90 0" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value="jxr tiltDownId('codeditor')" position=" -0.3 1.60 0" rotation="0 90 0" scale="0.1 0.1 0.1"></a-troika-text>
-->
<!-- somehow disable hand interaction despite, according to the documentation, it should rely on world position
<a-text target value="jxr qs #rig sa position 0 0 10" position="0 1.55 .5" rotation="0 180 0" scale="0.1 0.1 0.1"></a-text>
-->
<a-console position="2 2 0" rotation="0 -45 0" font-size="34" height=1 skip-intro=true></a-console> <a-console position="2 2 0" rotation="0 -45 0" font-size="34" height=1 skip-intro=true></a-console>
<!-- for Wolvic on Lynx support test -->
<a-entity thumbstick-shifting oculus-touch-controls="hand: left"></a-entity>
<a-entity thumbstick-shifting oculus-touch-controls="hand: right"></a-entity>
</a-scene> </a-scene>
</body> </body>
</script> </script>

@ -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
//<a-text target value="jxr observe selectedElement" position="0 1.25 -0.2" scale="0.1 0.1 0.1"></a-text>
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<handEls.length;i++){handEl=handEls[i];distance=this.calculateFingerDistance(handEl.components['hand-tracking-controls'].indexTipPosition);if(distance<this.data.pressDistance){if(!this.pressed){this.el.emit('pressedstarted');} this.pressed=true;return;}} if(this.pressed){this.el.emit('pressedended');} this.pressed=false;},
calculateFingerDistance:function(fingerPosition){var el=this.el;var worldPosition=this.worldPosition;worldPosition.copy(el.object3D.position);el.object3D.parent.updateMatrixWorld();el.object3D.parent.localToWorld(worldPosition);return worldPosition.distanceTo(fingerPosition);}
});
AFRAME.registerComponent('start-on-press', {
// should become a property of the component instead to be more flexible.
init: function(){
let el = this.el
this.el.addEventListener('pressedended', function (event) {
console.log(event)
// should ignore that if we entered XR recently
if (!primaryPinchStarted && wristShortcut.match(prefix)) interpretJXR(wristShortcut)
// seems to happen also when entering VR
// other action could possibly based on position relative to zones instead, i.e a list of bbox/functions pairs
})
}
})
function thumbToIndexPull(){
let p = document.querySelector('[pinchprimary]')
let tip = new THREE.Vector3(); // create once an reuse it
let proximal = new THREE.Vector3(); // create once an reuse it
let thumb = new THREE.Vector3(); // create once an reuse it
let touches = []
const threshold_thumb2tip = 0.01
const threshold_thumb2proximal = 0.05
let indexesTipTracking = setInterval( _ => {
// 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;
} })
}

@ -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...')
Loading…
Cancel
Save