@ -1,6 +1,5 @@
<!DOCTYPE html>
< html >
< title > SpaSca : Spatial Scaffolding< / title >
< head >
<!-- Suggestions? https://git.benetou.fr/utopiah/text - code - xr - engine/issues/ -->
@ -8,9 +7,7 @@
< 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 >
< script type = "module" src = 'dependencies/immers-client.js' > < / script >
<!-- for input sharing -->
< script src = 'dependencies/peerjs.min.js' > < / script >
@ -19,8 +16,8 @@
< 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 >
<!-- 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 >
@ -56,7 +53,6 @@
< / 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";
@ -219,90 +215,16 @@ AFRAME.registerComponent('web-url', {
}
})
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")
setTimeout( _ => {
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
} )
} )
immersClient = document.querySelector("immers-hud").immersClient
console.log(immersClient.profile.displayName, "connected")
})
});
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")
}, 1000)
/* not sure what's the right way... but timeout works, others don't.
document.addEventListener("immers-client-connected", _ => console.log("connected"))
window.addEventListener("immers-client-connected", _ => console.log("connected"))
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?
*/
@ -853,7 +775,6 @@ function appendToFeedbackHUD(txt){
function setFeedbackHUD(txt){
document.querySelector("#feedbackhud").setAttribute("value",txt)
setTimeout( _ => document.querySelector("#feedbackhud").setAttribute("value","") , 2000)
}
function appendToHUD(txt){
@ -861,7 +782,7 @@ function appendToHUD(txt){
if ( textHUD == startingText)
setHUD( txt )
else
setHUD( textHUD + txt )
setHUD( textHUD + " " + txt )
}
function setHUD(txt){
@ -886,28 +807,6 @@ AFRAME.registerComponent('waistattach',{
},
});
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'},
@ -990,24 +889,13 @@ AFRAME.registerComponent('pinchprimary', { // currently only 1 hand, the right o
// 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") )
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})
selectedElement.emit('released')
setTimeout( _ => setFeedbackHUD(""), 2000)
}
// unselect current target if any
selectedElement = null;
@ -1033,7 +921,6 @@ AFRAME.registerComponent('pinchprimary', { // currently only 1 hand, the right o
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
@ -1062,9 +949,7 @@ AFRAME.registerComponent('pinchprimary', { // currently only 1 hand, the right o
//selectedElement = clone
selectedElement = getClosestTargetElement( event.detail.position )
if (selectedElement) selectedElement.emit("picked")
// is it truly world position? See https://github.com/aframevr/aframe/issues/5182
// setFeedbackHUD( AFRAME.utils.coordinates.stringify( event.detail.position ) )
selectedElements.push({element:selectedElement, timestamp:Date.now(), primary:true})
// if close enough to a target among a list of potential targets, unselect previous target then select new
});
},
@ -1241,19 +1126,15 @@ AFRAME.registerComponent('hud', {
}
})
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 " ){
function addNewNote( text, position=`-0.2 1.1 -0.1`, scale= "0.1 0.1 0.1", id=null, classes=null, visible="true " ){
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_" + Date.now() // not particularly descriptive but content might change later on
if (classes)
newnote.className += classes
if (id) newnote.id = id
if (classes) newnote.className += classes
newnote.setAttribute("side", "double" )
var userFontColor = AFRAME.utils.getUrlParameter('fontcolor')
if (userFontColor & & userFontColor != "")
@ -1265,11 +1146,9 @@ function addNewNote( text, position=`-0.2 1.1 -0.1`, scale= "0.1 0.1 0.1", id=nu
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 ){
@ -1333,15 +1212,14 @@ function draw( position ){
}
// the goal is to associate objects as shape with volume to code snippet
function addGltfFromURLAsTarget( url, scale=1, position="0 1.7 -0.3" ){
function addGltfFromURLAsTarget( url, scale=1 ){
var el = document.createElement("a-entity")
AFRAME.scenes[0].appendChild(el)
el.setAttribute("gltf-model", url)
el.setAttribute("position", position)
el.setAttribute("position", "0 1.7 -0.3") // arbitrary for test
el.setAttribute("scale", scale + " " + scale + " " + scale)
targets.push(el)
return el
// consider https://sketchfab.com/developers/download-api/downloading-models/javascript
}
@ -1376,7 +1254,7 @@ function parseJXR( code ){
newcode = newcode.replace(/qs ([^\s]+)/ ,`document.querySelector('$1')`)
// sa X Y => .setAttribute("X", "Y")
newcode = newcode.replace(/ sa ([^\s]+) (.* )/,`.setAttribute('$1','$2')`)
newcode = newcode.replace(/ sa ([^\s]+) ([^\s]+ )/,`.setAttribute('$1','$2')`)
// problematic for position as they include spaces
newcode = newcode.replace(/obsv ([^\s]+)/ ,`newNoteFromObservableCell('$1')`)
@ -1396,15 +1274,9 @@ function parseJXR( code ){
}
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 )
}
addNewNote( hudTextEl.getAttribute("value") )
setHUD("")
} else if (code == "< ") { // Backspace equivalent
setHUD( hudTextEl.getAttribute("value").slice(0,-1))
@ -1478,12 +1350,12 @@ AFRAME.registerComponent('selectionboxonpinches', {
AFRAME.registerComponent('keyboard', {
init:function(){
let generatorName = this.attrName
const horizontaloffset = .7
const horizontalratio = 1/2 0
const horizontaloffset = .5
const horizontalratio = 1/3 0
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)
addNewNote( line[i], pos+" "+(1.6-ln*.03 )+" -.4", ".1 .1 .1", null, generatorName)
}
})
}
@ -1564,14 +1436,6 @@ function toggleVisibilityEntitiesFromClass(classname){
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)
}
@ -1764,488 +1628,15 @@ function cloneAndDistribute(){
}
}
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")
} ) )
}
const tile_extension = ".glb"
// could become a dedicated asset sets, e.g asset-metadata.json in that directory
const available_asset_kits = [
{
tile_URL : "../content/asset_kits/KenneyHexTiles/",
tiles : ["building_cabin", "building_castle", "building_dock", "building_farm", "building_house", "building_market", "building_mill", "building_mine", "building_sheep", "building_smelter", "building_tower", "building_village", "building_wall", "building_water", "dirt", "dirt_lumber", "grass", "grass_forest", "grass_hill", "path_corner", "path_cornerSharp", "path_crossing", "path_end", "path_intersectionA", "path_intersectionB", "path_intersectionC", "path_intersectionD", "path_intersectionE", "path_intersectionF", "path_intersectionG", "path_intersectionH", "path_start", "path_straight", "river_corner", "river_cornerSharp", "river_crossing", "river_end", "river_intersectionA", "river_intersectionB", "river_intersectionC", "river_intersectionD", "river_intersectionE", "river_intersectionF", "river_intersectionG", "river_intersectionH", "river_start", "river_straight", "sand", "sand_rocks", "stone", "stone_hill", "stone_mountain", "stone_rocks", "unit_boat", "unit_house", "unit_houseLarge", "unit_mill", "unit_tower", "unit_tree", "unit_wallTower", "water", "water_island", "water_rocks",],
tiles_types_full : [ "building_", "river_", "sand", "stone", "water"],
tiles_types_parts : [ "unit_", "path_" ],
hex_type : true,
},{
tile_URL : "../content/asset_kits/KenneyRetroMedieval/",
tiles_types_full : ["floor", "column", "tower", "wall"],
tiles_types_parts : ["battlement"],
tiles : [ "battlement", "battlement_cornerInner", "battlement_cornerOuter", "battlement_half", "column", "columnPaint", "columnPaint_damaged", "column_damaged", "detail_barrel", "detail_crate", "detail_crateSmall", "fence", "floor", "floor_flat", "floor_stairs", "floor_stairsCornerInner", "floor_stairsCornerOuter", "floor_steps", "floor_stepsCornerInner", "floor_stepsCornerOuter", "overhang", "overhang_fence", "overhang_round", "roof", "roof_corner", "roof_edge", "structure", "structure_poles", "structure_wall", "tower", "towerPaint", "towerPaint_base", "tower_base", "tower_edge", "tower_top", "wall", "wallFortified", "wallFortifiedPaint", "wallFortifiedPaint_gate", "wallFortifiedPaint_half", "wallFortified_gate", "wallFortified_gateHalf", "wallFortified_half", "wallPaint", "wallPaint_detail", "wallPaint_flat", "wallPaint_gate", "wallPaint_half", "wall_detail", "wall_flat", "wall_flatGate", "wall_gate", "wall_gateHalf", "wall_half", "wall_low" ],
hex_type : false,
}
]
var selected_asset_kit = 1
// consider also a set of assets, e.g this one but also another kit from the same artist
// consider the other direction, i.e how a single glTF could become a set of tiles
function displayAllTiles(){
const scale = 1/10
let last_type = null
available_asset_kits[selected_asset_kit].tiles.map( (t) => {
let x = -1*scale
if (!last_type) n = 0
const tiles_types = [ ...available_asset_kits[selected_asset_kit].tiles_types_full, ...available_asset_kits[selected_asset_kit].tiles_types_parts]
tiles_types.map( (tile_type,ttn) => {
if (t.indexOf(tile_type) > -1) {
x = ttn/10
if (tile_type != last_type) n = 0
last_type = tile_type
}
} )
let el = addGltfFromURLAsTarget( available_asset_kits[selected_asset_kit].tile_URL+t+tile_extension,
.09,
""+x+" 0.7 -"+n*scale )
// fine tuning should also be per asset set
// el.class = ...
n++
} )
// could consider a new spawner type so that picking a tile clones it first
// could do same behavior as on release or on picked, namely register listener then act on event
}
// try generating at scale, e.g 2, a landscape to explore based on type
// with scale adjusting as jxr line to be the Wondering pills/drinks/mushroom to change scale
// cf similar commands to move a class, consequently could add class after addGltfFromURLAsTarget
function randomTileFull(){
const tiles_full = available_asset_kits[selected_asset_kit].tiles.filter( t => { let present = false; available_asset_kits[selected_asset_kit].tiles_types_full.map(m => { if (t.indexOf(m)>-1) present = true; }); return present} )
return tiles_full[Math.floor(Math.random()*tiles_full.length)]
}
var tiles_snapping_grid = []
function getClosestTilesSnappingPosition( t, threshold=0.05 ){
let point = null
let found = tiles_snapping_grid.map( i => { return { pos:i, dist: i.distanceTo(t) } } )
.filter( t => t.dist < threshold )
.sort( (a,b) => a.dist > b.dist)
if (found & & found[0]) point = found[0].pos
return point
}
var tile_snapping_enabled = true
AFRAME.registerComponent('snap-on-pinchended', {
init: function(){
let el = this.el
this.el.addEventListener('released', function (event) {
if (tile_snapping_enabled) { // might generalize the name as now used for compound primitives too
el.setAttribute("rotation", "0 0 0")
// could limit to an axis or two, e.g here y axis probably should be kept or at least adjust to next 1/6th rotation
// could snap to invisible grid too, e.g every 1 or 1/10th unit
var pos = AFRAME.utils.coordinates.parse( el.getAttribute("position") )
pos.x = pos.x.toFixed(1) // i.e .1m so 1/10th of a meter here, 10cm
pos.y = pos.y.toFixed(1)
pos.z = pos.z.toFixed(1)
// could check first if that "spot" is "free", e.g not other targets on that position
// but then if not, what? move to another of the closest 6th closest points? (2 vertical, 2 horizontal, 2 depth) or even 8th with diagonales?
// if not? now what? move until there is a free spot?
el.setAttribute("animation__snap"+Date.now(), "property: position; to: "+AFRAME.utils.coordinates.stringify(pos)+"; dur: 200;");
//el.setAttribute("position", AFRAME.utils.coordinates.stringify(pos))
if (el.className == "compound_object"){
let thresholdDistance = 0.2 // based on object size
targets.filter( i => (
(i.className == el.className)
& & el.getAttribute("position").distanceTo(i.getAttribute("position")) == 0.2)
& & el.getAttribute("position").y == i.getAttribute("position").y
& & el.getAttribute("position").z == i.getAttribute("position").z
).map( _ => document.querySelector("#snapping-sound").components.sound.playSound() )
} // very restrictive, also doesn't repulse away
// if works, generalize and add to https://git.benetou.fr/utopiah/text-code-xr-engine/issues/66
// should come back from emit('released')
// could rely on getClosestTilesSnappingPosition()
// if it works, might check if position is not already used by a tile
}
})
}
})
function generateRandomPlace(max_i=10, max_j=10, scale=1/10, y=1.4){
// lifesize, y : -2, scale 1
// dollhouse, y : 1.4, scale 1/10
for (let i=0;i< max_i ; i + + ) {
for (let j=0;j< max_j ; j + + ) {
let offset_if_hex = 0
if (available_asset_kits[selected_asset_kit].hex_type & & j%2) offset_if_hex = 1/2
let pos = new THREE.Vector3( (i+offset_if_hex)*scale, y, (j*8.5/10)*scale )
el = addGltfFromURLAsTarget(
available_asset_kits[selected_asset_kit].tile_URL+randomTileFull()+tile_extension,
1*scale,
AFRAME.utils.coordinates.stringify( pos )
)
el.setAttribute('snap-on-pinchended', '')
el.className += "tiles"
tiles_snapping_grid.push( pos )
}
}
}
// could add behavior based on class or, maybe easier, add a snapping-after-release component
// it would register an event listener and the released element would trigger an event
function rescalePlace(scale = 10, yoffset=-1){
let places = Array.from( document.querySelectorAll(".tiles") )
tiles_snapping_grid = []
places.map( e => {
scl = e.getAttribute("scale"); e.setAttribute("scale", scl.x*scale+ " " + scl.y*scale + " " + scl.z*scale)
pos = e.getAttribute("position"); e.setAttribute("position", pos.x*scale+ " " + (pos.y+yoffset) + " " + pos.z*scale)
let pos3 = new THREE.Vector3( pos.x*scale, pos.y+yoffset, pos.z*scale )
tiles_snapping_grid.push( pos3 )
} )
}
function addScreenshot(){
screenshotcanvas = document.querySelector('a-scene').components.screenshot.getCanvas('perspective')
var sel = document.createElement("a-image") // could use a flat box instead, or use it as a frame
AFRAME.scenes[0].appendChild(sel)
sel.setAttribute("src", screenshotcanvas.toDataURL() )
sel.setAttribute("height", .1)
sel.setAttribute("width", .2)
sel.setAttribute("position", "0 1.4 -0.1")
targets.push(sel)
return sel
}
function newPrimitiveWithOutline( name="box", position="0 0 0", scale=".1 .1 .1" ){
let el = document.createElement("a-"+name)
let el_outline = document.createElement("a-"+name)
el.appendChild(el_outline)
el.setAttribute("scale", scale)
el.setAttribute("position", position)
el_outline.setAttribute("scale", "1.01 1.01 1.01")
el_outline.setAttribute("color", "gray")
el_outline.setAttribute("wireframe", "true")
el_outline.className = "outline_object"
return el
}
function addCompoundPrimitiveExample(position="0 1.4 -0.2"){
let el = generateCompoundPrimitiveExample(position)
AFRAME.scenes[0].appendChild(el)
targets.push(el)
el.setAttribute('snap-on-pinchended', true) // could set the parameter here, e.g sound if close to same type
return el
}
function addBlockCodeExample(){
el = addNewNote("hi")
el.setAttribute("color", "black")
el.setAttribute("outline-color", "white")
a = generateCompoundPrimitiveExample()
el.appendChild(a)
a.setAttribute("position", "0.1 0 -0.051")
el.setAttribute("position", "0 1.4 -0.2");
}
function generateCompoundPrimitiveExample(position="0 1.4 -0.2"){
var el = document.createElement("a-entity")
el.setAttribute("position", position)
el.id = "compound_object_" + Date.now()
el.className = "compound_object"
let parts = []
parts.push( newPrimitiveWithOutline("box", "0 0 0", ".2 .1 .1") )
parts.push( newPrimitiveWithOutline("box", ".125 0 0", ".05 .05 .05") )
parts.push( newPrimitiveWithOutline("box", "-.125 0.0375 0", ".05 .025 .1") )
parts.push( newPrimitiveWithOutline("box", "-.125 -0.0375 0", ".05 .025 .1") )
parts.push( newPrimitiveWithOutline("box", "-.125 0 0.0375", ".05 .05 .025") )
parts.push( newPrimitiveWithOutline("box", "-.125 0 -0.0375", ".05 .05 .025") )
parts.map( p => el.appendChild(p) )
return el
}
function addPrimitive( name, position="0 1.4 -0.2" ){
let el = newPrimitiveWithOutline( name )
el.setAttribute("position", position)
AFRAME.scenes[0].appendChild(el)
el.id = "template_object_" + name
el.className = "template_object"
targets.push(el)
el.setAttribute('clone-on-primarypinchstarted', true)
return el
}
AFRAME.registerComponent('clone-on-primarypinchstarted', {
init: function () {
let el = this.el
this.el.addEventListener('picked', function (event) {
var clone = selectedElement.cloneNode(true)
clone.removeAttribute('clone-on-primarypinchstarted')
clone.setAttribute( "scale", selectedElement.getAttribute("scale") ) // somehow lost?
clone.id += "_clone" + Date.now()
clone.className = "cloned"
targets.push(clone)
AFRAME.scenes[0].appendChild(clone)
selectedElement = clone
})
}
})
function addAllPrimitives(){
const other_primitives = ["camera", "cursor", "sky", "light", "sound", "videosphere"]
const other_primitives_with_param_needed = ["text", "gltf-model", "obj-model", "troika-text"]
Object.getOwnPropertyNames(AFRAME.primitives.primitives)
// thanks to https://github.com/Utopiah/aframe-inVR-blocks-based-editor/blob/master/aframe-invr-inspect.js
.map( i => i.replace("a-",""))
.filter( i => other_primitives.indexOf(i) < 0 )
.filter( i => other_primitives_with_param_needed.indexOf(i) < 0 ) / / temporarilty disabled
.map( (i,j) => addPrimitive( i, ""+ j/7 + " 1.4 -0.5" ) )
}
function startExperience(){
if (AFRAME.utils.device.checkHeadsetConnected())
AFRAME.scenes[0].enterVR();
document.querySelector("#snapping-sound").components.sound.playSound();
document.querySelector("#mainbutton").style.display = "none"
}
// could change model opacity based on hand position, fading out when within a (very small here) safe space
function removeOutlineFromEntity( el ){
[...el.querySelectorAll(".outline_object")].map( i => i.remove() )
}
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 changeColorLastId(){
let id = getIdFromPick() // applies on primary only
console.log("id?",id)
if (id) document.querySelector("#"+id).setAttribute("color", "red")
// this could instead be any function with any parameters
// see currying
//if (id) document.querySelector("#"+id).functionname(params)
// consider how with params it could be a curve or a number e.g distance between pinches
// i.e another action, like picking before
// can also be generalized to arbitrary selection, e.g classes, via .map()
}
function displayManipulateSlides(){
slides.map( (s,i) => {
let el = newPrimitiveWithOutline( "box", "" + ((-slides.length/2)/10 + i/5) + " 1.6 -0.2", ".2 .1 .1" )
el.setAttribute("src", slides_URL + s)
el.id += "slides_" + Date.now()
el.className = "slide"
AFRAME.scenes[0].appendChild(el)
targets.push(el)
})
}
const slides_URL = "../content/jxr-presentation-in-SpaSca/captures/jpg/"
const slides = [
"2023-01-20-151624_3840x2000_scrot.png.jpg",
"2023-01-20-151628_3840x2000_scrot.png.jpg",
"2023-01-20-151634_3840x2000_scrot.png.jpg",
"2023-01-20-151639_3840x2000_scrot.png.jpg",
"2023-01-20-151650_3840x2000_scrot.png.jpg",
"2023-01-20-151653_3840x2000_scrot.png.jpg",
"2023-01-20-151702_3840x2000_scrot.png.jpg",
"2023-01-20-151717_3840x2000_scrot.png.jpg",
"2023-01-20-151721_3840x2000_scrot.png.jpg",
"2023-01-20-151724_3840x2000_scrot.png.jpg",
"2023-01-20-151726_3840x2000_scrot.png.jpg",
"2023-01-20-151729_3840x2000_scrot.png.jpg",
"2023-01-20-151747_3840x2000_scrot.png.jpg",
"2023-01-20-151753_3840x2000_scrot.png.jpg",
"2023-01-20-151756_3840x2000_scrot.png.jpg",
"2023-01-20-151800_3840x2000_scrot.png.jpg",
"2023-01-20-151806_3840x2000_scrot.png.jpg",
"2023-01-20-151810_3840x2000_scrot.png.jpg",
"2023-01-20-151812_3840x2000_scrot.png.jpg",
"2023-01-20-151817_3840x2000_scrot.png.jpg",
"2023-01-20-151826_3840x2000_scrot.png.jpg",
"2023-01-20-151829_3840x2000_scrot.png.jpg",
"2023-01-20-151832_3840x2000_scrot.png.jpg",
"2023-01-20-151836_3840x2000_scrot.png.jpg",
"2023-01-20-151840_3840x2000_scrot.png.jpg",
"2023-01-20-151844_3840x2000_scrot.png.jpg",
"2023-01-20-151849_3840x2000_scrot.png.jpg",
"2023-01-20-151852_3840x2000_scrot.png.jpg",
"2023-01-20-151908_3840x2000_scrot.png.jpg",
"2023-01-20-151912_3840x2000_scrot.png.jpg",
"2023-01-20-151920_3840x2000_scrot.png.jpg",
"2023-01-20-151926_3840x2000_scrot.png.jpg",
"2023-01-20-151930_3840x2000_scrot.png.jpg",
]
/*
generalize selector to pick last Nth rather than very last
adapt getIdFromPick() with .slice() after filter then map on length-N instead of length-1
selector pickers : pickClass and pickId
display result in 3D HUD with rotating objects and selector value
ideally themselves also selectable/usable, e.g clone from HUD to bring back "out"
requires extra work as becoming a child will not work, own positionning
should fix that
could compare to world coordinates instead of "just" position attribute
add a clear selector function to avoid making the HUD unusable
could also pick via volume, e.g wireframe box
start with https://threejs.org/docs/#api/en/math/Box3.containsPoint
can iterate with https://threejs.org/docs/#api/en/math/Box3.containsBox
consider also https://threejs.org/docs/#api/en/math/Box3.intersectsBox
consider pick then apply, i.e changeColorLastId() but for next Id
should be cancealable
*/
< / script >
< div id = "observablehq-key" >
< div id = "observablehq-viewof-offsetExample-ab4c1560" > < / div >
< div id = "observablehq-result_as_html-ab4c1560" > < / div >
< / div >
< button id = mainbutton style = "z-index: 1; position: absolute; width:50%; margin: auto; text-align:center; top:45%; left:30%; height:30%;" onclick = "startExperience()" > Start the experience (hand tracking recommended)< / button >
< 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
toolbox disable-components-via-url enable-components-via-url NOcommands-from-external-json >
<!-- screenstack dynamic - view selectionboxonpinches keyboard glossary timeline issues fot
networked-scene="serverURL: https://naf.benetou.fr/; adapter: easyrtc; audio: true;"
-->
< a-assets >
@ -2257,49 +1648,70 @@ consider pick then apply, i.e changeColorLastId() but for next Id
< a-entity networked-hand-controls = "hand:right" > < / a-entity >
< / template >
< / a-assets >
< a-entity id = "rig" >
< a-sound src = "../content/street-crowd-ambience.mp3" autoplay = true loop = true volume = 0.2 > < / a-sound > <!-- warning skipped on Quest, does autoplay there -->
< a-sound id = "snapping-sound" src = "url(../content/magnets_snap.mp3)" > < / a-sound >
< 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-video src = "https://video.benetou.fr/download/videos/318c8408-c34a-430c-846d-f875dc3c343e-480.mp4" > < / a-video >
< a-video position = "0 2 -2" src = "https://video.benetou.frstreaming-playlists/hls/91634fb7-116e-43a1-a4e7-144dd92da17c/1.m3u8" > < / a-video >
< a-video position = "0 2 -2" src = "https://video.benetou.fr/videos/embed/91634fb7-116e-43a1-a4e7-144dd92da17c" > < / a-video >
-->
< 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 >
<!-- remove for NAF equivalent
< a-entity id = "my-tracked-left-hand" networked-hand-controls = "hand:left" networked = "template:#left-hand-default-template"
pinchsecondary wristattachsecondary="target: #box" >< / a-entity >
< a-entity id = "my-tracked-right-hand" networked-hand-controls = "hand:right" networked = "template:#right-hand-default-template"
pinchprimary >< / a-entity >
< a-entity class = movebypinch >
< a-text target value = "jxr document.getElementById('player').object3D.position.z++" position = "0 1.15 0.1" rotation = "-30 0 0" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr player.object3D.position.z--" position = "0 1.15 -0.1" rotation = "-30 0 0" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr player.object3D.position.x++" position = "-0.3 1.15 0" rotation = "-30 0 0" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr player.object3D.position.x--" position = "0.3 1.15 0" rotation = "-30 0 0" scale = "0.1 0.1 0.1" > < / a-text >
< / a-entity >
-->
<!-- works on desktop via interpretJXR() but not in VR by trying to pinch... disabled for now for demo clarity -->
< 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-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! -->
< a-entity id = "scaledworld" class = "hidableassets" scale = ".05 .05 .05" position = "0 1.45 -1" > <!-- can't be used for interactions otherwise becomes indirect -->
< a-box position = "-0.1 1.2 -0.3" scale = "0.5 0.5 0.5" rotation = "0 45 0" color = "#4CC3D9" > < / a-box >
< a-sphere position = "0 1.25 -5" radius = "1.25" color = "#EF2D5E" > < / a-sphere >
< a-cylinder position = "1 0.75 -3" radius = "0.5" height = "1.5" color = "#FFC65D" > < / a-cylinder >
< / a-entity >
< a-image id = background background-via-url visible = false position = "0 1.5 -1.02" scale = "2 1 1" src = "" > < / a-image >
< a-image visible = false class = mural-instructions src = "../content/future_of_text_symposium/mappinghypertext_typesofdiagrams2.png"
rotation="0 -45 0" position="1.5 1.7 -.7" scale=".4 .2 .2" >< / a-image >
< a-image visible = false class = mural-instructions src = "../content/future_of_text_symposium/mappinghypertext_typesofdiagrams1.png"
rotation="0 -45 0" position="1.5 1.4 -.7" scale=".4 .2 .2" >< / a-image >
< a-image visible = false class = mural-instructions src = "../content/future_of_text_symposium/mappinghypertext_semanticanalysisofdiagrams.png"
rotation="0 45 0" position="-1.5 1.7 -.7" scale=".4 .2 .2" >< / a-image >
< a-image visible = false class = mural-instructions src = "../content/future_of_text_symposium/mappinghypertext_mappingfusion.png"
rotation="0 45 0" position="-1.5 1.4 -.7" scale=".4 .2 .2" >< / a-image >
<!-- visual reminders of shortcuts, a poster on the far left/right of keyboard shortcuts -->
<!-- assets CabanaAndCurtains.glb Pond.glb TempleOfLife.glb JapaneseRoom.glb -->
< a-entity hide-on-enter-ar id = "environment" class = "hidableenvironment" gltf-model = "url(../content/Pond.glb)" scale = "80 80 80" position = "0 0.2 0.15" rotation = "0 -90 0" > < / a-entity >
< a-entity hide-on-enter-ar class = "hidableenvironment" gltf-model = "url(../content/CabanaAndCurtains.glb)" scale = ".010 .010 .010" position = "0 0.2 0.15" rotation = "0 0 0" > < / a-entity >
< a-entity light = "type: ambient; color: #BBB; intensity: 0.6" > < / a-entity >
< a-entity light = "type: directional; color: #FFF; intensity: 1.4" position = "-0.5 1 1" > < / a-entity >
< 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-sky hide-on-enter-ar color = "white" > < / a-sky >
< a-entity hide-on-enter-ar = "" id = "environment" class = "hidableenvironment" gltf-model = "../content/SourceCityToolkit/AR_Market.glb" scale = "1 1 1" position = "17 -10 -4" rotation = "0 0 0" > < / a-entity >
< a-entity hide-on-enter-ar = "" id = "environmentsky" class = "hidableenvironment" gltf-model = "../content/SourceCityToolkit/SKY_Market_day.glb" scale = "1 1 1" position = "17 -10 -4" rotation = "0 0 0" > < / a-entity >
< a-text target value = "instructions : \n--right pinch to move\n--left pinch to execute" position = "0 1.65 -0.2" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr addBlockCodeExample()" position = "0 1.65 -0.1" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr addAllPrimitives()" position = "0 1.60 -0.1" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr addCompoundPrimitiveExample()" position = "0 1.55 -0.1" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr tile_snapping_enabled = !tile_snapping_enabled" position = "0 1.40 -0.1" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target id = "getfromid_color" value = "jxr changeColorLastId()" position = "0 1.35 -0.1" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target id = "getfromid_id" value = "jxr getIdFromPick()" position = "0 1.30 -0.1" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target id = "displayManipulateSlides" value = "jxr displayManipulateSlides()" position = "0 1.25 -0.1" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target id = "locationreload" value = "jxr location.reload()" position = "0 1.20 -0.1" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr pushLeftClass('slide')" position = " -0.2 1.55 0.1" rotation = "0 90 0" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr pushRightClass('slide')" position = " -0.2 1.50 0.1" rotation = "0 90 0" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr pushUpClass('slide')" position = " -0.2 1.45 0.1" rotation = "0 90 0" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr pushDownClass('slide')" position = " -0.2 1.40 0.1" rotation = "0 90 0" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr pushBackClass('slide')" position = " -0.2 1.35 0.1" rotation = "0 90 0" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr pushFrontClass('slide')" position = " -0.2 1.30 0.1" rotation = "0 90 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-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 >
< a-entity light = "type: directional; color: #FFF; intensity: 0.6" position = "-0.5 1 1" > < / a-entity >
< a-sky hide-on-enter-ar color = "#add8e6" > < / a-sky >
<!-- permanent offline persistent e - ink based, rM2 size, reminder
< a-plane position = "0 2 -2" scale = "4 4 4" mirror > < / a-plane >
< a-plane position = "0 1 -1" scale = "0.21 0.15 1" rotation = "-30 0 0" wireframe = "true" > < / a-plane >
-->
< a-text target value = "instructions : pinch twice for distance then select element then execute cloneAndDistribute() " position = "0 1.65 -0.2" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr AFRAME.scenes[0].components.inspector.openInspector()" position = "0 1.25 -0.2" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr observe selectedElement" position = "0 1.15 -0.2" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr observe dl2p" position = "0 1.35 -0.2" scale = "0.1 0.1 0.1" > < / a-text >
< a-text target value = "jxr cloneAndDistribute()" position = "0 1.45 -0.2" scale = "0.1 0.1 0.1" > < / a-text >
< a-box target position = "-0.1 1.2 -0.3" scale = ".1 1 0.01" rotation = "0 45 0"
src="https://vatelier.benetou.fr/MyDemo/newtooling/textures/fabien.benetou.fr_Analysis_LibrarianMoveWalls.png">< / a-box >
< / a-scene >
< / body >
< / html >