commit 6a5c07e0efb0f50e63a6ee6f03190304f270eb1c Author: Fabien Benetou Date: Mon Mar 24 10:38:37 2025 +0100 first commit diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d6d732d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine +# probably a bad start here as a lot of packages are large so no benefit + # could restart from Debian instead + +RUN apk add ghostscript # tested for .pdf via convert +RUN apk add qpdf # to save as new PDF +RUN apk add font-noto # montage from imagemagick requires some fonts +RUN apk add imagemagick # tested for .jpg and .pdf +RUN apk add ffmpeg # for ogg tts (could probably find smaller...) +RUN apk add poppler-utils # for pdftohtml getting XML and images out with positions + +WORKDIR /usr/app +COPY ./stt/whisper.cpp/ /usr/app +COPY ./ /usr/app + +RUN npm install +# for now cheating with ./node_modules already there + +EXPOSE 3000 + +# Set up a default command +CMD [ "node","." ] diff --git a/backend/converters/epub.js b/backend/converters/epub.js new file mode 100644 index 0000000..dfab20d --- /dev/null +++ b/backend/converters/epub.js @@ -0,0 +1,14 @@ +const fs = require('fs'); + +function convert( filename, pages ){ +console.log(pages) + if (filename.endsWith('.pdf')) { + let data = pages.map( p => "").join('
') + // could have a richer datastructure with e.g. p.number for the page number and p.x and p.y for CSS absolute positioning + // probably need to apk add zip then zip the result + const outputFile = './public/saved/epub/'+filename+'.epub' + fs.writeFileSync(outputFile, data); + } +} + +module.exports.convert = convert diff --git a/backend/converters/html_from_pdf_with_image_urls.js b/backend/converters/html_from_pdf_with_image_urls.js new file mode 100644 index 0000000..b7246ac --- /dev/null +++ b/backend/converters/html_from_pdf_with_image_urls.js @@ -0,0 +1,13 @@ +const fs = require('fs'); + +function convert( filename, pages ){ +console.log(pages) + if (filename.endsWith('.pdf')) { + let data = pages.map( p => "").join('
') + // could have a richer datastructure with e.g. p.number for the page number and p.x and p.y for CSS absolute positioning + const outputFile = './public/saved/html/'+filename+'.html' + fs.writeFileSync(outputFile, data); + } +} + +module.exports.convert = convert diff --git a/backend/converters/montage.js b/backend/converters/montage.js new file mode 100644 index 0000000..8d41eaa --- /dev/null +++ b/backend/converters/montage.js @@ -0,0 +1,10 @@ +const {execSync} = require('child_process') + +function convert( filename, pages ){ +console.log(pages) + if (filename.endsWith('.pdf')) { + execSync( 'montage -font /usr/share/fonts/noto/NotoSansSymbols-Regular.ttf -geometry +0+0 -tile 5x '+pages.map( p => filename+'-'+p+'.jpg').join(' ')+' ./saved/montages/'+filename+'montage.jpg', {cwd:'public'}) + } +} + +module.exports.convert = convert diff --git a/backend/converters/ogg_tts.js b/backend/converters/ogg_tts.js new file mode 100644 index 0000000..97d691e --- /dev/null +++ b/backend/converters/ogg_tts.js @@ -0,0 +1,12 @@ +const {execSync} = require('child_process') + +function convert( filename ){ + if (filename.endsWith('.ogg')) { + //execSync( 'convert '+filename+' '+filename+'.jpg', {cwd:'public'}) + execSync( 'ffmpeg -i '+filename+' -ar 16000 -y /tmp/audio_for_tts.wav; LD_LIBRARY_PATH=/usr/app/stt/whisper.cpp/build/bin/ /usr/app/stt/whisper.cpp/build/bin/whisper-cli -f /tmp/audio_for_tts.wav -osrt -m /usr/app/stt/whisper.cpp/models/ggml-base.en.bin; mv /tmp/audio_for_tts.wav.srt '+filename+'.srt', {cwd:'public'}) + fetch('https://ntfy.benetou.fr/convertedwebdav', { method: 'POST', body: JSON.stringify({source: filename, extension: '.srt' }) }) + // could also update a file of conversions to keep track of provenance + } +} + +module.exports.convert = convert diff --git a/backend/converters/pdf.js b/backend/converters/pdf.js new file mode 100644 index 0000000..6bccb8a --- /dev/null +++ b/backend/converters/pdf.js @@ -0,0 +1,11 @@ +const {execSync} = require('child_process') + +function convert( filename ){ + if (filename.endsWith('.pdf')) { + execSync( 'convert '+filename+' '+filename+'.jpg', {cwd:'public'}) + fetch('https://ntfy.benetou.fr/convertedwebdav', { method: 'POST', body: JSON.stringify({source: filename, extension: '.jpg' }) }) + // could also update a file of conversions to keep track of provenance + } +} + +module.exports.convert = convert diff --git a/backend/converters/pdf_json.js b/backend/converters/pdf_json.js new file mode 100644 index 0000000..d890e17 --- /dev/null +++ b/backend/converters/pdf_json.js @@ -0,0 +1,11 @@ +const {execSync} = require('child_process') + +function convert( filename ){ + if (filename.endsWith('.pdf')) { + execSync( 'node ../extract_as_json.js public/'+filename,{cwd:'public'}) + fetch('https://ntfy.benetou.fr/convertedwebdav', { method: 'POST', body: JSON.stringify({source: filename, extension: '.json' }) }) + // could also update a file of conversions to keep track of provenance + } +} + +module.exports.convert = convert diff --git a/backend/converters/pdf_xml.js b/backend/converters/pdf_xml.js new file mode 100644 index 0000000..8cb8cb7 --- /dev/null +++ b/backend/converters/pdf_xml.js @@ -0,0 +1,11 @@ +const {execSync} = require('child_process') + +function convert( filename ){ + if (filename.endsWith('.pdf')) { + execSync( 'cp ../../'+filename+' . && pdftohtml -xml '+filename,{cwd:'public/saved/pdfxml'}) + fetch('https://ntfy.benetou.fr/convertedwebdav', { method: 'POST', body: JSON.stringify({source: filename, extension: '.xml' }) }) + // could also update a file of conversions to keep track of provenance + } +} + +module.exports.convert = convert diff --git a/backend/converters/ppt.js b/backend/converters/ppt.js new file mode 100644 index 0000000..18ff889 --- /dev/null +++ b/backend/converters/ppt.js @@ -0,0 +1,11 @@ +const {execSync} = require('child_process') + +function convert( filename ){ + if (filename.endsWith('.ppt')) { + execSync( 'soffice '+filename+' '+filename+'.jpg', {cwd:'public'}) + fetch('https://ntfy.benetou.fr/convertedwebdav', { method: 'POST', body: JSON.stringify({source: filename, extension: '.jpg' }) }) + // could also update a file of conversions to keep track of provenance + } +} + +module.exports.convert = convert diff --git a/backend/converters/resortedpdf.js b/backend/converters/resortedpdf.js new file mode 100644 index 0000000..9b813cd --- /dev/null +++ b/backend/converters/resortedpdf.js @@ -0,0 +1,10 @@ +const {execSync} = require('child_process') + +function convert( filename, pages ){ +console.log(pages) + if (filename.endsWith('.pdf')) { + execSync( 'qpdf '+filename+' --pages '+filename+' '+pages.join(',')+' -- ./saved/pdf/'+filename, {cwd:'public'}) + } +} + +module.exports.convert = convert diff --git a/backend/index.js b/backend/index.js new file mode 100644 index 0000000..32d95e6 --- /dev/null +++ b/backend/index.js @@ -0,0 +1,128 @@ +const express = require('express') +const fs = require('fs') +const path = require('path') +const app = express() +const port = 3000 + +const converters = ['convert' ] + +app.get('/fileswithmetadata', (req, res) => { + // should be sorted by modified date, see mtimeMs from fs.statSync(path) + let files = [] + let raw = fs.readdirSync('public') + raw.map( f => files.push( {name: f, metadata: fs.statSync(path.join('public',f)) }) ) + res.json( files ) + // consider also https://github.com/LinusU/fs-xattr +}) + +app.get('/files', (req, res) => { + res.json( fs.readdirSync('public') ) + // should be sorted by modified date, see mtimeMs from fs.statSync(path) +}) + +app.get('/', (req, res) => { + res.redirect('/index.html') +}) + +app.use(express.static('public')) + +app.listen(port, () => { + console.log(`open https://companion.benetou.fr on your WebXR device`) +}); + + +app.get('/save-as-new-html/:filename/:pages', (req, res) => { + pages = req.params.pages + filename = req.params.filename // unsafe, can rewrite other files + if (filename.startsWith('/') || filename.includes('..') || !filename.endsWith('.pdf')) return + try{ JSON.parse(pages) } catch { console.log('not json, file NOT saved!'); res.json('failed saved, not proper JSON!'); return } + pages = JSON.parse(pages) + console.log('savedLayout', pages) + //let savedFilename = Date.now()+'.resorted.pdf' + require('./converters/html_from_pdf_with_image_urls.js').convert(filename, pages) + res.json('saved/html/'+filename+'montage.jpg') + //res.json(savedFilename) +}) + +app.get('/save-as-new-montage/:filename/:pages', (req, res) => { + pages = req.params.pages + filename = req.params.filename // unsafe, can rewrite other files + if (filename.startsWith('/') || filename.includes('..') || !filename.endsWith('.pdf')) return + try{ JSON.parse(pages) } catch { console.log('not json, file NOT saved!'); res.json('failed saved, not proper JSON!'); return } + pages = JSON.parse(pages) + console.log('savedLayout', pages) + //let savedFilename = Date.now()+'.resorted.pdf' + require('./converters/montage.js').convert(filename, pages) + res.json('saved/montage/'+filename+'montage.jpg') + //res.json(savedFilename) +}) + +app.get('/save-as-new-pdf/:filename/:pages', (req, res) => { + pages = req.params.pages + filename = req.params.filename // unsafe, can rewrite other files + if (filename.startsWith('/') || filename.includes('..') || !filename.endsWith('.pdf')) return + try{ JSON.parse(pages) } catch { console.log('not json, file NOT saved!'); res.json('failed saved, not proper JSON!'); return } + pages = JSON.parse(pages) + console.log('savedLayout', pages) + //let savedFilename = Date.now()+'.resorted.pdf' + require('./converters/resortedpdf.js').convert(filename, pages) + res.json('saved/pdf/'+filename) + //res.json(savedFilename) +}) + +let savedLayout + +app.get('/save-layout/:layout', (req, res) => { + savedLayout = req.params.layout + // unsafe, assume JSON but could be anything + try{ JSON.parse(savedLayout) } catch { console.log('not json, file NOT saved!'); res.json('failed saved, not proper JSON!'); return } + console.log('savedLayout', savedLayout) + // could be saved to disk, thus to file, too + let savedFilename = Date.now()+'.layout.json' + fs.writeFileSync('./public/'+savedFilename, savedLayout) + // might be better to save in a dedicated directory in ./public + res.json(savedFilename) +}) + +let newFiles = [] +fs.watch('public', (eventType, filename) => { + console.log(`event type is: ${eventType}`); // rename can also be deleting... + // could consequently check if the file still exists, if not, had been deleted + if (filename) { + + console.log(`filename provided: ${filename}`) + if (eventType == "rename"){ + console.log("fs exists?", fs.existsSync('./public/'+filename)) // false despite existing + if (!fs.existsSync('./public/'+filename)) { + console.log(`${filename} deleted`) + } else { + // done on uploads because there might be temporary files that "accumuldates" until done then renamed + sequentialConverters( filename ) + } + } + if (eventType == "change"){ + if (newFiles.includes(filename)){ + console.log( 'skip, not a new file') + } else { + // sendEventsToAll({filename,eventType}) former SSE way + // fetch('https://ntfy.benetou.fr/fswatch', { method: 'POST', body: JSON.stringify({filename, eventType}) }) + console.log('new file', filename, '_________________________________________') + if ( !filename.includes('.live') ) { + newFiles.push(filename) + // bypass on convention, e.g. live in the filename + // alternatively could be a dedicated subdirectory + } else { console.log('live file, no future ignoring') } + sequentialConverters( filename ) + } + } + } else { + console.log('filename not provided'); + } +}); + +function sequentialConverters( filename ){ + require('./converters/pdf.js').convert(filename) + require('./converters/pdf_json.js').convert(filename) + require('./converters/ogg_tts.js').convert(filename) + require('./converters/pdf_xml.js').convert(filename) +} diff --git a/data/demo_q1.json b/data/demo_q1.json new file mode 100644 index 0000000..1c53e78 --- /dev/null +++ b/data/demo_q1.json @@ -0,0 +1,38 @@ +{ + "configuration": { + "description":"update the URL sequentially because they are hosted on the same domain", + "clarification":"usernames are used as identifiers and thus must be unique, even if leading to no behavior change. Example would q1step4 then q1step5.", + "prefixurl":"https://companion.benetou.fr/index.html?username=" + }, + "content":[ + {"name":"Curated demos for Q1", "old_description":"use the left wrist to show commands, look behind and press nextDemo()", +"alt":"You have a sphere on your left wrist, touch it to view code snippets", +"description":"You have a sphere on your left wrist.\n You can tap it to reveal snippets of code with your pointing finger.\n Touching these on the left side allows you to:\n - Move them with your right hand\n - Execute with your left hand\n\nTo go to the next step in the demo, look behind you and tap 'jxr demoNext()' (edited)", +"screenshot":"demoqueueq1.png", +"usernames":["demoqueueq1"] }, + {"name":"Physical Table in VR (alignment)", "video":"https://youtu.be/A_vH3wRVX_4?t=3336", "description":"move the yellow table from center and release it by your desk height", "screenshot":"tabletest.png", "usernames":["tabletest"]}, + {"name":"Tap wrist as shortcut", "description":"left wrist to show hide/code snippets","usernames":["q1_step_wrist"] }, + {"name":"Shortcut binding", "description":"drag and drop onto wrist to make new command\ntry with nextDemo() then tap it to move on", "usernames":["q1_step_shortcutset"] }, + {"name":"Highlight Text", "video":"https://youtu.be/A_vH3wRVX_4?t=5446", "description": "Puck a line from a PDF to change its coloor.\nThe result becomes available in 2D for yourself and others.\n\nUse the highlighters to freely draw over the document, under its text.\n\nSee https://companion.benetou.fr/highlights_example.html", "screenshot":"q1_step_highlights.png", "usernames":["q1_step_highlights"] }, + {"name":"References cards", "video":"https://youtu.be/A_vH3wRVX_4?t=6541", "description": "Load a bibliography and manipulate reference as cards.\nSee https://companion.benetou.fr/references_manual_v04.json", "screenshot":"q1_step_refcards.png", "usernames":["q1_step_refcards"] }, + {"name":"Manuscript stick to closest panels", "description":"Use the right wrist to show commands, show panels,\npick then release the manuscript from its center to drop it on the closest panel.", "usernames":["q1_step_snappanels"] }, + {"name":"Unfolding Cube", "screenshot":"demo_cube_screenshot.jpg", "description":"Unfold and fold the cube, scale it to room scale\nthen back to the size of your hand to mive.", "screenshot":"refoncubetester.png", "usernames":["refoncubetester"] }, + {"name":"Screenshot in VR", "description": "Document your process by taking screenshots\nthat become instantly available on the Web for yourself\nand to collaborators.\nSee https://companion.benetou.fr/audio_notes_example.html", "usernames":["q1_step_screenshot"] }, + {"name":"Audio recording ", "screenshot":"poweruser_screenshot_1739174489566.jpg", "description": "Document the screenshots by talking over them.\nThey also become available to share.\n\nTranscriptions are used to make the documentation searchable.\nSee https://companion.benetou.fr/audio_notes_example.html", "usernames":["q1_step_audio"] }, + {"name":"Visual Background ", "description":"(non-functional) for grey, room (3D model) and ornaments (animations in background), potential for ambient info as image or semantically integrated widgets", + "usernames":[ "backgroundexploration", "backgroundexplorationlowopacity", "backgroundexplorationlowwhitestatic", "backgroundexplorationlowwhite", "backgroundexplorationlowwhitegrids" ] + }, + {"name":"Customization via URL set", "description":"Modify the URL to customize the experience and share that with others, e.g. https://companion.benetou.fr/index.html?set_IDmanuscript_color=lightyellow", "usernames":["q1_step_urlcustom"] }, + {"name":"Upload Document via desktop", "description":"Using a desktop or laptop, drag and drop an image in the top right corner, see the result live in XR.", "usernames":["q1_step_showfile"] }, + {"name":"End of currated demos for Q1", "description":"Thank you for testing, please feel free to share idea on how to open this work more.", "usernames":["demoqueueq1end"] }, + {"unassigned-usernames":[ + "poweruser", + "thicknesstesteruser", + "jsonrefmanualtester", + "refoncubetester", + "cubetester", + "metatester10032025 ", + "thicknesstesteruser" + ]} + ] +} diff --git a/data/demos_example.html b/data/demos_example.html new file mode 100644 index 0000000..66adeb6 --- /dev/null +++ b/data/demos_example.html @@ -0,0 +1,123 @@ + + + + + + + + + + +
+ + + +
+ +
+
+
+ +

