@displayxr/inline3d 1.2.1 → 1.4.0

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.
package/js/inline3d.js CHANGED
@@ -24,6 +24,14 @@
24
24
  // { supported:false } and your page shows its normal 2D content — inline-3D is progressive
25
25
  // enhancement, never a hard dependency.
26
26
 
27
+ // Undock lives in its own module (it is a page-level action, not a per-frame concern, and it
28
+ // works standalone on a browser with no inline-3D at all). Imported here for ONE reason: to hand
29
+ // it a resolver so its API-first path can find the live XRDisplayLayer behind an element. The
30
+ // dependency runs one way — inline3d-undock.js imports nothing — so there is no cycle, and the
31
+ // three entry points are re-exported below so a page has a single import site.
32
+ import { undock, undockAvailable, undockUrl, tileScreenRect, setUndockLayerResolver } from './inline3d-undock.js';
33
+ export { undock, undockAvailable, undockUrl, tileScreenRect };
34
+
27
35
  // The document's single live manager. The browser's per-frame element-rect report is a
28
36
  // WHOLE-WIDGET setter — each live session pushes the complete list of rects to weave — so two
29
37
  // managers in one document overwrite each other frame by frame and neither one's tiles hold
@@ -31,6 +39,26 @@
31
39
  // manager and opens the next) are legitimate and the common case, so nothing is refused.
32
40
  let liveManager = null;
33
41
 
42
+ // How the undock helper's API-first path finds the layer behind an element. Registered here
43
+ // because only this module knows the canvas -> window -> layer map; the helper stays importable
44
+ // on its own (with no resolver, every call takes the protocol fallback).
45
+ setUndockLayerResolver((el) => {
46
+ const m = liveManager;
47
+ if (!m || !m._running || !el) return null;
48
+ // The canvas itself, then a woven canvas INSIDE the element (a card wrapping its tile), then
49
+ // the element sitting inside a window's own container (a button in the tile's box). Anything
50
+ // further away is not this window's rect and takes the fallback.
51
+ for (const win of m._windows.values()) if (win.canvas === el && win.layer) return win.layer;
52
+ for (const win of m._windows.values()) {
53
+ if (win.layer && typeof el.contains === 'function' && el.contains(win.canvas)) return win.layer;
54
+ }
55
+ for (const win of m._windows.values()) {
56
+ const box = win.canvas.parentElement;
57
+ if (win.layer && box && typeof box.contains === 'function' && box.contains(el)) return win.layer;
58
+ }
59
+ return null;
60
+ });
61
+
34
62
  const hasWebXR = () => typeof navigator !== 'undefined' && !!navigator.xr;
35
63
  const hasLayer = () =>
36
64
  typeof window !== 'undefined' && typeof window.XRDisplayLayer === 'function';
@@ -41,6 +69,76 @@ const hasLayer = () =>
41
69
  // no-op — the page still works, the overlay just weaves like before.
42
70
  const hasExclusion = () =>
43
71
  hasLayer() && 'excludeElement' in window.XRDisplayLayer.prototype;
72
+ // View rigs (XR_DXR_view_rig, browser-side `XRDisplayLayer.setViewRig`). The browser has always
73
+ // chained a rig descriptor onto its per-frame xrLocateViews — a DISPLAY rig with an identity
74
+ // pose whose only knob was `virtualDisplayHeight`. setViewRig opens the whole descriptor: a
75
+ // posed display rig, or a CAMERA rig that puts the app's own camera in the runtime's hands.
76
+ //
77
+ // The capability signal is the METHOD's presence, and that is deliberate: a Blink IDL attribute
78
+ // getter throws `Illegal invocation` when read off the prototype (the trap documented at length
79
+ // under occlusionByDrawOrder below), so an attribute would be undetectable on exactly the
80
+ // browser that has it. `'setViewRig' in prototype` calls nothing and is safe.
81
+ const hasViewRig = () => {
82
+ if (!hasLayer()) return false;
83
+ try {
84
+ return 'setViewRig' in window.XRDisplayLayer.prototype;
85
+ } catch {
86
+ return false; // a prototype that refuses to be probed is not a capability
87
+ }
88
+ };
89
+ // Display modes — the display's own capabilities, and the one thing a page can ask it to
90
+ // change. Three methods, all on XRDisplayLayer, all promise-returning:
91
+ //
92
+ // getDisplayInfo() the panel: physical size, pixel size, the view scale it
93
+ // RECOMMENDS. Null on a machine with no glasses-free display.
94
+ // getRenderingModes() every mode the runtime can put the panel in — view count, tile
95
+ // grid, per-view pixels, whether it is a hardware-3D mode, which
96
+ // one is active, and whether the browser may request it.
97
+ // requestRenderingMode(i) switch the panel to mode i. The browser renders exactly TWO
98
+ // views, so a mode with viewCount > 2 is listed and refused; a
99
+ // ONE-view mode IS requestable and is how a page goes flat.
100
+ //
101
+ // THE HARDWARE DISPLAY STATE (2D/3D) IS NOT A SEPARATE CONTROL, and that is the whole shape of
102
+ // this API. It is a CONSEQUENCE of the active rendering mode: request a one-view mode and the
103
+ // browser puts the panel in its 2D state and reports that mode active (the runtime carries on
104
+ // weaving the same fixed two-view atlas); request the two-view mode and the panel goes back to
105
+ // 3D. The transition arrives as `hardwaredisplaystatechange`. There is deliberately NO
106
+ // page-facing request for the hardware state on its own: a page that could move the panel flat
107
+ // while still submitting stereo would be showing the woven atlas flat, which is a blurry double
108
+ // image rather than 2D. Tying the two together makes that state unreachable.
109
+ //
110
+ // Probed the same way as setViewRig, and for the same reason: these are METHODS, so reading
111
+ // them off the prototype is a plain data-property read that calls nothing (an IDL *attribute*
112
+ // getter would throw `Illegal invocation` on the very browser that has it). All three are
113
+ // required — a browser with a partial set is a browser mid-implementation, and treating it as
114
+ // supported would hand a page a `requestRenderingMode is not a function` at the worst moment.
115
+ const DISPLAY_MODE_METHODS = ['getDisplayInfo', 'getRenderingModes', 'requestRenderingMode'];
116
+ // The two display events, fired on the XRSession rather than the layer — so a page hears about a
117
+ // mode or hardware-state change even while its tile's layer is closed (lazy mode), and one
118
+ // subscription covers every window in the document.
119
+ const DISPLAY_EVENTS = ['renderingmodechange', 'hardwaredisplaystatechange'];
120
+ const hasDisplayModes = () => {
121
+ if (!hasLayer()) return false;
122
+ try {
123
+ const proto = window.XRDisplayLayer.prototype;
124
+ return DISPLAY_MODE_METHODS.every((m) => typeof proto[m] === 'function');
125
+ } catch {
126
+ return false; // a prototype that refuses to be probed is not a capability
127
+ }
128
+ };
129
+ // Undock (XRDisplayLayer.undock / getUndockCapabilities) — lifting this window's asset out of
130
+ // the page into a floating native viewer over the desktop. Same METHOD probe, same reason. The
131
+ // helper in ./inline3d-undock.js falls back to the `displayxr-view:` OS protocol where this is
132
+ // absent, so a page never has to branch on it; what it IS good for is deciding whether to show
133
+ // an "undock" affordance at all (see `wall.undock`).
134
+ const hasUndock = () => {
135
+ if (!hasLayer()) return false;
136
+ try {
137
+ return typeof window.XRDisplayLayer.prototype.undock === 'function';
138
+ } catch {
139
+ return false;
140
+ }
141
+ };
44
142
  // ── draw-order occlusion (browser Phase 2, browser patches 0063/0064) ─────────────────────
