first commit

main
Fabien Benetou 1 week ago
commit 6a5c07e0ef
  1. 22
      backend/Dockerfile
  2. 14
      backend/converters/epub.js
  3. 13
      backend/converters/html_from_pdf_with_image_urls.js
  4. 10
      backend/converters/montage.js
  5. 12
      backend/converters/ogg_tts.js
  6. 11
      backend/converters/pdf.js
  7. 11
      backend/converters/pdf_json.js
  8. 11
      backend/converters/pdf_xml.js
  9. 11
      backend/converters/ppt.js
  10. 10
      backend/converters/resortedpdf.js
  11. 128
      backend/index.js
  12. 38
      data/demo_q1.json
  13. 123
      data/demos_example.html
  14. 13
      data/filters/another_content_filter_example.js
  15. 40
      data/filters/content_filter_examples.js
  16. 76
      data/filters/json_ref_manual.js
  17. 18
      data/filters/modifications_via_url.js
  18. 28
      data/filters/screenshot_ui.js
  19. 21
      data/filters/srt_to_json.js
  20. 19
      data/filters/txt.js
  21. 256
      data/gesture-exploration.js
  22. 2307
      data/index.html
  23. 995
      jxr-core.js

@ -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","." ]

@ -0,0 +1,14 @@
const fs = require('fs');
function convert( filename, pages ){
console.log(pages)
if (filename.endsWith('.pdf')) {
let data = pages.map( p => "<img src='/"+filename+'-'+p+".jpg'/>").join('<br>')
// 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

@ -0,0 +1,13 @@
const fs = require('fs');
function convert( filename, pages ){
console.log(pages)
if (filename.endsWith('.pdf')) {
let data = pages.map( p => "<img src='/"+filename+'-'+p+".jpg'/>").join('<br>')
// 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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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)
}

@ -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"
]}
]
}

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://fabien.benetou.fr/pub/home/future_of_text_demo/engine/dependencies/webdav.js"></script>
</head>
<body>
<div id=content></div>
<script>
// insert screenshots (could probably do the same way, i.e. filereader then webdav upload
const webdavURL = "https://webdav.benetou.fr";
const subdirWebDAV = "/fotsave/fot_sloan_companion_public/"
var webdavClient = window.WebDAV.createClient(webdavURL)
const hmdURL = "https://hmd.link/?https://companion.benetou.fr"
// const eventSourceConverted = new EventSource( `https://ntfy.benetou.fr/convertedwebdav/sse` )
// to use for live updates
async function getContent(){
let rootEl = document.getElementById("content")
const contents = await webdavClient.getDirectoryContents(subdirWebDAV);
// consider instead search https://github.com/perry-mitchell/webdav-client#search
contents.filter(f => f.basename.endsWith('demo_q1.json'))
.map(a => {
fetch(a.basename).then( r => r.json() ).then( r => {
r.content.filter( c => c.name ).map( c => {
let h2 = document.createElement("h2")
h2.innerText = c.name
rootEl.appendChild(h2)
if ( c.screenshot ){
let img = document.createElement("img")
img.src = c.screenshot
img.style.height = "200px"
rootEl.appendChild(img)
}
if ( c.description ){
let h3 = document.createElement("h3")
//h3.innerText = c.description //.replace()
h3.innerHTML = c.description.replace(/(.*) (http.*)/,'$1 <a href="$2">$2</a>').replaceAll('\n','<br>')
// could be innerHTML instead
// should make link clickable
rootEl.appendChild(h3)
}
if ( c.video ){
let ul = document.createElement("ul")
rootEl.appendChild(ul)
let li = document.createElement("li")
let link = document.createElement("a")
link.href = c.video
link.innerText = "video extract"
ul.appendChild(li)
link.target = "_blank"
li.appendChild(link)
}
if (c.usernames) {
let ul = document.createElement("ul")
rootEl.appendChild(ul)
c.usernames.map( h => {
let li = document.createElement("li")
let link = document.createElement("a")
link.href = "/index.html?username="+h
link.innerText = "link"
li.appendChild(link)
let spanEl = document.createElement("span")
spanEl.innerText = " "
li.appendChild(spanEl)
let linkHMD = document.createElement("a")
linkHMD.href = hmdURL + "/index.html?username="+h
linkHMD.target = "_blank"
linkHMD.innerText = "(open on other device)"
li.appendChild(linkHMD)
ul.appendChild(li)
})
}
let hr = document.createElement("hr")
rootEl.appendChild(hr)
})
})
})
}
getContent()
</script>
<div id=comments>
<br>
<br>
</div>
<h3>
ideas :
</h3>
<ul>
<li>integrate better live messages (via ?allowNtfyFeedbackHUD=true , e.g. https://companion.benetou.fr/index.htm?allowNtfyFeedbackHUD=true )
<li>JSON editing, either as-is or via PmWiki (including raw text within JSON) or with CodeMirror as editor (just text area is plain text should be enough
<li>feedback intertwined per demo (based on screenshot/audio recording demos)
<li>richer text rendering
<li>couple live messages with inView(targetSelector)
</ul>
<h3>
done :
</h3>
<ul>
<li>hmd.link/? to share directly over same local network
<li>link as clickable (right now only trailing ones, and 1 link per description maximum)
<li>source JSON URL https://webdav.benetou.fr/fotsave/fot_sloan_companion_public/demo_q1.json
<li>alternative descriptions https://futuretextlab.info/1st-quarter/ (more verbose but static)
<li>mobile view
<li>linked to editor : https://companion.benetou.fr/demos_editor_example.html
</ul>
</body>
</html>

@ -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 )

@ -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 )