+ideas : +

+ + + +

+done : +

+ + + + + diff --git a/data/filters/another_content_filter_example.js b/data/filters/another_content_filter_example.js new file mode 100644 index 0000000..f4a1bc9 --- /dev/null +++ b/data/filters/another_content_filter_example.js @@ -0,0 +1,13 @@ +function filterSVGImage( contentFilename ){ + let file = filesWithMetadata[contentFilename] + if (!file) return + + let contentType = file.contentType + + if ( contentType.includes("image") && contentFilename.endsWith(".svg")) { + console.log('it is an SVG image', contentFilename) + } + applyNextFilter( contentFilename ) +} + +sequentialFilters.push( filterSVGImage ) diff --git a/data/filters/content_filter_examples.js b/data/filters/content_filter_examples.js new file mode 100644 index 0000000..d7b6bd2 --- /dev/null +++ b/data/filters/content_filter_examples.js @@ -0,0 +1,40 @@ +// inspired by http://expressjs.com/en/guide/using-middleware.html + +function filterImage( contentFilename ){ + let file = filesWithMetadata[contentFilename] + if (!file) return + + let contentType = file.contentType + + if ( contentType.includes("image") ) { + console.log('normal image', contentFilename) + let fullPath = contentFilename + let el = document.createElement("a-image") + AFRAME.scenes[0].appendChild(el) + el.setAttribute("position", "0 "+(Math.random()+1)+" -1") + el.setAttribute("src", fullPath) + el.setAttribute("target", "") + el.id = fullPath.replaceAll('.','') + el.filename = fullPath + } + applyNextFilter( contentFilename ) +} + +sequentialFilters.push( filterImage ) + +function filterGlbOrGltf( contentFilename ){ + let file = filesWithMetadata[contentFilename] + if (!file) return + + let contentType = file.contentType + + if ( contentType.includes("model/gl") && (contentFilename.endsWith('gltf') || contentFilename.endsWith('glb') ) ) { + let el = document.createElement("a-gltf-model") + AFRAME.scenes[0].appendChild(el) + el.setAttribute("src", contentFilename) + el.setAttribute("target", "") + } + applyNextFilter( contentFilename ) +} + +sequentialFilters.push( filterGlbOrGltf ) diff --git a/data/filters/json_ref_manual.js b/data/filters/json_ref_manual.js new file mode 100644 index 0000000..fd3dd62 --- /dev/null +++ b/data/filters/json_ref_manual.js @@ -0,0 +1,76 @@ +function filterJSONRef( contentFilename ){ + let file = filesWithMetadata[contentFilename] + if (!file) return + + let contentType = file.contentType + + if ( contentType.includes("json") && contentFilename.endsWith(".json")) { + console.log('it is a manual reference JSON file', contentFilename) + fetch( contentFilename ).then( r => r.json() ).then( json => { + let ref = json["data-objects"] + ref.map( (r, i) => { + let el = addNewNote( r["bibtex-data"].title, "0 "+(1+i/20)+" -.5" ) + // should be able to specify a parent + el.id = r["object-id"] + el.classList.add("reference-entry") + el.data = r + // could add some new interaction onreleased/onpicked + el.setAttribute("onpicked", "console.log( selectedElements.at(-1).element.id )") + // el.setAttribute("onreleased", "console.log( selectedElements.at(-1).element.id )") + // could also toggle show/hide onreleased in a target area or close enough to something else + // show what though? all? "quick" layout engine? + let fullEl = addNewNote( Object.entries( r["bibtex-data"] ) + .filter( e => e[1] ) + .map( e => e.join(": ")) + .join("\n") , "-.1 "+(1+i/20)+" -.5" ) + fullEl.setAttribute("color", "black") + fullEl.setAttribute("outline-width", "0") + // filtering out empty values + fullEl.setAttribute("rotation", "45 0 0") + fullEl.setAttribute("scale", ".01 .01 .01") + fullEl.classList.add("reference-entry-card") + let backgroundEl = document.createElement("a-box") + backgroundEl.setAttribute("scale", "10 5 .1") + backgroundEl.setAttribute("position", "4.5 0 -.1") + fullEl.appendChild( backgroundEl ) + // if ACM and OA might be available via https://dl.acm.org/doi/pdf/DOI + // could then try to pass as PDF reader argument + // cf pageAsTextViaXML() and related in index.html + // note that it'd still need to fetch then upload via WebDAV + let pdf = r["bibtex-data"]["source-pdf"] + let acmoa = r["bibtex-data"]["free-acm-access"] + if (pdf && acmoa && pdf.startsWith("https://dl.acm.org")) { + // could then try to fetch content then upload via WebDAV + // should skip if already available + let pdfEl = document.createElement("a-box") + //pdfEl.setAttribute("scale", ".1 .1 .1") + pdfEl.setAttribute("position", "-.9 0 0") + fullEl.appendChild( pdfEl ) + + let truncated_filename = "3209542.3209570" // hardcoded example + // should instead try to fetch .xml on saved/pdfxml/ and if 200 then change color + if (pdf.endsWith( truncated_filename )) { + pdfEl.setAttribute("color", "green" ) + //pdfEl.setAttribute("value", "jxr console.log('"+truncated_filename+"')" ) + // what should become the target then? the cube? + // problematic because it becomes movable + //pdfEl.setAttribute("target", "" ) + + // then should add JXR open of target PDF + + /* + window.pageastextviaxmlsrc = "https://companion.benetou.fr/saved/pdfxml/"+truncated_filename+".xml" + pageAsTextViaXML() + highlightcommands.setAttribute("visible", true) + roundedpageborders.setAttribute("visible", true) + */ + + } + } + }) + }) + } + applyNextFilter( contentFilename ) +} + +sequentialFilters.push( filterJSONRef ) diff --git a/data/filters/modifications_via_url.js b/data/filters/modifications_via_url.js new file mode 100644 index 0000000..b89f2ad --- /dev/null +++ b/data/filters/modifications_via_url.js @@ -0,0 +1,18 @@ +function filterTextModifications( contentFilename ){ + let file = filesWithMetadata[contentFilename] + if (!file) return + + let contentType = file.contentType + + if ( contentType.includes("text") && contentFilename.endsWith("_modifications.txt")) { + console.log('it is an modification scheme', contentFilename) + console.log('try to pass it to parametersViaURL(data)') + fetch( contentFilename ).then( r => r.text() ).then( txt => { + const data = new URLSearchParams(txt) + parametersViaURL(data) + }) + } + applyNextFilter( contentFilename ) +} + +sequentialFilters.push( filterTextModifications ) diff --git a/data/filters/screenshot_ui.js b/data/filters/screenshot_ui.js new file mode 100644 index 0000000..214a046 --- /dev/null +++ b/data/filters/screenshot_ui.js @@ -0,0 +1,28 @@ +// inspired by http://expressjs.com/en/guide/using-middleware.html + +function filterScreenshot( contentFilename ){ + let file = filesWithMetadata[contentFilename] + if (!file) return + + let contentType = file.contentType + + if ( contentType.includes("image") && contentFilename.startsWith('screenshot_') && contentFilename.endsWith('.jpg') ) { + console.log('screenshot image', contentFilename) + let fullPath = contentFilename + let id = fullPath.replaceAll('.','') + // get element generated by the previous filter + // probably too fast + let elParent = document.getElementById( id ) + let el = document.createElement("a-entity") + elParent.appendChild( el ) + let elBox = document.createElement("a-box") + elBox.setAttribute("scale", ".1 .1 .1") + elBox.setAttribute("wireframe", "true") + elBox.setAttribute("color", "purple") + el.appendChild( elBox ) + + } + applyNextFilter( contentFilename ) +} + +sequentialFilters.push( filterScreenshot ) diff --git a/data/filters/srt_to_json.js b/data/filters/srt_to_json.js new file mode 100644 index 0000000..4547d53 --- /dev/null +++ b/data/filters/srt_to_json.js @@ -0,0 +1,21 @@ +function filterTextModifications( contentFilename ){ + let file = filesWithMetadata[contentFilename] + if (!file) return + + let contentType = file.contentType + + if ( contentType.includes("subrip") && contentFilename.endsWith(".srt")) { + console.log('it is an modification scheme', contentFilename) + console.log('try to pass it to parametersViaURL(data)') + fetch( contentFilename ).then( r => r.text() ).then( txt => { + console.log(txt.split(/$\n/).map(l=>{ + let subItem = l.split('\n') + let timings = subItem[1].split(' --> ') + return { id:Number(subItem[0]), timingStart:timings[0], timingEnd:timings[1], text:subItem[2] } + } )) + }) + } + applyNextFilter( contentFilename ) +} + +sequentialFilters.push( filterTextModifications ) diff --git a/data/filters/txt.js b/data/filters/txt.js new file mode 100644 index 0000000..e32753d --- /dev/null +++ b/data/filters/txt.js @@ -0,0 +1,19 @@ +function filterTextModifications( contentFilename ){ + + let idFromFilename = contentFilename.replaceAll('.','') // has to remove from proper CSS ID + + let file = filesWithMetadata[contentFilename] + if (!file) return + + let contentType = file.contentType + + if ( contentType.includes("text") && contentFilename.endsWith(".txt")) { + fetch( contentFilename ).then( r => r.text() ).then( txt => { + let el = addNewNote( txt ) + el.id = idFromFilename + }) + } + applyNextFilter( contentFilename ) +} + +sequentialFilters.push( filterTextModifications ) diff --git a/data/gesture-exploration.js b/data/gesture-exploration.js new file mode 100644 index 0000000..852b0ae --- /dev/null +++ b/data/gesture-exploration.js @@ -0,0 +1,256 @@ +/* potential improvements + +event on state switch, e.g. thumb up to thumb down or whatever to thumb down + but NOT thumb down to thumb down +sustained state, e.g. thumb down to thumb down for N seconds +extend proximityBetweenJointsCheck to any object3D or from 1 object3D to a class of entities (which themselves are object3D) +generalize showGestureDebug to any joint, not just thumb-tip of right hand + +*/ + +targetGesture = {"microgesture":{"type":"glyph","action":"Extension","context":["Contact","Air"],"parameters":{"pressure":{"start":null,"end":null,"type":"no_end"},"amplitude":{"start":null,"end":null,"type":"no_end"},"time":{"start":null,"end":null,"leftType":"none","rightType":"bigger"}},"actuator":[["thumb"]],"phalanx":[]}} +// supports both hands + +const fingersNames = ["index-finger", "middle-finger", "ring-finger", "pinky-finger","thumb"] +const tips = fingersNames.map( f => f+"-tip" ) +const thumbParts = ["metacarpal", "phalanx-proximal", "phalanx-distal"] // no phalanx-intermediate for thumb +const fingerParts = thumbParts.concat(["phalanx-intermediate"]) +const fingers = tips.concat( thumbParts.map( f => fingersNames.at(-1)+"-"+f ), fingerParts.flatMap( fp => fingersNames.slice(0,4).map( fn => fn+"-"+fp) ) ) +const allJointsNames = ["wrist"].concat( fingers ) // also has wrist, no fingers +// console.log( allJointsNames.sort() ) + +function shortVec3(v){ return {x:v.x.toFixed(3), y:v.y.toFixed(3), z:v.z.toFixed(3)} } ; + +// assumes joints, could be generalized to any Object3D +function proximityBetweenJointsCheck(joints){ + const thresholdDistance = .008 + // contacts even while hands resting + // 2cm : 8 + // 1cm : 4 + // 9mm : 2 + // 8mm : 0 ... but also prevents some contacts, e.g. finger tips accross fingers + // consequently would have to identify which contacts take place at rest + // might be from within same finger and thus potentially to filter out when "next" to each other joint + // e.g. finger tip could physiologically touch own metacarpal but no phalanx + // BUT it can for the same finger on the other hand + + let contacts = [] + + let combinations = joints.flatMap( (v, i) => joints.slice(i+1).map( w => [v, w] )) + // from https://stackoverflow.com/a/43241287/1442164 + + combinations.map( j => { + let rt = j[0].position + let lt = j[1].position + //console.log( 'checking: ', rt, lt ) + let dist = rt.distanceTo(lt) + if ( dist < thresholdDistance ) { + contacts.push( {dist: dist.toFixed(3), a:j[0].name, ah: j[0].parent.parent.el.id, b:j[1].name, bh: j[1].parent.parent.el.id } ) + // assumes a bone, could check first on type, otherwise can have different behavior + // could add the timestamp and position value at that moment + } + }) + + return contacts + // getting up to 45 contacts checking 5 finger tips on each hand, which is correct for C10,2 +} + +// could also attach the value then show next to the joint +let debugValue = {} + +function addDebbugGraph(){ + el = document.createElement("a-box") + el.id = "debuggraph" + el.setAttribute("scale", "1 .3 .01") + el.setAttribute("position", "0 1.4 -1") + AFRAME.scenes[0].appendChild(el) +} + +// used an array of points, e.g. pos.x over time, thus every 50ms xTimeSeries.push(pos.x) +function drawPoints(points){ + if (debugValue.length<10) return + let canvas = document.createElement('canvas'); + canvas.width = 1000; + canvas.height = 100 * Object.values( points).length + + const ctx = canvas.getContext("2d") + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + // might want to append (and thus track status) in in order to show the result live + // or "just" take the last 10 elements of array + // middle should be 0... as we can go negative on that axis + //points.slice(-10).map( (p,n) => { + let verticalOffsetSize = 50 + Object.values( points).map( (v,i) => { + ctx.beginPath() + ctx.moveTo(0, 0) + let values = v + if (v.length > 100) values = v.slice(-100) + ctx.strokeStyle = "black"; + values.map( (p,n) => { + let value = Math.floor( 100-1+p*100 ) + ctx.lineTo(n*10, value+i*verticalOffsetSize) + ctx.moveTo(n*10, value+i*verticalOffsetSize) + if (value>100-10 && value<100+10) { + console.log('customgesture', value) + AFRAME.scenes[0].emit('customgesture') + ctx.strokeStyle = "green"; + } + + }) + ctx.stroke() + }) + ctx.beginPath() + ctx.moveTo(0, 100-10) + ctx.lineTo(canvas.width, 100-10) + ctx.moveTo(0, 100+10) + ctx.lineTo(canvas.width, 100+10) + ctx.strokeStyle = "red"; + ctx.stroke() + let el = document.getElementById("debuggraph") + el.setAttribute("src", canvas.toDataURL() ) // somehow works on other canvas... + // el.object3D.children[0].material.needsUpdate = true + //console.log( el.src ) // works but does not update + return el +} + +// should be a component instead... +setTimeout( _ => { + const myScene = AFRAME.scenes[0].object3D + /* + setInterval( i => { + if (!myScene.getObjectByName("l_handMeshNode") ) return + const wrist = myScene.getObjectByName("r_handMeshNode").parent.getObjectByName("wrist") + let sum = Math.abs(wrist.rotation.y) + Math.abs(wrist.rotation.y) + Math.abs(wrist.rotation.z) + console.log( sum ) + if ( sum < .3 ) cubetest.setAttribute("position") = wrist.position // doesn't look good, cube on wrist is moving quite a bit too + // could check if all joints have close to 0 rotation on ... + // are roughly on the same y-plane of the wrist (facing up or down) + }, 500 ) + */ + /* + gestureThumbEndingAnyContact = setInterval( i => { + if (!myScene.getObjectByName("l_handMeshNode") ) return + // potential shortcuts : + const leftHandJoints = myScene.getObjectByName("l_handMeshNode").parent.children.filter( e => e.type == "Bone") + const rightHandJoints = myScene.getObjectByName("r_handMeshNode").parent.children.filter( e => e.type == "Bone") + const allHandsJoints = leftHandJoints.concat( rightHandJoints ) + + let posA = myScene.getObjectByName("r_handMeshNode").parent.getObjectByName("thumb-tip").position + let contactPointsToThumbA = leftHandJoints + .concat( rightHandJoints[0].parent.children.filter( e => e.name != 'thumb-tip' ) ) // right hand except thumb tip + .map( e => e.position.distanceTo(posA) ).filter( d => d < .02 ) // threshold of 2cm distance (must be bigger than thumb-tip to previous join position) + // relatively compact description and maybe relatively computively cheap + let pos = myScene.getObjectByName("l_handMeshNode").parent.getObjectByName("thumb-tip").position + let contactPointsToThumb = rightHandJoints + .concat( leftHandJoints[0].parent.children.filter( e => e.name != 'thumb-tip' ) ) // right hand except thumb tip + .map( e => e.position.distanceTo(pos) ).filter( d => d < .02 ) // threshold of 2cm distance (must be bigger than thumb-tip to previous join position) + if (contactPointsToThumb.length+contactPointsToThumbA.length < 1) console.log('no contact'); else console.log('thumb tip in contact with same hand or other hand') + // on contact could also return the join number/names + }, 500 ) + */ + + /* + testAvegageValue = setInterval( i => { + if (!myScene.getObjectByName("r_handMeshNode") ) return + let rt = myScene.getObjectByName("r_handMeshNode").parent.getObjectByName("thumb-tip"); + debugValue.x.push( rt.position.x ) + let v = debugValue.x + const windowSize = 10 // otherwise too long, e.g 100x500ms gives 5s average + if (v.length > windowSize) { + values = v.slice(-windowSize) + let avg = ( values.reduce( (acc,c) => acc+c )/windowSize) .toFixed(3) + console.log( avg ) + } + }, 50 ) + */ + + /* + showContactPoints = setInterval( i => { + + if (!myScene.getObjectByName("r_handMeshNode") ) return + let targetJoints = [] + tips.map( t => targetJoints.push( myScene.getObjectByName("r_handMeshNode").parent.getObjectByName(t) ) ) + tips.map( t => targetJoints.push( myScene.getObjectByName("l_handMeshNode").parent.getObjectByName(t) ) ) + // tips only + let contacts = proximityBetweenJointsCheck(targetJoints) + if (contacts.length) { + console.log( "contacts:", contacts ) + // {dist: dist.toFixed(3), a:j[0].name, ah: j[0].parent.parent.el.id, b:j[1].name, bh: j[1].parent.parent.el.id } + contacts.map( c => { + // show value or even just a temporary object there + let a = document.getElementById(c.ah).object3D.getObjectByName(c.a) + let b = document.getElementById(c.bh).object3D.getObjectByName(c.b) + const geometry = new THREE.BoxGeometry( .01, .01, .01 ) + const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } ) + const cube = new THREE.Mesh( geometry, material ) + a.add( cube ) + + }) + } + }) + */ + + /* + showGestureDistanceDebugJoints = setInterval( i => { + + if (!myScene.getObjectByName("r_handMeshNode") ) return + let targetJoints = [] + tips.map( t => targetJoints.push( myScene.getObjectByName("r_handMeshNode").parent.getObjectByName(t) ) ) + tips.map( t => targetJoints.push( myScene.getObjectByName("l_handMeshNode").parent.getObjectByName(t) ) ) + // console.log( targetJoints ) looks fine + //console.log( "contacts:", proximityBetweenJointsCheck(targetJoints) + // tips only + let targetJointsFull = [] + allJointsNames.map( t => targetJointsFull.push( myScene.getObjectByName("r_handMeshNode").parent.getObjectByName(t) ) ) + allJointsNames.map( t => targetJointsFull.push( myScene.getObjectByName("l_handMeshNode").parent.getObjectByName(t) ) ) + let contacts = proximityBetweenJointsCheck(targetJointsFull) + if (contacts.length) console.log( "contacts:", contacts ) + }) + */ + + /* + showGestureDistanceDebug = setInterval( i => { + if (!myScene.getObjectByName("r_handMeshNode") ) return + let rt = myScene.getObjectByName("r_handMeshNode").parent.getObjectByName("thumb-tip").position + let lt = myScene.getObjectByName("l_handMeshNode").parent.getObjectByName("thumb-tip").position + if ( rt.distanceTo(lt) < .1 ) + console.log( 'lt close to rt') + else + console.log( rt.distanceTo(lt) ) + }) + */ + + /* + showGestureDebug = setInterval( i => { + if (!myScene.getObjectByName("r_handMeshNode") ) return + let rt = myScene.getObjectByName("r_handMeshNode").parent.getObjectByName("thumb-tip"); + //console.log( shortVec3( rt.position ), shortVec3( rt.rotation ) ) + // could do for the 2x25 values... but then becomes unreadible, hence why showing sparklines could help + // can be done on HUD + + if (!debugValue.x){ + debugValue.x = [] + debugValue.y = [] + debugValue.z = [] + + debugValue.a = [] + debugValue.b = [] + debugValue.c = [] + } + + debugValue.x.push( rt.position.x ) + debugValue.y.push( rt.position.y ) + debugValue.z.push( rt.position.z ) + + debugValue.a.push( rt.rotation.x ) + debugValue.b.push( rt.rotation.y ) + debugValue.c.push( rt.rotation.z ) + + let el = document.getElementById("debuggraph") + if (!el) addDebbugGraph() + drawPoints( debugValue ) + }, 50 ) + */ +}, 1000) +// waiting for the scene to be loaded, could be component proper too... diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..4bf92dc --- /dev/null +++ b/data/index.html @@ -0,0 +1,2307 @@ + + + + + + + JXR filesystem and mimetype based explorations + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + + +
+
+

Drag File To Upload.

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