commit
6a5c07e0ef
@ -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…
Reference in new issue