janusweb 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.github/workflows/deploy.yml +71 -0
  2. package/Dockerfile +14 -0
  3. package/README.md +210 -7
  4. package/janusxr.com/.args +2 -0
  5. package/janusxr.com/.init.lua +30 -0
  6. package/media/assets/webui/apps/comms/voip.js +27 -25
  7. package/media/assets/webui/apps/editor/editor.js +3 -0
  8. package/media/assets/webui/apps/navigation/navigation.js +1 -1
  9. package/media/assets/webui/apps/xrfragment/level0-sidecarfile.js +308 -0
  10. package/media/assets/webui/apps/xrfragment/level2-hyperlink.js +265 -0
  11. package/media/assets/webui/apps/xrfragment/level7-engine-prefixes.export.js +93 -0
  12. package/media/assets/webui/apps/xrfragment/level7-engine-prefixes.import.js +229 -0
  13. package/media/assets/webui/apps/xrfragment/patch/metaquest-fix-flickering.js +15 -0
  14. package/media/assets/webui/apps/xrfragment/xrfragment.json +14 -0
  15. package/media/assets/webui/default.json +1 -0
  16. package/media/images/portal.svg +130 -0
  17. package/media/index.html +48 -1
  18. package/package.json +1 -1
  19. package/scripts/client.js +24 -0
  20. package/scripts/config.js +12 -1
  21. package/scripts/janusbase.js +1 -0
  22. package/scripts/janusparagraph.js +169 -22
  23. package/scripts/janusxrplayer.js +4 -6
  24. package/scripts/room.js +59 -36
  25. package/scripts/translators/paragraph/html-xml-rss.js +47 -0
  26. package/scripts/translators/peertube.js +89 -0
  27. package/scripts/translators/xrfragments.js +5 -5
  28. package/tests/room/paragraph.xml +47 -0
  29. package/utils/build.sh +1 -0
  30. package/utils/dev-link.sh +75 -0
  31. package/utils/release.binary.sh +36 -0
  32. package/utils/release.docker.sh +11 -0
