SpaSca : open SCAffolding to SPAcially and textualy explore interfaces
https://fabien.benetou.fr/pub/home/future_of_text_demo/engine/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1975 lines
83 KiB
1975 lines
83 KiB
<!DOCTYPE html>
|
|
<html>
|
|
<title>SpaSca : Spatial Scaffolding</title>
|
|
<head>
|
|
<!-- Suggestions? https://git.benetou.fr/utopiah/text-code-xr-engine/issues/ -->
|
|
|
|
<script src='dependencies/aframe.min.js'></script>
|
|
<script src='dependencies/aframe-html.js'></script>
|
|
<script src='dependencies/aframe-mirror.js'></script>
|
|
<script src='dependencies/aframe-troika-text.min.js'></script>
|
|
<script type="module" id=immersbundle src='dependencies/immers-client.js?save=true'></script>
|
|
<!--<script type="module" id=immersbundle src="https://cdn.jsdelivr.net/npm/immers-client/dist/destination.bundle.js?role=modFull"></script>-->
|
|
<script src="https://threejs.org/examples/js/exporters/GLTFExporter.js"></script>
|
|
|
|
<!-- for input sharing -->
|
|
<script src='dependencies/peerjs.min.js'></script>
|
|
<!-- for content sharing, using NAF -->
|
|
<script src='dependencies/socket.io.slim.js'></script>
|
|
<script src="https://naf.benetou.fr/easyrtc/easyrtc.js"></script>
|
|
<script src='dependencies/networked-aframe.min.js'></script>
|
|
|
|
<!-- still experimenting, see webdav.html -->
|
|
<script src='dependencies/webdav.js'></script>
|
|
|
|
<!-- replacing with local copies as CDNs are like unpkg tend to be slow
|
|
<script type="module" src="https://unpkg.com/immers-client/dist/destination.bundle.js"></script>
|
|
<script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
|
|
|
|
<script src="https://unpkg.com/aframe-troika-text/dist/aframe-troika-text.min.js"></script>
|
|
<script src="aframe-html.js"></script>
|
|
|
|
<script src="https://unpkg.com/peerjs@1.4.5/dist/peerjs.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.4.0/socket.io.slim.js"></script>
|
|
<script src="https://naf.benetou.fr/easyrtc/easyrtc.js"></script>
|
|
<script src="https://unpkg.com/networked-aframe@^0.10.0/dist/networked-aframe.min.js"></script>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/aframe-mirror@latest/index.js"></script>
|
|
-->
|
|
|
|
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
|
<link rel="manifest" href="site.webmanifest">
|
|
</head>
|
|
<body>
|
|
|
|
<div style='position:fixed;z-index:1; top: 0%; left: 0%; border-bottom: 70px solid transparent; border-left: 70px solid #eee; '>
|
|
<a href="https://git.benetou.fr/utopiah/text-code-xr-engine/issues/">
|
|
<img style='position:fixed;left:10px;' title='code repository' src='https://git.benetou.fr/assets/img/logo.svg/'>
|
|
</a>
|
|
</div>
|
|
|
|
<div>
|
|
<div id="observablehq-numberOfPages-835aa7e9"></div>
|
|
<div id="observablehq-result_as_html-ab4c1560"></div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
|
|
// just text
|
|
import {Runtime, Inspector} from "https://cdn.jsdelivr.net/npm/@observablehq/runtime@4/dist/runtime.js";
|
|
import define from "https://api.observablehq.com/@utopiah/from-pim-to-2d-to-3d-to-xr-explorations@2010.js?v=3";
|
|
import define2 from "https://api.observablehq.com/d/f219f0c440c6d5a2.js?v=3";
|
|
new Runtime().module(define, name => {
|
|
if (name === "numberOfPages") return new Inspector(document.querySelector("#observablehq-numberOfPages-835aa7e9"));
|
|
document.querySelector(".a-enter-vr").style.position = "fixed"
|
|
});
|
|
|
|
// HTML with interactable input
|
|
new Runtime().module(define2, name => {
|
|
if (name === "viewof offsetExample") return new Inspector(document.querySelector("#observablehq-viewof-offsetExample-ab4c1560"));
|
|
if (name === "result_as_html") return new Inspector(document.querySelector("#observablehq-result_as_html-ab4c1560"));
|
|
return ["result_no_name","result"].includes(name);
|
|
});
|
|
// setTimeout( _ => document.querySelector("#gui3d").setAttribute("html", "html:#observablehq-key;cursor:#cursor;" ) , 2000)
|
|
// <a-entity id="gui3d" class="observableui" position="0 1.5 -.4"></a-entity>
|
|
</script>
|
|
|
|
<script>
|
|
|
|
/*
|
|
|
|
motion to data
|
|
- integer, e.g distance from beginning to end
|
|
- curve, sampling N points between beginning and end
|
|
|
|
being able to use that in jxr commands, with example related to positioning entities
|
|
see https://git.benetou.fr/utopiah/text-code-xr-engine/issues/52#issuecomment-229
|
|
|
|
warning that selectedElement will get overwritten once executing a command by pinching
|
|
consequently in addition to have a history of executed commands
|
|
there should be a history of selected elements
|
|
and maybe their changed position states
|
|
*/
|
|
|
|
// motivated by https://git.benetou.fr/utopiah/text-code-xr-engine/issues/63
|
|
var reservedKeywords = ["selectedElement", "lastPointSketch ", "commandhistory", "groupSelection", "targets", "observe", "sa", "qs"]
|
|
// see generated file reserved-keywords for more yet not sufficient, see instead parseJXR()
|
|
// should also include some documentation
|
|
|
|
const prefix = /^jxr /
|
|
const codeFontColor = "lightgrey"
|
|
const fontColor= "white"
|
|
const wikiAsImages = "https://vatelier.benetou.fr/MyDemo/newtooling/wiki_graph.json"
|
|
const maxItems = 10
|
|
const now = Math.round( +new Date()/1000 ) //ms in JS, seconds in UNIX epoch
|
|
const baseCachedURL = "https://vatelier.benetou.fr/MyDemo/newtooling/textures/fabien.benetou.fr_"
|
|
const baseLiveURL = "https://vatelier.benetou.fr/MyDemo/newtooling/web/renders/fabien.benetou.fr_"
|
|
const queryFormatBaseURL = "https://fabien.benetou.fr/"
|
|
const imageExtension = ".png"
|
|
const renderSuffix = "?action=serverrender"
|
|
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.BoundingBoxHelper( bbox.object3D, 0x0000ff);
|
|
var groupHelpers = []
|
|
var primaryPinchStarted = false
|
|
var visible = true
|
|
var setupMode = false
|
|
var setupBBox = {}
|
|
var wristShortcut = "jxr switchToWireframe()"
|
|
var selectionPinchMode = false
|
|
var groupingMode = false
|
|
var sketchEl
|
|
var lastPointSketch
|
|
var hudTextEl // should instead rely on the #typinghud selector in most cases
|
|
const startingText = "[]"
|
|
var drawingMode = false
|
|
var added = []
|
|
const maxItemsFromSources = 20
|
|
let alphabet = ['abcdefghijklmnopqrstuvwxyz', '0123456789', '<>'];
|
|
var commandhistory = []
|
|
const savedProperties = [ "src", "position", "rotation", "scale", "value", ] // add newer properties e.g visibility and generator as class
|
|
var groupSelection = []
|
|
var cabin //storage for load/save. Should use a better name as this is a reminder of a past version rather than something semantically useful.
|
|
const url = "https://fabien.benetou.fr/PIMVRdata/CabinData?action="
|
|
var primarySide = 0
|
|
const sides = ["right", "left"]
|
|
var generators = "line-link-entities link screenstack dynamic-view selectionboxonpinches keyboard "
|
|
+ "commands-from-external-json glossary timeline issues web-url background-via-url observableui hidableenvironmentfot fot"
|
|
// could be an array proper completed on each relevant component registration
|
|
var heightAdjustableClasses = ["commands-from-external-json"]
|
|
var pinches = [] // position, timestamp, primary vs secondary
|
|
var dl2p = null // from distanceLastTwoPinches
|
|
var selectedElements = [];
|
|
|
|
// could add a dedicated MakeyMakey mode with a fixed camera, e.g bird eye view, and an action based on some physical input that others, thanks to NAF, could see or even use.
|
|
// ?inputmode=makeymakey
|
|
|
|
AFRAME.registerComponent('enable-components-via-url', {
|
|
init: function () {
|
|
var src = AFRAME.utils.getUrlParameter('enable-components-via-url')
|
|
if (src && src != "") {
|
|
src.split(",").map( c => {
|
|
this.el.setAttribute(c, "")
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
AFRAME.registerComponent('disable-components-via-url', {
|
|
init: function () {
|
|
var src = AFRAME.utils.getUrlParameter('disable-components-via-url')
|
|
if (src && src != "") {
|
|
src.split(",").map( c => {
|
|
Array.from( document.querySelectorAll("["+c+"]") ).map( e => { e.removeAttribute(c) })
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// e.g background https://fabien.benetou.fr/pub/home/metaverse.png might have to allow options like scale to allow for modifying both size and ratio
|
|
AFRAME.registerComponent('background-via-url', { // non interactive mode
|
|
init: function () {
|
|
let generatorName = this.attrName
|
|
var src = AFRAME.utils.getUrlParameter('background')
|
|
if (src && src != "") {
|
|
this.el.setAttribute( "visible", "true")
|
|
this.el.setAttribute( "src", src )
|
|
this.el.className += generatorName
|
|
Array.from( document.querySelectorAll(".mural-instructions") ).map( i => {
|
|
i.setAttribute("visible", "true")
|
|
i.className += generatorName
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
AFRAME.registerComponent('web-url', {
|
|
// e.g <a-entity id=inbrowser web-url position="0 1.5 -2.4"></a-entity>
|
|
// motivated by https://glitch.com/edit/#!/aframe-lil-gui?path=observablewidget.html
|
|
init: function () {
|
|
const url = "https://fabien.benetou.fr/Fabien/Principle?action=webvr"
|
|
var target = url
|
|
var src = AFRAME.utils.getUrlParameter('url')
|
|
// could also be a component parameter
|
|
var el = this.el
|
|
let generatorName = this.attrName
|
|
if (src && src != "") target = src
|
|
fetch(target).then( res => res.text() ).then( r => {
|
|
pageEl = document.createElement("div")
|
|
pageEl.id = "page"
|
|
pageEl.innerHTML = r
|
|
pageEl.style = "visibility:hidden;"
|
|
document.body.appendChild(pageEl)
|
|
el.setAttribute("html", "html:#page;cursor:#cursor;" )
|
|
el.className += generatorName
|
|
//backdrop
|
|
const geometry = new THREE.PlaneGeometry( el.object3D.children[0].geometry.parameters.width*1.1,
|
|
el.object3D.children[0].geometry.parameters.height*1.1 );
|
|
const material = new THREE.MeshBasicMaterial( {color: 0xffffff, side: THREE.DoubleSide} );
|
|
const plane = new THREE.Mesh( geometry, material );
|
|
plane.position.z = -.1
|
|
el.object3D.add( plane );
|
|
})
|
|
}
|
|
})
|
|
|
|
function sendGlbFromEl(el){
|
|
const gltfExporter = new THREE.GLTFExporter();
|
|
const mesh = el.object3D
|
|
|
|
const options = {
|
|
trs: true,
|
|
onlyVisible: true,
|
|
truncateDrawRange: false,
|
|
binary: true,
|
|
maxTextureSize: Infinity
|
|
};
|
|
|
|
gltfExporter.parse(
|
|
mesh,
|
|
function (result) {
|
|
if (immersClient) immersClient.sendModel("testing", new Blob([result]), "public")
|
|
console.log("sent blob")
|
|
// worked as https://immers.benetou.fr/s/639cb4171757b8382c120da1 of type model
|
|
// with glb as URL https://immers.benetou.fr/media/edf5641922e6371abb3118f56cd20b9b
|
|
},
|
|
function (error) {
|
|
console.log('An error happened during parsing', error);
|
|
},
|
|
options
|
|
);
|
|
}
|
|
|
|
var immersClient
|
|
// See dedicated issue https://git.benetou.fr/utopiah/text-code-xr-engine/issues/47
|
|
document.querySelector("#immersbundle").addEventListener('load',(event) => {
|
|
immersClient = document.querySelector("immers-hud").immersClient
|
|
document.querySelector('immers-hud').immersClient.sendModel = async function sendModel (name, glb, privacy, icon, to = []) {
|
|
return this.activities.model(name, glb, icon, to, privacy)
|
|
} // shim until API update
|
|
document.querySelector("immers-hud").setAttribute("access-role", "modFull")
|
|
document.querySelector("immers-hud").immersClient.addEventListener("immers-client-connected", _ => {
|
|
//immersClient.addEventListener("immers-client-new-message", e => addNewNote(e.detail.message.messageHTML) )
|
|
immersClient.addEventListener("immers-client-new-message", async e => {
|
|
if (e.detail.message.type == "chat"){
|
|
let msg = ( await immersClient.activities.getObject( e.detail.message.id ))
|
|
if (msg.object.context.location )
|
|
addNewNote( e.detail.message.messageHTML, msg.object.context.location.position ,
|
|
"0.1 0.1 0.1", null, "immerschat", "true", msg.object.context.location.rotation )
|
|
else
|
|
addNewNote( e.detail.message.messageHTML )
|
|
// could hook on pinchended
|
|
// immersClient.place.location = { position: "0 1.5 -2", rotation: "0 190 0" };
|
|
// immersClient.sendChatMessage(textvalue, "public");
|
|
}
|
|
if (e.detail.message.type == "media" && e.detail.message.mediaType == "image"){
|
|
console.log("src", e.detail.message.url)
|
|
let el = document.createElement("a-box")
|
|
el.setAttribute("position", -Math.random()+" "+Math.random()*3 + " -1")
|
|
el.setAttribute("width", ".1")
|
|
el.setAttribute("height", ".15")
|
|
el.setAttribute("depth", ".01")
|
|
el.setAttribute("src", e.detail.message.url.href)
|
|
AFRAME.scenes[0].appendChild(el)
|
|
}
|
|
if (e.detail.message.type == "other"){
|
|
let msg = ( await immersClient.activities.getObject( e.detail.message.id ))
|
|
console.log("maybe model, see object.type.model==model", msg )
|
|
}
|
|
})
|
|
immersClient.friendsList().then( r => {
|
|
if (r.length>0) addNewNote( "Friends:", "-1 1.65 -0.5")
|
|
r.map( (u,i) => {
|
|
let friendData = u.profile.displayName
|
|
if (u.locationName) friendData += " at " + u.locationName
|
|
if (u.locationURL) friendData += " (" + u.locationURL + " )"
|
|
// addNewNote( friendData, "-1 " + (1.6-i/20) + " -0.5") // should make this interpretable to join there
|
|
// hidden for workshop
|
|
} )
|
|
} )
|
|
})
|
|
});
|
|
|
|
function ims(msg){
|
|
if (!immersClient) { setFeedbackHUD("not connected via Immers"); return; }
|
|
immersClient.sendChatMessage(msg, "public")
|
|
} // shorthand for jxr command, still requires parenthesis and quotes though, could be better to have a dedicated visual shorthand, e.g >>
|
|
// can send code too e.g immersClient.sendChatMessage("jxr loadPageRange(3,4)", "public")
|
|
|
|
/* not sure what's the right way... but timeout works, others don't.
|
|
|
|
immers-client-friends-update or immers-client-new-message to keep track of conversations between recurring meeting? Say you join a room, spend a working session with colleagues then leave. Could these be used to in this context to send reminders to those who subscribed to that event?
|
|
*/
|
|
|
|
var polys
|
|
async function getPolyList(keyword){
|
|
//return await fetch('/search?keyword='+keyword).then( res => res.json() ).then( res => return res )
|
|
var response = await fetch('/search?keyword='+keyword);
|
|
var polys = await response.json()
|
|
return polys
|
|
}
|
|
|
|
// for testing purposes, disable when not local with asset caching server
|
|
//getPolyList("pizza").then( p => polys = p.results )
|
|
|
|
function cachePoly(res){
|
|
var n = 0;
|
|
res.map( i => { fetch(i.Thumbnail.replace("https://static.poly.pizza/","http://localhost:7777/getpoly?id=").replace(".webp","")) } ) ;
|
|
// see await Promise.all()
|
|
}
|
|
// should properly wait. Only once all queries are done then try to load.
|
|
|
|
function loadPolyThumbnails(res){
|
|
var n = 0;
|
|
res.map( i => {
|
|
var el = document.createElement("a-image");
|
|
el.setAttribute("src", i.Thumbnail.replace("https://static.poly.pizza/","http://localhost:7777polydata/"));
|
|
el.setAttribute("position", "0 1 "+n--/10);
|
|
// could instead attach e.g 9 items to the wrist using wristattachsecondary on a palette
|
|
el.setAttribute("scale", ".1 .1 .1")
|
|
el.setAttribute("loadpolyfomthumbnail", "") // that could then be used to execute on pinch based on src property
|
|
AFRAME.scenes[0].appendChild(el);
|
|
} )
|
|
}
|
|
|
|
function loadFirstPolyModel(res){
|
|
var n = 0;
|
|
var i = res[n]
|
|
var el = document.createElement("a-gltf-model");
|
|
el.setAttribute("src", i.Thumbnail.replace("https://static.poly.pizza/","http://localhost:7777polydata/").replace("webp","glb"));
|
|
el.setAttribute("position", "0 1 "+n--);
|
|
AFRAME.scenes[0].appendChild(el);
|
|
return el
|
|
}
|
|
|
|
function loadPolyModels(res){
|
|
// to load all models, rarely a good idea
|
|
var n = 0;
|
|
res.map( i => {
|
|
var el = document.createElement("a-gltf-model");
|
|
el.setAttribute("src", i.Thumbnail.replace("https://static.poly.pizza/","http://localhost:7777polydata/").replace("webp","glb"));
|
|
el.setAttribute("position", "0 1 "+n--);
|
|
AFRAME.scenes[0].appendChild(el);
|
|
// optionally rescale e.g rescaleModelFromPoly(el) // probably has to wait for it to be properly loaded, cf modelHasLoaded event
|
|
// e.g rescaleModelFromPoly ( loadFirstPolyModel(polys) ) won't work
|
|
} )
|
|
}
|
|
|
|
function rescaleModelFromPoly(modelEl){
|
|
// rescale to fit in 1m3 box
|
|
var bbox = new THREE.Box3().setFromObject( modelEl.object3D );
|
|
var rescale = 1 / ( (( bbox.max.x - bbox.min.x) + (bbox.max.y - bbox.min.y) + (bbox.max.z - bbox.min.z) ) /3 );
|
|
modelEl.setAttribute("scale", rescale+ " " + rescale + " " + rescale)
|
|
// could also leave untouched if within boundaries, e.g > 0.1 and < 1
|
|
}
|
|
|
|
// SYNC WITH HMD EDITS before trying this
|
|
/*
|
|
|
|
source as URL e.g https://fabien.benetou.fr/Fabien/Principle?action=source (locally Fabien.Principle.pmwiki)
|
|
usual parsing (e.g stop words)
|
|
dedicated PmWiki cleanup e.g no URL
|
|
clean up or rather highlight (e.g color not being black) with presence from https://github.com/wordset/wordset-dictionary
|
|
could also have a short dictionnary of stop words based on popularity
|
|
e.g https://en.wikipedia.org/wiki/Most_common_words_in_English#100_most_common_words
|
|
but seems so short it might not help much, could try long and popular instead
|
|
WPM * distance travelled as metric, not just 1
|
|
|
|
can generate layout for keyboard as (Ctrl shortcut) copiable items to append to AR clipbard
|
|
https://twitter.com/utopiah/status/1533690234424139779
|
|
could also use an optional type in addition to target
|
|
such items would be copied without pressing Ctrl
|
|
|
|
see remoteSave for saving to be able to use the result outside the HMD
|
|
*/
|
|
|
|
/*
|
|
|
|
use imagesFromURLs (used in screenstack) on https://vatelier.benetou.fr/MyDemo/newtooling/wiki_graph.json
|
|
and line-link-entities="target:#instructionA; source:#instructionC" between pages
|
|
|
|
*/
|
|
|
|
// see https://remote-keyboard.glitch.me on how to provide a remote keyboard (no BT) for hud keydown/keyup events
|
|
// consider alt server e.g 9000-peers-peerjsserver-bxw59h3fm87.ws-eu47.gitpod.io as peerjs isn't always reliable
|
|
// to do the same offline could add to express too, cf https://github.com/peers/peerjs-server#combining-with-existing-express-app
|
|
//new Peer("2022xrkbd").on('connection', conn => conn.on('data', data => processRemoteInputData(data) ))
|
|
const altServer = "9000-peers-peerjsserver-bxw59h3fm87.ws-eu47.gitpod.io"
|
|
//new Peer("2022xrkbd", {host: altServer}).on('connection', conn => conn.on('data', data => processRemoteInputData(data) ))
|
|
// disabled for now
|
|
function processRemoteInputData( data ){
|
|
// .status : keydown keyup pointermove
|
|
// on keydown or keyup, result un .key
|
|
// on pointermove, result un .x and .y
|
|
|
|
// could try to throw back as an event...
|
|
parseKeys( data.status, data.key )
|
|
if (data.status == "pointermove") parsePointer( data.x, data.y )
|
|
}
|
|
|
|
// for testing purposes, disable when not local with asset caching server
|
|
// SSE on a specific route to know if this file was updated, if so reload (would force leave VR) cf Inventing on Principle
|
|
/*
|
|
const source = new EventSource("streaming");
|
|
source.onmessage = message => {
|
|
console.log(message.data) // showing the updates without manually forcing a reload
|
|
if ((JSON.parse(message.data)).status == "reload" ) location.reload(true);
|
|
} ;
|
|
*/
|
|
// monitored server-side, index.js with fs
|
|
|
|
/* extrusion and more generally compactness of 3D object description :
|
|
|
|
Constructive Solid Geometry (CSG) https://openjscad.nodebb.com/topic/235/threejs-integration/11 as example of integration of JSCAD (modelling with code basically) with threejs, naively looks like an interesting intersection, ideally with spacial editing after (i.e pinching a vertex to update the resulting geometry)
|
|
*/
|
|
|
|
// consider PinePhone keyboard as something more usable that BT rollable
|
|
// PeerJS/WS(S) to share key.events
|
|
// would also work with iPad keyboard ... or another other device with keyboard and browser.
|
|
|
|
// refactoring : consider pluggable execution models and targets e.g eval(), containers, Observable, etc
|
|
// right now all mashed up together so creates both complexity and security risk
|
|
|
|
// consider also STT and translation experiment
|
|
// Codex by OpenAI (cf EP account) https://beta.openai.com/account/api-keys stored on ~/.openai-codex-test-xr used via backend
|
|
// with token e.g JWT could also consider ~/.bashrc ~/.bin or ~/Prototypes as commands
|
|
// esp. those allowing to integrate with specific hardware
|
|
|
|
// load as page loads
|
|
// <a-text target observablecell="targetid:observablehq-numberOfPages-835aa7e9" position="0 1.55 -0.2" scale="0.1 0.1 0.1"></a-text>
|
|
// interactive
|
|
// <a-text target value="jxr obsv observablehq-numberOfPages-835aa7e9" position="0 1.55 -0.2" scale="0.1 0.1 0.1"></a-text>
|
|
|
|
function newNoteFromObservableCell( cell ){
|
|
var targetEl = document.querySelector("#"+cell)
|
|
var potentialRes = document.querySelector("#observablehq-numberOfPages-835aa7e9>span")
|
|
if (potentialRes && potentialRes.children[1]){
|
|
addNewNote( potentialRes.children[1].innerText )
|
|
return
|
|
}
|
|
|
|
let observer = new MutationObserver(mutationRecords => {
|
|
addNewNote( mutationRecords[0].addedNodes[0].children[1].innerText )
|
|
});
|
|
|
|
observer.observe(targetEl, {
|
|
childList: true, // observe direct children
|
|
subtree: true, // and lower descendants too
|
|
characterDataOldValue: true // pass old data to callback
|
|
})
|
|
}
|
|
|
|
AFRAME.registerComponent('observablecell', { // non interactive mode
|
|
schema: {
|
|
targetid: {type: 'string'}
|
|
},
|
|
init: function () {
|
|
var el = this.el
|
|
var targetEl = document.querySelector("#"+this.data.targetid)
|
|
let observer = new MutationObserver(mutationRecords => {
|
|
addNewNote( mutationRecords[0].addedNodes[0].children[1].innerText )
|
|
});
|
|
|
|
observer.observe(targetEl, {
|
|
childList: true, // observe direct children
|
|
subtree: true, // and lower descendants too
|
|
characterDataOldValue: true // pass old data to callback
|
|
});
|
|
}})
|
|
|
|
// might mess thing up on Quest somehow... like typing does not seem to work anymore since.
|
|
|
|
/*
|
|
const registerServiceWorker = async () => {
|
|
if ('serviceWorker' in navigator) {
|
|
try {
|
|
const registration = await navigator.serviceWorker.register(
|
|
'sw-test/sw.js',
|
|
{
|
|
scope: '',
|
|
}
|
|
);
|
|
if (registration.installing) {
|
|
console.log('Service worker installing');
|
|
} else if (registration.waiting) {
|
|
console.log('Service worker installed');
|
|
} else if (registration.active) {
|
|
console.log('Service worker active');
|
|
}
|
|
} catch (error) {
|
|
console.error(`Registration failed with ${error}`);
|
|
}
|
|
}
|
|
};
|
|
*/
|
|
|
|
// 2 modes : interact/display (see the .hidableenvironment class)
|
|
// interact : small scale, 3D model of impact visible, keyboard visible, instruction visible
|
|
// display : changeable scale, everything but content not visible
|
|
|
|
// could try new movements dedicated to modifying text, in particular dedicated to coding
|
|
// e.g replacing a word by an expression el sa color red => dropping qs elname on el => qs elname sa color red
|
|
|
|
// for snapping display the current position (like now)
|
|
// and future position as transparency when within a certain radius to target + offset
|
|
// see getClosestTargetElement( pos ) when an object is already selected an moving
|
|
// only snap there if within certain distance
|
|
|
|
// replace console with an in VR equivalent, at least during the try/catch of eval() to get some feedback
|
|
|
|
function displaySnappablePositions( position ){
|
|
// to show while moving an object
|
|
// has to be close enough (below threshold)
|
|
}
|
|
|
|
function coloredBlocksFromScreens(colors, el){
|
|
// those are NOT updated at the moment
|
|
colors.map( (u,i) => {
|
|
var e = document.createElement("a-box")
|
|
e.setAttribute("color", u.split(" ")[1])
|
|
e.setAttribute("position",`2 1.8 -${i}`)
|
|
e.setAttribute("width","0.2")
|
|
e.setAttribute("depth","0.2")
|
|
el.appendChild(e)
|
|
})
|
|
}
|
|
|
|
function imagesFromURLs(urls, el, classes=null){
|
|
urls.map( (u,i) => {
|
|
var e = document.createElement("a-image")
|
|
if (u.indexOf("http")>-1)
|
|
e.setAttribute("src", u)
|
|
else
|
|
e.setAttribute("src", `screens/${u}`)
|
|
//e.setAttribute("position",`0 1.8 -${i}`)
|
|
e.setAttribute("position",`0 1.1 -${i/50}`) // flight mode
|
|
e.setAttribute("rotation",`-30 0 0`) // flight mode
|
|
e.setAttribute("scale", ".05 .05 .05") // have to scale down here otherwise move interactions aren't good
|
|
// could instead rely on https://github.com/visjs/vis-timeline
|
|
// as previously used in https://mobile.twitter.com/utopiah/status/1110576751577567233
|
|
e.setAttribute("width","2")
|
|
if (classes) e.className += classes
|
|
|
|
el.appendChild(e)
|
|
targets.push(e)
|
|
})
|
|
}
|
|
|
|
function URLs(urls, el){
|
|
urls.map( (u,i) => {
|
|
var e = document.createElement("a-text")
|
|
e.setAttribute("value", u.split(" ")[1])
|
|
e.setAttribute("position",`-1 1.25 -${i}`)
|
|
// incorrect as screens (and their average color) are done per minute but URL done per change of tab
|
|
//does not help, should be a text property instead
|
|
// e.setAttribute("width","10")
|
|
e.setAttribute("text", "wrapCount","200")
|
|
// el.appendChild(e) // disabled in flight mode
|
|
})
|
|
}
|
|
|
|
function stringWithPositionFromCoordinates(pos){
|
|
var el = getClosestTargetElement( pos, 0.5 )
|
|
// loosen up the threshold as we normally pick from the top left
|
|
|
|
// assumes a lot :
|
|
// NO rotation of the text, at all!
|
|
// single line of text
|
|
// scale only of 1 depth and uniform scaling
|
|
// left aligned
|
|
// probably only positive values
|
|
var selectedGlyph = {}
|
|
selectedGlyph.index = -1 // if we get an empty string
|
|
selectedGlyph.element = el
|
|
if (!el) return selectedGlyph
|
|
var glyph = el.object3D.children[0].geometry.visibleGlyphs
|
|
const matches = glyph.map( (t,i) => {
|
|
return {
|
|
el: el,
|
|
dist : Math.abs( pos.x - (
|
|
el.object3D.position.x + t.position[0]/(150/el.object3D.scale.x) ) ),
|
|
index : i
|
|
}
|
|
})
|
|
.filter( t => t.dist < 0.5 )
|
|
.sort( (a,b) => a.dist - b.dist )
|
|
// https://twitter.com/utopiah/status/1532766336941686784
|
|
if (matches.length > 0) {
|
|
selectedGlyph.index = matches[0].index
|
|
}
|
|
return selectedGlyph
|
|
}
|
|
|
|
function plot(equation,variablename="x",scale=5,step=1){
|
|
// could delete the past one document.querySelector("#plot")
|
|
// but nice to compare curves... should rather avoid adding grids instead.
|
|
var plot = document.querySelector("#plot")
|
|
if (!plot){
|
|
plot = document.createElement("a-entity")
|
|
targets.push(plot) // adding only once
|
|
plot.setAttribute("position", "0 1.5 -.5") // convenient position to test on desktop
|
|
plot.setAttribute("scale", ".01 .01 .01")
|
|
var idx = 0
|
|
for (var i=-scale;i<=scale;i+=step){
|
|
xl = `start: ${-scale} ${i} 0; end : ${scale} ${i} 0; opacity: 1;`
|
|
// weirdest "trick"... something using only `` on setAttribute produces empty string
|
|
// but indirecting once by setting a variable make the following one work?!
|
|
plot.setAttribute("line__"+ ++idx, xl)
|
|
plot.setAttribute("line__"+ ++idx, `start: ${i} ${-scale} 0; end : ${i} ${scale} 0; opacity: 1;`)
|
|
}
|
|
xaxis = `start: ${-scale} 0 0; end : ${scale} 0 0; opacity: 1; color:white;`
|
|
plot.setAttribute("line__axis_x", xaxis)
|
|
plot.setAttribute("line__axis_y", `start: 0 ${-scale} 0; end : 0 ${scale} 0; opacity: 1; color:white;`)
|
|
plot.setAttribute("line__axis_z", `start: 0 0 ${-scale}; end : 0 0 ${scale}; opacity: 1; color:white;`)
|
|
plot.id = "plot"
|
|
AFRAME.scenes[0].appendChild( plot )
|
|
}
|
|
var previousPoint = null
|
|
var curveId = +Date.now()
|
|
idx = 0
|
|
for (var i=-scale;i<=scale;i+=step/10){
|
|
var pos = i + " " + eval( "x="+i +";"+ equation) + " .1"
|
|
if (previousPoint) {
|
|
plot.setAttribute("line__user"+curveId+"section"+ ++idx, 'start: ' + previousPoint+ '; end : ' + pos + '; color:red;')
|
|
}
|
|
previousPoint = pos
|
|
}
|
|
// variablename seems unused
|
|
}
|
|
|
|
AFRAME.registerComponent('target', {
|
|
init: function () {
|
|
targets.push( this.el )
|
|
}
|
|
})
|
|
|
|
AFRAME.registerComponent('line-link-entities', {
|
|
schema: {
|
|
source: {type: 'selector'},
|
|
target: {type: 'selector'},
|
|
steps: {type: 'number', default: 1},
|
|
},
|
|
init: function () {
|
|
let generatorName = this.attrName
|
|
setTimeout( _ => { // stupid... but works.
|
|
if (!this.data.source || !this.data.target) return
|
|
var sourcePos = this.data.source.object3D.position
|
|
var targetPos = this.data.target.object3D.position
|
|
if (!sourcePos || !targetPos) return // might not be needed anymore
|
|
// adding a gltf inside an element prevents the parent from having coordinates (fast enough?)
|
|
var step = 0
|
|
var points = cut ([sourcePos, targetPos], 0, ++step)
|
|
points = cut (points, 0, ++step)
|
|
points = cut (points, points.length-2, step)
|
|
var el = this.el
|
|
el.className += generatorName
|
|
points.map( (p,i,arr) => {
|
|
if (arr[i+1])
|
|
el.setAttribute("line__"+i, "start:" + AFRAME.utils.coordinates.stringify( arr[i] ) + ";end: " + AFRAME.utils.coordinates.stringify( arr[i+1] ) )
|
|
})
|
|
}, 100 ) // could check instead if both elements have loaded
|
|
|
|
function cut(points, pos, step){
|
|
var a = points[pos]
|
|
var b = points[pos+1]
|
|
var midPos = new THREE.Vector3()
|
|
midPos.copy(a).add(b).divideScalar(2)
|
|
midPos.z -= a.distanceTo(b)/(step*10) // smoothed out but axis aligned
|
|
return [...points.slice(0,pos+1), midPos, ...points.slice(pos+1)]
|
|
}
|
|
}
|
|
});
|
|
|
|
function tryCachedImageOtherwiseRenderLive(pages){
|
|
let urls = []
|
|
pages.map( i => {
|
|
let cached = baseCachedURL + i.group + "_" + i.name + imageExtension
|
|
urls.push( cached )
|
|
fetch( cached).then( res => res.status ).then( r => { if (r==404)
|
|
// console.log("try to get new one", r, cached)
|
|
replaceCachedImageByLive(i.group, i.name)
|
|
} )
|
|
})
|
|
return urls
|
|
}
|
|
|
|
function replaceCachedImageByLive(group, name){
|
|
const live = baseLiveURL+group+"_"+name+imageExtension
|
|
fetch( live ).then( res => res.status ).then( r => {
|
|
if (r==200)
|
|
// check if in the "new" cache before doing a live query first
|
|
document.querySelector("[src='"+baseCachedURL+group+"_"+name+imageExtension+"']")
|
|
.setAttribute("src", live)
|
|
else
|
|
fetch( queryFormatBaseURL+group+"/"+name+renderSuffix ).then( res => res.json() )
|
|
.then( document.querySelector("[src='"+baseCachedURL+group+"_"+name+imageExtension+"']")
|
|
.setAttribute("src", live)
|
|
)
|
|
} )
|
|
}
|
|
|
|
AFRAME.registerComponent('screenstack', {
|
|
// this could be potentially be replaced with web-url
|
|
init: function () {
|
|
//load()
|
|
//if (cabin && cabin.length > 0) return // test doesn't seem to work well on new page / 1st load
|
|
// see CEREMA project, seems to handle caching better
|
|
var el = this.el
|
|
let generatorName = this.attrName
|
|
fetch(wikiAsImages).then(response => response.json()).then(data =>
|
|
imagesFromURLs(
|
|
tryCachedImageOtherwiseRenderLive(
|
|
Object.entries(data.Nodes).map(( [k, v] ) => { return {group:v.Group, name:v.Label} } ).slice(0,maxItems)
|
|
)
|
|
, el, generatorName )
|
|
)
|
|
// example time sorting
|
|
/* fetch('/screens').then(response => response.json()).then(data => console.log(
|
|
data.files.filter( i => i.indexOf("_000") < 0 ).map( i => Number(i.replace(".png", "")) ).filter( i => i > now-60 )
|
|
) )
|
|
*/
|
|
|
|
// works only locally for privacy reasons.
|
|
//fetch('colors.txt').then(response => response.text()).then(data => coloredBlocksFromScreens(data.split("\n").splice(-maxItems), el))
|
|
// timings should match as colors are generated from the screens
|
|
|
|
//fetch('currentURL.txt').then(response => response.text()).then(data => URLs(data.split("\n").splice(-maxItems), el ))
|
|
// could slice the array based on dates and e.g limit on current day or last 24hrs
|
|
}
|
|
});
|
|
|
|
function getClosestTargetElements( pos, threshold=0.05 ){
|
|
// TODO Bbox intersects rather than position
|
|
return targets.filter( e => e.getAttribute("visible") == true).map( t => { return { el: t, dist : pos.distanceTo(t.getAttribute("position") ) } })
|
|
.filter( t => t.dist < threshold )
|
|
.sort( (a,b) => a.dist > b.dist)
|
|
}
|
|
|
|
function getClosestTargetElement( pos, threshold=0.05 ){ // 10x lower threshold for flight mode
|
|
var res = null
|
|
const matches = getClosestTargetElements( pos, threshold)
|
|
if (matches.length > 0) res = matches[0].el
|
|
return res
|
|
}
|
|
|
|
/*
|
|
alternatively could looks for the intersecting bounding boxes of all targets
|
|
then from those pick the closest one (again based on center)
|
|
|
|
Both work well... but without any depth/thickness the chance of intersection are null...
|
|
extruding the plane to a volume on a known axis, e.g z here (no rotation) is trivial but limiting.
|
|
|
|
Could rely on the bounding sphere instead but not ideal when text is beside other pieces of text
|
|
|
|
Note that in practice we plan to bring geometry with the text, a la Scratch, to showcase grammar and potential to combine.
|
|
Consequently those problems would probably go away by intersecting with that geometry instead.
|
|
|
|
That still means an efficient solution, e.g convex hull or BVH
|
|
*/
|
|
|
|
// e.g addBackgroundBoxToTextElements( targets.filter( e => e.localName == "a-text" ) )
|
|
// note that background is "just" for the user in the sense that an invisible bounding box is enough for interactions
|
|
function addBackgroundBoxToTextElements( textElements ){
|
|
textElements.map( el => {
|
|
|
|
addBoundingBoxToTextElement( el )
|
|
|
|
var bbox = new THREE.Box3().setFromObject( el.object3D.children[0] )
|
|
// the text element itself has no volume whereas its first children is a mesh
|
|
var scale = el.getAttribute("scale").x // assume being uniform
|
|
var box = document.createElement("a-box")
|
|
el.appendChild( box )
|
|
box.setAttribute("width", (bbox.max.x - bbox.min.x) * 1/scale)
|
|
box.setAttribute("height", (bbox.max.y - bbox.min.y) * 1/scale)
|
|
box.setAttribute("depth", scale)
|
|
box.setAttribute("position", (bbox.max.x - bbox.min.x) * (1/scale) / 2 + " 0 " + -scale/2+0.0001 )
|
|
|
|
setTimeout( _ => { // AFrame induced delay
|
|
// should check instead how to make sure an object is indeed created.
|
|
// maybe via el.getObject3D()
|
|
var compoundbbox = new THREE.Box3().setFromObject( box.object3D )
|
|
compoundbbox.expandByObject( el.object3D )
|
|
var helperbox = new THREE.BoxHelper( box.object3D, 0x00ff00);
|
|
helperbox.update();
|
|
AFRAME.scenes[0].object3D.add(helperbox);
|
|
/*
|
|
var helpercompound = new THREE.BoundingBoxHelper( compoundbbox, 0x0000ff);
|
|
helpercompound.update();
|
|
AFRAME.scenes[0].object3D.add(helpercompound);
|
|
*/
|
|
}, 100)
|
|
})
|
|
}
|
|
|
|
function addBoundingBoxToTextElement( el ){
|
|
var meshEl = el.object3D.children.filter( e => (e.type == "Mesh") )[0]
|
|
var helper = new THREE.BoundingBoxHelper(meshEl, 0xff0000);
|
|
// otherwise doesn't work with icon...
|
|
helper.update();
|
|
AFRAME.scenes[0].object3D.add(helper);
|
|
el.setAttribute("box-uuid", helper.uuid )
|
|
groupHelpers.push( helper )
|
|
}
|
|
|
|
function removeBoundingBoxToTextElement( el ){
|
|
var uuid = el.getAttribute("box-uuid")
|
|
el.removeAttribute("box-uuid")
|
|
//AFRAME.scenes[0].object3D.traverse( e => { if (e.uuid == uuid) e.removeFromParent() })
|
|
//AFRAME.scenes[0].object3D.traverse( e => { if (e.uuid == uuid) AFRAME.scenes[0].object3D.remove(e) })
|
|
AFRAME.scenes[0].object3D.traverse( e => { console.log(e.uuid == uuid) })
|
|
AFRAME.scenes[0].object3D.traverse( e => { if (e.uuid == uuid) console.log("found", e)})
|
|
// somehow removing did work before ...
|
|
}
|
|
|
|
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 )
|
|
}
|
|
|
|
function addToGroup( position ){
|
|
var el = getClosestTargetElement( position )
|
|
if (!el) return
|
|
groupSelection.push( el )
|
|
addBoundingBoxToTextElement( el )
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
AFRAME.registerComponent('waistattach',{
|
|
schema: {
|
|
target: {type: 'selectorAll'},
|
|
},
|
|
init: function () {
|
|
var el = this.el
|
|
this.worldPosition=new THREE.Vector3();
|
|
},
|
|
tick: function () {
|
|
var worldPosition=this.worldPosition;
|
|
worldPosition.copy(this.el.object3D.position);this.el.object3D.parent.updateMatrixWorld();this.el.object3D.parent.localToWorld(worldPosition)
|
|
Array.from( this.data.target ).map( t => {
|
|
t.object3D.position.x = worldPosition.x
|
|
t.object3D.position.z = worldPosition.z
|
|
})
|
|
},
|
|
});
|
|
|
|
AFRAME.registerComponent('attach',{
|
|
schema: {
|
|
target: {type: 'selector'},
|
|
},
|
|
init: function () {
|
|
var el = this.el
|
|
this.worldPosition=new THREE.Vector3();
|
|
},
|
|
tick: function () {
|
|
var worldPosition=this.worldPosition;
|
|
worldPosition.copy(this.el.position);
|
|
this.el.parent.updateMatrixWorld();
|
|
this.el.parent.localToWorld(worldPosition)
|
|
rotation = this.el.rotation.x*180/3.14 + " " + this.el.rotation.y*180/3.14 + " " + this.el.rotation.z*180/3.14
|
|
this.data.target.setAttribute("rotation", rotation)
|
|
this.data.target.setAttribute("position",
|
|
AFRAME.utils.coordinates.stringify( worldPosition ) )
|
|
},
|
|
remove: function() {
|
|
}
|
|
});
|
|
|
|
AFRAME.registerComponent('wristattachsecondary',{
|
|
schema: {
|
|
target: {type: 'selector'},
|
|
},
|
|
init: function () {
|
|
var el = this.el
|
|
this.worldPosition=new THREE.Vector3();
|
|
},
|
|
tick: function () {
|
|
// 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 == "b_"+sides[secondarySide][0]+"_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('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
|
|
if (setupMode) setupBBox["B"] = event.detail.position
|
|
if ( setupBBox["A"] && setupBBox["B"] ) {
|
|
setupMode = false
|
|
setFeedbackHUD( JSON.stringify(setupBBox))
|
|
}
|
|
/*
|
|
selectionPinchMode = false
|
|
setHUD( AFRAME.utils.coordinates.stringify( bbox.min ),
|
|
AFRAME.utils.coordinates.stringify( bbox.max ) )
|
|
bbox.min.copy( zeroVector3 )
|
|
bbox.man.copy( zeroVector3 )
|
|
*/
|
|
});
|
|
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
|
|
var closests = getClosestTargetElements( event.detail.position )
|
|
//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")
|
|
if (content && immersClient && immersClient.connected){
|
|
immersClient.place.location = {
|
|
position: AFRAME.utils.coordinates.stringify(event.detail.position),
|
|
rotation: AFRAME.utils.coordinates.stringify( selectedElement.getAttribute("rotation") )
|
|
};
|
|
immersClient.sendChatMessage(content, "public");
|
|
}
|
|
selectedElements.push({element:selectedElement, timestamp:Date.now(), primary:true})
|
|
}
|
|
// unselect current target if any
|
|
selectedElement = null;
|
|
save()
|
|
if (setupMode) setupBBox["A"] = event.detail.position
|
|
// somehow keeps on setting up... shouldn't once done.
|
|
if ( setupBBox["A"] && setupBBox["B"] ) {
|
|
setupMode = false
|
|
setFeedbackHUD( JSON.stringify(setupBBox))
|
|
}
|
|
if ( drawingMode ) draw( event.detail.position )
|
|
if ( groupingMode ) addToGroup( event.detail.position )
|
|
selectionPinchMode = false
|
|
/*
|
|
setHUD( AFRAME.utils.coordinates.stringify( bbox.min ),
|
|
AFRAME.utils.coordinates.stringify( bbox.max ) )
|
|
bbox.min.copy( zeroVector3 )
|
|
bbox.man.copy( zeroVector3 )
|
|
*/
|
|
setTimeout( _ => primaryPinchStarted = false, 200) // delay otherwise still activate on release
|
|
|
|
var newPinchPos = new THREE.Vector3()
|
|
newPinchPos.copy(event.detail.position )
|
|
pinches.push({position:newPinchPos, timestamp:Date.now(), primary:true})
|
|
dl2p = distanceLastTwoPinches()
|
|
|
|
});
|
|
this.el.addEventListener('pinchmoved', function (event) {
|
|
// move current target if any
|
|
if (selectionPinchMode){
|
|
bbox.max.copy( event.detail.position )
|
|
if (!bbox.min.equal(zeroVector3))
|
|
selectionBox.update();
|
|
}
|
|
if (selectedElement && !groupingMode) {
|
|
selectedElement.setAttribute("position", event.detail.position)
|
|
document.querySelector("#rightHand").object3D.traverse( e => {
|
|
if (e.name == "b_"+sides[primarySide][0]+"_wrist")
|
|
selectedElement.setAttribute("rotation", e.rotation.x*180/3.14 + " " + e.rotation.y*180/3.14 + " " + e.rotation.z*180/3.14)
|
|
})
|
|
// rotation isn't ideal with the wrist as tend not have wrist flat as we pinch
|
|
}
|
|
});
|
|
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 )
|
|
// 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.
|
|
}
|
|
});
|
|
|
|
// testing on desktop
|
|
function switchToWireframe(){
|
|
visible = !visible
|
|
/*
|
|
targets.map( e => {
|
|
scale = 50// should be a variable instead
|
|
e.setAttribute("scale", visible ? ".05 .05 .05" : ".1 .1 .1" )
|
|
var pos = AFRAME.utils.coordinates.parse( e.getAttribute("position") )
|
|
//visible ? pos.z *= scale : pos.z /= scale // might be the opposite but anyway give the principle
|
|
e.setAttribute("position", AFRAME.utils.coordinates.stringify(pos))
|
|
// should actually be just for src, not for text notes... even though could be interesting
|
|
})
|
|
*/
|
|
var model = document.querySelector("#environment").object3D
|
|
model.traverse( o => { if (o.material) {
|
|
o.material.wireframe = visible;
|
|
o.material.opacity = visible ? 0.05 : 1;
|
|
o.material.transparent = visible;
|
|
} })
|
|
}
|
|
|
|
// add (JXR) shortcuts as PIM function from e.g https://observablehq.com/@utopiah/from-pim-to-2d-to-3d-to-xr-explorations
|
|
// allowing to search within PIM then show manipulable pages as preview.
|
|
|
|
// note that can be tested in VR also as jxr switchToWireframe()
|
|
// could make for nice in VR testing setup as eved notes
|
|
|
|
function enterSetupMode(){
|
|
// rely on 2 pinches to create a bounding box of safe interaction
|
|
// https://threejs.org/docs/#api/en/math/Box3.containsBox
|
|
setupMode = true
|
|
}
|
|
|
|
AFRAME.registerComponent('start-on-press', {
|
|
// should become a property of the component instead to be more flexible.
|
|
init: function(){
|
|
var el = this.el
|
|
this.el.addEventListener('pressedended', function (event) {
|
|
if (!primaryPinchStarted && wristShortcut.match(prefix)) interpretJXR(wristShortcut)
|
|
// other action could possibly based on position relative to zones instead, i.e a list of bbox/functions pairs
|
|
})
|
|
}
|
|
})
|
|
//---- other components : ------
|
|
// could become like https://twitter.com/utopiah/status/1264131327269502976
|
|
// can include a mini typing game to warm up finger placement
|
|
|
|
function distanceLastTwoPinches(){
|
|
let dist = null
|
|
if (pinches.length>1){
|
|
dist = pinches[pinches.length-1].position.distanceTo( pinches[pinches.length-2].position )
|
|
}
|
|
return dist
|
|
}
|
|
|
|
function startSelectionVolume(){
|
|
selectionPinchMode = true
|
|
// see setupBBox in pinchprimary and pinchsecondary
|
|
// then addBoundingBoxToTextElement()
|
|
}
|
|
// note that the bbox with vertical position model is still interesting
|
|
// (if within bounding box, try to execute code)
|
|
// because it allows grouping and sequentially rather executing line by line
|
|
// see https://threejs.org/docs/#api/en/math/Box3.containsBox
|
|
|
|
// save pose of targets and src locally and if available on PIM
|
|
/*
|
|
savingJSON = targets.map( e => {
|
|
rot : e.getAttribute("rotation"),
|
|
pos : e.getAttribute("position"),
|
|
scale : e.getAttribute("scale"),
|
|
src : e.getAttribute("src"),
|
|
value : e.getAttribute("value"),
|
|
})
|
|
*/
|
|
// load alt set of items e.g from https://observablehq.com/@utopiah/from-pim-to-2d-to-3d-to-xr-explorations
|
|
// or https://fabien.benetou.fr/pub/home/pimxr-experimentation/sources.json
|
|
|
|
// position should be configurable as rotation is handled by the OS
|
|
|
|
function parsePointer( x,y ){
|
|
console.log(x,y)
|
|
if (!sketchEl) {
|
|
sketchEl = document.createElement("a-entity")
|
|
// sketchEl.setAttribute("position", "0 1.4 -0.3") otherwise lines don't align
|
|
// could counter that offset but might be problematic later on with translations/rotations
|
|
targets.push( sketchEl )
|
|
AFRAME.scenes[0].appendChild(sketchEl)
|
|
}
|
|
var el = document.createElement("a-sphere")
|
|
var pos = x/1000 + " " + y/1000 + " 0"
|
|
// should offset and flip properly
|
|
el.setAttribute("position", pos)
|
|
el.setAttribute("radius", 0.01)
|
|
el.setAttribute("color", "green")
|
|
sketchEl.appendChild( el )
|
|
if (lastPointSketch){
|
|
var oldpos = AFRAME.utils.coordinates.stringify( lastPointSketch.getAttribute("position") )
|
|
sketchEl.setAttribute("line__"+ Date.now(), `start: ${oldpos}; end : ${pos};`)
|
|
}
|
|
lastPointSketch = el
|
|
|
|
}
|
|
|
|
function parseKeys(status, key){
|
|
var e = hudTextEl
|
|
if (status == "keyup"){
|
|
if (key == "Control"){
|
|
groupingMode = false
|
|
groupSelectionToNewNote()
|
|
}
|
|
}
|
|
if (status == "keydown"){
|
|
var txt = e.getAttribute("value")
|
|
if (txt == "[]")
|
|
e.setAttribute("value", "")
|
|
if (key == "Backspace" && txt.length)
|
|
e.setAttribute("value", txt.slice(0,-1))
|
|
if (key == "Control")
|
|
groupingMode = true
|
|
if (key == "Shift" && selectedElement)
|
|
e.setAttribute("value", selectedElement.getAttribute("value") )
|
|
else if (key == "Enter") {
|
|
if ( selectedElement ){
|
|
var clone = selectedElement.cloneNode()
|
|
clone.setAttribute("scale", "0.1 0.1 0.1") // somehow lost
|
|
AFRAME.scenes[0].appendChild( clone )
|
|
targets.push( clone )
|
|
selectedElement = clone
|
|
} else {
|
|
if (txt.match(prefix)) interpretJXR(txt)
|
|
// check if text starts with jxr, if so, also interpret it.
|
|
addNewNote(e.getAttribute("value"))
|
|
e.setAttribute("value", "")
|
|
}
|
|
} else {
|
|
// consider also event.ctrlKey and multicharacter ones, e.g shortcuts like F1, HOME, etc
|
|
if (key.length == 1)
|
|
e.setAttribute("value", e.getAttribute("value") + key )
|
|
}
|
|
save()
|
|
}
|
|
}
|
|
|
|
AFRAME.registerComponent('hud', {
|
|
init: function(){
|
|
var feedbackHUDel= document.createElement("a-troika-text")
|
|
feedbackHUDel.id = "feedbackhud"
|
|
feedbackHUDel.setAttribute("value", "")
|
|
feedbackHUDel.setAttribute("position", "-0.05 0.01 -0.2")
|
|
feedbackHUDel.setAttribute("scale", "0.05 0.05 0.05")
|
|
this.el.appendChild( feedbackHUDel )
|
|
var typingHUDel = document.createElement("a-troika-text")
|
|
typingHUDel.id = "typinghud"
|
|
typingHUDel.setAttribute("value", startingText)
|
|
typingHUDel.setAttribute("position", "-0.05 0 -0.2")
|
|
typingHUDel.setAttribute("scale", "0.05 0.05 0.05")
|
|
this.el.appendChild( typingHUDel )
|
|
hudTextEl = typingHUDel // should rely on the id based selector now
|
|
document.addEventListener('keyup', function(event) {
|
|
parseKeys('keyup', event.key)
|
|
});
|
|
document.addEventListener('keydown', function(event) {
|
|
parseKeys('keydown', event.key)
|
|
});
|
|
}
|
|
})
|
|
|
|
function addNewNote( text, position=`-0.2 1.1 -0.1`, scale= "0.1 0.1 0.1", id=null, classes=null, 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
|
|
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
|
|
}
|
|
|
|
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 draw( position ){
|
|
var drawingMoment = +Date.now()
|
|
var pos = AFRAME.utils.coordinates.stringify( position )
|
|
// add sphere per point
|
|
var el = document.createElement("a-sphere")
|
|
el.setAttribute("position", pos)
|
|
el.setAttribute("radius", 0.001)
|
|
el.setAttribute("color", "lightblue")
|
|
el.setAttribute("dateadded", drawingMoment )
|
|
// if previous point exist, draw line between both
|
|
var pastPoints = Array.from( document.querySelectorAll("[dateadded]") )
|
|
.sort( (a,b) => a.getAttribute("dateadded") - b.getAttribute("dateadded") )
|
|
if (pastPoints.length) {
|
|
var previousPoint = pastPoints[0]
|
|
var drawing = previousPoint.parentElement
|
|
var oldpos = AFRAME.utils.coordinates.stringify( previousPoint.getAttribute("position") )
|
|
drawing.setAttribute("line__"+ drawingMoment, `start: ${oldpos}; end : ${pos};`)
|
|
} else {
|
|
var drawing = document.createElement("a-entity")
|
|
drawing.className = "drawing"
|
|
AFRAME.scenes[0].appendChild( drawing )
|
|
}
|
|
drawing.appendChild( el )
|
|
// if sufficiently close to another sphere (the first) close the loop
|
|
if (pastPoints.length>1) {
|
|
var lastPoint = pastPoints[pastPoints.length-1]
|
|
var oldpos = AFRAME.utils.coordinates.stringify( lastPoint.getAttribute("position") )
|
|
if (lastPoint.getAttribute("position").distanceTo( position) < 0.1) // threshold
|
|
drawing.setAttribute("line__"+ drawingMoment + "_closeloop", `start: ${oldpos}; end : ${pos};`)
|
|
// then enter extrude mode (assume they are on 1 plane)
|
|
// should also prevent from adding points to the current drawing
|
|
// before investing too much effort in this, should consider how it would actually improve usage
|
|
// especially as we can add AFrame primitives with the keyboard
|
|
// intersection of kbd and hand tracked 6DoF being the primary usage
|
|
}
|
|
|
|
/* test values, must wait .1 second between otherwise there is no known position
|
|
(most likely AFrame to threejs delay)
|
|
draw( new THREE.Vector3(-0.04, 1.7, -1) );
|
|
draw( new THREE.Vector3(0.04, 1.7, -1) );
|
|
draw( new THREE.Vector3(0, 1.72, -1) );
|
|
*/
|
|
|
|
|
|
}
|
|
|
|
// the goal is to associate objects as shape with volume to code snippet
|
|
function addGltfFromURLAsTarget( url, scale=1 ){
|
|
var el = document.createElement("a-entity")
|
|
AFRAME.scenes[0].appendChild(el)
|
|
el.setAttribute("gltf-model", url)
|
|
el.setAttribute("position", "0 1.7 -0.3") // arbitrary for test
|
|
el.setAttribute("scale", scale + " " + scale + " " + scale)
|
|
targets.push(el)
|
|
|
|
// consider https://sketchfab.com/developers/download-api/downloading-models/javascript
|
|
}
|
|
|
|
function showhistory(){
|
|
setFeedbackHUD("history :\n")
|
|
commandhistory.map( i => appendToHUD(i.uninterpreted+"\n") )
|
|
}
|
|
|
|
function saveHistoryAsCompoundSnippet(){
|
|
addNewNote( commandhistory.map( e => e.uninterpreted ).join("\n") )
|
|
}
|
|
|
|
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 )
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
AFRAME.registerComponent('toolbox', { // ununsed
|
|
init: function(){
|
|
var el = this.el
|
|
var e = document.createElement("a-sphere")
|
|
e.setAttribute("scale", "0.1 0.1 0.1")
|
|
e.setAttribute("color", "lightblue")
|
|
e.setAttribute("pressable")
|
|
e.id = "toolboxsphere"
|
|
el.appendChild( e )
|
|
var e = document.createElement("a-cylinder")
|
|
e.setAttribute("scale", "0.1 0.1 0.1")
|
|
e.setAttribute("color", "darkred")
|
|
e.setAttribute("pressable")
|
|
e.id = "toolboxcylinder"
|
|
el.appendChild( e )
|
|
var e = document.createElement("a-box")
|
|
e.setAttribute("scale", "0.1 0.1 0.1")
|
|
e.setAttribute("color", "pink")
|
|
e.setAttribute("pressable")
|
|
e.id = "toolbox"
|
|
el.appendChild( e )
|
|
},
|
|
tick: function(){
|
|
var toolbox = document.querySelector("#toolbox")
|
|
var cam = document.querySelector("[camera]")
|
|
toolbox.object3D.position.x = cam.getAttribute("position").x-0.5
|
|
toolbox.object3D.position.z = cam.getAttribute("position").z+0.2
|
|
//toolbox.object3D.rotation.y = cam.getAttribute("rotation").y
|
|
}
|
|
})
|
|
|
|
// from https://aframe.io/aframe/examples/showcase/hand-tracking/pressable.js
|
|
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<this.data.pressDistance){if(!this.pressed){this.el.emit('pressedstarted');} this.pressed=true;return;}} if(this.pressed){this.el.emit('pressedended');} this.pressed=false;},
|
|
calculateFingerDistance:function(fingerPosition){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('selectionboxonpinches', {
|
|
init:function(){
|
|
AFRAME.scenes[0].object3D.add(selectionBox);
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
AFRAME.registerComponent('capturegesture', {
|
|
init:function(){this.handEls=document.querySelectorAll('[hand-tracking-controls]');},
|
|
tick:function(){
|
|
document.querySelector("#rightHand").object3D.traverse( e => { if (e.name == "b_r_wrist") console.log("rw", e.rotation) })
|
|
document.querySelector("#leftHand" ).object3D.traverse( e => { if (e.name == "b_l_wrist") console.log("rl", e.rotation) })
|
|
// should look up thumb-metacarpal and index-finger-metacarpal if not sufficient
|
|
// might trickle down iif wrist rotation itself is already good
|
|
// https://immersive-web.github.io/webxr-hand-input/
|
|
}
|
|
});
|
|
|
|
AFRAME.registerComponent('timeline', {
|
|
init:function(){
|
|
let generatorName = this.attrName
|
|
fetch("../content/fot_timeline.json").then(res => res.json() ).then(res => {
|
|
res.fot_timeline.slice(0,maxItemsFromSources).map( (c,i) => addNewNote( c.year+"_"+c.event, "1 "+i/10+" -1", ".1 .1 .1", null, generatorName) )
|
|
})
|
|
},
|
|
});
|
|
|
|
AFRAME.registerComponent('glossary', {
|
|
init:function(){
|
|
let generatorName = this.attrName
|
|
fetch("content/glossary.json").then(res => res.json() ).then(res => {
|
|
Object.values(res.entries).slice(0,maxItemsFromSources).map( (c,i) => addNewNote( c.phrase + c.entry.slice(0,50)+"..." , "-1 "+i/10+" -1", ".1 .1 .1", null, generatorName) )
|
|
})
|
|
},
|
|
});
|
|
|
|
AFRAME.registerComponent('fot', {
|
|
init:function(){
|
|
},
|
|
tick: function(){
|
|
let generatorName = this.attrName
|
|
fetch("https://fabien.benetou.fr/PIMVRdata/FoT?action=source#" + Date.now()).then(res => res.text() ).then(res => {
|
|
res.split("\n").slice(0,maxItemsFromSources).map( (n,i) => {
|
|
found = added.find((str) => str === n)
|
|
if (typeof found === 'undefined'){
|
|
added.push(n)
|
|
addNewNote( n, "-1 "+(1+i/10)+" -2.5", ".1 .1 .1", null, generatorName )
|
|
}
|
|
})
|
|
})
|
|
}
|
|
});
|
|
|
|
AFRAME.registerComponent('issues', {
|
|
init:function(){
|
|
let generatorName = this.attrName
|
|
// fetch("https://api.github.com/repos/Utopiah/relax-plus-think-space/issues").then(res => res.json() ).then(res => {
|
|
fetch("https://git.benetou.fr/api/v1/repos/utopiah/text-code-xr-engine/issues").then(res => res.json() ).then(res => {
|
|
res.slice(0,maxItemsFromSources).map( (n,i) => addNewNote( n.title, "0 "+(1+i/10)+" -1.8", ".1 .1 .1", null, generatorName ) )
|
|
})
|
|
},
|
|
});
|
|
|
|
AFRAME.registerComponent('dynamic-view', {
|
|
init:function(){
|
|
let generatorName = this.attrName
|
|
fetch("content/DynamicView.json").then(res => res.json() ).then(res => {
|
|
res.nodes.slice(0,maxItemsFromSources).map( n => addNewNote( n.title, "" + res.layout.nodePositions[n.identifier].x/100 + " " + res.layout.nodePositions[n.identifier].y/100 + " -1", ".1 .1 .1", null, generatorName ) )
|
|
})
|
|
},
|
|
});
|
|
|
|
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 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")
|
|
}
|
|
|
|
AFRAME.registerComponent('adjust-height-in-vr', {
|
|
init: function(){
|
|
AFRAME.scenes[0].addEventListener("enter-vr", _ => {
|
|
setTimeout( _ => { // getting the value right away returns 0, so short delay
|
|
userHeight = document.querySelector("#player").object3D.position.y
|
|
// assume the user does not change, some might prefer to use standing up first then sit down.
|
|
// otherwise explicit controls
|
|
heightAdjustableClasses.map( c => {
|
|
max = Math.max.apply(null, Array.from( document.querySelectorAll("."+c) ).map( e => e.object3D.position.y) )
|
|
min = Math.min.apply(null, Array.from( document.querySelectorAll("."+c) ).map( e => e.object3D.position.y) )
|
|
pushDownClass(c, userHeight - (max-min)/2 )
|
|
setFeedbackHUD( "adjusted height by:" + ( userHeight - (max-min)/2 ) )
|
|
} )
|
|
}, 100 )
|
|
})
|
|
}
|
|
})
|
|
|
|
AFRAME.registerComponent('commands-from-external-json', {
|
|
/*
|
|
// following discussion with Yanick
|
|
|
|
// for cabin.html to faciliate growth and flexibility
|
|
|
|
fetch('./commands.json') // then load like A-Frame elements
|
|
// e.g command //{name,defaultpose,autorun,description}
|
|
// could even be stored as wiki page
|
|
|
|
fetch('./templates.json')
|
|
// e.g [commands with optional pose]
|
|
|
|
// watch can be a template too
|
|
<a-text side="double" networked="template:#text-template;attachTemplateToLocal:false;" id="instructionA" value="Interactive instructions:" position="0 1.5 -0.2" scale="0.2 0.2 0.2"></a-text>
|
|
<a-text side="double" id="instructionB" target value="jxr lg Fox.glb 0.001" position="0 1.65 -0.2" scale="0.1 0.1 0.1">
|
|
<a-entity gltf-model="Fox.glb" scale="0.005 0.005 0.005"></a-entity>
|
|
</a-text>
|
|
<a-text side="double" networked="template:#text-template;attachTemplateToLocal:false;" id="instructionC" target value="jxr obsv observablehq-numberOfPages-835aa7e9" position="0 1.55 -0.2" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text side="double" networked="template:#text-template;attachTemplateToLocal:false;" id="instructionD" target value="jxr 1s startSelectionVolume()" position="0 1.50 -0.2" scale="0.1 0.1 0.1"></a-text>
|
|
*/
|
|
init:function(){
|
|
var el = this.el
|
|
let generatorName = this.attrName
|
|
var links = [ // could be in the commands file instead
|
|
"target:#instructionA; source:#instructionB",
|
|
"target:#instructionA; source:#instructionC",
|
|
"target:#instructionA; source:#instructionD",
|
|
]
|
|
links = []
|
|
//fetch("commands.json").then(res => res.json() ).then(res => {
|
|
var commandsURL = "https://fabien.benetou.fr/PIMVRdata/CabinCommands?action=source"
|
|
commandsURL = "https://fabien.benetou.fr/PIMVRdata/EngineSequentialTutorialCommands?action=source" // new default
|
|
var src = AFRAME.utils.getUrlParameter('commands-url')
|
|
if (src && src != "") commandsURL = src
|
|
fetch(commandsURL).then(res => res.json() ).then(res => {
|
|
// to consider for remoteLoad/remoteSave instead, to distinguish from url though.
|
|
// also potential security concern so might insure that only a specific user, with mandatory password access, added commands.
|
|
var visible = true
|
|
if (c.visible) visible = c.visible
|
|
res.map( c => addNewNote( c.value, c.position, c.scale, c.id, generatorName, c.visible) )
|
|
// missing name/title, autorun (true/false), description, 3D icon/visual, visiblity (useful for sequential tutorial)
|
|
links.map( l => { var linkEl = document.createElement("a-entity");
|
|
linkEl.setAttribute("line-link-entities", l)
|
|
el.appendChild(linkEl)
|
|
} )
|
|
var hideRest = AFRAME.utils.getUrlParameter('commands-hide-rest')
|
|
if (hideRest && hideRest != "") setTimeout( _ => toggleVisibilityAllButClass('commands-from-external-json'), 5000) // waiting for everything to have loaded...
|
|
})
|
|
},
|
|
});
|
|
|
|
function save(){
|
|
var data = targets.map( e => { return {
|
|
localname: e.localName,
|
|
src: e.getAttribute("src"),
|
|
position: e.getAttribute("position"),
|
|
rotation: e.getAttribute("rotation"),
|
|
scale: e.getAttribute("scale"),
|
|
value: e.getAttribute("value"),
|
|
} } )
|
|
cabin = data
|
|
localStorage.setItem('cabin', JSON.stringify( data) )
|
|
return data
|
|
// could be called on page exit, unsure if reliable in VR
|
|
// alternatively could be call after each content is moved or created
|
|
}
|
|
|
|
function load(){
|
|
if (localStorage.getItem('cabin'))
|
|
cabin = JSON.parse(localStorage.getItem('cabin'))
|
|
cabin.map( e => {
|
|
var newel = document.createElement(e.localname)
|
|
savedProperties.map( p => {
|
|
if (e[p] ) newel.setAttribute(p, e[p])
|
|
})
|
|
AFRAME.scenes[0].appendChild( newel )
|
|
})
|
|
}
|
|
|
|
function remoteLoad(){
|
|
fetch(url+'source')
|
|
.then( response => { return response.json() } )
|
|
.then( data => { console.log("remote data:", data) })
|
|
// does actually load back. Should consider what load() does instead.
|
|
|
|
// for the reMarkable write back in source, OCR/HWR could be done on the WebXR device instead
|
|
// alternatively "just" sending the .jpg thumbnail would be a good enough start
|
|
// note that highlights are also JSON files
|
|
// both might not be ideal directly in the original JSON but could be attachement as URLs
|
|
}
|
|
|
|
function remoteSave(){
|
|
fetch(url+'edit', {
|
|
method: 'POST',
|
|
headers: {'Content-Type':'application/x-www-form-urlencoded'},
|
|
body: "post=1&author=PIMVR&authpw=edit_password&text="+JSON.stringify( cabin )
|
|
}).then(res => res).then(res => console.log("saved remotely", res))
|
|
}
|
|
|
|
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 cloneAndDistribute(){
|
|
el = document.querySelector("a-box[src]") // page
|
|
// trying instead to rely on previously selected matching element and dl2p
|
|
|
|
// lack visual feedback to show what is indeed lastly selected or the distance found
|
|
//el = selectedElements[selectedElements.length-2] // not current command
|
|
times = Math.floor(dl2p*10) // also assume it's been done properly
|
|
if (times < 2) times = 7
|
|
|
|
offset = .5
|
|
for (var i = 0; i < times ; i++) { // equivalent of Blender array modifier
|
|
let newEl = el.cloneNode()
|
|
AFRAME.scenes[0].appendChild(newEl) // takes time...
|
|
setTimeout( setZ, 100, {el: newEl, z: -1-i*offset} )
|
|
newEl.addEventListener('hasLoaded', function (event) {
|
|
//this.object3D.position.z = i*offset
|
|
console.log("loaded") // doesnt seem to happen
|
|
})
|
|
}
|
|
|
|
function setZ(params){
|
|
params.el.object3D.position.z = params.z
|
|
}
|
|
}
|
|
|
|
function loadPageRange(start=1, end=-1, startPosition={x:0, y:1.3, z:-.7}, stepVector={x:.2, y:0, z:0}){
|
|
const baseURL = "https://fabien.benetou.fr/pub/home/future_of_text_demo/content/book_as_png/gfg_d-"
|
|
const extension = ".png"
|
|
|
|
// assumes portrait A4-ish
|
|
var rootEl = AFRAME.scenes[0]
|
|
if (end<0) end = start
|
|
|
|
let step = 0
|
|
for (let i=start; i<=end; i++){
|
|
step++
|
|
let el = document.createElement("a-box")
|
|
el.setAttribute("target", true)
|
|
//el.setAttribute("position", ""+ step/5+ " 1.3 -.7") // could be based on selectedElements last position instead
|
|
let pos = "" + startPosition.x+stepVector.x*step + " " + startPosition.y+stepVector.y*step + " " + startPosition.z+stepVector.z*step
|
|
el.setAttribute("position", pos)
|
|
// layout system could be parametric, e.g over x or y or z or another system
|
|
el.setAttribute("width", ".1")
|
|
el.setAttribute("height", ".15")
|
|
el.setAttribute("depth", ".01")
|
|
pageNumber = i
|
|
if (pageNumber<10) pageNumber = "0"+pageNumber
|
|
if (pageNumber<100) pageNumber = "0"+pageNumber
|
|
el.setAttribute("src", baseURL+pageNumber+extension)
|
|
el.setAttribute("pagenumber", pageNumber)
|
|
el.id = pageNumber + "_" + Date.now()
|
|
rootEl.appendChild(el)
|
|
let posInterface = "" + startPosition.x+stepVector.x*step + " " + startPosition.y+1+stepVector.y*step + " " + startPosition.z+stepVector.z*step
|
|
let UI = addNewNote("jxr nextPage('"+el.id+"')", posInterface, "0.1 0.1 0.1", el.id+"_interface")
|
|
//el.setAttribute("attach","target:#"+el.id+"_interface")
|
|
}
|
|
}
|
|
|
|
function writeWebDAV(){
|
|
const webdavurl = "https://webdav.benetou.fr";
|
|
const client = window.WebDAV.createClient(webdavurl)
|
|
async function w(path = "/file.txt"){ return await client.putFileContents(path, "SpaSca test"); }
|
|
w("/fot.txt") // need new permissions
|
|
}
|
|
|
|
function getPagesFromWebDAV(){
|
|
const webdavurl = "https://webdav.benetou.fr";
|
|
const client = window.WebDAV.createClient(webdavurl)
|
|
async function getDirectory(path = "/"){ return await client.getDirectoryContents(path); }
|
|
getDirectory("book_as_png").then( d => d.sort( (a,b) => (a.filename>b.filename)).slice(0,10).map( (c,i) => addPageFromURL(webdavurl+c.filename)))
|
|
}
|
|
|
|
function addPageFromURL(url){
|
|
if (url.indexOf(".png")<0) return
|
|
let el = document.createElement("a-box")
|
|
el.setAttribute("position", -Math.random()+" "+Math.random()*3 + " -1")
|
|
el.setAttribute("width", ".1")
|
|
el.setAttribute("height", ".15")
|
|
el.setAttribute("depth", ".01")
|
|
el.setAttribute("src", url)
|
|
AFRAME.scenes[0].appendChild(el)
|
|
return el
|
|
}
|
|
|
|
function getModelsFromWebDAV(){
|
|
const webdavurl = "https://webdav.benetou.fr";
|
|
const client = window.WebDAV.createClient(webdavurl)
|
|
async function getDirectory(path = "/"){ return await client.getDirectoryContents(path); }
|
|
getDirectory("models").then( d => d.sort( (a,b) => (a.filename>b.filename)).slice(0,10).map( (c,i) => addModelFromURL(webdavurl+c.filename)))
|
|
}
|
|
|
|
function addModelFromURL(url){
|
|
return addNewNote("jxr lg "+url+ " 0.001", -Math.random()+" "+Math.random()*3 + " -1")
|
|
// should try boxing it instead in 1m3
|
|
}
|
|
|
|
// same principle to go from nextPage() to openingLinkedPages() from wiki URL
|
|
// consider screenstack, could add a note to mode further
|
|
|
|
function loadWikiAsGraph(){
|
|
fetch(wikiAsImages).then(response => response.json()).then(data => {
|
|
Object.entries(data.Nodes).slice(0,maxItems).map( v => {
|
|
let pageName = v[0]
|
|
let targest = v[1].Targets
|
|
let el = addPageFromURL(baseLiveURL+pageName.replace(".","_")+imageExtension)
|
|
el.id = pageName
|
|
el.classname = "wikipage"
|
|
// should rely on tryCachedImageOtherwiseRenderLive(pages) instead
|
|
setTimeout( _ => {
|
|
let pos = el.getAttribute("position")
|
|
let UI = addNewNote("jxr openFromNode('"+el.id+"')", pos, "0.1 0.1 0.1", el.id+"_interface")
|
|
console.log("should add: addNewNote('jxr openNewNode("+pageName+")')")
|
|
}, 100 ) // wait for the entity to be actually added
|
|
// to be coupled with loadCodeFromPage()
|
|
// see also the idea that each wiki page wouldn't just be descriptive but also have code
|
|
// related pages
|
|
// https://fabien.benetou.fr/Fabien/Principle
|
|
// https://fabien.benetou.fr/CognitiveEnvironments/CognitiveEnvironments
|
|
// https://fabien.benetou.fr/Cookbook/Cognition
|
|
})
|
|
})
|
|
}
|
|
|
|
function nextPage(id){
|
|
console.log("nextpage()")
|
|
// assuming only direct parent for now
|
|
const baseURL = "https://fabien.benetou.fr/pub/home/future_of_text_demo/content/book_as_png/gfg_d-"
|
|
const extension = ".png"
|
|
let pageNumber = Number( id.split("_")[0] )
|
|
console.log(pageNumber+1)
|
|
loadPageRange(pageNumber+1)
|
|
}
|
|
|
|
function loadCodeFromPage(url="https://fabien.benetou.fr/Analysis/BeyondTheCaseAgainstBooks?action=source"){
|
|
// alternatively could load from a page number
|
|
fetch(url)
|
|
.then( r => r.text() )
|
|
.then(data => {
|
|
let code = data.split("\n").filter( l => (l.slice(0,2) == "[@") )[0].slice(2).slice(0,-2);
|
|
// example as PmWiki parsing
|
|
eval(code)
|
|
} )
|
|
}
|
|
|
|
function loadFromMastodon(statusesURL="https://mastodon.pirateparty.be/api/v1/accounts/56066/statuses"){
|
|
fetch(statusesURL).then( r => r.json() ).then( t => t.filter( i => i.in_reply_to_id == null ).map( (i,n) => {
|
|
let div = document.createElement("div")
|
|
div.innerHTML = i.content
|
|
addNewNote(div.innerText, "1 "+ (1.2+(n+1)/20) +" -0.4")
|
|
} ) )
|
|
}
|
|
|
|
// could change model opacity based on hand position, fading out when within a (very small here) safe space
|
|
</script>
|
|
<div id="observablehq-key">
|
|
<div id="observablehq-viewof-offsetExample-ab4c1560"></div>
|
|
<div id="observablehq-result_as_html-ab4c1560"></div>
|
|
</div>
|
|
<a-scene cursor="rayOrigin: mouse" raycaster="objects: [html]; interval:100;" adjust-height-in-vr
|
|
toolbox disable-components-via-url enable-components-via-url NOcommands-from-external-json keyboard >
|
|
<!-- screenstack dynamic-view selectionboxonpinches glossary timeline issues fot
|
|
networked-scene="serverURL: https://naf.benetou.fr/; adapter: easyrtc; audio: true;"
|
|
-->
|
|
<a-assets>
|
|
<template id="avatar-template"> <a-cylinder opacity=.3 scale=".2 1.2 .2" networked-audio-source></a-cylinder> </template>
|
|
<template id="left-hand-default-template">
|
|
<a-entity networked-hand-controls="hand:left"></a-entity>
|
|
</template>
|
|
<template id="right-hand-default-template">
|
|
<a-entity networked-hand-controls="hand:right"></a-entity>
|
|
</template>
|
|
</a-assets>
|
|
|
|
<a-entity id="rig">
|
|
<a-sound src="../content/summer-night-ambience.mp3" autoplay=true loop=true volume=0.5></a-sound><!-- warning skipped on Quest, does autoplay there -->
|
|
<a-entity id="player" networked="template:#avatar-template;attachTemplateToLocal:false;"
|
|
hud camera look-controls wasd-controls waistattach="target: .movebypinch" position="0 1.6 0"></a-entity>
|
|
<a-entity id="rightHand" pinchprimary hand-tracking-controls="hand: right;"></a-entity>
|
|
<a-entity id="leftHand" pinchsecondary wristattachsecondary="target: #box" hand-tracking-controls="hand: left;"></a-entity>
|
|
</a-entity>
|
|
|
|
<a-box pressable start-on-press id="box" scale="0.05 0.05 0.05" color="pink"></a-box>
|
|
<!-- could attach functions here... BUT then they have to be activable with the other hand! -->
|
|
<!-- visual reminders of shortcuts, a poster on the far left/right of keyboard shortcuts -->
|
|
|
|
<a-entity hide-on-enter-ar="" id="environment" class="hidableenvironment" gltf-model="../content/virtual_reality_meta_room_2022.glb" scale="" position="-0.10754 0.2 6.25885" rotation="0 90 0"></a-entity>
|
|
|
|
<a-entity light="type: ambient; color: #BBB; intensity: 0.6"></a-entity>
|
|
<a-entity light="type: directional; color: #FFF; intensity: 0.6" position="-0.5 1 1"></a-entity>
|
|
<a-sky hide-on-enter-ar src="../content/nebula.jpg"></a-sky>
|
|
|
|
<a-text target value="instructions : pinch numbers then > to open a page " position="0 1.65 -0.2" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text target value="jxr loadPageRange(1,13)" position="0 1.35 -0.1" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text target value="jxr loadCodeFromPage()" position="0 1.45 -0.1" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text target value="jxr qs #rig sa position 0 0 10" position="0 1.55 .5" rotation="0 180 0" scale="0.1 0.1 0.1"></a-text>
|
|
<!-- somehow disable hand interaction despite, according to the documentation, it should rely on world position -->
|
|
|
|
<a-troika-text value="SpaSca : Spatial Scaffolding" anchor="left" outline-width="5%" font="../content/ChakraPetch-Regular.ttf" position="-5.26197 6.54224 -1.81284"
|
|
scale="4 4 5" rotation="90 0 0" troika-text="outlineWidth: 0.01; strokeColor: #ffffff" material="flatShading: true; blending: additive; emissive: #c061cb"></a-troika-text>
|
|
|
|
<a-entity id="featureN">
|
|
<a-image rotation="0 180 0" position="-2 2 10.5" scale=".5 .5 .5" src="../content/features/containers.jpg"></a-image>
|
|
<a-text rotation="0 180 0" target value="containers" position="-2 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="-2 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="-2 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
</a-entity>
|
|
<a-image rotation="0 180 0" position="-1 2 10.5" scale=".5 .5 .5" src="../content/features/wireframe.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="0 2 10.5" scale=".5 .5 .5" src="../content/features/remarkable_sketch.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="1 2 10.5" scale=".5 .5 .5" src="../content/features/browsing_history.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="2 2 10.5" scale=".5 .5 .5" src="../content/features/codeembedding.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="3 2 10.5" scale=".5 .5 .5" src="../content/features/grouping.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="4 2 10.5" scale=".5 .5 .5" src="../content/features/inspector.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="5 2 10.5" scale=".5 .5 .5" src="../content/features/javascript_with_shortcuts.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="6 2 10.5" scale=".5 .5 .5" src="../content/features/load_3D_models.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="7 2 10.5" scale=".5 .5 .5" src="../content/features/math_plot.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="8 2 10.5" scale=".5 .5 .5" src="../content/features/networked_input.jpg"></a-image>
|
|
<a-image rotation="0 180 0" position="9 2 10.5" scale=".5 .5 .5" src="../content/features/observable_notebook.jpg"></a-image>
|
|
<a-text rotation="0 180 0" target="" value="Features" position="4.70348 3.07329 10.2" scale="" text=""></a-text>
|
|
<a-text rotation="0 180 0" target value="containers" position="-1 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="-1 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="-1 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text rotation="0 180 0" target value="containers" position="0 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="0 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="0 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text rotation="0 180 0" target value="containers" position="1 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="1 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="1 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text rotation="0 180 0" target value="potato" position="2 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="2 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="2 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text rotation="0 180 0" target value="containers" position="3 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="3 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="3 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text rotation="0 180 0" target value="containers" position="4 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="4 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="4 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text rotation="0 180 0" target value="containers" position="5 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="5 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="5 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text rotation="0 180 0" target value="containers" position="6 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="6 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="6 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
<a-text rotation="0 180 0" target value="containers" position="-2 2.25 10.5" scale=".5 .5 .5"></a-text>
|
|
<a-text rotation="0 180 0" target value="use the dxr prefix to send data to containers\n(requires backend)" position="-2 1.55 10.5" scale=".1 .1 .1"></a-text>
|
|
<a-text rotation="0 180 0" target value="dxr python print(7)" position="-2 1.35 10.5" scale="0.1 0.1 0.1"></a-text>
|
|
|
|
</a-scene>
|
|
</body>
|
|
</html>
|
|
|