45
143
  //
46
144
  // The browser composites ANY 2D content over a woven tile per-pixel BY DRAW ORDER — headers,
@@ -170,6 +268,158 @@ export function inline3dOcclusionByDrawOrder() {
170
268
  return hasDrawOrderOcclusion();
171
269
  }
172
270
 
271
+ /**
272
+ * True when this browser accepts a full VIEW RIG descriptor — `handle.setViewRig(rig)` and
273
+ * `addScene`'s `viewRig` option, i.e. a posed display rig or a camera rig, instead of only the
274
+ * scalar `virtualDisplayHeight`. Sync + cheap; implies {@link inline3DAvailable}.
275
+ *
276
+ * Reads a capability (the presence of `XRDisplayLayer.prototype.setViewRig`), never a version or
277
+ * UA string. False on every browser that predates the rig API — where `virtualDisplayHeight`
278
+ * still works, which is why a page needs to branch on this only if a camera rig is load-bearing
279
+ * for it: `setViewRig` no-ops loudly-once rather than throwing, so a page that just wants the
280
+ * extra control when it is there can call it unconditionally.
281
+ */
282
+ export function inline3dViewRigSupported() {
283
+ return inline3DAvailable() && hasViewRig();
284
+ }
285
+
286
+ /**
287
+ * True when this browser exposes the DISPLAY-MODE API — `getDisplayInfo()`,
288
+ * `getRenderingModes()` and `requestRenderingMode()` on the tile handle, i.e. the page can read
289
+ * what the panel is and ask it to change. Sync + cheap; implies {@link inline3DAvailable}.
290
+ *
291
+ * Reads a capability (the presence of all three methods on `XRDisplayLayer.prototype`), never a
292
+ * version or UA string, and demands ALL THREE: a browser shipping half the set is one mid-
293
+ * implementation, and calling it supported would surface as a `not a function` inside a click
294
+ * handler rather than as a feature that is simply absent.
295
+ *
296
+ * Everything the API drives is optional enhancement — the window weaves identically without it —
297
+ * so a page needs this only to decide whether to SHOW display controls. The handle methods
298
+ * themselves reject with a clear Error rather than throwing at import or create time.
299
+ */
300
+ export function inline3dDisplayModesSupported() {
301
+ return inline3DAvailable() && hasDisplayModes();
302
+ }
303
+
304
+ /**
305
+ * True when this browser can UNDOCK a window's asset into a floating native viewer through
306
+ * `XRDisplayLayer.undock()` — i.e. without the `displayxr-view:` protocol prompt the fallback
307
+ * path needs. Sync + cheap; implies {@link inline3DAvailable}.
308
+ *
309
+ * A page does not have to branch on this to undock (the helper falls back on its own); it is the
310
+ * probe for whether `wall.undock` carries capabilities, and for a UI that wants to say WHICH
311
+ * asset kinds this build can float.
312
+ */
313
+ export function inline3dUndockSupported() {
314
+ return inline3DAvailable() && hasUndock();
315
+ }
316
+
317
+ // The rig `virtualDisplayHeight` is shorthand for: a display rig, identity pose, all factors 1.
318
+ // Written out here because the automatic 1-view collapse has to be able to say "the default rig,
319
+ // but flat", and there is no way to express that as a scalar — the whole descriptor has to be sent.
320
+ function defaultDisplayRig(win) {
321
+ return {
322
+ type: 'display',
323
+ position: { x: 0, y: 0, z: 0 },
324
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
325
+ virtualDisplayHeight: win.virtualDisplayHeight > 0 ? win.virtualDisplayHeight : 0.24,
326
+ ipdFactor: 1,
327
+ parallaxFactor: 1,
328
+ perspectiveFactor: 1,
329
+ };
330
+ }
331
+
332
+ // The page's rig with the stereo dialled out: eye separation and head-tracking response to 0, so
333
+ // both eyes are rendered from the SAME place and the woven atlas carries one image twice.
334
+ //
335
+ // A COPY, never a mutation. A page driving a rig per frame reuses one descriptor object
336
+ // (cameraRigFromCamera's `out`), so zeroing the factors in place would write the flattening into
337
+ // the page's own state and it would never come back — the restore would restore 0.
338
+ function flattenedRig(rig) {
339
+ return { ...rig, ipdFactor: 0, parallaxFactor: 0 };
340
+ }
341
+
342
+ // Why the SDK collapses the rig behind the page's back, said once, where the code is.
343
+ let notedAutoCollapse = false;
344
+ function noteAutoCollapse() {
345
+ if (notedAutoCollapse) return;
346
+ notedAutoCollapse = true;
347
+ console.info(
348
+ '[inline3d] The active rendering mode is 1-view, so this SDK has zeroed every window rig ' +
349
+ '(ipdFactor/parallaxFactor -> 0) — both eyes now render from one place. The runtime keeps ' +
350
+ 'weaving the same two-view atlas whatever the page submits, so leaving stereo in it would ' +
351
+ 'put two slightly different images on a flat panel, i.e. a blurry double image instead of ' +
352
+ '2D. Your rendering is unchanged; the flattening is a copy applied on the way to the layer ' +
353
+ 'and it is undone the moment a 2-view mode goes active again.'
354
+ );
355
+ }
356
+
357
+ // ── reading the two display events ──────────────────────────────────────────────────────
358
+ //
359
+ // The payload is read DEFENSIVELY, in the shape the 0128-era handling already established: an
360
+ // event's own `detail` when it carries one, and otherwise the event object itself. A browser
361
+ // mid-implementation carried nothing at all and the state had to be read back — which still
362
+ // works, because an unreadable payload leaves the value unknown and the mode list answers it.
363
+ // `!== undefined` rather than `in`: a CustomEvent always HAS a `detail` property, and a null one
364
+ // is not a payload.
365
+ function eventDetail(e) {
366
+ return e && e.detail !== undefined && e.detail !== null ? e.detail : undefined;
367
+ }
368
+ function eventPayloads(e) {
369
+ const d = eventDetail(e);
370
+ return d !== undefined ? [d, e] : [e];
371
+ }
372
+ /** The new active mode index an event carries — a bare number, `.modeIndex`, or `.mode`. -1 = not stated. */
373
+ function eventModeIndex(e) {
374
+ for (const src of eventPayloads(e)) {
375
+ if (typeof src === 'number' && Number.isFinite(src)) return src;
376
+ if (src && typeof src === 'object') {
377
+ const v = src.modeIndex !== undefined ? src.modeIndex : src.mode;
378
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
379
+ }
380
+ }
381
+ return -1;
382
+ }
383
+ /** The hardware display state an event carries — a bare '2d'/'3d' or `.state`. null = not stated. */
384
+ function eventHardwareState(e) {
385
+ for (const src of eventPayloads(e)) {
386
+ if (src === '2d' || src === '3d') return src;
387
+ if (src && typeof src === 'object') {
388
+ const v = src.state !== undefined ? src.state : src.hardwareDisplayState;
389
+ if (v === '2d' || v === '3d') return v;
390
+ }
391
+ }
392
+ return null;
393
+ }
394
+
395
+ // One-shot notices about view rigs. Both are per-document, and both describe a situation that is
396
+ // identical on every frame — so warning per call would bury the page's own logs in a rAF loop.
397
+ let notedNoViewRig = false;
398
+ function noteNoViewRig() {
399
+ if (notedNoViewRig) return;
400
+ notedNoViewRig = true;
401
+ console.warn(
402
+ '[inline3d] This browser has no XRDisplayLayer.setViewRig, so the view rig was ignored ' +
403
+ '(the window still weaves — the runtime keeps the default display rig, scaled by ' +
404
+ "virtualDisplayHeight). Gate on inline3dViewRigSupported() if your page's framing " +
405
+ 'depends on the rig; further calls are silent.'
406
+ );
407
+ }
408
+
409
+ let notedRigWinsOverHeight = false;
410
+ function noteRigWinsOverHeight() {
411
+ if (notedRigWinsOverHeight) return;
412
+ notedRigWinsOverHeight = true;
413
+ console.warn(
414
+ '[inline3d] addScene got BOTH viewRig and virtualDisplayHeight; on a browser with rig ' +
415
+ 'support the rig wins and the height is dropped. They describe the same slot — ' +
416
+ 'virtualDisplayHeight is shorthand for exactly one rig (display, identity pose, all ' +
417
+ 'factors 1) — so there is no merge. This is only worth passing as a PAIR deliberately: ' +
418
+ 'the height is what a browser without setViewRig will use, so a camera-rig scene can ' +
419
+ 'name its own fallback framing. Otherwise say it once, inside the rig.'
420
+ );
421
+ }
422
+
173
423
  /**
174
424
  * Open the page's inline-3D session and return a manager you add windows to.
175
425
  *
@@ -189,7 +439,10 @@ export function inline3dOcclusionByDrawOrder() {
189
439
  * exclusively via addGlobalOverlay()/data-inline3d-overlay. Ignored (nothing is
190
440
  * scanned, no `will-change` is set on your DOM) on a browser with draw-order
191
441
  * occlusion, where chrome occludes tiles by itself.
192
- * @returns {Promise<Inline3D | {supported:false, error?:Error}>}
442
+ * @returns {Promise<Inline3D | {supported:false, error?:Error}>} the manager, which also carries
443
+ * the display API (`getDisplayInfo` / `getRenderingModes` / `requestRenderingMode` /
444
+ * `setStereoEnabled`, `on`/`off`) and `undock` — `{model, splat}` on a browser with
445
+ * `XRDisplayLayer.undock`, `null` on one without.
193
446
  */
