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.
 
 
 

2307 lines
105 KiB

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>JXR filesystem and mimetype based explorations</title>
<script src="https://fabien.benetou.fr/pub/home/future_of_text_demo/engine/dependencies/webdav.js"></script>
<script src="https://aframe.io/releases/1.6.0/aframe.min.js"></script>
<!-- <script src="https://unpkg.com/aframe-troika-text/dist/aframe-troika-text.min.js"></script> -->
<script src="https://cdn.jsdelivr.net/npm/aframe-troika-text/dist/aframe-troika-text.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/kylebakerio/a-console@1.0.2/a-console.js"></script>
<script>
let sequentialFiltersInteractionOnPicked = []
let sequentialFiltersInteractionOnReleased = []
</script>
<script src="interactions/onreleased/color_change.js"></script>
<script src="https://fabien.benetou.fr/pub/home/future_of_text_demo/engine/jxr-core_branch_teleport_alt_rot.js?version=cachebusing123455"></script>
<!-- modified to include fixed onreleased/ondpicked, should truly be merged on master -->
<script src="https://fabien.benetou.fr/pub/home/future_of_text_demo/engine/jxr-postitnote.js"></script>
<!-- use to define targets and left/right pinch interactions, respectively execute code and move targets -->
<script src="gesture-exploration.js"></script>
<script>
let sequentialFilters = []
</script>
<script src="filters/content_filter_examples.js"></script>
<script src="filters/screenshot_ui.js"></script>
<script src="filters/another_content_filter_example.js"></script>
<script src="filters/modifications_via_url.js"></script>
<script src="filters/srt_to_json.js"></script>
<script src="filters/txt.js"></script>
<script src="filters/json_ref_manual.js"></script>
<!-- order matters -->
</head>
<body>
<div style="position:fixed;z-index:1; top: 0%; left: 0%; border-bottom: 70px solid transparent; border-left: 70px solid #eee;">
<a href="https://git.benetou.fr/utopiah/text-code-xr-engine/issues/">
<img style="position:fixed;left:10px;" title="code repository"
src="https://fabien.benetou.fr/pub/home/future_of_text_demo/engine/gitea_logo.svg">
</a>
</div>
<!-- <input type="file" id="myFile" name="filename" onchange="updateImage(this)" style="z-index: 1000;position: absolute;"/> -->
<script>
const modifierName = "Shift"
const modifierColor = new THREE.Color( 'green' )
const modifierColorRevert = new THREE.Color( 'white' )
addEventListener("keydown", (event) => {
document.getElementById("typinghud").setAttribute("material","opacity", .5)
// get reset after a short while
if (event.key == modifierName && AFRAME.scenes[0].object3D.getObjectByName("r_handMeshNode") )
AFRAME.scenes[0].object3D.getObjectByName("r_handMeshNode").material.color = modifierColor
})
addEventListener("keyup", (event) => {
if (event.key == modifierName && AFRAME.scenes[0].object3D.getObjectByName("r_handMeshNode"))
AFRAME.scenes[0].object3D.getObjectByName("r_handMeshNode").material.color = modifierColorRevert
})
/* does not seem to work
window.addEventListener('popstate', function (event){
// executed when user enters new urlquery in browserbar
console.log('popstate, should updated urlParams then parametersViaURL(urlParams)')
// const urlParams = new URLSearchParams(window.location.search);
})
*/
// ---- file upload via WebDAV, notification via ntfy
let usernamePrefix = ""
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('username');
const sourceFromNextDemo = urlParams.get('sourceFromNextDemo');
const allowNtfyFeedbackHUD = urlParams.get('allowNtfyFeedbackHUD');
if (username) {
usernamePrefix = username+"_"
setTimeout( _ => AFRAME.scenes[0].setAttribute("current-demo-metadata", ''), 1000 )
}
// https://github.com/binwiederhier/ntfy/blob/main/examples/web-example-eventsource/example-sse.html#L24
const webdavURL = "https://webdav.benetou.fr";
//const subdirWebDAV = "/fotsave/" // could use /fot_sloan_companion_public/ instead
const subdirWebDAV = "/fotsave/fot_sloan_companion_public/"
var webdavClient = window.WebDAV.createClient(webdavURL)
function dropHandler(ev) {
console.log("File(s) dropped");
// Prevent default behavior (Prevent file from being opened)
ev.preventDefault();
if (ev.dataTransfer.items) {
// Use DataTransferItemList interface to access the file(s)
[...ev.dataTransfer.items].forEach((item, i) => {
// TODO seems to only work for 1 file, not multiple files
// If dropped items aren't files, reject them
if (item.kind === "file") {
const file = item.getAsFile();
console.log(`… file[${i}].name = ${file.name}`);
if (file.name == "index.html") {
console.warn('can not rewrite over index.html')
return
}
const reader = new FileReader();
reader.onload = (evt) => {
fileContent = evt.target.result
async function w(path = "/file.txt"){ return await webdavClient.putFileContents(path, fileContent); }
written = w(subdirWebDAV+usernamePrefix+file.name)
if (written){
fetch('https://ntfy.benetou.fr/fileuploadtowebdav', { method: 'POST', body: 'added '+usernamePrefix+file.name })
}
};
reader.readAsArrayBuffer(file);
}
});
} else {
// Use DataTransfer interface to access the file(s)
[...ev.dataTransfer.files].forEach((file, i) => {
console.log(`… file[${i}].name = ${file.name}`);
});
}
}
function dragOverHandler(ev) {
console.log("File(s) in drop zone");
// Prevent default behavior (Prevent file from being opened)
ev.preventDefault();
}
// ------------------------------------------------------------------------------------------------
let currentFilter = null
function applyNextFilter( filename ){
if ( currentFilter == null ) currentFilter = -1
currentFilter++
if ( sequentialFilters[currentFilter] ){
sequentialFilters[ currentFilter ]( filename )
} else {
console.log( "done filtering for", filename )
currentFilter = null
}
}
if (allowNtfyFeedbackHUD){
const eventSourceConverted = new EventSource( `https://ntfy.benetou.fr/feedbackhud/sse` )
eventSourceConverted.onmessage = (e) => {
console.log('converted', event)
setFeedbackHUD(JSON.parse(event.data).message)
// inView(targetSelector)
// could also parse and share if not relying on inView to help with each step
}
}
// use for content_filter_examples.js
const eventSourceFSWatch = new EventSource( `https://ntfy.benetou.fr/fswatch/sse` )
// eventSourceFSWatch.onmessage = (e) => { console.log('fswatch', event) }
// not particularly useful for now
// both events looks very similar so should be refactored and simplified
const eventSourceConverted = new EventSource( `https://ntfy.benetou.fr/convertedwebdav/sse` )
eventSourceConverted.onmessage = (e) => {
console.log('converted', event)
if (!event.data) return
let data = JSON.parse( event.data )
if (!data) return
let message = JSON.parse( data.message )
console.log('checking via /fileswithmetadata again', message)
fetch('/fileswithmetadata').then( r => r.json() ).then( r => {
r.map( f => filesWithMetadata[f.name] = f.metadata )
let matchingFiles = r.filter( f => f.name.startsWith(message.source) )
.filter( f => f.name.endsWith(message.extension) )
.sort( (a,b) => a.metadata.mtimeMs > b.metadata.mtimeMs )
showFile( matchingFiles[0].name) // could replace number by 0 for PDF
})
}
function toggleShowFile( filename ){
let idFromFilename = filename.replaceAll('.','') // has to remove from proper CSS ID
let el = document.getElementById( idFromFilename )
if (el) {
let vis = el.getAttribute("visible")
if (vis == true)
el.setAttribute("visible", false)
else
el.setAttribute("visible", true)
} else {
showFile( filename )
}
}
function showFile( filename ){
console.log('showFile', filename)
fetch( filename ).then( r => {
let idFromFilename = filename.replaceAll('.','') // has to remove from proper CSS ID
if (!filesWithMetadata[filename] ) filesWithMetadata[filename] = {}
filesWithMetadata[filename].contentType = r.headers.get('Content-Type')
filesWithMetadata[filename].idFromFilename = idFromFilename
console.log( 'ct metadata', filename, filesWithMetadata[filename].contentType )
applyNextFilter( filename )
// should emit an event when done, for now just console.log which isn't programmatic
})
}
const eventSource = new EventSource( `https://ntfy.benetou.fr/fileuploadtowebdav/sse` )
eventSource.onmessage = (e) => {
console.log(event)
if (!event.data) return
let data = JSON.parse( event.data )
if (!data) return
let filename = data.message.replace("added ","")
// should actual trigger when server side writting is done, not before otherwise the file is not yet available
// could also wait for conversion
if (!filename) return
if (usernamePrefix && !filename.startsWith(usernamePrefix)) return
setTimeout( _ => {
fetch( filename ).then( r => {
addIcon( filename, 0, document.getElementById("virtualdesktopplane") )
let idFromFilename = filename.replaceAll('.','') // has to remove from proper CSS ID
if (!r.ok){}
if (!filesWithMetadata[filename] ) filesWithMetadata[filename] = {}
filesWithMetadata[filename].contentType = r.headers.get('Content-Type')
filesWithMetadata[filename].idFromFilename = idFromFilename
console.log( 'ct metadata', filename, filesWithMetadata[filename].contentType )
applyNextFilter( filename )
})
}, 500) // random delay... fine for small files
}
let reader = new FileReader()
function updateImage(el){
console.log(el)
reader.readAsDataURL( document.getElementById("myFile").files[0] )
reader.addEventListener("load", event => {
let el = document.createElement("a-image")
AFRAME.scenes[0].appendChild(el)
el.setAttribute("position", "0 "+(Math.random()+1)+" -0.5" )
el.setAttribute("scale", ".1 .1 .1")
el.setAttribute("src", reader.result)
el.setAttribute("target", "")
el.id = "screenshot_"+Date.now()
})
}
</script>
<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>
<script>
/* filtering on files based on all found metadata (200 on .visualmeta.json)
based on occurences of keywords (or concepts)
most listed (dynamic, e.g most used keywords, sorted, take top 10)
*/
function loadBook(){
fetch('book_chapters.json').then( r => r.json() ).then( r => {
test_filteringFromVisualMeta(r)
})
}
// could also rely on https://observablehq.com/@spencermountain/topics-named-entity-recognition rather than delegate keyword/concept generation via API
keywordsCount = {}
AFRAME.registerComponent('gridplace', {
init: function () {
// should hide on entering AR
const size = 1;
const divisions = 10;
const gridHelper = new THREE.GridHelper( size, divisions, 0xaaaaaa, 0xaaaaaa );
this.el.object3D.add( gridHelper );
let innerRing = [ {x:0,z:1}, {x:1,z:0}, {x:0,z:-1}, {x:-1,z:0} ].map( offset => {
const gridHelper = new THREE.GridHelper( size, divisions, 0xcccccc, 0xcccccc );
gridHelper.position.x += offset.x
gridHelper.position.z += offset.z
this.el.object3D.add( gridHelper );
})
let midRing = [ {x:-1,z:1}, {x:1,z:1}, {x:1,z:-1}, {x:-1,z:-1},
{x:0,z:2}, {x:2,z:0}, {x:0,z:-2}, {x:-2,z:0} ].map( offset => {
const gridHelper = new THREE.GridHelper( size, divisions, 0xdddddd, 0xdddddd );
gridHelper.position.x += offset.x
gridHelper.position.z += offset.z
this.el.object3D.add( gridHelper );
})
}
})
AFRAME.registerComponent('canonical-view', {
init: function () {
// try to load default.layout.json and if it exists, use it, as canonical view
let view = 'default.layout.json' // could be a component parameter
fetch(view).then( r => r.text() ).then( r => applyNextFilter(view) )
}
})
let targetLocations = []
// could have different resulting actions
// saveToCompanion() with emailing after
function makeTargetLocationsVisible(){
// visualize targetLocations, should only do it once though
targetLocations.map( tl => {
let el = addNewNote( tl.position, tl.position )
let elPlate = document.createElement("a-box")
elPlate.setAttribute("position", "0.5 -0.1 0.2" )
elPlate.setAttribute("width", "1")
elPlate.setAttribute("height", ".01")
elPlate.setAttribute("depth", "1")
//elPlate.setAttribute("wireframe", "true")
setTimeout( _ => el.appendChild( elPlate ), 100 )
})
}
function fileDropped(){
targetLocations.map( tl => {
let pos = new THREE.Vector3()
let el = selectedElements.at(-1).element
el.object3D.getWorldPosition( pos )
let dist = pos.distanceTo( AFRAME.utils.coordinates.parse( tl.position ) )
// does not seem accurate, maybe due to the moving element to have parent
// should get world coordinate instead
console.log( dist )
// simplistic, should instead be using e.g. https://threejs.org/docs/#api/en/math/Box3.containsPoint
// allowing for vertical and horizontal trays of different sizes
if (dist < tl.distance) {
el.setAttribute("color", tl.color)
// testing sending to remarkable pro
let filename = selectedElements.at(-1).element.filename
fetch('/send-remarkablepro/'+filename) // should be configurable too, callback
console.log(tl.description, filename)
}
})
}
var filesWithMetadata = {}
AFRAME.registerComponent('list-files-sorted', {
init: function () {
fetch('/fileswithmetadata').then( r => r.json() ).then( r => {
// icon mode
let rootEl = document.getElementById("virtualdesktopplane")
r.map( f => filesWithMetadata[f.name] = f.metadata )
r.sort( (a,b) => a.metadata.mtimeMs < b.metadata.mtimeMs )
//.filter( f => ( f.name.endsWith('.png') || f.name.endsWith('.jpg') || f.name.endsWith('.glb') || f.name.endsWith('.gltf') ))
// inclusive filter
.filter( f => ( !f.name.endsWith('.html') && !f.name.endsWith('.js') ) )
// exclusive filter
.filter( f => f.name.startsWith(usernamePrefix) )
.filter( f => f.metadata.mtimeMs > Date.now()-(60*60*1000) ) // added during the last hour
.map( (text,i) => {
let el = addIcon( text.name, i, rootEl)
})
})
}
})
AFRAME.registerComponent('list-files', {
init: function () {
fetch('/files').then( r => r.json() ).then( r => {
// addNewNoteAsPostItNote( 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" ){
// text only mode
// r.map( (text,i) => addNewNote( text, '-0.4 '+(1.1+i/10)+' -0.4') )
// icon mode
let rootEl = document.getElementById("virtualdesktopplane")
r.map( (text,i) => {
let el = addIcon( text, i, rootEl)
})
})
}
})
let lastExecuted = {}
function newContentWithRefractoryPeriod(content){
if ( Date.now() - lastExecuted['newContentWithRefractoryPeriod'] < 500 ){
console.warn('ignoring, executed during the last 500ms already')
return
}
lastExecuted['newContentWithRefractoryPeriod'] = Date.now()
// decorator equivalent
applyNextFilter(content)
}
function addIcon(text, i, rootEl){
let idFromFilename = text.replaceAll('.','') // has to remove from proper CSS ID
const icon_prefix = "icon_"
if ( document.getElementById(icon_prefix+idFromFilename) ) return // avoid duplicates (assume either single directory or fullpath)
if (text.match(/.*\.pdf-(\d+)\.jpg/)) return // could consider more
if (text.match(/swp$|swx$|tmp$|~$|#$|#$/) ) return // cf newContent filtering with .swp and more
let el = document.createElement("a-box")
let x = Math.round(i/10)/10-(4/2)/10
let z = (-i%10)/10+(2/2)/10
el.id = icon_prefix+idFromFilename
el.filename = text
el.setAttribute("position", x+ " " + (1+z) + " 0 ") // vertical layout, leading to broken snapping
// el.setAttribute("position", x+ " 0 "+z) // horizontal layout
el.setAttribute("target", "")
el.setAttribute("value", "jxr applyNextFilter('"+text+"')") // hidden text here, first time, not convinced it's a good idea, not respecting the prinple
//el.setAttribute("value", "jxr newContent('"+text+"')") // hidden text here, first time, not convinced it's a good idea, not respecting the prinple
// problematic as it can execute multiple times, leading to overlapping yet hidden viewers or content
// consider lastExecuted['viewerFullscreen'] equivalent yet without blocking when done programmatically, e.g. when used on load or layout
// on picked, could resize the bounding box height to .05, onreleased back to .005
el.setAttribute("onpicked", "selectedElements.at(-1).element.setAttribute('wireframe', 'false')")
el.setAttribute("onreleased", "let el = selectedElements.at(-1).element; el.setAttribute('wireframe', 'true'); el.setAttribute('rotation', '0 0 0'); el.object3D.position.y=0; fileDropped()")
// add animations on height and position for children, to become a box proper then flatten back
// box
// Array.from( document.getElementById('icon_withdefaultjxrstylesjson').children ).map( l => l.getAttribute("position").y -= .02); document.getElementById('icon_withdefaultjxrstylesjson').setAttribute("height", "0.05"); document.getElementById('icon_withdefaultjxrstylesjson').getAttribute("position").y += 0.02
// flatten back
// Array.from( document.getElementById('icon_withdefaultjxrstylesjson').children ).map( l => l.getAttribute("position").y += .02); document.getElementById('icon_withdefaultjxrstylesjson').setAttribute("height", "0.005"); document.getElementById('icon_withdefaultjxrstylesjson').getAttribute("position").y -= 0.02
el.setAttribute("wireframe", "true")
el.setAttribute("width", ".05")
el.setAttribute("height", ".005")
el.setAttribute("depth", ".05")
let elPlate = document.createElement("a-box")
elPlate.setAttribute("position", "0 0.02 0" )
elPlate.setAttribute("width", ".04")
elPlate.setAttribute("height", ".04")
elPlate.setAttribute("depth", ".001")
elPlate.setAttribute("rotation", "-30 0 0" )
if (text.includes('.layout.json')) {
//elPlate.setAttribute("color", "lightblue" )
let elVisibleKnownType = document.createElement("a-troika-text")
elVisibleKnownType.setAttribute("position", "-0.002 0.02 0.003" )
elVisibleKnownType.setAttribute("value", '{Layout}' )
elVisibleKnownType.setAttribute("color", "#000" )
elVisibleKnownType.setAttribute("scale", ".04 .04 .04" )
el.appendChild(elVisibleKnownType)
let el3Delement = document.createElement("a-dodecahedron")
el3Delement.setAttribute("wireframe", "true" )
el3Delement.setAttribute("position", "0 0.05 0" )
el3Delement.setAttribute("color", "#000" )
el3Delement.setAttribute("radius", ".01" )
el.appendChild(el3Delement)
}
// color coding based on typed
if (!text.includes('.')) elPlate.setAttribute("color", "lightblue" )
// heuristic, assuming directories do not have a . is their name
/* disabled for now
let thumbnailUrl = "/thumbnails/"+text+'.png'
fetch( thumbnailUrl ).then( r => { if (r.ok) elPlate.setAttribute("src", thumbnailUrl) })
// include a visual preview as thumbnail, if available
*/
el.appendChild(elPlate)
let elFilename = document.createElement("a-troika-text")
elFilename.setAttribute("position", "0 0 0.01" )
elFilename.setAttribute("value", text )
// alternatively could use text as annotation
// elFilename.setAttribute("annotation", "content", name)
// rotate -90deg on x
// then 'jxr newContent('+filename+')' as value to make it executable on left pinch to open (default function)
// could optionally add more metadata e.g. file size, number of pages in documents, etc
elFilename.setAttribute("color", "#000" )
elFilename.setAttribute("scale", ".04 .04 .04" )
// some metadata e.g. file size or number of pages in documents could change depth, thickness
el.appendChild(elFilename)
rootEl.appendChild(el)
return el
}
let checkNewContent
</script>
<!-- <canvas width="1000px" height="1000px" id="transparent" style="display:none;"></canvas>-->
<canvas width="100px" height="100px" id="transparent" style="display:none;"></canvas>
<!-- low res feels actually nicer -->
<div style="position:fixed;z-index:1; top: 0%; right: 0%; ">
<div style="border-style:solid" id="drop_zone" ondrop="dropHandler(event);" ondragover="dragOverHandler(event);">
<center> <p>Drag File To Upload.</p> </center>
</div>
<a id=imagedownload download='highlight.png' href=''>download highlight (image)</a>
<a id=jsondownload download='highlight.json' href='[]'>download highlight (JSON)</a>
<a onclick="setupRecorder()" href='#recorder'>setup recorder</a>
<a onclick="latestAudioPlay()" href='#playaudio'>play audio</a>
<br>
<a id=customizedlinkforsharing href='https://hmd.link/?https://companion.benetou.fr/index.html?set_IDenvironment_visible=false&showfile=Apartment.glb'>customization example</a> (that you can then open on HMD on the same WiFi via the hmd.link URL)
<!-- could add JS toggle to modifications live then update customizedlinkforsharing.href -->
</div>
<script>
document.querySelector('#jsondownload').href=URL.createObjectURL( new Blob([JSON.stringify([])], { type:`text/json` }) )
let canvas = document.getElementById("transparent");
let ctx = canvas.getContext("2d");
AFRAME.registerComponent('raycaster-listen', {
// could also add the overlay transparent panel in front rather than manually adding it
init: function () {
// Use events to figure out what raycaster is listening so we don't have to
// hardcode the raycaster.
this.el.addEventListener('raycaster-intersected', evt => {
this.raycaster = evt.detail.el;
});
this.el.addEventListener('raycaster-intersected-cleared', evt => {
this.raycaster = null;
});
window.highlightToExport = []
},
tick: function () {
// return // stopped for demo
if (!this.raycaster) { return; } // Not intersecting.
let intersection = this.raycaster.components.raycaster.getIntersection(this.el);
if (!intersection) { return; }
//console.log(intersection.uv);
// window.highlightToExport.push( intersection.uv )
// this could also be a data structure to export after then be applied back on the source of the image (e.g. PDF)
ctx.fillStyle = "#0cc";
// should be based on currently / lastly picked highlighter
ctx.fillStyle = selectedElements.at(-1).element.querySelector("[raycaster]").getAttribute("raycaster").lineColor
const highres = false // for now actually better in low res
let x, y
if (!highres){
x = intersection.uv.x * 100 // should be also offsetsed by 100- on curved image (?!)
y = 100-intersection.uv.y * 100
ctx.fillRect(x,y,1,1)
} else {
x = intersection.uv.x * 1000 // should be also offsetsed by 100- on curved image (?!)
y = 1000-intersection.uv.y * 1000
ctx.fillRect(x,y,1,10)
}
/*
let x = intersection.uv.x * 100 // should be also offsetsed by 100- on curved image (?!)
let y = 100-intersection.uv.y * 100
*/
// will probably cause flickering... consider texture.needsUpdate instead or canvas.toDataURL(), might be faster
this.el.setAttribute("src", canvas.toDataURL() )
//document.querySelector('#imagedownload').href=document.getElementById('transparent').toDataURL()
// skipping for now to test for perf without saving
//document.querySelector('[download]').href=document.getElementById('transparent').toDataURL()
}
});
// from https://git.benetou.fr/utopiah/text-code-xr-engine/src/branch/gesture-manager/index.html#L55
AFRAME.registerComponent('live-selector-line', {
schema: {
// unfortunately problematic it here due to ID being based on filenames and thus including '.' in their name
// escaping .replaceAll('.','\.') while setting id also does not work
start: {type: 'selector'},
end: {type: 'selector'},
},
init: function(){
if (!this.data.start || !this.data.end){
console.warn('start or end selector on live-selector-line not found')
return
}
this.newLine = document.createElement("a-entity")
this.newLine.setAttribute("line", "start: 0, 0, 0; end: 0 0 0.01; color: red")
this.newLine.id = "start_"+this.data.start.id+"_end_"+this.data.end.id
AFRAME.scenes[0].appendChild( this.newLine )
this.lastStartPos=new THREE.Vector3()
this.lastEndPos=new THREE.Vector3()
},
tick: function(){
if (!this.data.start || !this.data.end){ return }
let startPos = this.data.start.getAttribute("position")
let endPos = this.data.end.getAttribute("position")
if (startPos != this.lastStartPos){
this.newLine.setAttribute("line", "start", AFRAME.utils.coordinates.stringify( startPos) )
this.lastStartPos = startPos.clone()
}
if (endPos != this.lastEndPos){
this.newLine.setAttribute("line", "end", AFRAME.utils.coordinates.stringify( endPos ) )
this.lastEndPos = endPos.clone()
}
},
})
function applyJXRStyle(userStyle){
userStyle.map( style => {
Array.from( document.querySelectorAll(style.selector) ).map( el => el.setAttribute(style.attribute, style.value))
})
}
function parametersViaURL(data){
for (const [key, value] of data) {
if (key.startsWith("set_")){
let [selector, componentName] = key.replaceAll('ID','#').split('_').slice(1)
Array.from( document.querySelectorAll(selector) ).map( el => el.setAttribute(componentName, value))
}
if (key == "showfile"){
showFile(value)
// seems to fail on Fortress.glb
// should be coupled with a filter cf sequentialFilters grew via e.g. <script src="filters/content_filter_examples.js"><...
// this would allow for re-usable yet optional modifications, so in practice nearly permanent
}
}
}
setTimeout( _ => {
// color scheme testing, unfortunately can't do CSS "proper"
// generalizing selector/attribute pairs though
// could be a user provided JSON, ideally CSS though as that's more common
const styles = {
light : [
{selector:'#start_file_sloan_testtxt_end_file_hello_worldtxt', attribute:'line', value: 'color:blue'},
{selector:'a-sky', attribute:'color', value: 'gray'},
{selector:'.notes', attribute:'color', value: 'black'},
{selector:'.notes', attribute:'outline-color', value: 'white'},
{selector:'a-troika-text a-plane', attribute:'color', value: 'red'},
{selector:'a-troika-text a-triangle', attribute:'color', value: 'darkred'}
],
print : [
{selector:'#start_file_sloan_testtxt_end_file_hello_worldtxt', attribute:'line', value: 'color:brown'},
{selector:'a-sky', attribute:'color', value: '#EEE'},
{selector:'.notes', attribute:'color', value: 'black'},
{selector:'.notes', attribute:'outline-color', value: 'white'},
{selector:'a-troika-text a-plane', attribute:'color', value: 'lightyellow'},
{selector:'a-troika-text a-triangle', attribute:'color', value: 'orange'}
],
}
parametersViaURL(urlParams)
makeTargetLocationsVisible()
wristShortcut = "jxr toggleHideAllJXRCommands()"
document.getElementById("typinghud").setAttribute("material","opacity", .01)
// could also gradually hide away, or show only after typing
hideAllJXRCommands()
// overrides user-visibility component due to the delay
/* exploration on highlighting by color within text
let startColor = Math.floor(Math.random()*100)
let endColor = Math.floor(startColor + Math.random()*100)
let range = {}
range[0] = 0xffffff
range[startColor] = 0x0099ff
range[endColor] = 0xffffff
hightlightabletext.setAttribute("troika-text", {colorRanges: range})
// should map from the highlight result of the raycaster, only when it hits
// could try to rely on https://github.com/protectwise/troika/blob/main/packages/troika-three-text/src/selectionUtils.js
// could start indirect, with sliders to
// grow/shrink a selection
// move it's starting position
*/
if (username && username == "thicknesstesteruser") {
thicknesscommands.setAttribute("visible", true)
Array.from( thicknesscommands.children ).map( c => c.setAttribute("visible", true) )
}
if (username && username == "tabletest") {
setTimeout( _ => { // example of conditional hint
if ( selectedElements.filter( el => el.element.id == "virtualdesktopplanemovable" && el.primary ).length < 1 )
setFeedbackHUD('pinch from the center of the yellow element')
}, 30*1000 )
manuscript.setAttribute("visible", false)
virtualdesktopplanemovable.setAttribute("visible", "true")
virtualdesktopplanemovable.setAttribute("onpicked", "selectedElements.at(-1).element.setAttribute('wireframe', 'true')")
virtualdesktopplanemovable.setAttribute("onreleased", "let el = selectedElements.at(-1).element; el.setAttribute('wireframe', 'false'); el.setAttribute('rotation', '0 0 0'); ")
}
if (username && username == "jsonrefmanualtester") {
console.clear()
}
if (username && username == "refoncubetester") {
console.clear()
showFile("references_manual_v04.json")
setTimeout( _ => { roundedpageborders.setAttribute("visible", "false") }, 1000 )
let cube = addCubeWithAnimations()
cube.setAttribute("target", "")
// should reparent 1st 6 cards to faces
setTimeout( _ => {
let refs = Array.from( document.querySelectorAll(".reference-entry") )
// refs.map( r => { r.parentElement = cube // doesn't seem to have any impact })
refs.map( (r,i) => {
r.object3D.parent = cubetest.object3D;
r.object3D.translateZ(.5);
r.object3D.translateY(-1);
r.object3D.scale.setScalar(.01)
} )
// should offset them down too
Array.from( document.querySelectorAll(".reference-entry-card") ).map( el => el.setAttribute("visible", "false"))
}, 500 )
let testingCommands = ["unfoldCube()", "roomScaleCube()", "palmScaleCube()", "refoldCube()"]
testingCommands.map( (c,i) => addNewNote("jxr " + c, "0.5 "+(1+i/10)+" -1" ) )
}
if (username && username == "cubetester") {
addCubeWithAnimations()
console.clear()
}
if (username && username == "metatester13032025") {
AFRAME.scenes[0].setAttribute("timed-demos", "")
}
if (username && username == "metatester10032025") {
// see demoqueueq1 user instead and related q1_* users
// could consider grouping if same prefix
console.clear()
const prefixUrl = "?username="
const optionUsers = ["refoncubetester", "backgroundexplorationlowopacity", "backgroundexplorationlowwhite", "backgroundexplorationlowwhitegrids", "backgroundexplorationlowwhitestatic"]
optionUsers.map( (c,i) => addNewNote("jxr location.assign('index.html?username=" + c + "')", "0.5 "+(1+i/10)+" -1" ) )
// somehow ? doesn't get escaped
// see timed-demos component instead
}
if (username && username == "instructionsonhands") {
AFRAME.scenes[0].setAttribute("instructions-on-hands", "")
}
if (username && username == "poweruser") {
// could instead use a per user limited visibility e.g. rely on AFRAME.registerComponent('user-visibility') untested for now
toggleHideAllJXRCommands()
// --- to demo :
// startViewCheck()
// showHighlight() // older version without images
//addImagesViaXML()
window.pageastextviaxmlsrc = "https://companion.benetou.fr/saved/pdfxml/3209542.3209570.xml"
//window.pageastextviaxmlsrc = "https://companion.benetou.fr/augmented_paper_xml/augmented_paper.xml"
// to try with another one...
pageAsTextViaXML()
//pageAsTextViaXML(5)
highlightcommands.setAttribute("visible", true)
roundedpageborders.setAttribute("visible", true)
//recordercommands.setAttribute("visible", true)
//addRecentAudioFiles()
// recordings to try the binding to annotation
// should check if dropped nearby colored annotations
/*
setTimeout( _ => {
Array.from( document.querySelectorAll(".highlightabletext") )[0].setAttribute("color", "aqua")
Array.from( document.querySelectorAll(".audiorecordings") ).map( el => {
el.setAttribute("onreleased", "associateLatestDropRecordingClosestHighlight()")
})
}, 2000 )
*/
// to test
// ----------------------------------------- gesture vertical hand -------------------------------------------
/*
let myScene = AFRAME.scenes[0].object3D
setInterval( i => {
if ( myScene.getObjectByName("r_handMeshNode") && myScene.getObjectByName("r_handMeshNode").parent.getObjectByName("wrist").rotation._y > -0.1
&& myScene.getObjectByName("r_handMeshNode").parent.getObjectByName("wrist").rotation._y < 0.1)
console.log('right hand about straight up')
// could try to use this with a minimum amplitude and above threshold, e.g. > .3m under 1s, trigger action
// should find better visualization, e.g. position as color curve? rotation as oriented sphere?
}, 500 )
*/
}
// ----------------------------------------- demo queue Q1 customizations -------------------------------------------
if (username && username == "demoqueueq1") {
instructions.setAttribute("visible", false)
// could be use with timer on selectedElements which already includes timestamps and primary/secondary
// selectedElements.filter( a => a.primary ).length
// selectedElements.filter( a => a.secondary ).length
// could use timestamp to show if after 30s either is still 0
// consider also setFeedbackHUD('hi')
manuscript.setAttribute("visible", false)
basiccommands.setAttribute("visible", false)
middlecommands.setAttribute("visible", false)
topsidecommands.setAttribute("visible", false)
document.querySelector("a-console").setAttribute("visible", false)
AFRAME.scenes[0].setAttribute("instructions-on-hands", "")
// not throroughly tested
}
if (username && username == "q1_step_urlcustom") {
const testUrlParams = new URLSearchParams( "set_IDmanuscript_color=lightyellow" )
parametersViaURL(testUrlParams)
}
if (username && username == "q1_step_refcards") {
manuscript.setAttribute("visible", false)
showFile("references_manual_v04.json")
setTimeout( _ => { roundedpageborders.setAttribute("visible", "false") }, 1000 )
let cube = addCubeWithAnimations()
cube.setAttribute("target", "")
cube.setAttribute("visible", "false")
setTimeout( _ => {
demoMetaDataName.object3D.translateX(-.9)
demoMetaDataDescription.object3D.translateX(-.9)
}, 1000)
}
if (username && username == "q1_step_showfile") {
const testUrlParams = new URLSearchParams( "showfile=175235.175237.pdf-0.jpg" )
parametersViaURL(testUrlParams)
}
if (username && username == "q1_step_highlights") {
window.pageastextviaxmlsrc = "https://companion.benetou.fr/saved/pdfxml/3209542.3209570.xml"
//window.pageastextviaxmlsrc = "https://companion.benetou.fr/augmented_paper_xml/augmented_paper.xml"
// to try with another one...
pageAsTextViaXML()
//pageAsTextViaXML(5)
highlightcommands.setAttribute("visible", true)
roundedpageborders.setAttribute("visible", true)
highlighterA.setAttribute("visible", true)
highlighterB.setAttribute("visible", true)
// eraser as black cube, same principle as highlighters
// move title/desc to the side
setTimeout( _ => {
demoMetaDataName.object3D.translateX(-.7)
demoMetaDataDescription.object3D.translateX(-.7)
}, 1000)
}
if (username && username == "q1_step_audio") {
recordercommands.setAttribute("visible", true)
addRecentAudioFiles()
// recordings to try the binding to annotation
// should check if dropped nearby colored annotations
}
if (username && username == "q1_step_screenshot") {
middlecommands.setAttribute("visible", false)
basiccommands.setAttribute("visible", false)
topsidecommands.setAttribute("visible", false)
let testingCommands = ["toggleShowCube()", 'setTimeout( _ => saveScreenshot("screenshot_"+Date.now()+".jpg") , 1000 )' ]
testingCommands.map( (c,i) => addNewNote("jxr " + c, "-0.5 "+(1+i/10)+" -.55" ) )
//recordercommands.setAttribute("visible", true)
//addRecentAudioFiles()
// recordings to try the binding to annotation
// should check if dropped nearby colored annotations
window.pageastextviaxmlsrc = "https://companion.benetou.fr/saved/pdfxml/3209542.3209570.xml"
//window.pageastextviaxmlsrc = "https://companion.benetou.fr/augmented_paper_xml/augmented_paper.xml"
// to try with another one...
pageAsTextViaXML()
//pageAsTextViaXML(5)
highlightcommands.setAttribute("visible", true)
roundedpageborders.setAttribute("visible", true)
highlighterA.setAttribute("visible", true)
highlighterB.setAttribute("visible", true)
// eraser as black cube, same principle as highlighters
// move title/desc to the side
setTimeout( _ => {
demoMetaDataName.object3D.translateX(-.7)
demoMetaDataDescription.object3D.translateX(-.7)
}, 2000)
}
// ----------------------------------------- constrained move -------------------------------------------
/*
virtualdesktopplanemovableblue.setAttribute("onpicked", "constraintsMoveSingleAxis(selectedElements.at(-1).element, 'x')")
virtualdesktopplanemovableblue.setAttribute("onreleased", "clearConstraintsMoveSingleAxis('x')")
virtualdesktopplanemovablegreen.setAttribute("onpicked", "constraintsMoveSingleAxis(selectedElements.at(-1).element, 'y')")
virtualdesktopplanemovablegreen.setAttribute("onreleased", "clearConstraintsMoveSingleAxis('y')")
virtualdesktopplanemovablered.setAttribute("onpicked", "constraintsMoveNoRotation(selectedElements.at(-1).element)")
virtualdesktopplanemovablered.setAttribute("onreleased", "clearConstraintsMoveNoRotation()")
const axesHelper = new THREE.AxesHelper(.1)
const colorGreen = new THREE.Color( 'green' )
const colorRed = new THREE.Color( 'red' )
const colorBlue = new THREE.Color( 'blue' )
const colorGray = new THREE.Color( 'gray' )
axesHelper.setColors(colorGreen, colorGray, colorGray)
cylinderorange.object3D.add( axesHelper )
// object related, should be axis bound
cylinderorange.setAttribute("onpicked", "constraintsTranslateX(selectedElements.at(-1).element)")
cylinderorange.setAttribute("onreleased", "clearConstraintsTranslateX()")
// rotation edit widget : donut + cone as arrow (interactable)
let helperDonut = document.createElement("a-torus")
helperDonut.setAttribute("radius", ".02")
helperDonut.setAttribute("rotation", "0 90 0")
helperDonut.setAttribute("radius-tubular", ".001")
helperDonut.setAttribute("segments-height", 8)
helperDonut.setAttribute("segments-width", 8)
helperDonut.setAttribute("opacity", .3)
cylinderpurple.appendChild(helperDonut)
let helperCone = document.createElement("a-cone")
helperCone.setAttribute("opacity", .3)
helperCone.setAttribute("rotation", "0 90 0")
helperCone.setAttribute("position", "0 0 .02")
helperCone.setAttribute("height", ".001")
helperCone.setAttribute("radius-top", ".001")
helperCone.setAttribute("radius-bottom", ".005")
helperCone.setAttribute("segments-height", 8)
helperCone.setAttribute("segments-width", 8)
cylinderpurple.appendChild(helperCone)
helperCone.setAttribute("target", "") // works via the console... but while in XR the target for direct rotation (on object) and this are too close
// could try apply on last/next picked item instead
helperCone.id = "cylinderpurplecone"
cylinderpurplecone.setAttribute("onpicked", "constraintsRotationX(selectedElements.at(-1).element)")
//cylinderpurplecone.setAttribute("onreleased", "clearConstraintsMoveNoRotation()")
// cylinderpurple.object3D.rotateX(.1)
// because direct rotation works (with target component) this is about constrained rotation
// e.g. snapping at 90deg angles
// onreleased could also be added from within onpicked... not necessarily clearer though
*/
}, 500)
AFRAME.registerComponent('user-visibility', {
schema: { username: {type: 'string'}, },
init: function(){
if (!this.data.username){ console.warn('username required'); return }
if (username && username == this.data.username){
this.el.setAttribute("visible", true)
} else {
this.el.setAttribute("visible", false)
}
}
})
function addImagesViaXML(src = "https://companion.benetou.fr/augmented_paper_xml/augmented_paper.xml"){
fetch( src ).then( r => r.text() ).then( txt => {
const parser = new DOMParser();
const doc = parser.parseFromString(txt, "application/xml");
Array.from( doc.querySelectorAll("image") ).map( i => showFile('/augmented_paper_xml/'+i.attributes.src.value));
setTimeout( _ => {
Array.from( doc.querySelectorAll("image") ).map( i => {
let el = document.getElementById('/augmented_paper_xml/'+i.attributes.src.value.replace('.',''));
el.setAttribute("width", i.attributes.width.value/1000);
el.setAttribute("height", i.attributes.height.value/1000);
el.setAttribute("position", ""+ i.attributes.left.value/1000+" "+ (i.attributes.top.value/1000+1)+ " "+(Math.random()/1000-.5));
})
}, 1000)
})
}
function pageAsTextViaXML(page=0){
let src = window.pageastextviaxmlsrc
fetch( src ).then( r => r.text() ).then( txt => {
Array.from( roundedpageborders.querySelectorAll(".highlightimagefromxmlitem,.highlightabletext") ).map( e => e.remove() )
targets = targets.filter( el => !el.classList.contains("highlightabletext"))
// assumes single document open this way
// probably safer performance-wise, otherwise rely on (high quality) image instead, no interaction needed
const parser = new DOMParser();
let doc = parser.parseFromString(txt, "application/xml")
const scalingFactor = 1/1000 // used for position of text and images
// could also use x/y/z offsets
// probably easier to append to an entity, either empty or used as (white) background
const xOffset = 0
const yOffset = 1
const zPos = -.5
// see also roundedpageborders
//Array.from( doc.children[0].children[page].querySelectorAll("text") ).map( (l,n) => addNewNote(l.textContent, ""+(l.attributes.left.value*scalingFactor+xOffset) + " " + (1-l.attributes.top.value*scalingFactor+yOffset) + " "+zPos, "0.045 0.045 0.045", "highlighttextfromxml_"+n, "highlighttextfromxmlitem" ) )
Array.from( doc.children[0].children[page].querySelectorAll("text") ).map( (l,n) => {
// addNewNote(l.textContent, ""+(l.attributes.left.value*scalingFactor+xOffset) + " " + (1-l.attributes.top.value*scalingFactor+yOffset) + " "+zPos, "0.045 0.045 0.045", "highlighttextfromxml_"+n, "highlighttextfromxmlitem" ) )
let tktxt = document.createElement("a-troika-text")
let pos = ""+(l.attributes.left.value*scalingFactor+xOffset) + " " + (1-l.attributes.top.value*scalingFactor+yOffset) + " "+zPos
let scale = "0.045 0.045 0.045"
tktxt.setAttribute("position", pos)
tktxt.setAttribute("originalposition", pos)
tktxt.setAttribute("originalpage", page)
// FIXME
//tktxt.setAttribute("originalsource", fileContent.meta.metadata['dc:title'])
//tktxt.setAttribute("originalidentifier", fileContent.meta.metadata['dc:identifier'])
tktxt.setAttribute("font-size", "0.009")
tktxt.setAttribute("color", "black")
tktxt.setAttribute("target", "")
tktxt.classList.add("highlightabletext")
tktxt.setAttribute("onpicked", "console.log(selectedElements.at(-1).element.getAttribute('value'))")
tktxt.setAttribute("onreleased", "let el = selectedElements.at(-1).element; if (true) el.setAttribute('color', highlightColor); el.setAttribute('rotation', ''); el.setAttribute('position', el.getAttribute('originalposition') )")
// resets back...
// change color
// only if above a certain threshold, e.g. held a long time, or released close to specific other item
// could also toggle coloring
// can be based on coloring pick with jxr
tktxt.setAttribute("value", l.textContent)
tktxt.setAttribute("anchor", "left")
roundedpageborders.appendChild(tktxt)
})
Array.from( doc.children[0].children[page].querySelectorAll("image") ).map( (l,n) => {
let el = document.createElement("a-box")
// is position via center of element so should offset it
el.setAttribute("src", "/augmented_paper_xml/"+l.attributes.src.value); // somehow set to #transparent...
el.setAttribute("width", l.attributes.width.value*scalingFactor);
el.setAttribute("height", l.attributes.height.value*scalingFactor);
el.setAttribute("depth", .01);
el.setAttribute("target", "")
el.id = "highlightimagefromxml_"+n
el.classList.add("highlightimagefromxmlitem")
el.classList.add("highlightabletext")
let w = l.attributes.width.value*scalingFactor
let h = l.attributes.height.value*scalingFactor
el.setAttribute("position", ""+ ""+(w/2+l.attributes.left.value*scalingFactor+xOffset)+" "+ (-h/2+1-l.attributes.top.value*scalingFactor+yOffset)+ " "+zPos)
roundedpageborders.appendChild(el)
})
} );
}
function previousPageForXMLText(){
if (pageNumberShownForHighlight>0)
changePageForXMLText(--pageNumberShownForHighlight)
return pageNumberShownForHighlight
}
function nextPageForXMLText(){
//if (pageNumberShownForHighlight<contentFromDocumentAsJSON.pages.length-1) changePageXMLText(++pageNumberShownForHighlight)
// needs the doc parsed
if (pageNumberShownForHighlight<11) changePageForXMLText(++pageNumberShownForHighlight) // hard coded and wrong...
return pageNumberShownForHighlight
}
function changePageForXMLText(pageNumber){
Array.from( document.querySelectorAll(".highlightabletext") ).map(el => el.remove())
// expected to be saved first by the user
// could be stored first using getHighlights() first, safer
highlightsBetweenPageChanges.push( getHighlights() )
pageAsTextViaXML(pageNumber)
}
let contentFromDocumentAsJSON
let pageNumberShownForHighlight = 0
function showHighlight(){
const originalDoc = "augmented_paper.pdf"
const contentJSON = originalDoc+".json"
const renderedFirstPage = originalDoc+"-0.jpg"
fetch(contentJSON).then( r => r.json() ).then( fileContent => {
let pageBackground = document.createElement("a-box")
pageBackground.id = 'pagebackground'
pageBackground.setAttribute("position", "0.1 1.7 -0.51")
pageBackground.setAttribute("scale", ".0011 .0011 .001")
pageBackground.setAttribute("width", "612")
pageBackground.setAttribute("height", "792")
AFRAME.scenes[0].appendChild(pageBackground)
contentFromDocumentAsJSON = fileContent
showPageForHighlight(fileContent, pageNumberShownForHighlight)
})
showFile(renderedFirstPage)
// adapting ratio based on page width/height
setTimeout( _ => {
let renderedFirstPageEl = document.getElementById( renderedFirstPage.replaceAll('.',''))
renderedFirstPageEl.setAttribute("scale", 612/792+" 1 0.1") // hard coded based on data from parsed JSON from PDFExtract
}, 200)
}
function previousPageForHighlight(){
if (pageNumberShownForHighlight>0)
changePageForHighlight(--pageNumberShownForHighlight)
return pageNumberShownForHighlight
}
function nextPageForHighlight(){
if (pageNumberShownForHighlight<contentFromDocumentAsJSON.pages.length-1)
changePageForHighlight(++pageNumberShownForHighlight)
return pageNumberShownForHighlight
}
let highlightsBetweenPageChanges = []
function changePageForHighlight(pageNumber){
Array.from( document.querySelectorAll(".highlightabletext") ).map(el => el.remove())
// expected to be saved first by the user
// could be stored first using getHighlights() first, safer
highlightsBetweenPageChanges.push( getHighlights() )
showPageForHighlight(contentFromDocumentAsJSON, pageNumber)
}
function showPageForHighlight(fileContent, pageNumber){
// example of direct layout but with incorrect fonts
const scale = 1/1000
const xOffset = -.2
const yOffset = 2.1
//let pageNumber = 0
fileContent.pages[pageNumber].content.map( str => {
let tktxt = document.createElement("a-troika-text")
tktxt.setAttribute("position", ""+(xOffset+str.x*scale) + " " + (yOffset-str.y*scale) + " -0.5")
tktxt.setAttribute("originalposition", ""+(xOffset+str.x*scale) + " " + (yOffset-str.y*scale) + " -0.5")
tktxt.setAttribute("originalpage", pageNumber)
tktxt.setAttribute("originalsource", fileContent.meta.metadata['dc:title'])
tktxt.setAttribute("originalidentifier", fileContent.meta.metadata['dc:identifier'])
tktxt.setAttribute("font-size", "0.005")
tktxt.setAttribute("color", "black")
tktxt.setAttribute("target", "")
tktxt.classList.add("highlightabletext")
tktxt.setAttribute("onpicked", "console.log(selectedElements.at(-1).element.getAttribute('value'))")
tktxt.setAttribute("onreleased", "let el = selectedElements.at(-1).element; if (true) el.setAttribute('color', highlightColor); el.setAttribute('rotation', ''); el.setAttribute('position', el.getAttribute('originalposition') )")
// resets back...
// change color
// only if above a certain threshold, e.g. held a long time, or released close to specific other item
// could also toggle coloring
// can be based on coloring pick with jxr
tktxt.setAttribute("value", str.str)
tktxt.setAttribute("anchor", "left")
AFRAME.scenes[0].appendChild(tktxt)
// somehow looks more demanding than own addNewNote based on it...
// can then process for all str that is NOT the default color, i.e. black
// ".highlightabletext"
// then become exportable JSON
})
}
function nonBlackHighlitableTexts(){
return Array.from( document.querySelectorAll(".highlightabletext") ).filter( el => el.getAttribute("color") != "black" )
}
function getHighlights(){
let data = {}
let foundHighlights = nonBlackHighlitableTexts()
data.source = foundHighlights.map( el => el.getAttribute("originalsource")).slice(-1) // assuming single source for now!
data.identifier = foundHighlights.map( el => el.getAttribute("originalidentifier")).slice(-1) // assuming single source for now!
data.highlights = foundHighlights.map( el => { return {page: el.getAttribute("originalpage"), color: el.getAttribute("color"), content: el.getAttribute("value")}})
return data
}
let highlightColor = 'aqua'
function constraintsRotationX(el){
el.object3D.rotateX(.1)
}
// relative translation, using translateX/Y/Z
// amount based on distance between current position and starting position
// becomes tricky due to on-going rotation... so using ghost object
function constraintsTranslateX(el){
window.cmv_value = new THREE.Vector3()
el.object3D.getWorldPosition( window.cmv_value ) // goes back to initial position, not new one after being picked...
window.cmv_el_original = el
window.cmv_el_clone = el.cloneNode(true) // can not override the hand picking pose use a "ghost" object
window.cmv_el_clone.setAttribute("opacity", .3)
// arguably this can be swapped, namely the opacity of the original could be lower and the clone higher, then swap on release
window.cmv_el_clone.classList.add("ghost_element")
AFRAME.scenes[0].appendChild(window.cmv_el_clone)
window.cmv = setInterval( _ => {
window.cmv_el_clone.object3D.translateX( window.cmv_el_original.object3D.position.distanceTo( window.cmv_value )/100 )
// might try sqrt() or log() to avoid going away too fast
// quite wonky... safer to keep the initial rotation
},10)
}
function clearConstraintsTranslateX(){
clearInterval( window.cmv )
window.cmv_el_original.object3D.position.copy(cmv_el_clone.object3D.position)
window.cmv_el_original.object3D.rotation.copy(cmv_el_clone.object3D.rotation)
Array.from( document.querySelectorAll('.ghost_element')).map( el => el.remove())
}
function constraintsMoveSingleAxis(el, axis){
// could visually add a colored helper arrow on the axis
// ArrowHelper, GridHelper, AxesHelper, PlaneHelper
window.cmv_value = AFRAME.utils.coordinates.stringify( el.getAttribute("rotation") )
window.cmv_el_original = el
window.cmv_el_clone = el.cloneNode(true) // can not override the hand picking pose use a "ghost" object
window.cmv_el_clone.setAttribute("opacity", .3)
// arguably this can be swapped, namely the opacity of the original could be lower and the clone higher, then swap on release
window.cmv_el_clone.classList.add("ghost_element")
AFRAME.scenes[0].appendChild(window.cmv_el_clone)
window.cmv = setInterval( _ => {
window.cmv_el_clone.object3D.position[axis] = window.cmv_el_original.object3D.position[axis]
window.cmv_el_clone.object3D.rotation.copy(cmv_el_original.object3D.rotation)
// could also be axis locked by only copying the x/y/z value of position
},10)
}
function clearConstraintsMoveSingleAxis(axis){
clearInterval( window.cmv )
if (axis!="x") window.cmv_el_original.object3D.position.x = window.cmv_el_clone.object3D.position.x
if (axis!="y") window.cmv_el_original.object3D.position.y = window.cmv_el_clone.object3D.position.y
if (axis!="z") window.cmv_el_original.object3D.position.z = window.cmv_el_clone.object3D.position.z
Array.from( document.querySelectorAll('.ghost_element')).map( el => el.remove())
}
function constraintsMoveNoRotation(el){
window.cmv_value = AFRAME.utils.coordinates.stringify( el.getAttribute("rotation") )
window.cmv_el_original = el
window.cmv_el_clone = el.cloneNode(true) // can not override the hand picking pose use a "ghost" object
window.cmv_el_clone.setAttribute("opacity", .3)
// arguably this can be swapped, namely the opacity of the original could be lower and the clone higher, then swap on release
window.cmv_el_clone.classList.add("ghost_element")
AFRAME.scenes[0].appendChild(window.cmv_el_clone)
window.cmv = setInterval( _ => {
window.cmv_el_clone.setAttribute("position", AFRAME.utils.coordinates.stringify( window.cmv_el_original.getAttribute("position") ) )
// could also be axis locked by only copying the x/y/z value of position
},10)
}
function clearConstraintsMoveNoRotation(){
clearInterval( window.cmv )
Array.from( document.querySelectorAll('.ghost_element')).map( el => el.remove())
window.cmv_el_original.setAttribute("rotation", window.cmv_value)
}
function toggleHideAllJXRCommands(){
let jxrCommands = Array.from( document.querySelectorAll("a-troika-text") ).filter( c => c.getAttribute("value").startsWith("jxr"))
if (!jxrCommands) return
let visible = jxrCommands[0].getAttribute("visible")
hideAllJXRCommands(!visible)
}
function hideAllJXRCommands(visible="false"){
Array.from( document.querySelectorAll("a-troika-text") ).filter( c => c.getAttribute("value").startsWith("jxr")).map( c => c.setAttribute("visible", visible))
}
function bumpSelection(invert = false){
let newStart = Number(Object.entries( hightlightabletext.getAttribute("troika-text").colorRanges )[1][0])+1
if (invert) newStart-=2
if (newStart<0) newStart = 0
let lengthString = hightlightabletext.getAttribute("troika-text").value.length
if (newStart>lengthString) newStart = lengthString-1
let end = Number(Object.entries( hightlightabletext.getAttribute("troika-text").colorRanges )[2][0])
// can fail by simplifying to 2 value instead of 3 when arriving at 0
let range = {}
range[0] = 0xffffff
range[newStart] = 0x0099ff
range[end] = 0xffffff
hightlightabletext.setAttribute("troika-text", {colorRanges: range})
}
function growSelection(invert = false){
let start = Number(Object.entries( hightlightabletext.getAttribute("troika-text").colorRanges )[1][0])
let newEnd = Number(Object.entries( hightlightabletext.getAttribute("troika-text").colorRanges )[2][0])+1
if (invert) newEnd-=2
if (newEnd<0) newStart = 0
let lengthString = hightlightabletext.getAttribute("troika-text").value.length
if (newEnd>lengthString) newStart = lengthString-1
let range = {}
range[0] = 0xffffff
range[start] = 0x0099ff
range[newEnd] = 0xffffff
hightlightabletext.setAttribute("troika-text", {colorRanges: range})
}
let selections = []
function extractSelection(){
// should have a refractory period, i.e. don't repeat if done less than 1s ago
if ( Date.now() - lastExecuted['getSelectionWithRefractoryPeriod'] < 500 ){
console.warn('ignoring, executed during the last 500ms already')
return
}
lastExecuted['getSelectionWithRefractoryPeriod'] = Date.now()
let startPos = Number(Object.entries( hightlightabletext.getAttribute("troika-text").colorRanges )[1][0])
let endPos = Number(Object.entries( hightlightabletext.getAttribute("troika-text").colorRanges )[2][0])
let text = hightlightabletext.getAttribute("troika-text").value.substring(startPos, endPos)
addNewNote(text)
selections.push(text)
document.querySelector('#jsondownload').href=URL.createObjectURL( new Blob([JSON.stringify(selections)], { type:`text/json` }) )
}
// trying to find a convenient way to be responsive with controllers to interact, not just hand tracking
AFRAME.registerComponent('raycaster-targets', {
init: function () {
// Use events to figure out what raycaster is listening so we don't have to hardcode the raycaster.
this.el.addEventListener('raycaster-intersected', evt => { this.raycaster = evt.detail.el; });
this.el.addEventListener('raycaster-intersected-cleared', evt => { this.raycaster = null; });
},
tick: function () {
if (!this.raycaster) { return; } // Not intersecting.
let intersection = this.raycaster.components.raycaster.getIntersection(this.el);
if (!intersection) { return; }
console.log(intersection.point);
this.el.setAttribute( "color", "red" )
}
})
AFRAME.registerComponent('setupable', {
init: function () {
let setupableEl = this.el
if (!setupableEl.id){
console.warn('setupable fail, target element needs unique ID')
// could also check on geometry, i.e. box only for now
return
}
let w = setupableEl.getAttribute("width")
let h = setupableEl.getAttribute("height")
let d = setupableEl.getAttribute("depth")
let controlPointEl1 = document.createElement("a-sphere")
controlPointEl1.setAttribute("position", "" + w/2 + " " + h/2 + " " + d/2)
// should be based on element width/height/depth
controlPointEl1.setAttribute("radius", ".02")
controlPointEl1.setAttribute("wireframe", "true")
controlPointEl1.setAttribute("segments-height", 8)
controlPointEl1.setAttribute("segments-width", 8)
controlPointEl1.setAttribute("color", "yellow")
controlPointEl1.setAttribute("target", "")
controlPointEl1.setAttribute("controlpoint", setupableEl.id)
controlPointEl1.id = "controlPointEl1_"+setupableEl.id
controlPointEl1.setAttribute("onpicked", "selectedElements.at(-1).element.setAttribute('wireframe', 'false')")
controlPointEl1.setAttribute("onreleased", "setupEntity('"+setupableEl.id+"')")
// AFRAME.scenes[0].appendChild(controlPointEl1)
setupableEl.appendChild(controlPointEl1)
let controlPointEl2 = document.createElement("a-sphere")
controlPointEl2.setAttribute("position", "" + -w/2 + " " + -h/2 + " " + -d/2)
// should be based on element width/height/depth
controlPointEl2.setAttribute("radius", ".02")
controlPointEl2.setAttribute("wireframe", "true")
controlPointEl2.setAttribute("segments-height", 8)
controlPointEl2.setAttribute("segments-width", 8)
controlPointEl2.setAttribute("color", "yellow")
controlPointEl2.setAttribute("target", "")
controlPointEl2.setAttribute("controlpoint", setupableEl.id)
controlPointEl2.setAttribute("onreleased", "setupEntity('"+setupableEl.id+"')")
controlPointEl2.id = "controlPointEl2_"+setupableEl.id
// AFRAME.scenes[0].appendChild(controlPointEl1)
setupableEl.appendChild(controlPointEl2)
// console.log('setupable', AFRAME.scenes) // very weird... scene should be loaded when component register and initiate
},
//tick: function () { }
})
function setupEntity( id ){
// assuming some world/axis alignment
let setupableEl = document.getElementById(id)
let controlPointEl1 = document.getElementById( "controlPointEl1_"+setupableEl.id )
let controlPointEl2 = document.getElementById( "controlPointEl2_"+setupableEl.id )
let middlePos = new THREE.Vector3()
middlePos = controlPointEl1.object3D.position.clone()
middlePos.add(controlPointEl2.object3D.position.clone())
middlePos.divideScalar(2)
middlePos.add( setupableEl.object3D.position.clone() ) // becoming world coordinates instead, assuming that the parent object has no parent
setupableEl.setAttribute("position", AFRAME.utils.coordinates.stringify(middlePos) )
let w = Math.abs( controlPointEl1.object3D.position.x - controlPointEl2.object3D.position.x )
let h = Math.abs( controlPointEl1.object3D.position.y - controlPointEl2.object3D.position.y )
let d = Math.abs( controlPointEl1.object3D.position.z - controlPointEl2.object3D.position.z )
setupableEl.setAttribute("width", w)
setupableEl.setAttribute("height", h)
setupableEl.setAttribute("depth", d)
// could also some rotations as we can assume a table to be flat
}
function loadOnPannels(){
let url ="https://webdav.benetou.fr/fotsave/fot_sloan_companion_public/augmented_paper.pdf-"
let extension = ".jpg"
let selector = "#deskpanels"
Array.from( document.querySelector(selector).children )
.map( (p,i) => {
p.setAttribute("wireframe", "false");
p.setAttribute("depth", "1");
p.setAttribute("src", url+i+extension)
})
}
function toggleAnchors(){
let anchorsFound = false
targets.map( t => { el = t.querySelector("a-sphere[color=blue]"); if (el) anchorsFound = true} )
if (anchorsFound){
targets.map( t => { el = t.querySelector("a-sphere[color=blue]"); if (el) el.remove()} )
targets.map( t => { el = t.querySelector("a-sphere[color=blue]"); if (el) el.remove()} )
// somehow needed twice for table anchor
} else {
makeAnchorsVisibleOnTargets()
}
}
function noteFromMetaData(){
f = Object.values(filesWithMetadata)[0]
addNewNote( "First file\n\n"+Object.getOwnPropertyNames( f )
.filter( p => (p == "size" || p.endsWith("timeMs")) )
.map( p => p + "\t: " + f[p]).join('\n') )
// should instead take any filename, ideally any object, including interface ones
// then visually highlight metadata with dedicated affordances
// consider also metadata on non-files, e.g. every thing in <a-scene> with an ID
}
/* PDF reflow demo
loadOnPannels() // should make them target too
r = Array.from( document.querySelector("#deskpanels").children ).sort( (a,b) => a.object3D.position.z > b.object3D.position.z ).map( el => el.getAttribute("src")).map( url => url.replace(/.*pdf-/,'').replace('.jpg','')).map( r => Number(r)+1)
// could also filter
// in
// only objects with a class or property (e.g. target)
// out
// object with a class (e.g. only .document_part)
// outside of a volume
// e.g. z < -1 || z > 1 || x < -1 ...
// this could be made visible
// could also provide feedback as preview
// e.g. selected pages 1, 2, 4, 5 (in that orqder) (in that order)
fetch("/save-as-new-pdf/augmented_paper.pdf/"+JSON.stringify(r))
// should send back URL after, maybe via ntfy
// jpg (montage) or
// https://stackoverflow.com/questions/37709879/how-to-generate-a-collage-image-like-shown
// via fetch("/save-as-new-montage/augmented_paper.pdf/"+JSON.stringify(r))
// indexing on 0 so no need for +1 on page number
// HTML working
// alternatively copy this converter than make
// epub, PmWiki, MarkDown, etc
// not to be downloaded though, with live URL, still usable
// SVG (using <image href"">)
// https://developer.mozilla.org/en-US/docs/Web/SVG/Element/image
// can maybe be done client side via file reader
// glb
// potentially from glTF with external images then https://github.com/donmccurdy/glTF-Transform
// or https://gltf-transform.dev/cli
// can be done client side via threejs
// cf e.g. https://git.benetou.fr/utopiah/text-code-xr-engine/issues/24
*/
let mediaRecorder
let chunks = []
// could instead be an array of audio elements
let audioElements = []
// then create element and push
let audioRecordingBlob
// will request permissions, potentially kicking out of XR
// probably safer to do so via a microphone emoji in 2D
// could also call setupRecorder() if mediaRecorder is still null
// removes a step for the user
function setupRecorder(){
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
console.log("getUserMedia supported.");
navigator.mediaDevices
.getUserMedia( { audio: true, },)
// Success callback
.then((stream) => {
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => {
chunks.push(e.data);
};
mediaRecorder.onstop = (e) => {
const audioBlob = new Blob(chunks, { type: "audio/ogg; codecs=opus" });
audioRecordingBlob = audioBlob
chunks = [];
const audio = document.createElement("audio")
audio.src = window.URL.createObjectURL(audioBlob)
audio.play()
audioElements.push(audio)
addXRAudioWidget( audioElements.length-1 )
// should add a new AFrame entity too, so that it has a player
// ideally it's also play/pause as toggle, not just play (as of right now)
}
})
// Error callback
.catch((err) => {
console.error(`The following getUserMedia error occurred: ${err}`);
});
} else {
console.error("getUserMedia not supported on your browser!");
}
}
let latest_audio_id
// for Apple support on Vision Pro
// cf https://stackoverflow.com/questions/31776548/why-cant-javascript-play-audio-files-on-iphone-safari
const audio = new Audio();
audio.autoplay = true;
function latestAudioPlay(){
// onClick of first interaction on page before I need the sounds
// (This is a tiny MP3 file that is silent and extremely short - retrieved from https://bigsoundbank.com and then modified)
audio.src = "data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA"
if (latest_audio_id) audio.src = latest_audio_id.src
audio.play()
}
async function addRecentAudioFiles(){
const contents = await webdavClient.getDirectoryContents(subdirWebDAV);
// consider instead search https://github.com/perry-mitchell/webdav-client#search
contents.filter(f => f.basename.endsWith('.ogg'))
.sort( (a,b) => new Date(a.lastmod).getTime() < new Date(b.lastmod).getTime() ) // newest first
.slice(0,4) // top or last?
.map(a => {
const audio = document.createElement("audio")
audio.src = a.basename
audioElements.push(audio)
addXRAudioWidget( audioElements.length-1 )
})
}
function addXRAudioWidget(n){
// should become available via showFile() thus become a filter
let rootEl = document.getElementById("audiowidgets")
let el = document.createElement("a-entity")
let color= Math.random().toString(16).substr(-6)
let miniID = Math.random().toString(36).slice(-1).toUpperCase()+Math.floor(Math.random()*10)
el.id = color + '_' + miniID
latest_audio_id = el.id
el.innerHTML =
//`<a-troika-text anchor=left target value="jxr latestAudioPlay()" rotation="0 90 0" position="0 ${-n*.1} 0" scale="0.1 0.1 0.1">
`<a-troika-text class="audiorecordings" anchor=left target value="jxr audioElements[${n}].play()" rotation="0 90 0" position="0 ${-n*.1} 0" scale="0.1 0.1 0.1">
<a-entity scale=".2 .2 .2" class="icon speaker" position="-.5 0 0">
<a-cylinder color=gray radius=.3 rotation="0 0 90"></a-cylinder>
<a-cone color=gray radius-top=".1" rotation="0 0 90"></a-cone>
<a-box color=${'#'+color} scale="" position="1 0 0"></a-box>
<a-troika-text value="${miniID}" position="1 0 .51" font-size=1></a-troika-text>
</a-entity>
</a-troika-text>`
rootEl.appendChild(el)
// <a-sphere color=purple radius=".5" position="1 0 0"></a-sphere>
}
function associateLatestDropRecordingClosestHighlight(){
let el = selectedElements.at(-1).element
// check distance to all highlights
let foundHighlights = nonBlackHighlitableTexts()
// find closest under threshold...
foundHighlights.map( h => {
// doesn't seem to get world coordinates, probably due to parenting
let p1 = new THREE.Vector3()
el.object3D.getWorldPosition( p1 )
let p2 = new THREE.Vector3()
h.object3D.getWorldPosition( p2 )
let dist = p1.distanceTo( p2 )
console.log( dist )
// even while trying to get world coordinates the distance seems of, close to 1 when it should be 0.
// it does seems to be proportional though, i.e. it increases while moving objects away
// could visually show what has been tested, i.e. last 2 elements and a line between both
})
}
function saveAudioFile(filename="audiofile.ogg"){
// this could support multiple audio element as input, not "just" audioRecordingBlob, i.e the last one
const reader = new FileReader();
reader.onloadend = (evt) => {
fileContent = evt.target.result
async function w(path = "/file.txt"){ return await webdavClient.putFileContents(path, fileContent); }
written = w(subdirWebDAV+usernamePrefix+filename)
if (written){
fetch('https://ntfy.benetou.fr/fileuploadtowebdav', { method: 'POST', body: 'added '+usernamePrefix+filename })
// available then as https://webdav.benetou.fr/fotsave/fot_sloan_companion_public/audiofile.ogg
}
};
reader.readAsArrayBuffer(audioRecordingBlob);
// always saving the last recorded one
}
function saveHighlights(filename="highlights.json"){
async function w(path = "/file.txt"){ return await webdavClient.putFileContents(path, JSON.stringify(getHighlights())); }
// note that this only single page saving, should instead consider highlightsBetweenPageChanges but after dedup
console.log('browsable in 2D at https://companion.benetou.fr/highlights_example.html')
written = w(subdirWebDAV+usernamePrefix+filename)
if (written){
fetch('https://ntfy.benetou.fr/fileuploadtowebdav', { method: 'POST', body: 'added '+usernamePrefix+filename })
// available then as https://webdav.benetou.fr/fotsave/fot_sloan_companion_public/highlights.json
}
}
// TODO annotations via screenshot richer UX
// add filter/screenshotux.js
// add audio recorder capability
// highlighters
function saveScreenshot(filename="screenshot_test.jpg"){
// https://git.benetou.fr/utopiah/text-code-xr-engine/commit/d5bc01251ecb2380c9be0b456d2a7b68fd16e4f2
document.querySelector('a-scene').components.screenshot.getCanvas('perspective').toBlob( blob => {
let imgBlob = new File([blob], filename, { type: "image/jpeg"});
const reader = new FileReader();
reader.onloadend = (evt) => {
fileContent = evt.target.result
async function w(path = "/file.txt"){ return await webdavClient.putFileContents(path, fileContent); }
written = w(subdirWebDAV+usernamePrefix+filename)
if (written){
fetch('https://ntfy.benetou.fr/fileuploadtowebdav', { method: 'POST', body: 'added '+usernamePrefix+filename })
// available then as https://webdav.benetou.fr/fotsave/fot_sloan_companion_public/screenshot_test.jpg
}
};
reader.readAsArrayBuffer(imgBlob);
}, "image/jpeg", 0.8);
}
function saveCSLJson(filename="example_bibliography.csl.json"){
// could get again get snippets of a certain type, e.g. .bibliographyitem only
// filtered on within a specific volume, e.g. within unit cube around center but .5m above floor and in front
// so not centered on 0 0 0 but rather on 0 1 -.5 (can be visualized via e.g. wireframe)
// can rely on a filter/ instead
let readExample = "https://webdav.benetou.fr/fotsave/ExportedItems-FromZoteroAsCSLJSON.json"
fetch(readExample).then( r => r.json() ).then( fileContent => {
// fileContent.map( i => { return {title:i.title, note:i.note.split('\n')}})
fileContent.map( (i,n) => addNewNote( i.title, position=`-0.2 ${1+n*.1} -0.1`, "0.1 0.1 0.1", "bibliographyitem_"+n, "bibliographyitem" ) )
// could instead make snippets with addNewNote(i.title) then add the right class, etc
setTimeout( _ => {
console.log( Array.from( document.querySelectorAll(".bibliographyitem") )
.filter( i => i.object3D.position.y > 0.5 && i.object3D.position.y < 1.5 )
.sort( (a,b) => a.object3D.position.y > b.object3D.position.y )
)
// could then save back
}, 500)
async function w(path = "/file.txt"){ return await webdavClient.putFileContents(path, JSON.stringify(fileContent)); }
// written = w(subdirWebDAV+usernamePrefix+filename)
// in turn available as https://companion.benetou.fr/poweruser_example_bibliography.csl.json
})
}
// requires running locally, e.g. OLLAMA_HOST=0.0.0.0 OLLAMA_ORIGINS=* ollama serve on the same machine (hence does not work remotely or on HMD)
function ollamaToNote(llmPrompt = "Why is the sky blue?" ){
// could be used based on the transcription of an audio recording
fetch('http://localhost:11434/api/generate', { method: 'POST',body:
'{\n "model": "deepseek-r1:1.5b",\n "prompt": "'+llmPrompt+'",\n "stream": false\n}'
})
.then( res => res.json() ).then( res => addNewNote(res.response))
//.then( res => res.json() ).then( res => addNewNote(res.response.replace(/think[\s\S]*?think/,'')))
// here with deepseek could remove the <think></think> part
}
function startViewCheck(){
let visualArrow = document.createElement("a-cone")
visualArrow.id = 'visualarrow'
visualArrow.setAttribute("opacity", .3)
visualArrow.setAttribute("position", "0 0.5 -1")
visualArrow.setAttribute("scale", "0.1 0.1 0.1")
visualArrow.setAttribute("segments-height", 8)
visualArrow.setAttribute("segments-width", 8)
player.appendChild(visualArrow)
setInterval( i => inView(document.querySelector("a-console")), 100 )
}
// -----=========== snap closest =============--------------------------------------------------------------------------------------------
function snapClosest(snappable=null){ // not selector but array
let lastPick = selectedElements.at(-1).element
let smallestEl
let smallestDistance
if (!snappable) snappable = Array.from( deskpanels.children )
snappable.map( p => {
let d = lastPick.object3D.position.distanceTo( p.object3D.position )
if (!smallestEl) {
smallestEl = p
smallestDistance = d
}
if (d < smallestDistance) {
smallestEl = p
smallestDistance = d
}
})
if (smallestEl){
lastPick.object3D.position.copy( smallestEl.object3D.position )
lastPick.object3D.rotation.copy( smallestEl.object3D.rotation )
lastPick.object3D.rotateX(-Math.PI/2)
// can't read the text... it is backward
// arbitrary offset for pannels, could be a parameter
}
}
// -----=========== HUD visibility =============--------------------------------------------------------------------------------------------
function toggleHUDVisibility(){
let opacity = typinghud.getAttribute("material").opacity
if ( opacity > .1 )
typinghud.setAttribute("material","opacity", .1)
else
typinghud.setAttribute("material","opacity", .5)
}
// -----=========== Cube =============--------------------------------------------------------------------------------------------
function toggleShowCube(){
if ( cubetest.getAttribute("visible") )
cubetest.setAttribute("visible", "false")
else
cubetest.setAttribute("visible", "true")
}
function addCubeWithAnimations(){
let cube = document.createElement("a-entity")
cube.id = "cube"
cube.setAttribute("position", "0.2 1.2 -.7")
//cube.setAttribute("position", "0 1 -1")
cube.setAttribute("target", "")
AFRAME.scenes[0].appendChild(cube)
function cubeFace(parentElement){
let face = document.createElement("a-box")
face.setAttribute("scale", ".1 .1 .01")
face.setAttribute("wireframe", "true")
let axis = document.createElement("a-entity")
// not actually used
axis.appendChild(face)
parentElement.appendChild(axis)
return face
}
let elFaceName
let face_number = 0
let f = cubeFace(cube)
f.id = "face_"+"ABCDEF"[face_number++]
elFaceName = document.createElement("a-troika-text")
elFaceName.setAttribute("value", f.id )
f.appendChild(elFaceName)
const targetAngle = .03
const animate = false
// https://animejs.com/documentation/#JSobject
// https://threejs.org/docs/#api/en/core/Object3D.rotateOnAxis
if (animate) AFRAME.ANIME({targets: cube.children[0].object3D, update: function(){ cube.children[0].object3D.rotateX(-targetAngle)} })
//AFRAME.ANIME({targets: cube.children[0].object3D, update: function(){ cube.children[0].object3D.translateY(-.001)} })
//AFRAME.ANIME({targets: cube.children[0].object3D, update: function(){ cube.children[0].object3D.translateY(-.002)} })
f = cubeFace(cube)
f.id = "face_"+"ABCDEF"[face_number++]
elFaceName = document.createElement("a-troika-text")
elFaceName.setAttribute("value", f.id )
f.appendChild(elFaceName)
// adding visible name to help identify on movement and animations
f.setAttribute("position", ".05 0 .05")
f.setAttribute("rotation", "0 90 0")
if (animate) AFRAME.ANIME({targets: cube.children[1].object3D, update: function(){ cube.children[1].object3D.rotateZ(-targetAngle)} })
f = cubeFace(cube)
f.id = "face_"+"ABCDEF"[face_number++]
elFaceName = document.createElement("a-troika-text")
elFaceName.setAttribute("value", f.id )
f.appendChild(elFaceName)
f.setAttribute("position", "0 0 .1")
if (animate) AFRAME.ANIME({targets: cube.children[2].object3D, update: function(){ cube.children[2].object3D.rotateX(targetAngle)} })
f = cubeFace(cube)
f.id = "face_"+"ABCDEF"[face_number++]
elFaceName = document.createElement("a-troika-text")
elFaceName.setAttribute("value", f.id )
f.appendChild(elFaceName)
f.setAttribute("position", "-0.05 0 .05")
f.setAttribute("rotation", "0 90 0")
if (animate) AFRAME.ANIME({targets: cube.children[3].object3D, update: function(){ cube.children[3].object3D.rotateZ(targetAngle)} })
// bottom face
f = cubeFace(cube)
f.id = "face_"+"ABCDEF"[face_number++]
elFaceName = document.createElement("a-troika-text")
elFaceName.setAttribute("value", f.id )
f.appendChild(elFaceName)
f.setAttribute("rotation", "90 0 0")
f.setAttribute("position", "0 -0.05 0.05")
// top face
f = cubeFace(cube)
f.id = "face_"+"ABCDEF"[face_number++]
elFaceName = document.createElement("a-troika-text")
elFaceName.setAttribute("value", f.id )
f.appendChild(elFaceName)
f.setAttribute("rotation", "90 0 0")
f.setAttribute("position", "0 0.05 0.05")
cube.id = "cubetest"
return cube
}
// both functions should be toggable as ways to revert
function unfoldCube(){
// should save rotation/positions first
Array.from( cubetest.querySelectorAll("a-box") ).map( (f,i) => {
f.formerRotation = f.getAttribute("rotation")
f.setAttribute("rotation", "0 0 0")
f.formerPosition = AFRAME.utils.coordinates.stringify( f.getAttribute("position") )
f.setAttribute("position", i/(10-1)+" 0 0")
} )
// each face has a parent with also element with an offset position
}
function refoldCube(){
// should save rotation/positions first
Array.from( cubetest.querySelectorAll("a-box") ).map( (f,i) => {
f.setAttribute("rotation", f.formerRotation )
f.setAttribute("position", f.formerPosition )
} )
// each face has a parent with also element with an offset position
}
function roomScaleCube(){
cubetest.setAttribute("scale", "20 20 20"); cubetest.setAttribute("position", "0 1 -1"); cubetest.setAttribute("rotation", "0 0 0")
}
function palmScaleCube(){
cubetest.setAttribute("scale", "1 1 1"); cubetest.setAttribute("position", "0 1 -1")
}
function addCube(){
let cube = document.createElement("a-entity")
cube.setAttribute("position", "1 1 -.5")
cube.setAttribute("target", "")
AFRAME.scenes[0].appendChild(cube)
let cubeloweraxis = document.createElement("a-entity")
cubeloweraxis.setAttribute("position", "-.1 0 0")
cubeloweraxis.setAttribute("animation__rot", "property:rotation.z; to:90")
cubeloweraxis.setAttribute("animation__pos", "property:position; to:-.1 -.1 0")
let sll = document.createElement("a-box")
sll.setAttribute("scale", ".1 .1 .01")
sll.setAttribute("position", "0.05 0 0")
sll.setAttribute("rotation", "0 90 0")
sll.setAttribute("wireframe", "true")
cubeloweraxis.appendChild(sll)
cube.appendChild(cubeloweraxis)
// too complicated... should make 1 face, rotate along it's bottom axis then duplicate it and rotate accordingly
// note that 2 faces are specials in regard to animations
// bottom part do not move
// top part move alongside another moving face, e.g. front face
let ssra = document.createElement("a-entity")
ssra.setAttribute("position", ".1 0 0")
ssra.setAttribute("animation__rot", "property:rotation.z; to:-90")
ssra.setAttribute("animation__pos", "property:position; to:.1 0 0")
let srr = document.createElement("a-box")
srr.setAttribute("scale", ".1 .1 .01")
srr.setAttribute("position", ".05 0 0")
srr.setAttribute("rotation", "0 90 0")
srr.setAttribute("wireframe", "true")
ssra.appendChild(srr)
cube.appendChild(ssra)
let sl = document.createElement("a-box")
sl.setAttribute("scale", ".1 .1 .01")
sl.setAttribute("position", "0 -.05 0")
sl.setAttribute("rotation", "90 0 0")
sl.setAttribute("wireframe", "true")
cube.appendChild(sl)
let sr = document.createElement("a-box")
sr.setAttribute("scale", ".1 .1 .01")
sr.setAttribute("position", "0 .05 0")
sr.setAttribute("rotation", "90 0 0")
sr.setAttribute("wireframe", "true")
cube.appendChild(sr)
let sb = document.createElement("a-box")
sb.setAttribute("scale", ".1 .1 .01")
sb.setAttribute("position", "0 0 -.05")
sb.setAttribute("wireframe", "true")
cube.appendChild(sb)
let sf = document.createElement("a-box")
sf.setAttribute("scale", ".1 .1 .01")
sf.setAttribute("position", "0 0 .05")
sf.setAttribute("wireframe", "true")
cube.appendChild(sf)
return cube
}
// -----=========== indicator in view =============--------------------------------------------------------------------------------------------
let isInView = false
function inView(targetSelector){
// https://stackoverflow.com/a/69955650/1442164
const frustum = new THREE.Frustum()
const camera = AFRAME.scenes[0].camera
const matrix = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)
frustum.setFromProjectionMatrix(matrix)
isInView = frustum.containsPoint(targetSelector.object3D.position)
if (isInView){
// could also use HUD on state change e.g. if (isInView) setFeedbackHUD('Out of view')
visualarrow.setAttribute("opacity", .01)
} else {
visualarrow.setAttribute("opacity", .3)
visualarrow.object3D.lookAt( targetSelector.object3D.position )
// seems it needs an offset, maybe due to the cone initial rotation (i.e. pointing up)
visualarrow.object3D.rotateX(1)
// still looks vertically off
}
}
// -----=========== adjust pinch thickness line =============--------------------------------------------------------------------------------------------
function adjustPinchThicknessLines(thickness){
document.querySelector("#rightHand").object3D.traverse( o => { if (o.material) o.material.wireframeLinewidth=thickness } )
}
// -----=========== demos management =============--------------------------------------------------------------------------------------------
let demos
function nextDemo(){
// basically just caching at this point...
if ( demos ) {
moveToNextDemo()
} else {
fetch('/demo_q1.json').then( r => r.json() ).then( r => {
demos = r["content"]
moveToNextDemo()
})
}
}
function moveToNextDemo(){
let internalOrigin = "&sourceFromNextDemo=true" // should be use to display a welcome message, clarifying the name of the demo and what can be done
let availableDemos = [].concat( ...demos.filter( d => d.usernames ).map( d => d.usernames ) )
// too complicated data structure... (due to not having 1-1 URL/demo matching, should simplify that)
// possibly using a tree where each demos has an optional next one, then following that instead (can become a cycling graph...)
// if modifying though then must modify demos_example.html too (which is short so no problem)
let pos = availableDemos.indexOf( username )
if (pos > -1 && pos < availableDemos.length-1)
location.href = "/index.html?username=" + availableDemos[++pos] + internalOrigin
if (pos == -1)
location.href = "/index.html?username=" + availableDemos[0] + internalOrigin
}
function addDemoScreenshot( demo ){
let el = document.createElement("a-image")
AFRAME.scenes[0].appendChild(el)
el.setAttribute("position", "0 "+(Math.random()+1)+" 0.5" )
el.setAttribute("rotation", "0 180 0")
el.setAttribute("scale", ".1 .1 .1")
el.setAttribute("src", demo.screenshot)
el.setAttribute("target", "")
// consider child of
// addNewNote("jxr location.href='/index.html?username="+u+"'", "0.5 "+(1+i/10)+" .5", "0 180 0" )
// instead BUT assumes 1 screenshot per username, thus URL
}
function addMetaDataCurrentDemo(){
let matches = demos.filter( d => d.usernames?.includes(username) )
matches.map( currentDemo => {
if (currentDemo.name) {
let nameEl = addNewNote(currentDemo.name, "0.1 1.8 -.4")
nameEl.id = "demoMetaDataName"
}
if (currentDemo.description) {
let descriptionEl = addNewNote(currentDemo.description, "0.1 1.7 -.5")
descriptionEl.id = "demoMetaDataDescription"
}
})
}
AFRAME.registerComponent('current-demo-metadata', {
init: function () {
if ( demos ) {
addMetaDataCurrentDemo()
} else {
fetch('/demo_q1.json').then( r => r.json() ).then( r => {
demos = r["content"]
addMetaDataCurrentDemo()
})
}
}
})
AFRAME.registerComponent('timed-demos', {
init: function () {
fetch('/demo_q1.json').then( r => r.json() ).then( r => {
demos = r["content"]
const baseURL = r["configuration"].prefixurl
r["content"].filter( c => c.usernames?.length>0 ).map( c=> {
if (c.screenshot) addDemoScreenshot( c )
c.usernames.map( u => console.log( baseURL+u ) )
c.usernames.map( (u,i) => {
addNewNote("jxr location.href='/index.html?username="+u+"'", "0.5 "+(1+i/10)+" .5", "0 180 0" )
})
})
// addNewNote("jxr nextDemo()", "-0.5 1 -.5" )
})
}
})
// -----=========== ... =============--------------------------------------------------------------------------------------------
// generalize snapClosest() to any selector
// use that as example on result of audio notes for manuscript editing
// could also test, if snapped on manuscript, then append content to it
// -----=========== sequential filters on interactions, e.g. on drop and on release =============--------------------------------------------------------------------------------------------
// should also do on move but slightly different logic
// should generalize to any event
// on drop meta data optional... an in VR debug mode in order to show e.g. position/rotation of the last dropped element
// arguably could be ANOTHER middleware case, namely onreleased does one action per item... but could also do more for all items, with filtering per class, position, etc
// could NOT be added to 'onreleased' or 'onpicked' component within the try/catch block, before and after the eval
// because it means it would only apply to such elements
// should instead be applied to ALL targets
// consequently should be modifying the target component
// test case : onreleased : if (el.getAttribute("color") == "red") console.log( el.getAttribute("position") )
let currentFilterOnPicked = null
let currentFilterOnReleased = null
function applyNextFilterInteraction( element, filters, filter ){
filters.map( f => f(element) ) // simplified version, no next() as done with filters
console.log( "done filtering for" )
}
function colorChangeJXROnly( el ){
if ( el.getAttribute("value")?.includes("jxr") ) el.setAttribute("color", "green")
}
sequentialFiltersInteractionOnReleased.push( colorChangeJXROnly )
function colorChangeSpecificCommandNameOnly( el ){
if ( el.getAttribute("value")?.includes("location.reload") ){
el.setAttribute("color", "pink")
// could try applying to only that segment... requires a bit of troika text syntax (and assumptions, e.g. only appear once)
el.setAttribute("scale", ".2 .2 .2")
}
}
sequentialFiltersInteractionOnReleased.push( colorChangeSpecificCommandNameOnly )
function colorChangeSpecificPerId( el ){
if ( el.id == "virtualdesktopplanemovable" ) el.setAttribute("color", "#ddd")
if ( el.id == "manuscript" ) el.setAttribute("color", "white")
}
sequentialFiltersInteractionOnReleased.push( colorChangeSpecificPerId )
// could it be a lambda?
// should try on absolute position, relative position, etc
// should show non overlapping consequences, e.g. one filter change color, the next changes scale
// thus showcasing composability
// then add those filters as examples to remix
// -----=========== ... =============--------------------------------------------------------------------------------------------
AFRAME.registerComponent('instructions-on-hands', {
init: function () {
let rightEl = addNewNote( "pinch to move" )
rightEl.setAttribute("scale", ".05 .05 .05")
rightEl.setAttribute("position", "0 .03 -.05")
rightEl.setAttribute("rotation", "0 -90 0")
rightEl.id = "right_hand_instruction"
let leftEl = addNewNote( "pinch to execute" )
leftEl.setAttribute("scale", ".05 .05 .05")
leftEl.setAttribute("position", "0 .03 .02")
leftEl.setAttribute("rotation", "0 90 0")
leftEl.id = "left_hand_instruction"
},
tick: function (time, timeDelta) {
// definitley overkill... should have a connected event instead
const r_hand = AFRAME.scenes[0].object3D.getObjectByName("r_handMeshNode")?.parent.getObjectByName("thumb-metacarpal")
const l_hand = AFRAME.scenes[0].object3D.getObjectByName("l_handMeshNode")?.parent.getObjectByName("thumb-metacarpal")
if ( r_hand && right_hand_instruction.object3D.parent == AFRAME.scenes[0].object3D ) right_hand_instruction.object3D.parent = r_hand
if ( l_hand && left_hand_instruction.object3D.parent == AFRAME.scenes[0].object3D ) left_hand_instruction.object3D.parent = l_hand
// break expected target behavior
// on pick re-parent to scene then parent back on released
}
})
// -----=========== ... =============--------------------------------------------------------------------------------------------
AFRAME.registerComponent('useraddednote', {
events: {
useraddednote: function (e) {
let noteEl = e.detail.element
if ( noteEl.getAttribute("value").startsWith("jxr ") ) return
noteEl.classList.add("manuscriptnote")
// dirty mix of threejs and AFrame...
noteEl.object3D.parent = manuscript.object3D;
//noteEl.setAttribute("position", manuscript.children[0].getAttribute("position") )
//noteEl.setAttribute("rotation", manuscript.children[0].getAttribute("rotation") )
setTimeout( _ => noteEl.object3D.position.set( -.4, .4 + - document.querySelectorAll(".manuscriptnote").length/10, .51), 100 )
// messes up direct picking after, so could do an interaction filter on pick for this class
// relatively complex to keep track of but should work
// could until then prevent picking, e.g. removing the target
},
}
})
</script>
<a-scene useraddednote list-files-sorted xr-mode-ui="enabled: true; enterAREnabled: true; XRMode: xr;">
<a-entity id="rig">
<a-entity id="player" hud camera look-controls wasd-controls waistattach="target: .movebypinch" position="0 1.6 0"></a-entity>
<a-entity laser-controls="hand: left" raycaster="objects: .collidable; far: .5"></a-entity>
<a-entity oculus-touch-controls="hand: right"></a-entity>
<a-entity id="rightHand" pinchprimary wristattachprimary="target: #otherbox" hand-tracking-controls="hand: right;"></a-entity>
<a-entity id="leftHand" pinchsecondary wristattachsecondary="target: #box" hand-tracking-controls="hand: left;"></a-entity>
</a-entity>
<a-sphere visible=true id=groundfor360 scale="2 .1 2" color="#ccc"></a-sphere>
<a-sphere segments-width=12 segments-height=12 pressable="" start-on-press="" id="box" radius="0.033" color="gray"></a-sphere>
<a-box pressable="" start-on-press-other="" id="otherbox" scale=".05 .05 .05" opacity=.3 wireframe=false color="white"></a-box>
<a-troika-text value="SpaSca : Spatial Scaffolding" anchor="left" outline-width="5%" font="https://fabien.benetou.fr/pub/home/future_of_text_demo/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 id="environmentsky" class="hidableenvironment" hide-on-enter-ar color="darkgray"></a-sky>
<a-entity visible="false" hide-on-enter-ar="" id="environment" rotation="0 -90 0" position="0 .65 0" scale='' gltf-model="url(Apartment.glb)" class="hidableenvironment" ></a-entity>
<a-troika-text id=instructions anchor="left" target value="instructions : \n--right pinch to move\n--left pinch to execute" position="0 1.65 -2" scale="0.1 0.1 0.1"></a-troika-text>
<a-entity id=basiccommands position="0 0 0.3">
<a-troika-text anchor=left target id="locationreload" value="jxr location.reload()" rotation="0 90 0" position="-.5 .9 0" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target id="toggleAnchors" value="jxr toggleAnchors()" rotation="0 90 0" position="-.5 1.25 0" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target id="bumptableup" value="jxr virtualdesktopplanemovable.object3D.position.y+=.1" annotation="content:bump table" rotation="90 90 0" position="-.5 1.15 0" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target id="bumptabledown" value="jxr virtualdesktopplanemovable.object3D.position.y-=.1" annotation="content:bump table down" rotation="90 90 0" position="-.5 1.0 0" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target id="bumptabledown" value="jxr loadOnPannels()" annotation="content:load on panels" rotation="90 90 0" position="-.5 1.05 0" scale="0.1 0.1 0.1"></a-troika-text>
</a-entity>
<a-console position="-1 1.3 0" rotation="-45 90 0" font-size="34" height="0.5" skip-intro="true"></a-console>
<a-box visible=false id="virtualdesktopplane" wireframe=true position="0 .9 -.5" height=.01 depth=.4></a-box>
<a-box visible=false id="virtualdesktopplanemovable" target setupable position="0 1.4 -.5" color="yellow" width=1 height=.01 depth=.02></a-box>
<!--
<a-box id="virtualdesktopplanemovablered" target position="0 1.6 -.3" color="red" width=.1 height=.01 depth=.1></a-box>
<a-box id="virtualdesktopplanemovablegreen" target position="-.5 1.6 -.3" color="green" width=.1 height=.01 depth=.1></a-box>
<a-box id="virtualdesktopplanemovableblue" target position=".5 1.6 -.3" color="blue" width=.1 height=.01 depth=.1></a-box>
<a-cylinder id="cylinderorange" target position=".25 1.7 -.3" color="orange" rotation="45 0 45" height=.1 radius=.01></a-cylinder>
<a-cylinder id="cylinderpurple" target position="-.25 1.7 -.3" color="purple" rotation="45 0 45" height=.1 radius=.01></a-cylinder>
-->
<a-entity visible=true id=middlecommands>
<a-troika-text anchor=left target value="jxr deskpanels.setAttribute('visible', 'true')" rotation="45 0 0" annotation="content:show panels" position="-.1 1.20 -.4" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value="jxr deskpanels.setAttribute('visible', 'false')" rotation="45 0 0" annotation="content:hide panels" position="-.1 1.10 -.4" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value="jxr environment.setAttribute('visible', 'true')" rotation="45 0 0" annotation="content:show background" position="-.5 1.20 -.4" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value="jxr environment.setAttribute('visible', 'false')" rotation="45 0 0" annotation="content:hide background" position="-.5 1.10 -.4" scale="0.1 0.1 0.1"></a-troika-text>
</a-entity>
<a-entity visible=false id=selectioncommands>
<a-troika-text anchor=left target value="jxr bumpSelection()" rotation="45 0 0" annotation="content:push selection" position=".5 1.20 -.4" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value="jxr bumpSelection(true)" rotation="45 0 0" annotation="content:pull selection" position=".5 1.15 -.4" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value="jxr growSelection()" rotation="45 0 0" annotation="content:grow selection" position=".5 1.10 -.4" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value="jxr growSelection(true)" rotation="45 0 0" annotation="content:shrink selection" position=".5 1.05 -.4" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value="jxr extractSelection()" rotation="45 0 0" annotation="content:clone selection" position=".5 1.00 -.4" scale="0.1 0.1 0.1"></a-troika-text>
</a-entity>
<a-entity visible=false id=deskpanels>
<a-box class=panel position="1 1.3 -1" rotation="45 -45 0" wireframe=true height=.01 depth=.4></a-box>
<a-box class=panel position="1 1.3 0" rotation="45 -90 0" wireframe=true height=.01 depth=.4></a-box>
<a-box class=panel position="1 1.3 1" rotation="45 -135 0" wireframe=true height=.01 depth=.4></a-box>
<a-box class=panel position="0 1.3 1.5" rotation="45 180 0" wireframe=true height=.01 depth=.4></a-box>
<a-box class=panel position="-1 1.3 1" rotation="-45 -45 0" wireframe=true height=.01 depth=.4></a-box>
<a-box class=panel position="-1 1.3 0" rotation="45 90 0" wireframe=true height=.01 depth=.4></a-box>
<a-box class=panel position="-1 1.3 -1" rotation="45 45 0" wireframe=true height=.01 depth=.4></a-box>
</a-entity>
<a-entity id=topsidecommands>
<a-troika-text anchor=left target value='jxr setTimeout( _ => saveScreenshot("screenshot_"+Date.now()+".jpg") , 1000 )' rotation="0 90 0" position="-.7 1.50 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value='jxr startViewCheck()' rotation="0 90 0" position="-.7 1.70 .2" scale="0.1 0.1 0.1"></a-troika-text>
</a-entity>
<a-entity visible=false id=highlightcommands>
<a-troika-text anchor=left target color="black" value='jxr highlightColor="black"' rotation="0 -90 0" position=".7 1.70 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target color="yellow" value='jxr highlightColor="yellow"' rotation="0 -90 0" position=".7 1.60 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target color="orange" value='jxr highlightColor="orange"' rotation="0 -90 0" position=".7 1.50 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target color="aqua" value='jxr highlightColor="aqua"' rotation="0 -90 0" position=".7 1.40 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target color="lime" value='jxr highlightColor="lime"' rotation="0 -90 0" position=".7 1.30 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value='jxr console.log( getHighlights() )' rotation="0 -90 0" position=".7 1.10 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value='jxr saveHighlights()' rotation="0 -90 0" position=".7 1.00 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value='jxr nextPageForXMLText()' rotation="0 -90 0" position=".7 0.80 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value='jxr previousPageForXMLText()' rotation="0 -90 0" position=".7 0.70 .2" scale="0.1 0.1 0.1"></a-troika-text>
<!--
<a-troika-text anchor=left target value='jxr nextPageForHighlight()' rotation="0 -90 0" position=".7 0.80 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value='jxr previousPageForHighlight()' rotation="0 -90 0" position=".7 0.70 .2" scale="0.1 0.1 0.1"></a-troika-text>
-->
</a-entity>
<a-entity visible=false id=recordercommands>
<a-troika-text anchor=left target value="jxr setupRecorder()" rotation="0 90 0" position="-.7 1.30 .2" scale="0.1 0.1 0.1">
<a-entity scale=".2 .2 .2" class="icon microphone" position="-.5 0 0">
<a-sphere color=black segments-width=8 segments-height=8 position="0 1 0"></a-sphere>
<a-box color=gray scale="" position=""></a-box>
<a-box color=white scale=".4 2 .4" position="0 -1 0"></a-box>
</a-entity>
</a-troika-text>
<a-troika-text anchor=left target value="jxr mediaRecorder.start(); microphone_recording_indicator.emit('start')" rotation="0 90 0" position="-.7 1.20 .2" scale="0.1 0.1 0.1">
<a-entity scale=".2 .2 .2" class="icon recordmicrophone" position="-.5 0 0">
<a-sphere id=microphone_recording_indicator
animation="property: opacity; from: 1; to: 0; loop: true; dir: alternate; easing: easeInExpo; autoplay: false; startEvents:start; pauseEvents:pause;"
color=red scale=".7 .7 .1" segments-width=8 segments-height=8 position="0 1 1"></a-sphere>
<a-sphere color=black segments-width=8 segments-height=8 position="0 1 0"></a-sphere>
<a-box color=gray scale="" position=""></a-box>
<a-box color=white scale=".4 2 .4" position="0 -1 0"></a-box>
</a-entity>
</a-troika-text>
<a-troika-text anchor=left target value="jxr mediaRecorder.stop(); microphone_recording_indicator.emit('pause')" rotation="0 90 0" position="-.7 1.10 .2" scale="0.1 0.1 0.1">
<a-entity scale=".2 .2 .2" class="icon nonmicrophone" position="-.5 0 0">
<a-sphere color=black segments-width=8 segments-height=8 position="0 1 0"></a-sphere>
<a-box color=gray scale="" position=""></a-box>
<a-box color=white scale=".4 2 .4" position="0 -1 0"></a-box>
<a-box color=red scale=".4 3 .4" rotation="0 0 45" position="0 0 .5"></a-box>
<a-box color=red scale=".4 3 .4" rotation="0 0 -45" position="0 0 .5"></a-box>
</a-entity>
</a-troika-text>
<a-troika-text anchor=left target value="jxr saveAudioFile(latest_audio_id+'.ogg')" rotation="0 90 0" position="-.7 1.00 .2" scale="0.1 0.1 0.1">
<a-entity scale=".2 .2 .2" class="icon audiofile" position="-.5 0 0">
<a-box color=white scale="2 2 .1" position="0 0 0"></a-box>
<a-cone color=gray radius-top=".1" rotation="0 0 -90" scale="1 1 .4" position="0 0 0"></a-cone>
</a-entity>
</a-troika-text>
<a-entity id=audiowidgets position="-.7 .90 .2"></a-entity>
</a-entity>
<a-entity visible=false position="-.4 0 -.5" target user-visibility="username:thicknesstesteruser" id="thicknesscommands">
<a-troika-text anchor=left target value='jxr adjustPinchThicknessLines(1)' position=".7 1.40 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value='jxr adjustPinchThicknessLines(2)' position=".7 1.30 .2" scale="0.1 0.1 0.1"></a-troika-text>
<a-troika-text anchor=left target value='jxr adjustPinchThicknessLines(3)' position=".7 1.20 .2" scale="0.1 0.1 0.1"></a-troika-text>
</a-entity>
<a-entity id="roundedpageborders" target visible=false>
<a-entity position="0.5 1.4 -0.51">
<a-image position="0 0 .001" scale="1 1 .1" class="drawable" raycaster-listen src="#transparent" ></a-image>
<a-box id='pagebackgroundxml' scale=".0015 .0015 .001" width=612 height=792></a-box>
<a-torus position=".39 .5 0" color="#43A367" arc="90" radius=".1" radius-tubular="0.01"></a-torus>
<a-torus position="-.39 .5 0" rotation="0 0 90" color="#43A367" arc="90" radius=".1" radius-tubular="0.01"></a-torus>
<a-torus position=".39 -.5 0" rotation="180 0 0" color="#43A367" arc="90" radius=".1" radius-tubular="0.01"></a-torus>
<a-torus position="-.39 -.5 0" rotation="180 0 90" color="#43A367" arc="90" radius=".1" radius-tubular="0.01"></a-torus>
<a-cylinder position="-.49 0 0" color="#43A367" radius=".02" ></a-cylinder>
<a-cylinder position=".49 0 0" color="#43A367" radius=".02" ></a-cylinder>
<a-cylinder position="0 .6 0" height=.8 rotation="0 0 90" color="#43A367" radius=".02" ></a-cylinder>
<a-cylinder position="0 -.6 0" height=.8 rotation="0 0 90" color="#43A367" radius=".02" ></a-cylinder>
</a-entity>
</a-entity>
<a-entity visible=false position="-.4 1 -.5" target user-visibility="username:poweruser" id=highlighterA>
<a-entity rotation="-30 0 30">
<a-entity raycaster="direction:0 1 0; objects: .drawable; showLine: true; far: .03; lineColor: purple; lineOpacity: 0.5"></a-entity>
<a-cone color=gray radius-top="0" radius-bottom=".01" height=.05></a-cone>
<a-cylinder position="0 -.07 0" height=.1 color=gray radius=".01" ></a-cylinder>
</a-entity>
</a-entity>
<a-entity visible=false position="-.2 1 -.5" target user-visibility="username:poweruser" id=highlighterB>
<a-entity rotation="-30 0 30">
<a-entity raycaster="direction:0 1 0; objects: .drawable; showLine: true; far: .03; lineColor: #0cc; lineOpacity: 0.5"></a-entity>
<a-cone color=gray radius-top="0" radius-bottom=".01" height=.05></a-cone>
<a-cylinder position="0 -.07 0" height=.1 color=gray radius=".01" ></a-cylinder>
</a-entity>
</a-entity>
<a-box id=manuscript position="-.3 1.5 -.5" target onreleased="snapClosest()" scale=".21 .29 .01">
<a-troika-text anchor=left value='Manuscript...' color=black position="-0.4 .4 .51" scale="0.1 0.1 0.1"></a-troika-text>
</a-box>
<!-- cube grids, low opacity, no color, etc -->
<a-entity visible=false user-visibility="username:backgroundexploration" id=backgroundexploration>
<a-sphere wireframe=true animation="property: rotation.x; from: 0; to: 360; loop: true; dur: 100000;" color=red segments-width=8 radius=250 segments-height=8 ></a-sphere>
<a-sphere wireframe=true animation="property: rotation.y; from: 0; to: 360; loop: true; dur: 100000;" color=green segments-width=8 radius=250 segments-height=8 ></a-sphere>
<a-sphere wireframe=true animation="property: rotation.z; from: 0; to: 360; loop: true; dur: 100000;" color=blue segments-width=8 radius=250 segments-height=8 ></a-sphere>
</a-entity>
<a-entity visible=false user-visibility="username:backgroundexplorationlowopacity" id=backgroundexploration>
<a-sphere wireframe=true animation="property: rotation.x; from: 0; to: 360; loop: true; dur: 100000;" opacity=.1 color=red segments-width=8 radius=250 segments-height=8 ></a-sphere>
<a-sphere wireframe=true animation="property: rotation.y; from: 0; to: 360; loop: true; dur: 100000;" opacity=.1 color=green segments-width=8 radius=250 segments-height=8 ></a-sphere>
<a-sphere wireframe=true animation="property: rotation.z; from: 0; to: 360; loop: true; dur: 100000;" opacity=.1 color=blue segments-width=8 radius=250 segments-height=8 ></a-sphere>
</a-entity>
<a-entity visible=false user-visibility="username:backgroundexplorationlowwhitestatic" id=backgroundexploration>
<a-sphere wireframe=true segments-width=8 radius=250 segments-height=8 ></a-sphere>
</a-entity>
<a-entity visible=false user-visibility="username:backgroundexplorationlowwhite" id=backgroundexploration>
<a-sphere wireframe=true animation="property: rotation.x; from: 0; to: 360; loop: true; dur: 100000;" segments-width=8 radius=250 segments-height=8 ></a-sphere>
<a-sphere wireframe=true animation="property: rotation.y; from: 0; to: 360; loop: true; dur: 100000;" segments-width=8 radius=250 segments-height=8 ></a-sphere>
<a-sphere wireframe=true animation="property: rotation.z; from: 0; to: 360; loop: true; dur: 100000;" segments-width=8 radius=250 segments-height=8 ></a-sphere>
</a-entity>
<a-entity opacity=.1 gridplace visible=false user-visibility="username:backgroundexplorationlowwhitegrids" id=backgroundexploration>
</a-entity>
<a-troika-text visible=false user-visibility="username:demoqueueq1" id=demoqueueq1 anchor=left target value='jxr nextDemo()' position=".7 1.30 .5" rotation="0 180 0" scale="0.1 0.1 0.1"></a-troika-text>
</a-scene>
</body>
</html>