@ -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 )

@ -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 )

@ -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 )

@ -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 )

@ -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 )

@ -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...

File diff suppressed because it is too large Load Diff

@ -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
//<a-text target value="jxr observe selectedElement" position="0 1.25 -0.2" scale="0.1 0.1 0.1"></a-text>
newcode = newcode.replace(/observe ([^\s]+)/,`bindVariableValueToNewNote('$1')`)
// could proxy instead... but for now, the quick and dirty way :
// e.g qs a-sphere sa color red =>
// document.querySelector("a-sphere").setAttribute("color", "red")
newcode = newcode.replace(/lg ([^\s]+) ([^\s]+)/ ,`addGltfFromURLAsTarget('$1',$2)`)
// order matters, here we only process the 2 params if they are there, otherwise 1
newcode = newcode.replace(/lg ([^\s]+)/ ,`addGltfFromURLAsTarget('$1')`)
return newcode
}
function interpretJXR( code ){
if (!code) return
if (code.length == 1) { // special case of being a single character, thus keyboard
if (code == ">") { // Enter equivalent
content = hudTextEl.getAttribute("value")
if (Number.isFinite(Number(content))) {
loadPageRange(Number(content));
} else {
addNewNote( content )
}
setHUD("")
} else if (code == "<") { // Backspace equivalent
setHUD( hudTextEl.getAttribute("value").slice(0,-1))
} else {
appendToHUD( code )
}
}
if (!code.match(prefix)) return
var uninterpreted = code
var parseCode = ""
code.split("\n").map( lineOfCode => parseCode += parseJXR( lineOfCode ) + ";" )
// could ignore meta code e.g showhistory / saveHistoryAsCompoundSnippet
commandhistory.push( {date: +Date.now(), uninterpreted: uninterpreted, interpreted: parseCode} )
console.log( parseCode )
try {
eval( parseCode )
} catch (error) {
console.error(`Evaluation failed with ${error}`);
}
// unused keyboard shortcuts (e.g BrowserSearch) could be used too
// opt re-run it by moving the corresponding text in target volume
}
function bindVariableValueToNewNote(variableName){
// from observe jxr keyword
const idName = "bindVariableValueToNewNote"+variableName
addNewNote( variableName + ":" + eval(variableName), `-0.15 1.4 -0.1`, "0.1 0.1 0.1", idName, "observers", "true" )
// could add to the HUD instead and have a list of these
return setInterval( _ => {
const value = variableName+";"+eval(variableName)
// not ideal for DOM elements, could have shortcuts for at least a-text with properties, e.g value or position
document.getElementById(idName).setAttribute("value", value)
}, 100 )
}
AFRAME.registerComponent('gltf-jxr', {
events: {
"model-loaded": function (evt) {
this.el.object3D.traverse( n => { if (n.userData.jxr) {
console.log(n.userData)
// need to make gltf become a child of a note to be executable on pinch
// try reparenting first... otherwise var clone = this.el.cloneNode(true)
// might not be great, cf https://github.com/aframevr/aframe/issues/2425
let pos = this.el.object3D.position.clone()
let rot = this.el.object3D.rotation.clone()
this.el.remove()
let note = addNewNote( n.userData.jxr, pos, "0.1 0.1 0.1", null, "gltf-jxr-source")
let clone = this.el.cloneNode(true)
clone.setAttribute('position', '0 0 0')
clone.setAttribute('scale', '10 10 10') // assuming not scaled until now, surely wrong
// need rescaling to current scale by 1/0.1, clone.setAttribute(
clone.removeAttribute('gltf-jxr')
note.appendChild(clone)
}
})
},
},
/* example of backend code to annotate the glTF
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read('PopsicleChocolate.glb');
const node = document.getRoot() // doesn't seem to work.listNodes().find((node) => node.getName() === 'RootNode');
node.setExtras({jxr: "jxr addNewNote('hi')"});
await io.write('output.glb', document);
*/
});
// ==================================== interactions beyond pinch ======================================================
AFRAME.registerComponent('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;i<handEls.length;i++){
handEl=handEls[i];distance=this.calculateFingerDistance(handEl.components['hand-tracking-controls'].indexTipPosition);
if(distance>0 && distance<this.data.pressDistance){
if(!this.pressed){this.el.emit('pressedstarted');}
this.pressed=true;return;}
}
if(this.pressed){this.el.emit('pressedended');} // somehow happens on click, outside of VR
this.pressed=false;
},
calculateFingerDistance:function(fingerPosition){
let parentPos = document.getElementById('rig').getAttribute('position')
fingerPosition.add( parentPos )
var el=this.el;
var worldPosition=this.worldPosition;
worldPosition.copy(el.object3D.position);
el.object3D.parent.updateMatrixWorld();
el.object3D.parent.localToWorld(worldPosition);
return worldPosition.distanceTo(fingerPosition);
}
});
AFRAME.registerComponent('start-on-press-other', {
// should become a property of the component instead to be more flexible.
init: function(){
let el = this.el
this.el.addEventListener('pressedended', function (event) {
console.log(event)
// should ignore that if we entered XR recently
if (!primaryPinchStarted && wristShortcut.match(prefix)) interpretJXR("jxr toggleShowCube()")
// if (!primaryPinchStarted && wristShortcut.match(prefix)) interpretJXR("jxr toggleShowFile('manuscript.txt')")
// FIXME should toggle the display of manuscript
// seems to happen also when entering VR
// other action could possibly based on position relative to zones instead, i.e a list of bbox/functions pairs
})
}
})
AFRAME.registerComponent('start-on-press', {
// should become a property of the component instead to be more flexible.
init: function(){
let el = this.el
this.el.addEventListener('pressedended', function (event) {
console.log(event)
// should ignore that if we entered XR recently
if (!primaryPinchStarted && wristShortcut.match(prefix)) interpretJXR(wristShortcut)
// seems to happen also when entering VR
// other action could possibly based on position relative to zones instead, i.e a list of bbox/functions pairs
})
}
})
function thumbToIndexPull(){
let p = document.querySelector('[pinchprimary]')
let tip = new THREE.Vector3(); // create once an reuse it
let proximal = new THREE.Vector3(); // create once an reuse it
let thumb = new THREE.Vector3(); // create once an reuse it
let touches = []
const threshold_thumb2tip = 0.01
const threshold_thumb2proximal = 0.05
let indexesTipTracking = setInterval( _ => {
// cpnsider getObjectByName() instead
p.object3D.traverse( e => { if (e.name == 'index-finger-tip' ) tip = e.position })
//index-finger-phalanx-distal
//index-finger-phalanx-intermediate
p.object3D.traverse( e => { if (e.name == 'index-finger-phalanx-proximal' ) proximal = e.position })
p.object3D.traverse( e => { if (e.name == 'thumb-tip' ) thumb = e.position })
let touch = {}
touch.date = Date.now()
touch.thumb2tip = thumb.distanceTo(tip)
if (!touch.thumb2tip) return
touch.thumb2proximal = thumb.distanceTo(proximal)
//console.log( touch.thumb2tip, touch.thumb2proximal )
// usually <1cm <4cm (!)
//if ((touch.thumb2tip && touch.thumb2tip < threshold_thumb2tip)
//|| (touch.thumb2proximal && touch.thumb2proximal < threshold_thumb2proximal))
if (touch.thumb2tip < threshold_thumb2tip
|| touch.thumb2proximal < threshold_thumb2proximal){
if (touches.length){
let previous = touches[touches.length-1]
if (touch.date - previous.date < 300){
if (touch.thumb2tip < threshold_thumb2tip &&
previous.thumb2proximal < threshold_thumb2proximal){
console.log('^')
p.emit('thumb2indexpull')
}
if (touch.thumb2proximal < threshold_thumb2proximal &&
previous.thumb2tip < threshold_thumb2tip){
console.log('v')
p.emit('thumb2indexpush')
}
}
}
touches.push(touch)
}
}, 50)
// TODO
// Bind thumb2indexpush/thumb2indexpull to zoom in/out "world" i.e all assets that aren't "special" e.g self, lights, UI
}
let changeovercheck
AFRAME.registerComponent('changeover', {
schema: { color : {type: 'string'} },
init: function () {
// (this.el, this.data.content)
if (changeovercheck) return
let player = document.getElementById('player') // assuming single player, non networked
console.log('adding timer')
changeovercheck = setInterval( _ => {
let pos = player.getAttribute('position').clone()
pos.y = 0.1 // hard coded but should be from component element
let hits = Array.from(document.querySelectorAll('[changeover]'))
.filter( e => e.getAttribute("visible") == true)
.map( t => { return { el: t, dist : pos.distanceTo(t.getAttribute("position") ) } })
.filter( t => t.dist < 0.02 )
.sort( (a,b) => a.dist > b.dist)
//console.log(hits.length)
if (hits.length>0) {
setFeedbackHUD('touching cone')
console.log('touching cone')
hits[hits.length-1].el.setAttribute('color', 'red')
}
}, 50)
}
})
// to add only on selectable elements, thus already with a target component attached
AFRAME.registerComponent('pull', {
events: {
picked: function (evt) {
this.startePos = this.el.getAttribute('position').clone()
this.starteRot = this.el.getAttribute('rotation')//.clone() not necessary as converted first
this.decimtersEl = document.createElement('a-troika-text')
AFRAME.scenes[0].appendChild(this.decimtersEl)
},
moved: function (evt) {
let pos = AFRAME.utils.coordinates.stringify( this.startePos )
let oldpos = AFRAME.utils.coordinates.stringify( this.el.getAttribute('position') )
AFRAME.scenes[0].setAttribute("line__pull", `start: ${oldpos}; end : ${pos};`)
let d = this.startePos.distanceTo( this.el.getAttribute('position') )
// could show a preview state before release, e.g
let decimeters = Math.round(d*10)
console.log('pulling '+decimeters+' pages')
// update visible value instead, ideally under line but still facing user
let textPos = new THREE.Vector3()
textPos.lerpVectors(this.startePos, this.el.getAttribute('position'), .7)
this.decimtersEl.setAttribute('position', textPos )
this.decimtersEl.setAttribute('rotation', this.el.getAttribute('rotation') )
this.decimtersEl.setAttribute('value', decimeters )
},
released: function (evt) {
let d = this.startePos.distanceTo( this.el.getAttribute('position') )
console.log('This entity was released '+ d + 'm away from picked pos')
this.el.setAttribute('position', AFRAME.utils.coordinates.stringify( this.startePos ))
this.el.setAttribute('rotation', AFRAME.utils.coordinates.stringify( this.starteRot ))
AFRAME.scenes[0].removeAttribute("line__pull")
this.decimtersEl.remove()
},
},
});
// ==================================== utils on entities and classes ======================================================
function toggleVisibilityEntitiesFromClass(classname){
let entities = Array.from( document.querySelectorAll("."+classname) )
if (entities.length == 0) return
let state = entities[0].getAttribute("visible") // assume they are all the same
if (state)
entities.map( e => e.setAttribute("visible", "false"))
else
entities.map( e => e.setAttribute("visible", "true"))
}
function pushLeftClass(classname, value=.1){
Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.x -= value)
}
function pushRightClass(classname, value=.1){
Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.x += value)
}
function pushUpClass(classname, value=.1){
Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.y += value)
}
function pushDownClass(classname, value=.1){
// can be used for accessibiliy, either directly or sampling e.g 10s after entering VR to lower based on the estimated user height
Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.y -= value)
}
function pushBackClass(classname, value=.1){
Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.z -= value)
}
function pushFrontClass(classname, value=.1){
Array.from( document.querySelectorAll("."+classname) ).map( e => e.object3D.position.z += value)
}
function toggleVisibilityAllGenerators(){
generators.split(" ").map( g => toggleVisibilityEntitiesFromClass(g) )
// not hidableassets though
}
function toggleVisibilityAll(){
toggleVisibilityAllGenerators()
toggleVisibilityEntitiesFromClass("hidableassets")
}
function toggleVisibilityAllButClass(classname){
generators.split(" ").filter( e => e != classname).map( g => toggleVisibilityEntitiesFromClass(g) )
toggleVisibilityEntitiesFromClass("hidableassets")
}
function switchSide(){
// mostly works... but event listeners are not properly removed. Quickly creates a mess, low performance and unpredictable.
document.querySelector("#"+sides[primarySide]+"Hand").removeAttribute("pinchprimary")
document.querySelector("#"+sides[secondarySide]+"Hand").removeAttribute("pinchsecondary")
document.querySelector("#"+sides[secondarySide]+"Hand").removeAttribute("wristattachsecondary")
document.querySelector("#"+sides[secondarySide]+"Hand").setAttribute("pinchprimary", "")
document.querySelector("#"+sides[primarySide]+"Hand").setAttribute("pinchsecondary", "")
document.querySelector("#"+sides[primarySide]+"Hand").setAttribute("wristattachsecondary", "target: #box")
if (primarySide == 0) {
secondarySide = 0
primarySide = 1
} else {
primarySide = 0
secondarySide = 1
}
}
function getIdFromPick(){
let id = null
let pp = selectedElements.filter( e => e.primary )
if (pp && pp[pp.length-1] && pp[pp.length-1].element ){
if (!pp[pp.length-1].element.id) pp[pp.length-1].element.id= "missingid_"+Date.now()
id = pp[pp.length-1].element.id
setFeedbackHUD(id)
}
return id
}
function getClassFromPick(){ // should be classes, for now assuming one
let classFound = null
let pp = selectedElements.filter( e => e.primary )
if (pp && pp[pp.length-1] && pp[pp.length-1].element ){
//if (!pp[pp.length-1].element.className) pp[pp.length-1].element.className= "missingclass"
// arguable
classFound = pp[pp.length-1].element.className
setFeedbackHUD(classFound)
}
return classFound
}
function getArrayFromClass(classname){
return Array.from( document.querySelectorAll("."+classname) )
}
function applyToClass(classname, callback, value){
// example applyToClass("template_object", (e, val ) => e.setAttribute("scale", val), ".1 .1 .2")
getArrayFromClass(classname).map( e => callback(e, value))
// could instead become a jxr shortcut, namely apply a set attribute to a class of entities
}
function addDropZone(position="0 1.4 -0.6", callback=setFeedbackHUD, radius=0.11){
// consider how this behavior could be similar to the wrist watch shortcut
// namely binding it to a jxr function
let el = document.createElement("a-sphere")
el.setAttribute("wireframe", true)
el.setAttribute("radius", radius)
el.setAttribute("position", position)
el.id = "dropzone_"+Date.now()
AFRAME.scenes[0].appendChild( el )
let sphere = new THREE.Sphere( AFRAME.utils.coordinates.parse( position ), radius )
// could become movable but would then need to move the matching sphere too
// could be a child of that entity
let pincher = document.querySelector('[pinchprimary]')
pincher.addEventListener('pinchended', function (event) {
if (selectedElements.length){
let lastDrop = selectedElements[selectedElements.length-1]
if ((Date.now() - lastDrop.timestamp) < 1000){
if (sphere.containsPoint( lastDrop.element.getAttribute("position"))){
// should be a threejs sphere proper, not a mesh
console.log("called back" )
callback( lastDrop.selectedElement )
}
}
}
})
// never unregister
return el
}
// ==================================== facilitating debugging ======================================================
function makeAnchorsVisibleOnTargets(){
targets.map( t => {
let controlSphere = document.createElement("a-sphere")
controlSphere.setAttribute("radius", 0.05)
controlSphere.setAttribute("color", "blue")
controlSphere.setAttribute("wireframe", "true")
controlSphere.setAttribute("segments-width", 8)
controlSphere.setAttribute("segments-height", 8)
t.appendChild( controlSphere )
}) // could provide a proxy to be able to monitor efficiently
}
function switchToWireframe(){
let model = document.querySelector("#environment")?.object3D
if (model) model.traverse( o => { if (o.material) {
let visible = !o.material.wireframe
o.material.wireframe = visible;
o.material.opacity = visible ? 0.05 : 1;
o.material.transparent = visible;
} })
}
// avoiding setOnDropFromAttribute() as it is not idiosyncratic and creates timing issues
Loading…
Cancel
Save