194
447
  export async function createInline3D(opts = {}) {
195
448
  const { referenceSpace = 'viewer', lazy = true, rootMargin = '50% 0px', autoChrome = true } = opts;
@@ -286,6 +539,24 @@ class Inline3D {
286
539
  // Set once the legacy occlusion machinery has been retired (draw-order browser whose
287
540
  // capability flag could only be read from a live layer). See _standDownLegacyOcclusion.
288
541
  this._stoodDown = false;
542
+ // ── display state ────────────────────────────────────────────────────────────────
543
+ // What the panel is doing, as last REPORTED (the first getRenderingModes read, then every
544
+ // renderingmodechange / hardwaredisplaystatechange). Never what was last requested: a
545
+ // refused request must leave every one of these untouched.
546
+ this._activeModeIndex = -1;
547
+ this._activeViewCount = 0; // 0 = not read yet
548
+ this._hardwareDisplayState = null; // '2d' | '3d' | null (never reported)
549
+ this._modes = null; // last getRenderingModes() result, for the viewCount lookup
550
+ // The rig-collapse latch, MANAGER-wide because the mode is the display's, not a window's.
551
+ // While true every rig that leaves for a layer is pushed flat (a copy — each window's own
552
+ // `viewRig` always holds what the page asked for, untouched).
553
+ this._stereoCollapsed = false;
554
+ this._displayListeners = new Map(); // event type -> Set(callback), for on()/off()
555
+ this._primedDisplayState = false;
556
+ // Undock capabilities, refreshed off the first live layer (see _refreshUndock). Null is the
557
+ // load-bearing value: it means this browser has no XRDisplayLayer.undock at all.
558
+ this.undock = hasUndock() ? { model: false, splat: false } : null;
559
+ this._undockRead = false;
289
560
  this._running = true;
290
561
  this._lazy = lazy;
291
562
  this._observer =
@@ -310,6 +581,7 @@ class Inline3D {
310
581
  }
311
582
  liveManager = this;
312
583
  session.addEventListener('end', () => this._teardown());
584
+ this._bindDisplayEvents();
313
585
  this._scanChrome(); // page chrome usually exists before the session does
314
586
  this._bindLifecycle();
315
587
  this._armDprWatch();
@@ -491,10 +763,21 @@ class Inline3D {
491
763
  * @param {object} [opts]
492
764
  * @param {number} [opts.virtualDisplayHeight=0.24] metres of virtual display the scene is
493
765
  * composed for. Larger = the element shows a bigger slice of the world.
766
+ * @param {object} [opts.viewRig] a full view-rig descriptor (XRViewRigInit) instead of the
767
+ * scalar height: a POSED display rig, or a CAMERA rig that hands the runtime your app
768
+ * camera's pose/FOV/convergence and lets eye tracking perturb its frustum. Supersedes
769
+ * virtualDisplayHeight (which is one particular display rig), and can be replaced per
770
+ * frame with `handle.setViewRig()`. Ignored on a browser without rig support, which
771
+ * falls back to `virtualDisplayHeight` if one was given (that pair is the one reason
772
+ * to pass both) — either way the window still weaves.
494
773
  * @param {Element} [opts.observe=canvas] element whose visibility gates lazy create/close.
495
774
  * @returns {{remove():void}}
496
775
  */
497
776
  addScene(canvas, onFrame, opts = {}) {
777
+ // The rig and the height describe the same one slot in the layer init, so warn where a
778
+ // caller has said it twice — silently dropping one of two things the page explicitly asked
779
+ // for is how a scene ends up framed at a scale nobody chose.
780
+ if (opts.viewRig && opts.virtualDisplayHeight !== undefined) noteRigWinsOverHeight();
498
781
  const win = this._register(canvas, 'scene', { virtualDisplayHeight: 0.24, ...opts });
499
782
  win.onFrame = onFrame;
500
783
  win.ownsBuffer = false; // the app sizes a scene canvas; we never touch canvas.width/height
@@ -528,12 +811,189 @@ class Inline3D {
528
811
  if (!el || !win.excluded.delete(el)) return;
529
812
  this._dropExclusion(win, el);
530
813
  },
814
+ /**
815
+ * Replace this window's VIEW RIG — the descriptor the runtime locates views against.
816
+ * Cheap enough to call every frame (that is the intended use: a rig is per-locate, so
817
+ * animating one means sending new values, not tweening anything).
818
+ *
819
+ * Remembered on the window as well as pushed at the layer, so the lazy lifecycle cannot
820
+ * quietly lose it (see `viewRig` in _register). Returns whether it reached a LIVE layer:
821
+ * false also means "stored, and it will build the next layer" for a window that is
822
+ * currently scrolled away — which is the honest answer for a page driving this per frame,
823
+ * and the reason it is a boolean rather than void.
824
+ *
825
+ * ONE FRAME OF LAG, by construction. The browser locates views BEFORE the page's rAF, so
826
+ * the rig you set during frame N drives the views delivered in frame N+1. For a slow
827
+ * knob (a slider, a settled camera) that is invisible; for a camera that moves with the
828
+ * pointer it is not, and the fix is not to fight it — send an IDENTITY-posed camera rig
829
+ * and parent your eye cameras under the app camera, so three composes the world pose with
830
+ * zero lag (see `cameraRigFromCamera(..., {attach:true})` + `EyeCamera.setLocalFromView`).
831
+ *
832
+ * While `setStereoEnabled(false)` is in force the rig you pass here is STORED AS GIVEN and
833
+ * pushed FLAT (ipd/parallax 0) — the flattening is a latch on the way out, not a value
834
+ * written into your descriptor, so a page driving a rig every frame cannot undo the 2D
835
+ * state by simply carrying on, and `setStereoEnabled(true)` restores exactly what you last
836
+ * asked for.
837
+ */
838
+ setViewRig: (rig) => {
839
+ win.viewRig = rig || null;
840
+ return this._pushViewRig(win);
841
+ },
842
+ /**
843
+ * The panel this window is weaving on: physical size in metres, pixel size, and the view
844
+ * scale the runtime RECOMMENDS. Resolves null on a machine with no glasses-free display.
845
+ *
846
+ * `recommendedViewScaleX/Y` are ADVISORY. The browser cannot resize a page's canvas, so
847
+ * nothing applies them for you: a page honours them by sizing its OWN backing store
848
+ * (canvas.width/height, `renderer.setSize`) to `viewPixels x scale`. Ignoring them costs
849
+ * sharpness or fill rate, never correctness.
850
+ *
851
+ * @returns {Promise<object|null>}
852
+ */
853
+ getDisplayInfo: () => this._layerCall(win, 'getDisplayInfo', 'getDisplayInfo()'),
854
+ /**
855
+ * Every rendering mode the runtime can put this display in, as reported by the runtime:
856
+ * `{modeIndex, modeName, viewCount, viewScaleX, viewScaleY, tileColumns, tileRows,
857
+ * viewWidthPixels, viewHeightPixels, hardwareDisplay3D, isActive, isRequestable}`.
858
+ *
859
+ * The list is the DISPLAY's, not the browser's, so it includes modes this browser cannot
860
+ * drive: the browser is fixed at TWO views, so any mode with `viewCount !== 2` is reported
861
+ * with `isRequestable: false` and `requestRenderingMode` refuses it. Show those rows — they
862
+ * are what the panel can do — but mark them, don't offer them.
863
+ *
864
+ * @returns {Promise<ReadonlyArray<object>>}
865
+ */
866
+ getRenderingModes: () => this._getRenderingModes(win),
867
+ /**
868
+ * Ask the runtime to switch the display to the mode with this `modeIndex`. A thin
869
+ * pass-through — it resolves and rejects exactly as the browser does.
870
+ *
871
+ * Rejects with a `TypeError` for a mode with `viewCount > 2` (the browser renders exactly
872
+ * two views and cannot fill a 4-view atlas — no view synthesis exists anywhere in this
873
+ * stack) or an unknown index, and with a `NotSupportedError` `DOMException` when the
874
+ * request was not forwardable. The browser raises the TypeError SYNCHRONOUSLY; this
875
+ * pass-through is async, so it reaches you as a rejection either way and one `.catch()`
876
+ * covers both. On a browser without the API at all it rejects with a plain Error saying so.
877
+ *
878
+ * A ONE-VIEW MODE IS REQUESTABLE, and requesting it is how a page goes flat: the browser
879
+ * puts the panel in its 2D hardware state and reports that mode active. The SDK then
880
+ * collapses every window's rig automatically (see {@link setStereoEnabled}) off the
881
+ * resulting `renderingmodechange` — so the request itself changes nothing about your
882
+ * rendering, and a REFUSED request changes nothing at all.
883
+ *
884
+ * On success the session fires `renderingmodechange` — see {@link on}. That event, not
885
+ * this promise, is when the new mode is in effect.
886
+ *
887
+ * @param {number} modeIndex
888
+ * @returns {Promise<void>}
889
+ */
890
+ requestRenderingMode: (modeIndex) => this._requestRenderingMode(modeIndex, win),
891
+ /**
892
+ * SUGAR over {@link requestRenderingMode}, and nothing more. `false` requests the first
893
+ * mode with `viewCount === 1 && isRequestable`; `true` requests the first with
894
+ * `viewCount === 2 && isRequestable`. It never touches the hardware display state
895
+ * directly — there is no such call in this API — and it never touches your rig.
896
+ *
897
+ * THE RIG COLLAPSE IS NOT PART OF THIS CALL. When a 1-view mode actually goes ACTIVE the
898
+ * SDK zeroes every window's `ipdFactor`/`parallaxFactor` on the way to the layer, and
899
+ * restores them when a 2-view mode goes active; that is driven by the
900
+ * `renderingmodechange` event (and by the first `getRenderingModes()` read), so it happens
901
+ * however the mode changed — this call, another tab, the shell — and a request that is
902
+ * REFUSED leaves everything exactly as it was, in both directions.
903
+ *
904
+ * The flattening is a COPY pushed at the layer, never a write into your descriptor: a page
905
+ * driving `setViewRig` every frame keeps having its rig stored intact and pushed flat, and
906
+ * the restore is exactly the rig it last asked for. A page that never set a rig gets the
907
+ * exact descriptor equivalent of its `virtualDisplayHeight`.
908
+ *
909
+ * Rejects when no such mode is listed (a plain Error naming what was looked for), and
910
+ * otherwise exactly as `requestRenderingMode` does. Resolves to the boolean asked for —
911
+ * the request was accepted; the mode is in force when the event says so.
912
+ *
913
+ * @param {boolean} enabled
914
+ * @returns {Promise<boolean>}
915
+ */
916
+ setStereoEnabled: (enabled) => this._setStereoEnabled(enabled, win),
917
+ /**
918
+ * Subscribe to one display event, re-emitted on this handle:
919
+ *
920
+ * `renderingmodechange` `{type, modeIndex, viewCount, mode, detail}`
921
+ * `hardwaredisplaystatechange` `{type, state:'2d'|'3d', detail}`
922
+ *
923
+ * They originate on the XRSession, not on the layer — so they arrive even for a window
924
+ * whose layer is currently closed, and a page that only wants to KNOW does not have to
925
+ * hold a live tile. `detail` is the browser's own payload, kept as-is.
926
+ *
927
+ * Returns an unsubscribe function; `off(type, cb)` does the same. Inert (returns a no-op)
928
+ * on a browser without the API.
929
+ *
930
+ * @param {'renderingmodechange'|'hardwaredisplaystatechange'} type
931
+ * @param {(e:object) => void} cb
932
+ * @returns {() => void}
933
+ */
934
+ on: (type, cb) => this.on(type, cb),
935
+ /** Drop a listener registered with {@link on}. */
936
+ off: (type, cb) => this.off(type, cb),
937
+ /**
938
+ * BOTH display events through one callback — the older shape, kept because pages use it.
939
+ * The callback gets the same normalised object `on()` delivers (`{type, ...}` plus
940
+ * `detail`). Returns an unsubscribe function.
941
+ *
942
+ * @param {(e:{type:string, detail:any}) => void} cb
943
+ * @returns {() => void}
944
+ */
945
+ onDisplayModeChange: (cb) => this._onDisplayModeChange(cb),
531
946
  // Read-only counters, for pages that want to see the load-induced mono fallback rather
532
947
  // than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere.
533
948
  stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }),
534
949
  };
535
950
  }
