SpaSca : open SCAffolding to SPAcially and textualy explore interfaces
https://fabien.benetou.fr/pub/home/future_of_text_demo/engine/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
948 lines
36 KiB
948 lines
36 KiB
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")
|
|
}
|
|
})
|
|
|
|
// note that set is an array of elements from e.g getArrayFromClass(classname)
|
|
// it is NOT a selector!
|
|
function getClosestElements( pos, threshold=0.05, set ){
|
|
// assumes pos has now no offset
|
|
// TODO Bbox intersects rather than position
|
|
return set.filter( e => e.getAttribute("visible") == true)
|
|
.map( t => {
|
|
let posTarget = new THREE.Vector3()
|
|
t.object3D.getWorldPosition( posTarget )
|
|
let d = pos.distanceTo( posTarget )
|
|
return { el: t, dist : d }
|
|
})
|
|
.filter( t => t.dist < threshold && t.dist > 0 )
|
|
.sort( (a,b) => a.dist > b.dist)
|
|
}
|
|
|
|
function getClosestElement( pos, threshold=0.05, set ){ // 10x lower threshold for flight mode
|
|
var res = null
|
|
// assumes both hands have the same (single) parent, if any
|
|
let parentPos = document.getElementById('rig').getAttribute('position')
|
|
pos.add( parentPos )
|
|
const matches = getClosestElements( pos, threshold, set)
|
|
if (matches.length > 0) res = matches[0].el
|
|
return res
|
|
}
|
|
|
|
function getClosestTargetElement( pos, threshold=0.05 ){ // 10x lower threshold for flight mode
|
|
return getClosestElement( pos, threshold, targets)
|
|
}
|
|
|
|
// ==================================== 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})
|
|
// could push on pinchstarted instead
|
|
// 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 )
|
|
// if (selectedElement) selectedElement.emit('released', {element:selectedElement, timestamp:Date.now(), primary:false})
|
|
// would be coherent BUT so far NOT testing on e.detail.primary thus probably getting unexpected behaviors
|
|
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
|
|
let pos = event.detail.position
|
|
let parentPos = document.getElementById('rig').getAttribute('position')
|
|
pos.add( parentPos )
|
|
//var closests = getClosestTargetElements( pos )
|
|
//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")
|
|
selectedElement.emit('released', {element:selectedElement, timestamp:Date.now(), primary:true})
|
|
} else {
|
|
AFRAME.scenes[0].emit('emptypinchreleased', {position:event.detail.position, timestamp:Date.now() })
|
|
}
|
|
// 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) {
|
|
let pos = event.detail.position
|
|
let parentPos = document.getElementById('rig').getAttribute('position')
|
|
pos.add( parentPos )
|
|
pos.sub( selectedElements.at(-1).startingPosition )
|
|
selectedElement.setAttribute("position", pos )
|
|
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")
|
|
} else {
|
|
AFRAME.scenes[0].emit('emptypinchmoved', {position:event.detail.position, timestamp:Date.now() })
|
|
}
|
|
});
|
|
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) {
|
|
let startingPosition = new THREE.Vector3()
|
|
selectedElement.parentEl.object3D.getWorldPosition( startingPosition )
|
|
selectedElements.push({element:selectedElement, timestamp:Date.now(), startingPosition: startingPosition, primary:true})
|
|
selectedElement.emit("picked")
|
|
} else {
|
|
AFRAME.scenes[0].emit('emptypinch', {position:event.detail.position, timestamp:Date.now() })
|
|
}
|
|
// 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
|
|
// could support multi
|
|
events: {
|
|
released: function (e) {
|
|
let code = this.el.getAttribute('onreleased')
|
|
// 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}`);
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
AFRAME.registerComponent('onpicked', {
|
|
// could support multi
|
|
events: {
|
|
picked: function (e) {
|
|
let code = this.el.getAttribute('onpicked')
|
|
// 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 onNextPrimaryPinch(callback){
|
|
// could add an optional filter, e.g only on specific ID or class
|
|
// e.g function onNextPrimaryPinch(callback, filteringSelector){}
|
|
let lastPrimary = selectedElements.filter( e => e.primary ).length
|
|
let checkForNewPinches = setInterval( _ => {
|
|
if (selectedElements.filter( e => e.primary ).length > lastPrimary){
|
|
let latest = selectedElements[selectedElements.length-1].element
|
|
if (latest) callback(latest)
|
|
clearInterval(checkForNewPinches)
|
|
}
|
|
}, 50) // relatively cheap check, filtering on small array
|
|
}
|
|
|
|
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, sourceElement ){
|
|
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 )
|
|
if (sourceElement){
|
|
let consoleEl = sourceElement.querySelector("a-console")
|
|
if (consoleEl === null){
|
|
consoleEl = document.createElement("a-console")
|
|
consoleEl.setAttribute("position","1 -.4 0")
|
|
consoleEl.setAttribute("font-size","30")
|
|
consoleEl.setAttribute("height",".4")
|
|
consoleEl.setAttribute("width","2")
|
|
consoleEl.setAttribute("skip-intro",true)
|
|
sourceElement.appendChild(consoleEl)
|
|
}
|
|
consoleEl.setAttribute("visible", "true")
|
|
setTimeout( _ => consoleEl.setAttribute("visible", "false") , 10000)
|
|
}
|
|
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();
|
|
this.skip = false
|
|
if (! this.data.target ) this.skip = true
|
|
},
|
|
tick: function () {
|
|
if (this.skip) return
|
|
|
|
// 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
|
|
// modified to support teleportation via #rig
|
|
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>0 && distance<this.data.pressDistance){
|
|
if(!this.pressed){this.el.emit('pressedstarted');}
|
|
this.pressed=true;return;}
|
|
}
|
|
if(this.pressed){this.el.emit('pressedended');} // somehow happens on click, outside of VR
|
|
this.pressed=false;
|
|
},
|
|
calculateFingerDistance:function(fingerPosition){
|
|
let parentPos = document.getElementById('rig').getAttribute('position')
|
|
fingerPosition.add( parentPos )
|
|
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;
|
|
} })
|
|
}
|
|
|