@@ -0,0 +1,308 @@
1
+ /* XR URI Fragments spec (https://xrfragment.org)
2
+ *
3
+ * level0 sidecar files as per the XR URI Fragments spec
4
+ * https://xrfragment.org/#sidecar%20files
5
+ *
6
+ * NOTE: you can also load sidecarfiles in a roomscript manually:
7
+ *
8
+ * elation.events.add(null, 'room_load_complete', function(e){
9
+ * if( room?.sidecarfile ) room.sidecarfile.load()
10
+ * })
11
+ */
12
+
13
+ elation.require([], function() {
14
+
15
+ elation.extend('janusweb.sidecarfile', class {
16
+ constructor(object) {
17
+ this.scene = elation.engine.instances.default.systems.world.scene['world-3d']
18
+ this._object = object
19
+ this.extension = /\.(gltf|glb|dae)$/
20
+ this.extensionXRF = /\.xrf\./
21
+ if( room.url.match(this.extension) && this.isXRF(this.hideSystemFolder) ){
22
+ this.load()
23
+ }
24
+ }
25
+
26
+ load(){
27
+ this.cleanup()
28
+ this.loadWebVTT() // https://xrfragment.org/#sidecar%20files
29
+ this.loadSound() // https://xrfragment.org/#sidecar%20files
30
+ this.initStartButton()
31
+ this.initSubtitle()
32
+ }
33
+
34
+ hideSystemFolder(o){
35
+ // https://xrfragment.org/#system%20folders
36
+ if(o.name[0] == '_'){
37
+ o.visible = false
38
+ o.pickable = false
39
+ }
40
+ }
41
+
42
+ isXRF(cb){
43
+ // for XRF heuristics see https://xrfragment.org/#%F0%9F%93%9C%20level0%3A%20File
44
+ let heuristic = room.url.match(this.extensionXRF)
45
+ this.scene.traverse( (o) => {
46
+ if( o.userData?.href ) heuristic = true // has XRF hyperlink
47
+ if( o.name[0] == '_' ) heuristic = true // has XRF system folder
48
+ })
49
+ return heuristic
50
+ }
51
+
52
+ loadSound(){ // https://xrfragment.org/#sidecar%20files
53
+ room.removeObject('xrf_audio')
54
+ const audio = room.url.replace(this.extension,'.ogg')
55
+ room.loadNewAsset("sound", {id:"xrf_audio", src:audio,proxy:false})
56
+ this.sound = room.createObject('sound',{ id: "xrf_audio", js_id: 'xrf_audio', loop:true })
57
+ }
58
+
59
+ loadWebVTT(){ // https://xrfragment.org/#sidecar%20files
60
+ const webvtt = room.url.replace(this.extension,'.vtt')
61
+ fetch( webvtt )
62
+ .then( (res) => res.text() )
63
+ .then( (webvtt) => {
64
+ this.webvtt = this.parseWEBVTT(webvtt)
65
+ })
66
+ .catch( () => false ) // no biggy (optional)
67
+ }
68
+
69
+ initStartButton(){
70
+ this.btn = room.createObject('object',{
71
+ id: 'cube',
72
+ js_id: 'btnstart',
73
+ pos: player.localToWorld( V(0,1.8,-1) ),
74
+ scale: '0.5 0.125 0.01',
75
+ col: '0.5 0.5 0.5',
76
+ lighting:false,
77
+ sync: true,
78
+ billboard: 'y',
79
+ collision_id: 'cube'
80
+ })
81
+ const label = this.btn.createObject('text',{
82
+ text: 'Start experience',
83
+ lighting:false,
84
+ col: '0 0 0',
85
+ pos: '0 -0.15 0.5',
86
+ scale: '1.2 5 1.2',
87
+ })
88
+ this.btn.addEventListener('click', () => this.start() )
89
+ this.positionStartButton()
90
+ }
91
+
92
+ positionStartButton(){
93
+ setTimeout( () => {
94
+ this.btn.pos = player.localToWorld( V(0,1.8,-1) )
95
+ }, 500)
96
+ }
97
+
98
+ initSubtitle(){
99
+ this.subtitle = player.createObject('paragraph',{
100
+ js_id: 'subtitle',
101
+ pos: '0 1.7 -1.5',
102
+ lighting: false,
103
+ pickable: false,
104
+ css: `.paragraphcontainer{
105
+ background: transparent;
106
+ text-align:center;
107
+ height:100%;
108
+ width:100%;
109
+ display: flex;
110
+ align-items: center;
111
+ font-size:55px;
112
+ line-height:55px;
113
+ text-shadow: 0px 0px 2px #FFFF;
114
+ color:black;
115
+ }
116
+ .subtitle{
117
+ width:100%;
118
+ }
119
+ .loading{
120
+ background:#FFF;
121
+ display:inline-block;
122
+ font-size:22px;
123
+ font-family: monospace;
124
+ line-height:30px;
125
+ padding:20px;
126
+ border-radius:15px;
127
+ }
128
+ `,
129
+ text_col: '1 1 1',
130
+ back_col: '1 1 1'
131
+ })
132
+ this.subtitle.objects['3d'].depthWrite = false
133
+ this.subtitle.objects['3d'].depthTest = false
134
+ this.subtitle.objects['3d'].renderOrder = 100
135
+ this.subtitle.setHTML = (html) => {
136
+ this.subtitle.visible = true
137
+ this.subtitle.text = `<div class='subtitle'>${html}</div>`
138
+ }
139
+ }
140
+
141
+ update(){
142
+ if( this.update.id || !this.playing ) return // throttle
143
+ const advance = () => {
144
+ if( !this?.sound?.timeoffset && this?.sound?.audio?.context){
145
+ this.sound.timeoffset = this.sound.audio.context.currentTime
146
+ }
147
+ if( this?.sound?.timeoffset != undefined &&
148
+ this.webvtt?.items[ this.webvtt?.i ] &&
149
+ this?.sound?.playStarted &&
150
+ this?.sound?.audio?.context?.currentTime ){
151
+
152
+ const time = this.sound.audio.context.currentTime - this.sound.timeoffset;
153
+ let item = this.webvtt.items[ this.webvtt.i ]
154
+ if( !item.seen && time > item.start.ts ){
155
+ this.subtitle.setHTML(`
156
+ ${ item.who ? `<b>${item.who}:</b><br/>` : '' }
157
+ ${item.text.replace(/\n/g,'<br/>')}
158
+ `)
159
+ if( room.hyperlink && item.href && !item.seen ){
160
+ room.hyperlink.execute(item.href, {portalActivateDelay:6000})
161
+ }
162
+ item.seen = true
163
+ }
164
+ if( item.seen && time > item.stop.ts ){
165
+ this.subtitle.visible = false
166
+ this.webvtt.i++
167
+ }
168
+ }
169
+ this.update.id = false
170
+ }
171
+ this.update.id = setTimeout( () => advance(), 100 ) // throttle
172
+ }
173
+
174
+ // naive webvtt parser
175
+ parseWEBVTT(text) {
176
+ let i = 0;
177
+ const WEBVTT_HEADER = /^WEBVTT/i;
178
+ const TIME_LINE = /^([0-9:.]+)\s+-->\s+([0-9:.]+)(?:\s+(.+))?$/;
179
+ const WHO = /^<v ([^>]+)>/
180
+ const HREF = / href:([^ ]+)/
181
+ const result = { type: "WEBVTT", items: [], i: 0 };
182
+ const lines = text.split(/\r?\n/)
183
+ .map( (line) => line.trim() )
184
+ const vttToSeconds = (vttString) => {
185
+ const parts = vttString.split(':');
186
+ let hours = 0, minutes = 0, secondsWithMs;
187
+ if (parts.length === 3) { [hours, minutes, secondsWithMs] = parts; }
188
+ else { [minutes, secondsWithMs] = parts; } // Format is MM:SS.mmm
189
+ const [seconds, milliseconds] = secondsWithMs.split('.');
190
+ return (
191
+ (parseInt(hours) * 3600) +
192
+ (parseInt(minutes) * 60) +
193
+ (parseInt(seconds) * 1) +
194
+ parseInt(milliseconds || 0)
195
+ );
196
+ }
197
+
198
+ // Skip the WEBVTT header line
199
+ if (WEBVTT_HEADER.test(lines[i].trim())) i++
200
+ while (i < lines.length) {
201
+ if ( lines[i] === "") { i++; continue; } // Skip empty lines
202
+ // Match cue timing line
203
+ const timeMatch = lines[i].match(TIME_LINE);
204
+ if (timeMatch) {
205
+ const item = {
206
+ start: {str:timeMatch[1], ts: vttToSeconds(timeMatch[1]) },
207
+ stop: {str:timeMatch[2], ts: vttToSeconds(timeMatch[2]) },
208
+ href: lines[i].match(HREF) ? lines[i].match(HREF)[1] : false,
209
+ who: false,
210
+ text: ""
211
+ };
212
+ i++;
213
+ const speakerMatch = lines[i]?.match(WHO);
214
+ if (speakerMatch) {
215
+ item.who = speakerMatch[1];
216
+ lines[i] = lines[i].replace(WHO,'');
217
+ }
218
+ // Collect all text lines until next empty line
219
+ while (i < lines.length && lines[i].trim() !== "") {
220
+ item.text += (item.text ? "\n" : "") + lines[i] ;
221
+ i++;
222
+ }
223
+
224
+ result.items.push(item);
225
+ } else { i++; }
226
+ }
227
+ return result;
228
+ }
229
+
230
+ start(){
231
+ // reset subtitles
232
+ if( this?.webvtt?.items ){
233
+ this.webvtt.items.map( (item) => item.seen = undefined )
234
+ this.webvtt.i = 0;
235
+ }
236
+ // start sound
237
+ this.sound.pos = '0 0 0'
238
+ if( this.sound.isPlaying ) this.sound.stop()
239
+ this.sound.timeoffset = undefined
240
+ this.sound.play()
241
+ // ensure subtitle
242
+ this.subtitle.visible = false
243
+ player.add(this.subtitle)
244
+ // hide button
245
+ this.btn.visible = false
246
+ this.btn.pickable = false
247
+ // mark playing
248
+ this.playing = true
249
+ }
250
+
251
+
252
+ stop(){
253
+ this.playing = false
254
+ this.update.id = false
255
+ this.subtitle.visible = false
256
+ this.subtitle.text = ''
257
+ this.btn.visible = true
258
+ this.btn.pickable = true
259
+ }
260
+
261
+ cleanup(){
262
+ const deleteJsId = (js_id) => {
263
+ for( let i in player.children )
264
+ if( player.children[i].js_id == js_id )
265
+ player.remove( player.children[i] )
266
+ }
267
+ deleteJsId('subtitle')
268
+ }
269
+
270
+ })
271
+ });
272
+
273
+ xrf_install_sidecarfiles = function(){
274
+ if( !room.objects?.scene?.modelasset?.loaded ) {
275
+ return setTimeout( xrf_install_sidecarfiles, 300 )
276
+ }
277
+ if( !room.sidecarfile ) room.sidecarfile = new elation.janusweb.sidecarfile(room);
278
+ else{
279
+ room.sidecarfile.positionStartButton() // wait for user being spawned
280
+ room.sidecarfile.stop()
281
+ }
282
+ }
283
+
284
+ xrf_install_sidecarfiles()
285
+
286
+ elation.events.add(null, 'room_load_complete', xrf_install_sidecarfiles )
287
+ elation.events.add(null, 'room_enable', xrf_install_sidecarfiles )
288
+ elation.events.add(null, 'janusweb_script_frame', function(){
289
+ if( room?.sidecarfile ) room.sidecarfile.update()
290
+ })
291
+ elation.events.add(null, 'room_load_start', function(e){
292
+ if( !e.data ) return
293
+ if( room?.sidecarfile?.subtitle ) room.sidecarfile.subtitle.setHTML(`<div class='loading'>🔗 ${e.data.name}<br/><br/>please wait..</div>`)
294
+ })
295
+
296
+ elation.events.add(null, 'room_disable', function(){
297
+ if( room?.sidecarfile?.subtitle ) room.sidecarfile.stop()
298
+ })
299
+
300
+ // some convenience WebVTT cue settings => room function mappings
301
+ // href:#fadeAudioOut&spawnhere => room.fadeAudioOut()
302
+ // href:#myfunc=3 => room.myfunc(3)
303
+ elation.events.add(null, 'href', function(e){
304
+ if( room?.hyperlink ){
305
+ const {url,hash} = room.hyperlink.getUrlObject(e.data.href)
306
+ hash.forEach( (v,k) => { if( room[k] ) room[k](v) })
307
+ }
308
+ })
@@ -0,0 +1,265 @@
1
+ /* XR URI Fragments spec (https://xrfragment.org)
2
+ *
3
+ * level2 explicit hyperlinks (embedded in 3D scenes/files) as per the XR URI Fragments spec
4
+ *
5
+ * When clicking an href-value, the user(camera) is teleported/imported to the referenced object.
6
+ * Usecases: spatial anchors, object imports, hyperlink bridge between JML<>3D files
7
+ * see more: https://xrfragment.org/#%F0%9F%93%9C%20level2%3A%20explicit%20hyperlinks
8
+ */
9
+
10
+
11
+ elation.require([], function() {
12
+
13
+ elation.extend('janusweb.hyperlink', class {
14
+ constructor(object) {
15
+ this.scene = elation.engine.instances.default.systems.world.scene['world-3d']
16
+ this._object = object
17
+ this.init()
18
+ }
19
+
20
+ init(){
21
+ this.cleanup()
22
+ this.scan( this.scene )
23
+ this.setupShroud()
24
+ }
25
+
26
+ setupShroud(){
27
+ // show shroud when teleporting
28
+ for( let i in player.head.children ){
29
+ let n = player.head.children[i]
30
+ if( n.js_id == 'xrf_shroud' ) this.shroud = n // found previrous one
31
+ }
32
+ if( this.shroud ) return // we're done!
33
+ this.shroud = room.createObject('object', {
34
+ id: 'sphere',
35
+ js_id: 'xrf_shroud',
36
+ scale: V(1),
37
+ lighting: false,
38
+ col: 'black',
39
+ cull_face: 'none',
40
+ depth_test: false,
41
+ depth_write: false,
42
+ shadow_cast: false,
43
+ shadow_receive: false,
44
+ renderorder: 1000,
45
+ visible: false,
46
+ });
47
+ player.head.add(this.shroud._target);
48
+
49
+ // patch setPlayerPosition() with shroud animations during local teleports
50
+ room.setPlayerPosition = (
51
+ (original) => function(room){
52
+ this.hyperlink.showShroud()
53
+ return original.apply(this,room)
54
+ }.bind(room)
55
+ )(room.setPlayerPosition)
56
+ }
57
+
58
+ scan(scene,cb){
59
+ scene.traverse( (object) => {
60
+ this.detectHUDLUT(object)
61
+ this.detectHref(object)
62
+ })
63
+ }
64
+
65
+ detectHref(object){
66
+ if( !object?.userData?.href || object.hasHref ) return
67
+
68
+ const jobj = this.toJanusObject(object)
69
+ jobj.addEventListener("click", () => this.execute(object.userData.href,{jobj,scene:this.scene}) )
70
+ object.hasHref = true
71
+ }
72
+
73
+ cleanup(){
74
+ const head = player.head.objects['3d']
75
+ head.children
76
+ .filter( (child) => child.xrf_cleanup ? child : false )
77
+ .map( (child) => child.xrf_cleanup() )
78
+ }
79
+
80
+ detectHUDLUT(object){
81
+ // XR Fragment HUD extensions: https://xrfragment.org/#teleport%20camera%20spawnpoint
82
+ if( object.type == 'PerspectiveCamera' && object.name == 'spawn' && object.children.length ){
83
+ const head = player.head.objects['3d']
84
+ // move children to player head
85
+ while( object.children.length ){
86
+ object.children[0].xrf_cleanup = function(me,object){
87
+ object.add( me ) // add back
88
+ }.bind(null, object.children[0], object )
89
+ head.add( object.children[0] )
90
+ }
91
+ }
92
+ }
93
+
94
+ execute = function(href,opts){
95
+ const {url,hash} = this.getUrlObject(href)
96
+ opts = opts ? {...opts, url, hash} : {url,hash}
97
+ console.log("hyperlink: "+href)
98
+ elation.events.fire({element: this, type: 'href', data: {href,opts}});
99
+ if( room.url != url.origin+url.pathname ) return this.executeExternal(href,opts)
100
+ hash.forEach( (v,k) => {
101
+ const {operator,param} = this.getOperators(k)
102
+ switch( param ){
103
+ case "t": // W3C URI Time fragment not implemented (yet)
104
+ case "loop": this._object.loop = operator != '-'
105
+ case "pos": // legacy fallthrough
106
+ default: // level2: internal teleports/spawn
107
+ // https://xrfragment.org/#%F0%9F%93%9C%20level2%3A%20explicit%20hyperlinks
108
+ room.referrer = room.urlhash
109
+ room.urlhash = v || k
110
+ if( this.scene.getObjectByName(v) ){
111
+ room.setPlayerPosition()
112
+ this.spawnBackLink()
113
+ }
114
+ // level2: animation triggers
115
+ // https://xrfragment.org/#%F0%9F%93%9C%20level2%3A%20explicit%20hyperlinks
116
+ for( let i in this._object.children ){
117
+ const model = this._object.children[i]
118
+ if( model.animations && model.animations.map ){
119
+ model.animations.map( (a) => {
120
+ if( !String(a.name).toLowerCase() == String(v).toLowerCase() ) return
121
+ console.log("xrfragment: activating animation '"+a.name+"'")
122
+ model.anim_id = a.name
123
+ })
124
+ }
125
+ }
126
+ return
127
+ break;
128
+ }
129
+ })
130
+
131
+ const fullUrl = href.match(/^#/) ? `${room.url}${url.hash}` : url.href
132
+ room.url = fullUrl
133
+ if( !url.protocol.match(/^xrf:/) ){
134
+ // level4: https://xrfragment.org/doc/RFC_XR_Fragments.html#xrf-uri-scheme
135
+ janus.updateClientURL(fullUrl)
136
+ }
137
+ elation.events.fire({element: room, type: 'room_change', data: room});
138
+ }
139
+
140
+ spawnBackLink(){
141
+ for( let i in room.objects ){
142
+ if( i.match(/^reciprocal-hashlink/) ) room.removeObject(i)
143
+ }
144
+ let link = room.createObject('link', {
145
+ rotation: '0 45 0',
146
+ url: room.url+'#'+room.referrer,
147
+ title: 'go back',
148
+ round: true,
149
+ shader_id: 'defaultportal',
150
+ js_id: 'reciprocal-hashlink',
151
+ });
152
+ setTimeout( () => {
153
+ link.position = player.localToWorld(V(1.5,0,1.5))
154
+ },10)
155
+ }
156
+
157
+ executeExternal(href, opts){
158
+ if( opts.portalActivateDelay ){
159
+ setTimeout( () => {
160
+ janus.setActiveRoom( href, room.url)
161
+ }, opts.portalActivateDelay)
162
+ }
163
+ player.spawnPortal(href)
164
+ elation.events.fire({element: this, type: 'href_portal', data: {href,opts}});
165
+ }
166
+
167
+ getUrlObject = function(href){
168
+ const urlExpanded = href[0] == '#'
169
+ ? room.url+href
170
+ : href.match(/:\//) ? href : room.baseurl+href
171
+ const url = new URL( urlExpanded )
172
+ const hash = new URLSearchParams( String(url.hash).substr(1) )
173
+ return {url,hash}
174
+ }
175
+
176
+ getOperators = function(k){
177
+ let operator = ''
178
+ // scan for operator
179
+ if( k[0].match(/[-+]/) ){
180
+ operator = k[0]
181
+ k = k.substr(1)
182
+ }
183
+ return {operator,param:k}
184
+ }
185
+
186
+ toJanusObject = function(object){
187
+ // we are not using object.parts[ ... ] because
188
+ // glTF animated collidable objects require special sync/setup:
189
+ // collider must be child of THREE object (to get animated), not janusobject
190
+ // NOTE: avoid jobj.add() since that reparents the object (andw breaks glTF anims)
191
+ let jo = this._object.createObject("object",{
192
+ js_id: object.name
193
+ })
194
+ object.parent.add( jo.objects['3d'] )
195
+ jo.objects['3d'] = object
196
+ jo.collidable = true
197
+ jo.removeCollider();
198
+ const collider = object.clone()
199
+ collider.position.set(0,0,0)
200
+ collider.rotation.set(0,0,0)
201
+ collider.scale.set(1,1,1)
202
+ jo.setCollider('mesh',{mesh: collider})
203
+ jo.colliders.parent = object
204
+ return jo
205
+ }
206
+
207
+ showShroud = function(){
208
+ this.shroud.visible = true;
209
+ this.shroud.opacity = 1;
210
+ }
211
+
212
+ update = function(){
213
+ if (this.shroud?.visible && !janus.loading) {
214
+ if (this.shroud.opacity > .001) {
215
+ this.shroud.opacity *= .9;
216
+ if (this.shroud.opacity <= .001) {
217
+ this.shroud.visible = false;
218
+ this.shroud.opacity = 0;
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ })
225
+ });
226
+
227
+ xrf_install_hyperlinks = function(){
228
+ if( !room.objects?.scene?.modelasset?.loaded ) {
229
+ return setTimeout( xrf_install_hyperlinks, 300 )
230
+ }
231
+ if( !room.hyperlink ){
232
+ room.hyperlink = new elation.janusweb.hyperlink(room)
233
+ janus.loading = false // force!
234
+ }
235
+ }
236
+
237
+ xrf_install_hyperlinks()
238
+
239
+ // update urlbar when user or browser activates href
240
+ elation.events.add(null, 'href', function(e){
241
+ const scene = elation.engine.instances.default.systems.world.scene['world-3d']
242
+ const urlbar = document.querySelector('janus-ui-urlbar ui-input')
243
+ const href = e?.data?.href
244
+ if( urlbar ){
245
+ urlbar.value = href[0] == '#' ? urlbar.value.replace(/#.*/,'') + href : href
246
+ }else console.warn("xrfragment: cannot find urlbar")
247
+ })
248
+
249
+ elation.events.add(null, 'room_load_complete', xrf_install_hyperlinks )
250
+ elation.events.add(null, 'room_disable', function(e){
251
+ if( room?.hyperlink ) room.hyperlink.cleanup()
252
+ })
253
+
254
+ elation.events.add(null, 'room_enable', function(e){
255
+ if( room?.hyperlink ) room.hyperlink.init()
256
+ else xrf_install_hyperlinks()
257
+ })
258
+
259
+ elation.events.add(null, 'janusweb_script_frame', function(){
260
+ if( room?.hyperlink ) room.hyperlink.update()
261
+ })
262
+
263
+ if( room.urlhash ){
264
+ elation.events.fire({element: this, type: 'href', data: {href: `#${room.urlhash}`}});
265
+ }
@@ -0,0 +1,93 @@
1
+ xrf_export = {
2
+
3
+ node(e){
4
+ const {userData,n} = e.data
5
+ for( let k in userData ){
6
+ if( k.match(/^-(three)-/) || k == 'href' ){ // keep hrefs + `-three-*` engine prefixes
7
+ n.userData[k] = userData[k] // but ditch `-janus-*` (we'll regenerate them)
8
+ }
9
+ if( k == 'thing' && String(userData[k].componentname).match('engine.things.janus') ){
10
+ const thing = userData[k]
11
+ switch( thing.componentname ){
12
+ case "engine.things.janusparagraph":
13
+ case "engine.things.janustext":
14
+ case "engine.things.janusportal":
15
+ case "engine.things.januswebsurface":
16
+ case "engine.things.janusroom":
17
+
18
+ if( thing.componentname != 'engine.things.janusroom' ){
19
+ n.name = String(`-janus-${thing.js_id}`).replace(/.*janus-/,'-janus-')
20
+ n.userData['-janus-tag'] = thing.componentname
21
+ .replace('janusportal','januslink')
22
+ .replace('engine.things.janus','')
23
+ }
24
+ if( thing.componentname.match(/engine\.things\.janus(portal|link)/) && n.children[0] ){
25
+ // convert portal to XRF href
26
+ n.children[0].userData['href'] = thing.url
27
+ }
28
+ // write XRF `-janus-*` engine prefixes
29
+ const attrs = xrf_export.getAttributes(thing)
30
+ for( let i in attrs ){
31
+ if( !i.match(/^-janus-(use_local_asset)/) ){
32
+ n.userData[`-janus-${i}`] = attrs[i]
33
+ }
34
+ }
35
+ break;
36
+ }
37
+ }
38
+ }
39
+ },
40
+ scene(e){
41
+ // https://xrfragment.org/#%F0%9F%93%9Clevel7%3A%20engine%20prefixes
42
+ e.data.scene.userData['-janus-source'] = room.getRoomSource()
43
+ },
44
+ getAttributes(thing){
45
+ if( !thing ) return {}
46
+ let proxy = thing.getProxyObject(),
47
+ propdefs = thing._thingdef.properties,
48
+ proxydefs = proxy._proxydefs,
49
+ attrs = {};
50
+
51
+ // this code is almost a duplicate of elation's .summarizeXML() but with inversed (glTF) position
52
+ for (let k in proxydefs) {
53
+ let proxydef = proxydefs[k],
54
+ propdef = elation.utils.arrayget(propdefs, proxydef[1]);
55
+ if ( k != 'room' && k != 'tagName' && k != 'classList' && proxydef[0] == 'property' && propdef) {
56
+ let val = elation.utils.arrayget(thing.properties, proxydef[1]);
57
+ let defaultval = propdef.default;
58
+ if (val instanceof THREE.Vector2) {
59
+ if (defaultval instanceof THREE.Vector2) defaultval = defaultval.toArray();
60
+ if (!('default' in propdef) || ('default' in propdef && !(val.x == defaultval[0] && val.y == defaultval[1]))) {
61
+ attrs[k] = val.toArray().map(n => Math.round(n * 10000) / 10000).join(' ');
62
+ }
63
+ } else if (val instanceof THREE.Vector3) {
64
+ if (defaultval instanceof THREE.Vector3) defaultval = defaultval.toArray();
65
+ if (!('default' in propdef) || ('default' in propdef && !(val.x == defaultval[0] && val.y == defaultval[1] && val.z == defaultval[2]))) {
66
+ attrs[k] = val.toArray().map(n => Math.round(n * -10000) / 10000).join(' ');
67
+ }
68
+ } else if (val instanceof THREE.Color) {
69
+ if (defaultval instanceof THREE.Color) defaultval = defaultval.toArray();
70
+ if (!('default' in propdef) || defaultval === null || ('default' in propdef && !(val.r == defaultval[0] && val.g == defaultval[1] && val.b == defaultval[2]))) {
71
+ attrs[k] = val.toArray().map(n => Math.round(n * 10000) / 10000).join(' ');
72
+ }
73
+ } else if (val instanceof THREE.Euler) {
74
+ if (defaultval instanceof THREE.Euler) defaultval = defaultval.toArray();
75
+ if (!('default' in propdef) || ('default' in propdef && !(val.x == defaultval[0] && val.y == defaultval[1] && val.z == defaultval[2]))) {
76
+ attrs[k] = val.toArray().slice(0, 3).map(n => Math.round(n * 10000) / 10000).join(' ');
77
+ }
78
+ } else if (val instanceof THREE.Quaternion) {
79
+ if (defaultval instanceof THREE.Quaternion) defaultval = defaultval.toArray();
80
+ if (!('default' in propdef) || ('default' in propdef && !(val.x == defaultval[0] && val.y == defaultval[1] && val.z == defaultval[2] && val.w == defaultval[3]))) {
81
+ attrs[k] = val.toArray().map(n => Math.round(n * 10000) / 10000).join(' ');
82
+ }
83
+ } else if (val !== propdef.default && val !== null && val !== '') {
84
+ attrs[k] = val;
85
+ }
86
+ }
87
+ }
88
+ return attrs;
89
+ }
90
+ }
91
+
92
+ elation.events.add(null, 'webui_editor_export_node', xrf_export.node )
93
+ elation.events.add(null, 'webui_editor_export', xrf_export.scene )