536
951
 
952
+ // ── the display (wall level) ──────────────────────────────────────────────────────────
953
+ //
954
+ // The panel is the DOCUMENT's, not a tile's: one display, one active rendering mode, one
955
+ // hardware state. These four are the same calls the tile handles carry (kept there because
956
+ // pages use them), routed through whichever window currently holds a live layer — so they
957
+ // keep working while a lazy tile is scrolled away, as long as ANY tile is live.
958
+
959
+ /** The panel: physical size, pixel size, the view scale it recommends. Null where there is none. */
960
+ getDisplayInfo() {
961
+ return this._layerCall(this._liveWindow(null), 'getDisplayInfo', 'getDisplayInfo()');
962
+ }
963
+
964
+ /** Every rendering mode the runtime can put this display in. See the handle's doc comment. */
965
+ getRenderingModes() {
966
+ return this._getRenderingModes(null);
967
+ }
968
+
969
+ /** Ask the runtime to switch the display to `modeIndex`. Pass-through; see the handle's doc. */
970
+ requestRenderingMode(modeIndex) {
971
+ return this._requestRenderingMode(modeIndex, null);
972
+ }
973
+
974
+ /** Sugar over {@link requestRenderingMode}: false -> a 1-view mode, true -> the 2-view mode. */
975
+ setStereoEnabled(enabled) {
976
+ return this._setStereoEnabled(enabled, null);
977
+ }
978
+
979
+ /**
980
+ * The hardware display state as last REPORTED by `hardwaredisplaystatechange` — `'2d'`,
981
+ * `'3d'`, or `null` when the browser has not said yet. Never what was last requested.
982
+ */
983
+ get hardwareDisplayState() {
984
+ return this._hardwareDisplayState;
985
+ }
986
+
987
+ /** The active mode's index and view count as last read/reported. `viewCount` 0 = not read yet. */
988
+ get activeMode() {
989
+ return { modeIndex: this._activeModeIndex, viewCount: this._activeViewCount };
990
+ }
991
+
992
+ /** True while the SDK is holding every window's rig flat because a 1-view mode is active. */
993
+ get stereoCollapsed() {
994
+ return this._stereoCollapsed;
995
+ }
996
+
537
997
  close() {
538
998
  try {
539
999
  this.session.end();
@@ -545,6 +1005,371 @@ class Inline3D {
545
1005
 
546
1006
  // ── internals ───────────────────────────────────────────────────────────────────────
547
1007
 
1008
+ // ── view rig + display modes ──────────────────────────────────────────────────────────
1009
+
1010
+ /**
1011
+ * The rig this window's layer should actually be holding right now: what the page asked for,
1012
+ * flattened when `setStereoEnabled(false)` is latched. Null means "say nothing" — leave the
1013
+ * browser on the `virtualDisplayHeight` shorthand it was built with.
1014
+ *
1015
+ * Used in BOTH directions (push at a live layer, build a new one), which is the point: a tile
1016
+ * that scrolls away and rebuilds while stereo is off must not come back in 3D.
1017
+ */
1018
+ _effectiveViewRig(win) {
1019
+ if (!this._stereoCollapsed) {
1020
+ // `stereoSynthRig`: this window never had a rig of its own, so going flat had to SEND one
1021
+ // (there is no way to say "the default, but flat" as a scalar). Coming back therefore has
1022
+ // to send the un-flat version explicitly too — returning null here would leave the layer
1023
+ // holding the flattened rig forever. The descriptor it sends is the exact equivalent of the
1024
+ // `virtualDisplayHeight` the layer was built with, so nothing about the framing moves.
1025
+ return win.viewRig || (win.stereoSynthRig ? defaultDisplayRig(win) : null);
1026
+ }
1027
+ // Going flat has to SEND a descriptor even for a window that never had a rig of its own
1028
+ // (there is no way to say "the default, but flat" as a scalar), and that fact has to be
1029
+ // remembered: coming back must then send the un-flat version explicitly, or the layer would
1030
+ // hold the flattened rig forever. Recorded here rather than in the collapse itself because a
1031
+ // window CREATED while the panel is already flat goes down this path on its first activate.
1032
+ if (!win.viewRig) win.stereoSynthRig = true;
1033
+ return flattenedRig(win.viewRig || defaultDisplayRig(win));
1034
+ }
1035
+
1036
+ /**
1037
+ * Latch (or release) the manager-wide rig collapse and push every window's rig again.
1038
+ *
1039
+ * Driven ONLY by what the display reports — the first `getRenderingModes()` read and every
1040
+ * `renderingmodechange` — never by a request. That is what makes a refused request a no-op in
1041
+ * both directions: nothing here runs unless the mode actually changed.
1042
+ */
1043
+ _setStereoCollapsed(collapsed) {
1044
+ const next = !!collapsed;
1045
+ if (this._stereoCollapsed === next) return;
1046
+ this._stereoCollapsed = next;
1047
+ if (next) noteAutoCollapse();
1048
+ for (const win of this._windows.values()) {
1049
+ // Diagnostics only: the factors that were in force when the panel went flat. The restore
1050
+ // itself just re-pushes `win.viewRig`, which was never mutated.
1051
+ if (next) {
1052
+ const rig = win.viewRig || defaultDisplayRig(win);
1053
+ win.stereoSaved = { ipdFactor: rig.ipdFactor, parallaxFactor: rig.parallaxFactor };
1054
+ } else {
1055
+ win.stereoSaved = null;
1056
+ }
1057
+ this._pushViewRig(win);
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Push the effective rig at the live layer. Returns whether it reached one — the boolean
1063
+ * `handle.setViewRig` documents ("false = stored, and it will build the next layer").
1064
+ */
1065
+ _pushViewRig(win) {
1066
+ if (!hasViewRig()) {
1067
+ noteNoViewRig();
1068
+ return false;
1069
+ }
1070
+ if (!win.layer) return false;
1071
+ const rig = this._effectiveViewRig(win);
1072
+ try {
1073
+ win.layer.setViewRig(rig);
1074
+ return true;
1075
+ } catch {
1076
+ // A closed layer or a descriptor the browser refused. Neither is worth throwing over in a
1077
+ // per-frame call — the window keeps weaving on the rig it already has.
1078
+ return false;
1079
+ }
1080
+ }
1081
+
1082
+ /**
1083
+ * Forward one display-mode call to this window's live layer, as a promise.
1084
+ *
1085
+ * Two ways it cannot proceed, and they are DIFFERENT failures worth different messages: the
1086
+ * browser has no such API at all (nothing will ever make this work — check
1087
+ * `inline3dDisplayModesSupported()` first), or the API is there but this window has no live
1088
+ * layer yet (lazy mode, scrolled away, or called before the first activation — try again once
1089
+ * the tile is on screen). Neither throws synchronously: these sit behind click handlers and a
1090
+ * rejected promise is what a page can actually handle.
1091
+ */
1092
+ _layerCall(win, method, label, args = []) {
1093
+ if (!hasDisplayModes()) {
1094
+ return Promise.reject(
1095
+ new Error(
1096
+ `[inline3d] ${label} needs a DisplayXR Browser with the display-mode API ` +
1097
+ '(XRDisplayLayer.getDisplayInfo/getRenderingModes/requestRenderingMode). ' +
1098
+ 'Gate on inline3dDisplayModesSupported().'
1099
+ )
1100
+ );
1101
+ }
1102
+ // `win` is null when the WALL-level call ran with no window live at all — the same failure
1103
+ // as a window whose layer is closed, and the same message covers both.
1104
+ if (!win || !win.layer) {
1105
+ return Promise.reject(
1106
+ new Error(
1107
+ `[inline3d] ${label} needs a live weave layer, and this window has none right now ` +
1108
+ '(lazy mode closes the layer while the tile is off screen). Call it once the tile ' +
1109
+ 'is visible, or create the manager with { lazy: false }.'
1110
+ )
1111
+ );
1112
+ }
1113
+ // Wrapped so a SYNCHRONOUS throw from the browser (requestRenderingMode raises TypeError
1114
+ // that way for a non-2-view mode) arrives as a rejection like every other failure.
1115
+ try {
1116
+ return Promise.resolve(win.layer[method](...args));
1117
+ } catch (e) {
1118
+ return Promise.reject(e);
1119
+ }
1120
+ }
1121
+
1122
+ /** The first window currently holding a live layer, or null. Every display call needs one. */
1123
+ _liveWindow(preferred) {
1124
+ if (preferred && preferred.layer) return preferred;
1125
+ for (const win of this._windows.values()) if (win.layer) return win;
1126
+ return preferred || null;
1127
+ }
1128
+
1129
+ /**
1130
+ * `getRenderingModes()` with the manager's cache kept honest — every read updates the list the
1131
+ * event path looks `viewCount` up in, and the first one PRIMES the display state (a page can
1132
+ * open with the panel already flat, and the rig has to be collapsed for that too).
1133
+ */
1134
+ async _getRenderingModes(win) {
1135
+ const list = await this._layerCall(
1136
+ this._liveWindow(win),
1137
+ 'getRenderingModes',
1138
+ 'getRenderingModes()'
1139
+ );
1140
+ const modes = Array.isArray(list) ? list : [];
1141
+ this._modes = modes;
1142
+ const active = modes.find((m) => m.isActive);
1143
+ if (active) {
1144
+ this._activeModeIndex = active.modeIndex;
1145
+ this._activeViewCount = active.viewCount;
1146
+ if (active.viewCount === 1 || active.viewCount === 2) {
1147
+ this._setStereoCollapsed(active.viewCount === 1);
1148
+ }
1149
+ }
1150
+ this._primedDisplayState = true;
1151
+ return list;
1152
+ }
1153
+
1154
+ /** `requestRenderingMode()` — a pass-through; see the handle's doc comment for the contract. */
1155
+ _requestRenderingMode(modeIndex, win) {
1156
+ return this._layerCall(
1157
+ this._liveWindow(win),
1158
+ 'requestRenderingMode',
1159
+ 'requestRenderingMode()',
1160
+ [modeIndex]
1161
+ );
1162
+ }
1163
+
1164
+ /**
1165
+ * The sugar behind `setStereoEnabled` — pick a mode by view count and request it. Nothing
1166
+ * else: the rig follows the resulting `renderingmodechange`, not this call.
1167
+ */
1168
+ async _setStereoEnabled(enabled, win) {
1169
+ const want = enabled ? 2 : 1;
1170
+ const modes = await this._getRenderingModes(win);
1171
+ const list = Array.isArray(modes) ? modes : [];
1172
+ const mode = list.find((m) => m.viewCount === want && m.isRequestable);
1173
+ if (!mode) {
1174
+ throw new Error(
1175
+ `[inline3d] setStereoEnabled(${!!enabled}) found no requestable ${want}-view mode on ` +
1176
+ `this display (${list.length} mode(s) listed). It is sugar over requestRenderingMode() ` +
1177
+ 'and cannot invent one — read getRenderingModes() and drive the list yourself.'
1178
+ );
1179
+ }
1180
+ if (mode.isActive) return !!enabled; // already there; the request would be a no-op anyway
1181
+ await this._requestRenderingMode(mode.modeIndex, win);
1182
+ return !!enabled;
1183
+ }
1184
+
1185
+ // ── display events ────────────────────────────────────────────────────────────────────
1186
+
1187
+ /**
1188
+ * Subscribe the manager ONCE to each session event and fan out from there. One subscription
1189
+ * per document rather than one per caller, because the SDK has to act on these itself (the
1190
+ * automatic rig collapse) whether or not the page is listening.
1191
+ */
1192
+ _bindDisplayEvents() {
1193
+ const session = this.session;
1194
+ if (!session || typeof session.addEventListener !== 'function') return;
1195
+ if (!hasDisplayModes()) return;
1196
+ session.addEventListener('renderingmodechange', (e) => {
1197
+ this._onRenderingModeChange(e).catch(() => {});
1198
+ });
1199
+ session.addEventListener('hardwaredisplaystatechange', (e) => {
1200
+ this._onHardwareDisplayStateChange(e).catch(() => {});
1201
+ });
1202
+ }
1203
+
1204
+ /**
1205
+ * The hardware display state moved. The browser's event is payload-free (an XRSessionEvent),
1206
+ * so the state is READ, not parsed: under the mode-only contract the page-facing active mode's
1207
+ * `hardwareDisplay3D` IS the hardware state by construction (a 1-view mode is presented for
1208
+ * exactly as long as the hardware is in the state it declares; a 2-view mode's default state
1209
+ * is what the runtime restored). A payload, if a future browser adds one, wins over the read.
1210
+ */
1211
+ _onHardwareDisplayStateChange(e) {
1212
+ const stated = eventHardwareState(e);
1213
+ const detail = eventDetail(e);
1214
+ const deliver = (state) => {
1215
+ if (state) this._hardwareDisplayState = state;
1216
+ this._emitDisplay({
1217
+ type: 'hardwaredisplaystatechange',
1218
+ state: state || this._hardwareDisplayState,
1219
+ detail,
1220
+ });
1221
+ };
1222
+ // A stated payload is delivered synchronously (a page can act in the same task); the
1223
+ // browser's payload-free event is delivered once the table has been read below.
1224
+ if (stated) deliver(stated);
1225
+ // Re-read the table either way and adopt what it says the active mode is. This is what
1226
+ // carries the 1-view -> 2-view RETURN: the browser presents a 1-view mode on top of the
1227
+ // runtime's unchanged 2-view mode, so when the page asks for the 2-view mode back the
1228
+ // runtime has no mode change to report and fires no renderingmodechange - only the
1229
+ // hardware moves. Read here, that return still restores the rig, and in the right order
1230
+ // (hardware back in 3D first, then the parallax comes back).
1231
+ // _getRenderingModes adopts the active mode (and moves the rig) itself; remember what was
1232
+ // active BEFORE the read so a change can still be told to the page afterwards.
1233
+ const prevIndex = this._activeModeIndex;
1234
+ const prevViews = this._activeViewCount;
1235
+ return this._getRenderingModes(null)
1236
+ .then((list) => {
1237
+ const active = (Array.isArray(list) ? list : []).find((m) => m.isActive) || null;
1238
+ if (active && (active.modeIndex !== prevIndex || active.viewCount !== prevViews)) {
1239
+ this._emitDisplay({
1240
+ type: 'renderingmodechange',
1241
+ modeIndex: this._activeModeIndex,
1242
+ viewCount: this._activeViewCount || null,
1243
+ mode: active,
1244
+ detail: null,
1245
+ });
1246
+ }
1247
+ if (!stated) {
1248
+ const read = active && typeof active.hardwareDisplay3D === 'boolean' ? (active.hardwareDisplay3D ? '3d' : '2d') : null;
1249
+ deliver(read);
1250
+ }
1251
+ })
1252
+ .catch(() => {
1253
+ // no live layer / no API: the stated payload already went out; a payload-free event is
1254
+ // still delivered with whatever was last known.
1255
+ if (!stated) deliver(null);
1256
+ });
1257
+ }
1258
+
1259
+ /**
1260
+ * A rendering mode went active. Two jobs, in this order: learn its VIEW COUNT (which is what
1261
+ * the rig collapse turns on, and which only the mode list carries), then tell the page.
1262
+ *
1263
+ * The list is re-read rather than trusted from cache — it is the runtime's, and a mode can
1264
+ * change under us — but the read is best-effort: with no live layer the index the event
1265
+ * carried is all there is, and the event is still worth delivering.
1266
+ */
1267
+ async _onRenderingModeChange(e) {
1268
+ const detail = eventDetail(e);
1269
+ const evIndex = eventModeIndex(e);
1270
+ let mode = null;
1271
+ try {
1272
+ const list = await this._getRenderingModes(null);
1273
+ const arr = Array.isArray(list) ? list : [];
1274
+ mode = (evIndex >= 0 ? arr.find((m) => m.modeIndex === evIndex) : null) || arr.find((m) => m.isActive) || null;
1275
+ } catch {
1276
+ /* no live layer / no API — fall through to what the event itself said */
1277
+ }
1278
+ if (mode) {
1279
+ this._activeModeIndex = mode.modeIndex;
1280
+ this._activeViewCount = mode.viewCount;
1281
+ } else if (evIndex >= 0) {
1282
+ this._activeModeIndex = evIndex;
1283
+ }
1284
+ // Only ever driven by a REPORTED view count. An unknown one (the read failed) leaves the rig
1285
+ // exactly as it is — half-collapsing on a guess is worse than being one event late.
1286
+ if (this._activeViewCount === 1 || this._activeViewCount === 2) {
1287
+ this._setStereoCollapsed(this._activeViewCount === 1);
1288
+ }
1289
+ this._emitDisplay({
1290
+ type: 'renderingmodechange',
1291
+ modeIndex: this._activeModeIndex,
1292
+ viewCount: this._activeViewCount || null,
1293
+ mode,
1294
+ detail,
1295
+ });
1296
+ }
1297
+
1298
+ /** Deliver one normalised display event to every listener. A throwing page handler is contained. */
1299
+ _emitDisplay(ev) {
1300
+ const set = this._displayListeners.get(ev.type);
1301
+ if (!set) return;
1302
+ for (const cb of [...set]) {
1303
+ try {
1304
+ cb(ev);
1305
+ } catch (err) {
1306
+ console.error(`[inline3d] ${ev.type} listener threw`, err);
1307
+ }
1308
+ }
1309
+ }
1310
+
1311
+ /**
1312
+ * Listen for `renderingmodechange` / `hardwaredisplaystatechange` on this manager. See the
1313
+ * handle's `on()` doc for the payload shapes. Returns an unsubscribe function.
1314
+ */
1315
+ on(type, cb) {
1316
+ if (typeof cb !== 'function') throw new TypeError('[inline3d] on() takes (type, function).');
1317
+ if (!DISPLAY_EVENTS.includes(type)) {
1318
+ throw new TypeError(
1319
+ `[inline3d] on() knows ${DISPLAY_EVENTS.join(' / ')}, got ${JSON.stringify(type)}.`
1320
+ );
1321
+ }
1322
+ let set = this._displayListeners.get(type);
1323
+ if (!set) this._displayListeners.set(type, (set = new Set()));
1324
+ set.add(cb);
1325
+ return () => this.off(type, cb);
1326
+ }
1327
+
1328
+ /** Drop a listener registered with {@link on}. */
1329
+ off(type, cb) {
1330
+ const set = this._displayListeners.get(type);
1331
+ if (set) set.delete(cb);
1332
+ }
1333
+
1334
+ /** Both display events through one callback — the older shape. Returns an unsubscribe. */
1335
+ _onDisplayModeChange(cb) {
1336
+ if (typeof cb !== 'function') {
1337
+ throw new TypeError('[inline3d] onDisplayModeChange() takes a function.');
1338
+ }
1339
+ const offs = DISPLAY_EVENTS.map((type) => this.on(type, cb));
1340
+ return () => {
1341
+ for (const off of offs) off();
1342
+ };
1343
+ }
1344
+
1345
+ // ── undock ────────────────────────────────────────────────────────────────────────────
1346
+
1347
+ /**
1348
+ * Re-read `layer.getUndockCapabilities()` into `wall.undock`.
1349
+ *
1350
+ * `undock` starts as `{model:false, splat:false}` on a browser that HAS the API, because the
1351
+ * capabilities can only be read off a live layer and there is none at create time — false is
1352
+ * the honest pre-read value ("not known to work"), and `null` is reserved for the thing a page
1353
+ * actually branches on: no `XRDisplayLayer.undock` at all. Called automatically on the first
1354
+ * layer activation; call it again whenever a page wants a fresh answer.
1355
+ *
1356
+ * @returns {Promise<{model:boolean, splat:boolean}|null>}
1357
+ */
1358
+ async refreshUndock() {
1359
+ if (!hasUndock()) return (this.undock = null);
1360
+ const win = this._liveWindow(null);
1361
+ if (!win || !win.layer || typeof win.layer.getUndockCapabilities !== 'function') {
1362
+ return this.undock;
1363
+ }
1364
+ try {
1365
+ const caps = await win.layer.getUndockCapabilities();
1366
+ this.undock = { model: !!(caps && caps.model), splat: !!(caps && caps.splat) };
1367
+ } catch {
1368
+ /* a refusal is not a capability change — keep the last answer */
1369
+ }
1370
+ return this.undock;
1371
+ }
1372
+
548
1373
  _register(canvas, kind, opts) {
549
1374
  if (this._windows.has(canvas)) this._remove(canvas);
550
1375
  // Own compositing layer: makes the canvas a distinct quad the weave can track. Harmless
@@ -565,6 +1390,18 @@ class Inline3D {
565
1390
  reqW: opts.width || 0,
566
1391
  reqH: opts.height || 0,
567
1392
  virtualDisplayHeight: opts.virtualDisplayHeight || 0,
1393
+ // The rig this window's NEXT layer is built with. Latest wins, and it is kept on the
1394
+ // window rather than only pushed at the live layer because the lazy lifecycle destroys
1395
+ // and rebuilds layers behind the page's back: a tile that scrolls away and back would
1396
+ // otherwise silently revert to the default display rig mid-scene.
1397
+ viewRig: opts.viewRig || null,
1398
+ // The automatic 1-view collapse (the latch itself is manager-wide — the mode is the
1399
+ // display's, not this window's). `stereoSaved` records the factors in force when the panel
1400
+ // went flat, for diagnostics; the restore just re-pushes `viewRig`, which the flattening
1401
+ // never touched. `stereoSynthRig` records that this window's LAYER has been handed an
1402
+ // explicit rig at least once, so coming back has to send the un-flat one explicitly.
1403
+ stereoSaved: null,
1404
+ stereoSynthRig: false,
568
1405
  observeEl: opts.observe || canvas,
569
1406
  ctx: kind === 'scene' ? null : canvas.getContext('2d'),
570
1407
  repaint: () => this._paint(win, null),
@@ -621,15 +1458,53 @@ class Inline3D {
621
1458
  // layers churn with scroll, so activations double as cheap rescan points.
622
1459
  this._scanChrome();
623
1460
  try {
624
- // virtualDisplayHeight (display-rig m2v) tells the runtime what scale this
625
- // window's scene is authored at, so it returns render-ready scaled views.
626
- const init =
627
- win.virtualDisplayHeight > 0 ? { virtualDisplayHeight: win.virtualDisplayHeight } : {};
1461
+ // What scale/pose the runtime should report views at. virtualDisplayHeight (display-rig
1462
+ // m2v) is the scalar shorthand; a viewRig is the whole descriptor and therefore replaces
1463
+ // it rather than combining with it. Building the layer WITH the rig (instead of
1464
+ // constructing then calling setViewRig) matters for a re-activated window: the layer's
1465
+ // very first located frame is already on the page's rig, so a tile scrolling back into
1466
+ // view never shows one frame of default framing.
1467
+ //
1468
+ // The rig is only offered to a browser that HAS rigs. An older one would take the init
1469
+ // object, find no member it knows, and fall back to its own default height — so a page
1470
+ // that passed both (a camera rig plus the height an older browser should use) would get
1471
+ // neither. Gating here is what makes that fallback pair actually work.
1472
+ //
1473
+ // _effectiveViewRig, not win.viewRig: while setStereoEnabled(false) is latched the rig is
1474
+ // the FLAT one, and a window with no rig of its own still gets one (the exact descriptor
1475
+ // equivalent of its virtualDisplayHeight, with the factors zeroed) — otherwise a tile that
1476
+ // scrolled away in 2D would rebuild itself in 3D behind the page's back.
1477
+ const rig = hasViewRig() ? this._effectiveViewRig(win) : null;
1478
+ const init = rig
1479
+ ? { viewRig: rig }
1480
+ : win.virtualDisplayHeight > 0
1481
+ ? { virtualDisplayHeight: win.virtualDisplayHeight }
1482
+ : {};
628
1483
  win.layer = new XRDisplayLayer(this.session, win.canvas, init);
629
1484
  } catch {
630
1485
  win.layer = null;
631
1486
  return;
632
1487
  }
1488
+ // Nothing about the hardware state is re-asserted here, and that is the point: the panel's
1489
+ // mode is the DISPLAY's, it survives a tile scrolling away, and this SDK never requests it
1490
+ // behind the page's back. The rig went into the init above already flattened if a 1-view
1491
+ // mode is active (_effectiveViewRig), which is the only half a new layer has to be told.
1492
+ //
1493
+ // FIRST LAYER, FIRST READ. The display's capabilities can only be read off a live layer, so
1494
+ // this is the earliest point the SDK can learn (a) which mode is active — a page can open
1495
+ // with the panel already flat, and the rig has to be collapsed for that too — and (b) what
1496
+ // this build can undock. Both best-effort and unawaited: they run inside the scroll-driven
1497
+ // activation path and must never break it.
1498
+ if (!this._primedDisplayState && hasDisplayModes()) {
1499
+ this._primedDisplayState = true; // one attempt per manager, not one per activation
1500
+ this._getRenderingModes(win).catch(() => {
1501
+ this._primedDisplayState = false; // the read failed; let the next activation try again
1502
+ });
1503
+ }
1504
+ if (hasUndock() && this.undock && !this._undockRead) {
1505
+ this._undockRead = true;
1506
+ this.refreshUndock().catch(() => {});
1507
+ }
633
1508
  // First real layer: if the occlusion capability is per-instance, this is the earliest point
634
1509
  // it can be read (see sampleDrawOrderOcclusion) — and if it says the browser occludes by
635
1510
  // draw order, retire whatever legacy machinery already started before we could know.
@@ -1184,6 +2059,9 @@ class Inline3D {
1184
2059
  }
1185
2060
  }
1186
2061
  this._windows.clear();
2062
+ // Page listeners go with the session that fed them: a manager whose session has ended will
2063
+ // never emit again, and holding the callbacks would keep the page's closures alive.
2064
+ this._displayListeners.clear();
1187
2065
  }
1188
2066
  }
1189
2067
 
@@ -1220,8 +2098,8 @@ function drawEye(ctx, src, sx, sy, sw, sh, dx, dy, dw, dh, radius, feather) {
1220
2098
 
1221
2099
  // Fade this EYE's outer edges to transparent, so the 3D window dissolves into the page
1222
2100
  // instead of ending at a hard rectangle. Same spirit as the runtime feathering a 3D zone's
1223
- // edge — but note that is the hardware WISH MASK (lens control, never content); this is the
1224
- // content-side equivalent, and the two are independent.
2101
+ // edge — but note that is the hardware WISH MASK (it drives the hardware display state, never
2102
+ // content); this is the content-side equivalent, and the two are independent.
1225
2103
  //
1226
2104
  // Per-eye, like cornerRadius, and for the same reason: the weave splits the element's rect
1227
2105
  // down the middle, so anything applied across the whole (side-by-side) buffer gets halved —