@displayxr/inline3d 0.0.1 → 1.1.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 ADDED
@@ -0,0 +1,1221 @@
1
+ // inline3d.js — the DisplayXR inline-3D SDK. Dependency-free.
2
+ //
3
+ // Turn any HTML <canvas> into a glasses-free-3D "window" on a DisplayXR display, inside an
4
+ // otherwise ordinary web page. One page, one WebXR session, MANY weaved windows — and any
5
+ // content:
6
+ // • a still side-by-side (SBS) 3D photo → wall.addImage(canvas, url)
7
+ // • an SBS 3D video / movie → wall.addVideo(canvas, videoEl)
8
+ // • a live-rendered stereo scene (three.js, WebGL) → wall.addScene(canvas, onFrame)
9
+ //
10
+ // The DisplayXR runtime batches every visible window into ONE weave call per frame, so a
11
+ // scrolling wall of many 3D windows stays cheap. This SDK keeps that easy: it owns the
12
+ // fiddly parts (the SBS buffer contract, correct feature-detection, the compositor-layer
13
+ // hint, and — for many windows — a lazy create/close lifecycle) so your page code is short.
14
+ //
15
+ // ── THE ONE CONTRACT ────────────────────────────────────────────────────────────────────
16
+ // A weaved window is a <canvas> whose BACKING BUFFER holds side-by-side stereo — the left
17
+ // eye in the left half, the right eye in the right half — while its on-screen CSS box is
18
+ // whatever shape you want the viewer to see. The weave un-squishes the two halves back onto
19
+ // the box. So a square 3D photo is a 2:1 buffer in a square box; a 16:9 3D movie is a 32:9
20
+ // buffer in a 16:9 box. addImage/addVideo maintain this for you; addScene hands you the two
21
+ // eye viewports and you render into them.
22
+ //
23
+ // On any non-DisplayXR browser (or a 2D monitor) createInline3D() resolves to
24
+ // { supported:false } and your page shows its normal 2D content — inline-3D is progressive
25
+ // enhancement, never a hard dependency.
26
+
27
+ // The document's single live manager. The browser's per-frame element-rect report is a
28
+ // WHOLE-WIDGET setter — each live session pushes the complete list of rects to weave — so two
29
+ // managers in one document overwrite each other frame by frame and neither one's tiles hold
30
+ // still. Tracked here only to warn: sequential sessions (a route change that closes one
31
+ // manager and opens the next) are legitimate and the common case, so nothing is refused.
32
+ let liveManager = null;
33
+
34
+ const hasWebXR = () => typeof navigator !== 'undefined' && !!navigator.xr;
35
+ const hasLayer = () =>
36
+ typeof window !== 'undefined' && typeof window.XRDisplayLayer === 'function';
37
+ // Overlay exclusion (browser#18): 2D DOM painted OVER a weaved window (hover plates,
38
+ // badges) would otherwise be woven along with the content and come out garbled. Browsers
39
+ // with XRDisplayLayer.excludeElement punch a per-pixel 2D hole in the weave there
40
+ // (final = M·weave + (1−M)·2D, M=0 inside the overlay rect). Older browsers: silent
41
+ // no-op — the page still works, the overlay just weaves like before.
42
+ const hasExclusion = () =>
43
+ hasLayer() && 'excludeElement' in window.XRDisplayLayer.prototype;
44
+ // ── draw-order occlusion (browser Phase 2, browser patches 0063/0064) ─────────────────────
45
+ //
46
+ // The browser composites ANY 2D content over a woven tile per-pixel BY DRAW ORDER — headers,
47
+ // badges, dropdowns, translucent scrims, even a full-tile plate — with nothing declared by the
48
+ // page. Every exclusion mechanism in this file (auto-chrome, page-global overlays,
49
+ // data-inline3d-overlay, handle.exclude) exists only to fake that on browsers without it, so
50
+ // where it is on, all of it stands down.
51
+ //
52
+ // DETECTION IS A CAPABILITY READ, NEVER A VERSION. `excludeElement` is still fully present on a
53
+ // Phase-2 browser (the browser change is viz-side; it touches no Blink file, and the declarations
54
+ // are collected as before and simply have no effect downstream), so `hasExclusion()` cannot tell
55
+ // the generations apart — and a UA/version gate is worthless for a page that pins an SDK for
56
+ // years. The signal is a capability flag the browser exposes; ABSENT today, so this reads false
57
+ // on everything currently shipping and the legacy path below runs unchanged.
58
+ //
59
+ // TWO THINGS THE FLAG'S SHAPE HAS TO RESPECT, both learned the hard way:
60
+ // 1. NEVER read the value off `XRDisplayLayer.prototype`. A Blink IDL attribute getter throws
61
+ // `TypeError: Illegal invocation` when its receiver is the prototype instead of an instance,
62
+ // so the "obvious" probe `!!XRDisplayLayer.prototype.occlusionByDrawOrder` would THROW on
63
+ // precisely the browser it is meant to detect. `'x' in prototype` is safe (no getter call)
64
+ // but reports only presence.
65
+ // 2. Presence is not the answer. The browser's split is switch-gated
66
+ // (`--inline-3d-occlusion`, off by default until it becomes the default), so a build that
67
+ // HAS the attribute can still legitimately report false, and the truth is process-wide
68
+ // rather than per-layer.
69
+ // Hence the shape asked of the browser: a STATIC readonly boolean on the interface object,
70
+ // `XRDisplayLayer.occlusionByDrawOrder` — process-wide like the switch it reflects, readable
71
+ // with no session and no layer (a page decides its DOM before it ever creates one), and immune
72
+ // to (1) because there is no prototype receiver involved. Should the flag instead land as a
73
+ // per-instance attribute, sampleDrawOrderOcclusion() below picks it up off the first live layer.
74
+ let drawOrderOcclusion = null; // null = not decided yet for this document
75
+ function hasDrawOrderOcclusion() {
76
+ if (drawOrderOcclusion !== null) return drawOrderOcclusion;
77
+ if (!hasLayer()) return false;
78
+ try {
79
+ const v = window.XRDisplayLayer.occlusionByDrawOrder;
80
+ if (typeof v === 'boolean') return (drawOrderOcclusion = v); // static flag: authoritative
81
+ } catch {
82
+ /* a capability flag that throws is no capability — fall through to the instance path */
83
+ }
84
+ return false; // undecided reads as false: the legacy path is the safe default
85
+ }
86
+
87
+ /**
88
+ * Per-instance fallback: read the flag off a real layer, once, if that is the shape it landed
89
+ * in. Presence is probed on the prototype with `in` (safe) and the VALUE is read from the
90
+ * instance (the only legal receiver). Returns the decided value, or null if the browser exposes
91
+ * no flag at all — in which case nothing is cached and the legacy path stays on.
92
+ */
93
+ function sampleDrawOrderOcclusion(layer) {
94
+ if (drawOrderOcclusion !== null) return drawOrderOcclusion;
95
+ if (!layer || !hasLayer()) return null;
96
+ if (!('occlusionByDrawOrder' in window.XRDisplayLayer.prototype)) return null;
97
+ try {
98
+ return (drawOrderOcclusion = !!layer.occlusionByDrawOrder);
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ // The "you don't need this any more" notice, at most once per document: the legacy calls stay
105
+ // live API (they must, so one page runs on both generations), so this is not a warning — it is
106
+ // the one line that stops an author debugging an exclusion that is correctly doing nothing.
107
+ let notedAutomaticOcclusion = false;
108
+ function noteAutomaticOcclusion() {
109
+ if (notedAutomaticOcclusion) return;
110
+ notedAutomaticOcclusion = true;
111
+ console.info(
112
+ '[inline3d] This browser composites 2D over woven 3D automatically, per-pixel by draw ' +
113
+ 'order — overlay exclusion is obsolete here, so exclude()/addGlobalOverlay()/' +
114
+ 'data-inline3d-overlay/autoChrome are accepted and ignored. Your 2D chrome already ' +
115
+ 'occludes the tiles correctly. The calls are harmless (keep them if you also ship to ' +
116
+ 'older DisplayXR Browsers); gate on inline3dOcclusionByDrawOrder() to drop them.'
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Cheap, synchronous "can this browser even attempt inline-3D?" gate — true only in the
122
+ * DisplayXR Browser with the feature enabled. Use it to decide page UI up front.
123
+ *
124
+ * It deliberately does NOT call navigator.xr.isSessionSupported('inline-3d'): that is an
125
+ * async round-trip to the OS weave service which resolves FALSE if it runs before the
126
+ * service has bound (typically at page load), a false-negative that silently drops you to
127
+ * 2D. The authoritative signal is whether createInline3D() actually acquires a session.
128
+ */
129
+ export function inline3DAvailable() {
130
+ return hasWebXR() && hasLayer();
131
+ }
132
+
133
+ /**
134
+ * True when 2D painted ON a woven tile (hover plate, badge, sticky header) composites as
135
+ * crisp 2D over the woven 3D instead of being woven — by declaration (browser#18 overlay
136
+ * exclusion) or, on a newer browser, automatically. Use it to choose the on-image overlay
137
+ * path when available and a weave-safe fallback (e.g. a caption band below the tile)
138
+ * otherwise. That question has the same answer on both generations, so this stays true on a
139
+ * draw-order-occlusion browser; ask inline3dOcclusionByDrawOrder() when you need to know
140
+ * WHICH mechanism you are on. Implies inline3DAvailable(). Sync + cheap.
141
+ */
142
+ export function inline3dOverlaySupported() {
143
+ return hasExclusion() || hasDrawOrderOcclusion();
144
+ }
145
+
146
+ /**
147
+ * True when the browser occludes woven tiles with 2D content AUTOMATICALLY — any 2D that
148
+ * paints over a tile (header, badge, dropdown, translucent scrim) composites per-pixel by
149
+ * draw order, with nothing declared. When true, this SDK's exclusion machinery is off:
150
+ * `autoChrome` does not scan, `data-inline3d-overlay` is not watched, and
151
+ * `exclude()`/`addGlobalOverlay()` are accepted (so one page runs on both generations) but do
152
+ * nothing — including the `will-change` promotion they used to force on your elements.
153
+ *
154
+ * Pages need not branch on this at all: the legacy calls are harmless where it is true, and
155
+ * still required where it is false. Branch only to skip work of your own — a `data-` attribute
156
+ * you would otherwise maintain, a full-tile plate the legacy path has to refuse, or a
157
+ * near-solid background you only keep because a translucent bar used to be risky.
158
+ *
159
+ * Sync + cheap. Reads a readonly capability flag on `XRDisplayLayer`, never a version or UA
160
+ * string; false on every browser that has not exposed the flag, which is the safe answer (the
161
+ * SDK then runs the legacy exclusion path, which is what such a browser needs).
162
+ *
163
+ * One caveat if the flag lands as a per-layer attribute rather than the static one this SDK asks
164
+ * for: it can only be read once a layer exists, so a call made before the first window activates
165
+ * answers false and the same call answers true a frame later. Nothing in the SDK depends on the
166
+ * early answer, but a page that wants to branch its DOM up front should re-check (or just leave
167
+ * the legacy calls in — they are harmless).
168
+ */
169
+ export function inline3dOcclusionByDrawOrder() {
170
+ return hasDrawOrderOcclusion();
171
+ }
172
+
173
+ /**
174
+ * Open the page's inline-3D session and return a manager you add windows to.
175
+ *
176
+ * @param {object} [opts]
177
+ * @param {string} [opts.referenceSpace='viewer'] WebXR reference space for the eye poses.
178
+ * @param {boolean} [opts.lazy=true] Create each window's weave layer only while it is
179
+ * (near-)visible and close it when it scrolls away — so a long wall only pays for
180
+ * what's on screen. Set false for a single always-on element.
181
+ * @param {string} [opts.rootMargin='50% 0px'] IntersectionObserver margin for lazy mode;
182
+ * the default pre-arms a window half a viewport early so a fast scroll never shows a
183
+ * raw (un-woven) frame.
184
+ * @param {boolean} [opts.autoChrome=true] Auto-exclude page chrome: sticky/fixed elements
185
+ * near the top of the DOM (headers, toolbars) are registered as page-global overlays
186
+ * automatically — the bar itself plus its text/replaced descendants — so woven
187
+ * windows scroll UNDER the chrome without any per-app wiring. Opt an element (and
188
+ * its subtree) out with `data-inline3d-no-overlay`; set false to manage chrome
189
+ * exclusively via addGlobalOverlay()/data-inline3d-overlay. Ignored (nothing is
190
+ * scanned, no `will-change` is set on your DOM) on a browser with draw-order
191
+ * occlusion, where chrome occludes tiles by itself.
192
+ * @returns {Promise<Inline3D | {supported:false, error?:Error}>}
193
+ */
194
+ export async function createInline3D(opts = {}) {
195
+ const { referenceSpace = 'viewer', lazy = true, rootMargin = '50% 0px', autoChrome = true } = opts;
196
+ if (!inline3DAvailable()) return { supported: false };
197
+ let session;
198
+ try {
199
+ // requestSession is Blink-local and resolves immediately when the feature is present —
200
+ // the correct detection path (see inline3DAvailable's note on isSessionSupported).
201
+ session = await navigator.xr.requestSession('inline-3d');
202
+ } catch (e) {
203
+ return { supported: false, error: e };
204
+ }
205
+ let refSpace = null;
206
+ try {
207
+ refSpace = await session.requestReferenceSpace(referenceSpace);
208
+ } catch {
209
+ /* rAF still fires without a ref space; views are just null (fine for image/video). */
210
+ }
211
+ return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome });
212
+ }
213
+
214
+ /**
215
+ * Back-compatible single-scene helper: open a session, weave one canvas, drive a render
216
+ * callback with the two eye views each frame. Equivalent to
217
+ * createInline3D({lazy:false}) → addScene(canvas, onFrame).
218
+ * Returns { supported, close() } (plus the manager as .wall) or { supported:false }.
219
+ */
220
+ export async function startInline3D(
221
+ canvas,
222
+ { onFrame, referenceSpace = 'viewer', virtualDisplayHeight = 0.24 } = {}
223
+ ) {
224
+ const wall = await createInline3D({ referenceSpace, lazy: false });
225
+ if (!wall.supported) return wall;
226
+ // Forward the scene-scale knob: without it addScene's default applies, and a caller who
227
+ // authored for a different virtual display size has no way to say so.
228
+ wall.addScene(canvas, onFrame, { virtualDisplayHeight });
229
+ return { supported: true, wall, session: wall.session, close: () => wall.close() };
230
+ }
231
+
232
+ // Mutual rect-overlap fraction at which an overlay becomes indistinguishable from the canvas
233
+ // it sits on — the same >=70% the browser's own layer matcher uses. See _isFullTileOverlay.
234
+ const FULL_TILE_OVERLAP = 0.7;
235
+
236
+ /**
237
+ * Elements inside `root` worth their own overlay plate: anything with a direct
238
+ * non-whitespace text node, plus replaced/painted elements (img, svg, video,
239
+ * canvas, form controls). See _scanChrome for why chrome text is plated
240
+ * per-element instead of relying on the bar's own raster (browser#83).
241
+ */
242
+ const CHROME_REPLACED = new Set(['IMG', 'SVG', 'VIDEO', 'CANVAS', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']);
243
+ function chromeTextPlates(root) {
244
+ const plates = [];
245
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
246
+ for (let el = walker.nextNode(); el; el = walker.nextNode()) {
247
+ if (el.closest('[data-inline3d-no-overlay]')) continue;
248
+ if (CHROME_REPLACED.has(el.tagName.toUpperCase())) {
249
+ plates.push(el);
250
+ continue;
251
+ }
252
+ for (const child of el.childNodes) {
253
+ if (child.nodeType === Node.TEXT_NODE && child.nodeValue.trim()) {
254
+ plates.push(el);
255
+ break;
256
+ }
257
+ }
258
+ }
259
+ return plates;
260
+ }
261
+
262
+ class Inline3D {
263
+ constructor(session, refSpace, { lazy, rootMargin, autoChrome = true }) {
264
+ this.supported = true;
265
+ this.session = session;
266
+ this.refSpace = refSpace;
267
+ this._windows = new Map(); // canvas -> window record
268
+ this._globalOverlays = new Set(); // page-global overlays excluded from EVERY window
269
+ // el -> Set(window) currently excluding it. Isolation (will-change) is a GLOBAL
270
+ // property of the element while exclusion is PER-WINDOW, so the promotion has to
271
+ // be reference-counted: without this the first window to drop an element
272
+ // un-promotes it while other windows still need it isolated, and the element
273
+ // silently falls back into the canvas layer (→ it lands in that tile's SBS weave
274
+ // input and gets woven). Page-global overlays span many windows, so they are
275
+ // exactly the case that breaks.
276
+ this._isolatedBy = new WeakMap();
277
+ // Auto-chrome (sticky/fixed page furniture found by _scanChrome). Tracked apart
278
+ // from _globalOverlays so pruning disconnected chrome never touches overlays the
279
+ // app registered itself.
280
+ this._autoChrome = autoChrome;
281
+ this._autoChromeEls = new Set();
282
+ this._lastChromeScan = 0;
283
+ // Elements already reported as full-tile overlays, so the refusal is logged once per
284
+ // element instead of on every re-activate / overlay-scan sync.
285
+ this._fullTileWarned = new WeakSet();
286
+ // Set once the legacy occlusion machinery has been retired (draw-order browser whose
287
+ // capability flag could only be read from a live layer). See _standDownLegacyOcclusion.
288
+ this._stoodDown = false;
289
+ this._running = true;
290
+ this._lazy = lazy;
291
+ this._observer =
292
+ lazy && typeof IntersectionObserver === 'function'
293
+ ? new IntersectionObserver((entries) => this._onIntersect(entries), { rootMargin })
294
+ : null;
295
+ // Frame-loop bookkeeping: one loop, identified, so a restart can retire a stalled
296
+ // predecessor instead of running two (see _requestFrame / _watchForStalledFrames).
297
+ this._loopId = 0;
298
+ this._framePending = false;
299
+ this._frameCount = 0;
300
+ this._frameWatchdog = null;
301
+ this._suspended = null;
302
+ if (liveManager && liveManager._running && liveManager !== this) {
303
+ console.warn(
304
+ '[inline3d] A second inline-3D session is live in this document. The browser\'s ' +
305
+ 'element-rect channel is a whole-widget setter, so both managers clobber each ' +
306
+ "other's rect list every frame — tiles may flicker, ghost, or weave at a stale " +
307
+ 'rect. Use ONE createInline3D() per document and add every window to it; if you ' +
308
+ 'are switching views, close() the previous manager first.'
309
+ );
310
+ }
311
+ liveManager = this;
312
+ session.addEventListener('end', () => this._teardown());
313
+ this._scanChrome(); // page chrome usually exists before the session does
314
+ this._bindLifecycle();
315
+ this._armDprWatch();
316
+ this._requestFrame();
317
+ }
318
+
319
+ /** Number of windows whose weave layer is currently live (on-screen in lazy mode). */
320
+ get liveCount() {
321
+ let n = 0;
322
+ for (const w of this._windows.values()) if (w.layer) n++;
323
+ return n;
324
+ }
325
+
326
+ /**
327
+ * Register a PAGE-GLOBAL 2D overlay (a fixed/sticky header, a floating toolbar) —
328
+ * an element that lives OUTSIDE any tile's container and can overlap MANY tiles as
329
+ * they scroll under it. It's excluded from every window's weave (current and future),
330
+ * re-applied automatically whenever a lazy window re-activates, so you register it ONCE
331
+ * instead of calling handle.exclude(el) per tile (which races window lifecycles).
332
+ *
333
+ * Note (browser#18, pre-#22): this keeps the element out of each tile's SBS weave input,
334
+ * but the per-tile present can still seam page-global chrome that spans tile gaps during
335
+ * scroll — the systematic fix is the DP-composited whole-window present (browser#22).
336
+ * No-op on browsers without excludeElement (progressive enhancement).
337
+ *
338
+ * @deprecated on a browser with draw-order occlusion (inline3dOcclusionByDrawOrder()):
339
+ * page chrome occludes every tile there with nothing registered. The call is accepted and
340
+ * stored, does nothing, and stays required on older browsers — so keep it unless your page
341
+ * targets Phase-2 browsers only.
342
+ */
343
+ addGlobalOverlay(el) {
344
+ if (!el || this._globalOverlays.has(el)) return;
345
+ // Store it even where occlusion is automatic: the registration is API, a page may read
346
+ // nothing back but must be able to run unchanged on both browser generations. The
347
+ // exclusion below is a no-op there (see _applyExclusion).
348
+ if (hasDrawOrderOcclusion()) noteAutomaticOcclusion();
349
+ this._globalOverlays.add(el);
350
+ for (const win of this._windows.values()) if (win.layer) this._applyExclusion(win, el);
351
+ }
352
+
353
+ /** Stop treating `el` as a page-global overlay and drop it from every live window. */
354
+ removeGlobalOverlay(el) {
355
+ if (!el || !this._globalOverlays.delete(el)) return;
356
+ for (const win of this._windows.values()) if (win.layer) this._dropExclusion(win, el);
357
+ }
358
+
359
+ /**
360
+ * Auto-chrome scan: find sticky/fixed page furniture and register it as page-global
361
+ * overlays, no app wiring required. Runs at session start and again on every layer
362
+ * activation (throttled) so late-mounted chrome is picked up as tiles churn.
363
+ *
364
+ * Two deliberate choices:
365
+ * - SHALLOW scan (top 3 DOM levels under <body>): page chrome lives there; a deep
366
+ * sticky element (a table header inside a scroller) is content, not chrome.
367
+ * - Besides the chrome element itself, its TEXT / replaced descendants are registered
368
+ * individually (browser#83): the browser re-composites an excluded element by
369
+ * geometrically matching its rect to a composited-layer quad (>=70% area overlap),
370
+ * and a full-width bar can raster as several cc tile quads — each a fraction of the
371
+ * bar's rect, so none match and the bar never stages. A near-solid bar hides that
372
+ * failure everywhere except its text (a uniform color weaves to itself). The small
373
+ * per-text plates each promote to their own layer and match ~1:1, closing the
374
+ * visible failure regardless of how the bar rasters.
375
+ *
376
+ * Opt-out: `data-inline3d-no-overlay` on an element skips it and its whole subtree.
377
+ * Elements containing a woven window are never plated (that would hand the weave
378
+ * input back to the compositor as crisp 2D).
379
+ */
380
+ _scanChrome() {
381
+ // Draw-order occlusion makes this whole scan pointless work: the chrome already occludes
382
+ // every tile per-pixel. Bail BEFORE the DOM walk, so a Phase-2 page pays neither the
383
+ // querySelectorAll + getComputedStyle sweep (once a second, at every layer activation)
384
+ // nor the `will-change` promotions it would hand out across the page's furniture.
385
+ if (hasDrawOrderOcclusion()) return;
386
+ if (!this._autoChrome || !hasExclusion() || typeof document === 'undefined') return;
387
+ const now = Date.now();
388
+ if (now - this._lastChromeScan < 1000) return; // activations burst during scroll
389
+ this._lastChromeScan = now;
390
+ const body = document.body;
391
+ if (!body) return;
392
+ const found = new Set();
393
+ const candidates = body.querySelectorAll(':scope > *, :scope > * > *, :scope > * > * > *');
394
+ for (const el of candidates) {
395
+ if (el.closest('[data-inline3d-no-overlay]')) continue;
396
+ const pos = getComputedStyle(el).position;
397
+ if (pos !== 'fixed' && pos !== 'sticky') continue;
398
+ let containsWindow = false;
399
+ for (const canvas of this._windows.keys()) {
400
+ if (el === canvas || el.contains(canvas)) {
401
+ containsWindow = true;
402
+ break;
403
+ }
404
+ }
405
+ if (containsWindow) continue;
406
+ found.add(el);
407
+ for (const plate of chromeTextPlates(el)) found.add(plate);
408
+ }
409
+ // Prune auto-registrations that (a) left the document, or (b) NOW contain a woven
410
+ // window (windows register after the constructor's first scan — leaving such a
411
+ // wrapper plated would hand the tile back to the compositor as crisp 2D). Connected,
412
+ // window-free elements are left alone even when no longer detected: a still-connected
413
+ // element may also have been registered by the app, and _globalOverlays is one set —
414
+ // never yank something the app might be counting on.
415
+ for (const el of this._autoChromeEls) {
416
+ let containsWindow = false;
417
+ if (el.isConnected) {
418
+ for (const canvas of this._windows.keys()) {
419
+ if (el === canvas || el.contains(canvas)) {
420
+ containsWindow = true;
421
+ break;
422
+ }
423
+ }
424
+ }
425
+ if (!el.isConnected || containsWindow) {
426
+ this._autoChromeEls.delete(el);
427
+ this.removeGlobalOverlay(el);
428
+ }
429
+ }
430
+ for (const el of found) {
431
+ if (!this._globalOverlays.has(el)) {
432
+ this._autoChromeEls.add(el);
433
+ this.addGlobalOverlay(el);
434
+ }
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Weave a still side-by-side 3D image into `canvas`.
440
+ * @param {HTMLCanvasElement} canvas a 2D canvas; the SDK owns its backing buffer.
441
+ * @param {string|HTMLImageElement|ImageBitmap|HTMLCanvasElement} source full SBS content
442
+ * (left eye = left half). A URL string is loaded for you.
443
+ * @param {object} [opts]
444
+ * @param {number} [opts.width] [opts.height] per-eye buffer resolution in px; defaults to
445
+ * the canvas's CSS box size × devicePixelRatio (so the box shape sets the aspect).
446
+ * @param {number} [opts.cornerRadius=0] round each eye's corners in buffer px (CSS
447
+ * border-radius can't: it would round the packed SBS square's outer corners and
448
+ * come out lopsided after the eye-split).
449
+ * @param {number} [opts.feather=0] fade each eye's outer edges to transparent over this
450
+ * many buffer px, so the 3D window dissolves into the page instead of ending at a
451
+ * hard rectangle. Same reason CSS can't do it: a mask/filter on the canvas applies
452
+ * across the packed SBS pair, so each eye would get an inner fade along the split
453
+ * line and only half its outer edge.
454
+ * @returns {{remove():void}}
455
+ */
456
+ addImage(canvas, source, opts = {}) {
457
+ const win = this._register(canvas, 'image', opts);
458
+ win.ready = loadImage(source).then((img) => {
459
+ win.img = img;
460
+ win.repaint();
461
+ });
462
+ return this._handle(canvas, win);
463
+ }
464
+
465
+ /**
466
+ * Weave a playing SBS 3D video into `canvas` (redrawn every frame while visible).
467
+ * @param {HTMLCanvasElement} canvas a 2D canvas; the SDK owns its backing buffer.
468
+ * @param {HTMLVideoElement} video a full-SBS 3D video, already play()-ing (left = left).
469
+ * @param {object} [opts] same width/height/cornerRadius as addImage.
470
+ * @returns {{remove():void}}
471
+ */
472
+ addVideo(canvas, video, opts = {}) {
473
+ const win = this._register(canvas, 'video', opts);
474
+ win.video = video;
475
+ return this._handle(canvas, win);
476
+ }
477
+
478
+ /**
479
+ * Weave a live-rendered stereo scene into `canvas`. YOU own the canvas (its size, its
480
+ * WebGL/2D context); the SDK only creates the weave layer and calls you each frame with
481
+ * the two eye views. Render each view into `layer.getViewport(view)` (an {x,y,width,
482
+ * height} into the canvas) using `view.projectionMatrix` + `view.transform.matrix`.
483
+ * See inline3d-three.js for three.js glue (camera + element-scale helpers).
484
+ * The session reports per-eye off-axis (Kooima) views already scaled to your scene by
485
+ * `virtualDisplayHeight` (the display-rig m2v knob): author your scene in metres for a
486
+ * display that tall, put focused content at z=0, and render the views DIRECTLY — the
487
+ * runtime owns the projection AND the scale, so there is no per-frame world scaling in
488
+ * your app.
489
+ * @param {HTMLCanvasElement} canvas
490
+ * @param {(views:XRView[], layer:XRDisplayLayer, frame:XRFrame)=>void} onFrame
491
+ * @param {object} [opts]
492
+ * @param {number} [opts.virtualDisplayHeight=0.24] metres of virtual display the scene is
493
+ * composed for. Larger = the element shows a bigger slice of the world.
494
+ * @param {Element} [opts.observe=canvas] element whose visibility gates lazy create/close.
495
+ * @returns {{remove():void}}
496
+ */
497
+ addScene(canvas, onFrame, opts = {}) {
498
+ const win = this._register(canvas, 'scene', { virtualDisplayHeight: 0.24, ...opts });
499
+ win.onFrame = onFrame;
500
+ win.ownsBuffer = false; // the app sizes a scene canvas; we never touch canvas.width/height
501
+ return this._handle(canvas, win);
502
+ }
503
+
504
+ /**
505
+ * The handle every add*() returns. `exclude(el)` marks 2D DOM painted over this window
506
+ * (a hover plate, a play badge) so the weave leaves it crisp 2D instead of garbling it
507
+ * (browser#18). Queued if the layer isn't live yet (lazy mode) and re-applied on every
508
+ * re-activate; a browser without excludeElement silently ignores it (the overlay weaves
509
+ * like before — progressive enhancement, like the rest of this SDK). Prefer the
510
+ * declarative `data-inline3d-overlay` attribute (see _startOverlayScan) unless you need
511
+ * to exclude an element outside the window's container.
512
+ *
513
+ * On a browser with draw-order occlusion the overlay is already composited over the woven
514
+ * 3D per-pixel, so exclude()/unexclude() are stored-and-ignored (see _applyExclusion).
515
+ */
516
+ _handle(canvas, win) {
517
+ return {
518
+ remove: () => this._remove(canvas),
519
+ exclude: (el) => {
520
+ if (!el) return;
521
+ // Stored, not honoured, where occlusion is automatic — same reason as
522
+ // addGlobalOverlay: one page, both browser generations.
523
+ if (hasDrawOrderOcclusion()) noteAutomaticOcclusion();
524
+ win.excluded.add(el);
525
+ this._applyExclusion(win, el);
526
+ },
527
+ unexclude: (el) => {
528
+ if (!el || !win.excluded.delete(el)) return;
529
+ this._dropExclusion(win, el);
530
+ },
531
+ };
532
+ }
533
+
534
+ close() {
535
+ try {
536
+ this.session.end();
537
+ } catch {
538
+ /* end() also fires our 'end' handler → _teardown */
539
+ }
540
+ this._teardown();
541
+ }
542
+
543
+ // ── internals ───────────────────────────────────────────────────────────────────────
544
+
545
+ _register(canvas, kind, opts) {
546
+ if (this._windows.has(canvas)) this._remove(canvas);
547
+ // Own compositing layer: makes the canvas a distinct quad the weave can track. Harmless
548
+ // when the compositor would have promoted it anyway.
549
+ canvas.style.willChange = 'transform';
550
+ canvas.style.transform = 'translateZ(0)';
551
+ const win = {
552
+ canvas,
553
+ kind,
554
+ layer: null,
555
+ img: null,
556
+ video: null,
557
+ onFrame: null,
558
+ ready: null,
559
+ ownsBuffer: kind !== 'scene',
560
+ cornerRadius: opts.cornerRadius || 0,
561
+ feather: opts.feather || 0,
562
+ reqW: opts.width || 0,
563
+ reqH: opts.height || 0,
564
+ virtualDisplayHeight: opts.virtualDisplayHeight || 0,
565
+ observeEl: opts.observe || canvas,
566
+ ctx: kind === 'scene' ? null : canvas.getContext('2d'),
567
+ repaint: () => this._paint(win, null),
568
+ // Overlay exclusion (browser#18): explicit handle.exclude() elements and
569
+ // [data-inline3d-overlay] descendants found by the auto-scan. Applied to the
570
+ // layer on every (re-)activate; the browser clears its own set on layer close.
571
+ excluded: new Set(),
572
+ autoExcluded: new Set(),
573
+ overlayObserver: null,
574
+ // Box/dpr watch, live only while the window is (see _startSizeWatch).
575
+ sizeObserver: null,
576
+ resizePending: false,
577
+ };
578
+ this._windows.set(canvas, win);
579
+ if (this._lazy && this._observer) {
580
+ this._observer.observe(win.observeEl);
581
+ } else {
582
+ this._activate(win);
583
+ }
584
+ return win;
585
+ }
586
+
587
+ _remove(canvas) {
588
+ const win = this._windows.get(canvas);
589
+ if (!win) return;
590
+ if (this._observer) this._observer.unobserve(win.observeEl);
591
+ this._deactivate(win);
592
+ this._windows.delete(canvas);
593
+ }
594
+
595
+ _onIntersect(entries) {
596
+ for (const e of entries) {
597
+ // The observed element may be a wrapper; find the window it belongs to.
598
+ let win = null;
599
+ for (const w of this._windows.values()) {
600
+ if (w.observeEl === e.target) {
601
+ win = w;
602
+ break;
603
+ }
604
+ }
605
+ if (!win) continue;
606
+ if (e.isIntersecting) this._activate(win);
607
+ else this._deactivate(win);
608
+ }
609
+ }
610
+
611
+ _activate(win) {
612
+ if (win.layer) return;
613
+ // Pick up page chrome (incl. late-mounted) before the exclusion loop below —
614
+ // layers churn with scroll, so activations double as cheap rescan points.
615
+ this._scanChrome();
616
+ try {
617
+ // virtualDisplayHeight (display-rig m2v) tells the runtime what scale this
618
+ // window's scene is authored at, so it returns render-ready scaled views.
619
+ const init =
620
+ win.virtualDisplayHeight > 0 ? { virtualDisplayHeight: win.virtualDisplayHeight } : {};
621
+ win.layer = new XRDisplayLayer(this.session, win.canvas, init);
622
+ } catch {
623
+ win.layer = null;
624
+ return;
625
+ }
626
+ // First real layer: if the occlusion capability is per-instance, this is the earliest point
627
+ // it can be read (see sampleDrawOrderOcclusion) — and if it says the browser occludes by
628
+ // draw order, retire whatever legacy machinery already started before we could know.
629
+ if (sampleDrawOrderOcclusion(win.layer) === true) this._standDownLegacyOcclusion();
630
+ // Re-apply overlay exclusions (browser#18): the browser's layer-side set died with
631
+ // the previous layer (lazy close), so a re-activated window must re-declare its own
632
+ // explicit exclusions, the page-global overlays, and the attribute-scanned overlays,
633
+ // then resume watching for changes.
634
+ for (const el of win.excluded) this._applyExclusion(win, el);
635
+ for (const el of this._globalOverlays) this._applyExclusion(win, el);
636
+ this._startOverlayScan(win);
637
+ if (win.ownsBuffer) {
638
+ this._sizeBuffer(win, /*sbs*/ true);
639
+ this._paint(win, null); // first SBS paint (video will refresh each frame)
640
+ }
641
+ this._startSizeWatch(win);
642
+ }
643
+
644
+ _deactivate(win) {
645
+ this._stopOverlayScan(win);
646
+ this._stopSizeWatch(win);
647
+ if (win.layer) {
648
+ try {
649
+ win.layer.close();
650
+ } catch {
651
+ /* already closed */
652
+ }
653
+ win.layer = null;
654
+ }
655
+ // Leave a flat (left-eye-only) frame so an off-screen image/video still shows 2D.
656
+ if (win.ownsBuffer && win.kind !== 'scene') {
657
+ this._sizeBuffer(win, /*sbs*/ false);
658
+ this._paint(win, null);
659
+ }
660
+ }
661
+
662
+ // ── overlay exclusion (browser#18) ─────────────────────────────────────────────────
663
+
664
+ /**
665
+ * Refuse a FULL-TILE overlay — an element whose rect is (near-)congruent with its own
666
+ * window's canvas.
667
+ *
668
+ * The browser re-composites an excluded element by geometrically matching its rect to a
669
+ * composited-layer quad (>=70% area overlap, see _scanChrome). A plate that covers the whole
670
+ * tile matches the tile's OWN canvas quad, so the CANVAS gets staged as the overlay: it
671
+ * leaves the weave input entirely and the tile presents its raw side-by-side buffer —
672
+ * squished halves, no 3D. That is a destroyed tile, not a degraded one, so skip the
673
+ * exclusion and say why instead of honouring it.
674
+ *
675
+ * The test is MUTUAL (>=70% of both rects) so page-global chrome stays legal: a sticky
676
+ * header may cover a small tile completely, but the tile is a small fraction of the header,
677
+ * so the header never looks congruent with any one canvas.
678
+ *
679
+ * Limit: it judges the rect it can measure now. A plate that is display:none at
680
+ * registration measures empty (and excluding it is harmless while hidden), so a plate that
681
+ * only becomes full-tile once shown slips through — the authoring rule stands on its own
682
+ * (docs/authoring-inline-3d.md § 2D overlays ON a 3D window).
683
+ */
684
+ _isFullTileOverlay(win, el) {
685
+ if (!el || typeof el.getBoundingClientRect !== 'function') return false;
686
+ const e = el.getBoundingClientRect();
687
+ const c = win.canvas.getBoundingClientRect();
688
+ const eArea = e.width * e.height;
689
+ const cArea = c.width * c.height;
690
+ if (eArea <= 0 || cArea <= 0) return false; // hidden / detached — nothing to judge
691
+ const iw = Math.min(e.right, c.right) - Math.max(e.left, c.left);
692
+ const ih = Math.min(e.bottom, c.bottom) - Math.max(e.top, c.top);
693
+ if (iw <= 0 || ih <= 0) return false;
694
+ const inter = iw * ih;
695
+ if (inter / eArea < FULL_TILE_OVERLAP || inter / cArea < FULL_TILE_OVERLAP) return false;
696
+ if (!this._fullTileWarned.has(el)) {
697
+ this._fullTileWarned.add(el);
698
+ console.warn(
699
+ '[inline3d] Refusing a full-tile overlay: this element covers its own woven canvas, ' +
700
+ "and the browser's geometric matcher cannot tell the two apart — it would stage " +
701
+ 'the CANVAS as the overlay and the tile would show its raw side-by-side buffer ' +
702
+ 'instead of 3D. Make the overlay a PARTIAL region of the tile (a caption band, a ' +
703
+ 'badge, a corner plate), or move it outside the tile and register it with ' +
704
+ 'addGlobalOverlay().',
705
+ el
706
+ );
707
+ }
708
+ return true;
709
+ }
710
+
711
+ _applyExclusion(win, el) {
712
+ // Automatic occlusion: nothing to declare, and nothing to promote. Returning here (before
713
+ // the full-tile guard) is also why a full-tile plate is legal on such a browser — there is
714
+ // no geometric matcher to confuse, so there is no refusal and no warning.
715
+ if (hasDrawOrderOcclusion()) return;
716
+ if (!win.layer || !hasExclusion()) return;
717
+ if (this._isFullTileOverlay(win, el)) return;
718
+ // Force the overlay onto its OWN composited layer so the browser can grab it
719
+ // as an isolated resource (the element rastered on transparency) and
720
+ // composite it OVER the woven 3D — final = plate + (1−plate.a)·woven, true
721
+ // 2D-over-3D. `will-change: transform` reliably promotes to a compositing
722
+ // layer even in the single-render-pass weave config (a CSS filter does NOT —
723
+ // its render surface is flattened away there). Remember we set it so
724
+ // unexclude can restore.
725
+ let refs = this._isolatedBy.get(el);
726
+ if (!refs) {
727
+ refs = new Set();
728
+ this._isolatedBy.set(el, refs);
729
+ }
730
+ refs.add(win);
731
+ if (!el.dataset.inline3dIsolated) {
732
+ el.dataset.inline3dPriorWillChange = el.style.willChange || '';
733
+ const wc = el.style.willChange && el.style.willChange !== 'auto'
734
+ ? el.style.willChange + ', transform'
735
+ : 'transform';
736
+ el.style.willChange = wc;
737
+ el.dataset.inline3dIsolated = '1';
738
+ }
739
+ try {
740
+ win.layer.excludeElement(el);
741
+ } catch {
742
+ /* closed layer / detached element — the per-frame report drops empties anyway */
743
+ }
744
+ }
745
+
746
+ _dropExclusion(win, el) {
747
+ // Nothing was ever excluded or promoted, so there is nothing to undo — and in particular
748
+ // this must not touch the element's `will-change`, which is the page's own here.
749
+ if (hasDrawOrderOcclusion()) return;
750
+ const refs = this._isolatedBy.get(el);
751
+ if (refs) refs.delete(win);
752
+ // Only un-promote once NO window needs this element isolated any more.
753
+ if (!refs || refs.size === 0) this._unpromote(el);
754
+ if (!win.layer || !hasExclusion()) return;
755
+ try {
756
+ win.layer.unexcludeElement(el);
757
+ } catch {
758
+ /* ignore */
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Undo the SDK's own compositing promotion on `el`, restoring the `will-change` the page had
764
+ * (which may be none). Only ever touches an element the SDK promoted — the marker dataset
765
+ * flag is what says so. Returns whether there was anything to undo.
766
+ */
767
+ _unpromote(el) {
768
+ if (!el || !el.dataset || !el.dataset.inline3dIsolated) return false;
769
+ el.style.willChange = el.dataset.inline3dPriorWillChange || '';
770
+ delete el.dataset.inline3dPriorWillChange;
771
+ delete el.dataset.inline3dIsolated;
772
+ return true;
773
+ }
774
+
775
+ /**
776
+ * Retire the legacy occlusion machinery, once, on learning the browser occludes by draw order.
777
+ *
778
+ * Only reachable when the capability could not be read until the first layer existed (the
779
+ * per-instance flag shape): by then one auto-chrome scan may have run and promoted page
780
+ * furniture. Where the flag is readable up front — the shape this SDK asks for — nothing has
781
+ * started and this finds nothing to do.
782
+ *
783
+ * Registrations the APP made are kept as API state (its `removeGlobalOverlay(el)` must still
784
+ * find `el` registered); only the SDK's own side effects on the page's DOM are reversed. The
785
+ * auto-chrome set is dropped entirely: it was never the app's, and nothing will re-add it.
786
+ */
787
+ _standDownLegacyOcclusion() {
788
+ if (this._stoodDown) return;
789
+ this._stoodDown = true;
790
+ let retired = false;
791
+ for (const win of this._windows.values()) {
792
+ if (win.overlayObserver) {
793
+ this._stopOverlayScan(win);
794
+ retired = true;
795
+ }
796
+ for (const el of win.excluded) if (this._unpromote(el)) retired = true;
797
+ }
798
+ for (const el of this._autoChromeEls) {
799
+ this._globalOverlays.delete(el);
800
+ this._unpromote(el);
801
+ retired = true;
802
+ }
803
+ this._autoChromeEls.clear();
804
+ for (const el of this._globalOverlays) if (this._unpromote(el)) retired = true;
805
+ this._isolatedBy = new WeakMap(); // every promotion is gone; the refcounts with them
806
+ if (retired) noteAutomaticOcclusion(); // only worth saying if work was actually thrown away
807
+ }
808
+
809
+ // Declarative overlays: any element marked `data-inline3d-overlay` inside the window's
810
+ // container (the canvas's parent — where an over-the-window plate must live to be
811
+ // positioned over it) is auto-excluded while the window is live, and tracked through
812
+ // add/remove/toggle by one MutationObserver per active window. Hidden overlays cost
813
+ // nothing: a display:none element reports an empty rect browser-side, so show/hide of a
814
+ // hover plate needs no attribute churn — mark it once, toggle `display` freely. (Hide
815
+ // with display, not opacity/visibility: those still report a full rect, so the weave
816
+ // hole would stay punched under an invisible plate.)
817
+ _startOverlayScan(win) {
818
+ // No observer at all where occlusion is automatic: `data-inline3d-overlay` needs no
819
+ // honouring, so a page keeps its attributes (harmless, portable) and pays no
820
+ // MutationObserver per live tile.
821
+ if (hasDrawOrderOcclusion()) return;
822
+ if (!hasExclusion() || typeof MutationObserver !== 'function') return;
823
+ const container = win.canvas.parentElement;
824
+ if (!container) return;
825
+ const sync = () => {
826
+ const marked = new Set(container.querySelectorAll('[data-inline3d-overlay]'));
827
+ for (const el of win.autoExcluded) {
828
+ if (!marked.has(el)) {
829
+ win.autoExcluded.delete(el);
830
+ this._dropExclusion(win, el);
831
+ }
832
+ }
833
+ for (const el of marked) {
834
+ if (!win.autoExcluded.has(el)) {
835
+ win.autoExcluded.add(el);
836
+ this._applyExclusion(win, el);
837
+ }
838
+ }
839
+ };
840
+ sync();
841
+ win.overlayObserver = new MutationObserver(sync);
842
+ win.overlayObserver.observe(container, {
843
+ childList: true,
844
+ subtree: true,
845
+ attributes: true,
846
+ attributeFilter: ['data-inline3d-overlay'],
847
+ });
848
+ }
849
+
850
+ _stopOverlayScan(win) {
851
+ if (win.overlayObserver) {
852
+ win.overlayObserver.disconnect();
853
+ win.overlayObserver = null;
854
+ }
855
+ // The browser clears the layer-side set on close; mirror that so a re-activate
856
+ // re-scans from scratch (the container's overlays may have changed while dark).
857
+ win.autoExcluded.clear();
858
+ }
859
+
860
+ /** The per-eye buffer size this window should have right now (explicit, or box × dpr). */
861
+ _eyeSize(win) {
862
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
863
+ return {
864
+ w: win.reqW || Math.round((win.canvas.clientWidth || 256) * dpr),
865
+ h: win.reqH || Math.round((win.canvas.clientHeight || 256) * dpr),
866
+ };
867
+ }
868
+
869
+ _sizeBuffer(win, sbs) {
870
+ const { w: boxW, h: boxH } = this._eyeSize(win);
871
+ win.eyeW = boxW;
872
+ win.eyeH = boxH;
873
+ win.canvas.width = sbs ? boxW * 2 : boxW; // SBS = two eye tiles wide
874
+ win.canvas.height = boxH;
875
+ win.sbs = sbs;
876
+ }
877
+
878
+ // ── box / devicePixelRatio changes ──────────────────────────────────────────────────
879
+ //
880
+ // _sizeBuffer runs at activate and deactivate ONLY, so a live window whose CSS box or
881
+ // devicePixelRatio changes underneath it keeps its old backing store: the same SBS pixels
882
+ // are stretched onto a differently-shaped box and the two eyes come out mis-squeezed, with
883
+ // no error, until the tile happens to re-activate. A responsive reflow, a flex sibling
884
+ // appearing, a browser zoom or a drag to a different-scale monitor all do it. So: watch the
885
+ // box while the window is live, and re-derive the buffer when it actually moves.
886
+
887
+ _startSizeWatch(win) {
888
+ if (typeof ResizeObserver !== 'function') return;
889
+ if (win.sizeObserver) return;
890
+ // Scene canvases are the app's (ownsBuffer false) — never touch their width/height.
891
+ // An explicit {width, height} is box-independent by definition, so nothing to watch.
892
+ if (!win.ownsBuffer || (win.reqW && win.reqH)) return;
893
+ win.sizeObserver = new ResizeObserver(() => this._onBoxChange(win));
894
+ win.sizeObserver.observe(win.canvas);
895
+ }
896
+
897
+ _stopSizeWatch(win) {
898
+ if (win.sizeObserver) {
899
+ win.sizeObserver.disconnect();
900
+ win.sizeObserver = null;
901
+ }
902
+ win.resizePending = false;
903
+ }
904
+
905
+ /**
906
+ * Re-derive one live window's SBS buffer and repaint it. Debounced to one animation frame:
907
+ * ResizeObserver and a dpr flip both fire in bursts during a drag-resize or a zoom, and
908
+ * every resize reallocates the backing store and clears it.
909
+ */
910
+ _onBoxChange(win) {
911
+ if (!win.layer || !win.ownsBuffer || win.resizePending) return;
912
+ win.resizePending = true;
913
+ const run = () => {
914
+ if (!win.resizePending) return;
915
+ win.resizePending = false;
916
+ if (!win.layer || !win.ownsBuffer) return;
917
+ const { w, h } = this._eyeSize(win);
918
+ if (w === win.eyeW && h === win.eyeH) return; // observer fired, geometry didn't move
919
+ this._sizeBuffer(win, /*sbs*/ true);
920
+ this._paint(win, null); // repaint NOW: setting canvas.width cleared the buffer
921
+ };
922
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
923
+ else run();
924
+ }
925
+
926
+ /**
927
+ * devicePixelRatio is invisible to ResizeObserver — a browser zoom or a move to a
928
+ * different-scale monitor leaves the CSS box the same number of CSS px while the buffer
929
+ * that box deserves changes. A `(resolution: Ndppx)` query flips exactly when dpr leaves
930
+ * its current value, so arm one, and re-arm it on the new value each time.
931
+ */
932
+ _armDprWatch() {
933
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
934
+ this._disarmDprWatch();
935
+ let q;
936
+ try {
937
+ q = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
938
+ } catch {
939
+ return; // no resolution-query support: box changes are still covered
940
+ }
941
+ const onChange = () => {
942
+ if (!this._running) return;
943
+ this._armDprWatch(); // this query is stale the moment it fires
944
+ for (const win of this._windows.values()) if (win.layer) this._onBoxChange(win);
945
+ };
946
+ try {
947
+ q.addEventListener('change', onChange);
948
+ } catch {
949
+ return;
950
+ }
951
+ this._dprWatch = { q, onChange };
952
+ }
953
+
954
+ _disarmDprWatch() {
955
+ if (!this._dprWatch) return;
956
+ try {
957
+ this._dprWatch.q.removeEventListener('change', this._dprWatch.onChange);
958
+ } catch {
959
+ /* ignore */
960
+ }
961
+ this._dprWatch = null;
962
+ }
963
+
964
+ _paint(win, _views) {
965
+ if (win.kind === 'scene' || !win.ctx) return;
966
+ const src = win.kind === 'video' ? win.video : win.img;
967
+ if (!src) return;
968
+ if (win.kind === 'video' && (src.readyState || 0) < 2) return; // no frame yet
969
+ const c = win.canvas;
970
+ const ctx = win.ctx;
971
+ const srcW = src.videoWidth || src.naturalWidth || src.width;
972
+ const srcH = src.videoHeight || src.naturalHeight || src.height;
973
+ if (!srcW || !srcH) return;
974
+ ctx.clearRect(0, 0, c.width, c.height);
975
+ if (!win.sbs) {
976
+ // Flat fallback: left eye only, stretched to the square buffer.
977
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, c.width, c.height, win.cornerRadius, win.feather);
978
+ return;
979
+ }
980
+ const halfDst = c.width / 2;
981
+ // A single stretched draw maps SBS source → SBS buffer (left→left, right→right); the
982
+ // per-eye path is only needed to bake decoration (rounded corners / edge feather), which
983
+ // MUST be applied to each eye separately — see drawEye/featherEye.
984
+ if (win.cornerRadius > 0 || win.feather > 0) {
985
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, win.cornerRadius, win.feather); // L
986
+ drawEye(ctx, src, srcW / 2, 0, srcW / 2, srcH, halfDst, 0, halfDst, c.height, win.cornerRadius, win.feather); // R
987
+ } else {
988
+ ctx.drawImage(src, 0, 0, srcW, srcH, 0, 0, c.width, c.height);
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Arm the next session frame. `force` starts a NEW loop even though one is nominally
994
+ * pending: each loop carries an id and only the current id re-arms, so a stalled
995
+ * predecessor (a bfcache restore whose callback never fired) is retired rather than
996
+ * doubled if it ever does fire.
997
+ */
998
+ _requestFrame(force) {
999
+ if (!this._running) return;
1000
+ if (this._framePending && !force) return;
1001
+ const id = force ? ++this._loopId : this._loopId;
1002
+ this._framePending = true;
1003
+ try {
1004
+ this.session.requestAnimationFrame((t, f) => {
1005
+ if (id !== this._loopId) return; // superseded loop — let it die here
1006
+ this._framePending = false;
1007
+ this._frameCount++;
1008
+ this._frame(t, f);
1009
+ });
1010
+ } catch {
1011
+ this._framePending = false; // session going away; 'end' → _teardown handles it
1012
+ }
1013
+ }
1014
+
1015
+ _frame(t, f) {
1016
+ if (!this._running) return;
1017
+ this._requestFrame();
1018
+ const pose = this.refSpace ? f.getViewerPose(this.refSpace) : null;
1019
+ const views = pose ? pose.views : null;
1020
+ for (const win of this._windows.values()) {
1021
+ if (!win.layer) continue;
1022
+ if (win.kind === 'scene') {
1023
+ if (views && win.onFrame) win.onFrame(views, win.layer, f);
1024
+ } else {
1025
+ // Repaint image AND video every frame. The weave reads each window's
1026
+ // composited canvas quad per frame; a canvas that isn't redrawn can have
1027
+ // its layer dropped from the aggregated frame, so the weave reads a stale
1028
+ // sub-rect and the window flickers to a horizontal smear. A still image's
1029
+ // redraw is one cheap GPU drawImage — keep it live.
1030
+ this._paint(win, views);
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // ── page lifecycle: bfcache, freeze, restore (browser#87) ───────────────────────────
1036
+ //
1037
+ // A weaved window's rect reaches the compositor from the session's own rAF: every frame the
1038
+ // live session pushes the full list of rects to weave, and the ONLY way to clear a rect is
1039
+ // to push a list without it. So a page that simply stops running frames leaves its last
1040
+ // list standing — the rects keep weaving over whatever is on screen now. Back/forward
1041
+ // navigation does exactly that: bfcache freezes the page mid-loop, the woven tiles stay
1042
+ // pinned where they were, and the next page inherits ghost 3D windows (browser#87).
1043
+ //
1044
+ // The fix is to make the LAST frames before suspension report an empty list: deactivate
1045
+ // every live window while frames still run, remember which ones were live, and restore them
1046
+ // on the way back. pagehide covers bfcache entry and unload; freeze covers a discarded
1047
+ // background tab where pagehide does not fire.
1048
+
1049
+ _bindLifecycle() {
1050
+ if (typeof window === 'undefined') return;
1051
+ this._onPageHide = () => this._suspend();
1052
+ this._onPageShow = (e) => this._resume(!!(e && e.persisted));
1053
+ window.addEventListener('pagehide', this._onPageHide);
1054
+ window.addEventListener('pageshow', this._onPageShow);
1055
+ // Page Lifecycle API (Blink): a frozen tab never gets pagehide/pageshow.
1056
+ if (typeof document !== 'undefined' && 'onfreeze' in document) {
1057
+ this._onFreeze = () => this._suspend();
1058
+ this._onResume = () => this._resume(true);
1059
+ document.addEventListener('freeze', this._onFreeze);
1060
+ document.addEventListener('resume', this._onResume);
1061
+ }
1062
+ }
1063
+
1064
+ _unbindLifecycle() {
1065
+ if (typeof window === 'undefined') return;
1066
+ if (this._onPageHide) window.removeEventListener('pagehide', this._onPageHide);
1067
+ if (this._onPageShow) window.removeEventListener('pageshow', this._onPageShow);
1068
+ if (this._onFreeze && typeof document !== 'undefined') {
1069
+ document.removeEventListener('freeze', this._onFreeze);
1070
+ document.removeEventListener('resume', this._onResume);
1071
+ }
1072
+ this._onPageHide = this._onPageShow = this._onFreeze = this._onResume = null;
1073
+ if (this._frameWatchdog) {
1074
+ clearTimeout(this._frameWatchdog);
1075
+ this._frameWatchdog = null;
1076
+ }
1077
+ }
1078
+
1079
+ /** Close every live layer so the outgoing frames report an empty rect list. */
1080
+ _suspend() {
1081
+ if (!this._running || this._suspended) return;
1082
+ const was = [];
1083
+ for (const win of this._windows.values()) {
1084
+ if (win.layer) {
1085
+ was.push(win);
1086
+ this._deactivate(win);
1087
+ }
1088
+ }
1089
+ this._suspended = was;
1090
+ }
1091
+
1092
+ /**
1093
+ * Coming back: re-arm the windows that were live. In lazy mode the IntersectionObserver
1094
+ * owns that decision, and re-observing re-delivers the CURRENT intersection state — so a
1095
+ * tile the user scrolled away from before leaving stays dark, and only what is actually on
1096
+ * screen re-weaves. Chrome is rescanned because a restored page may have remounted it.
1097
+ */
1098
+ _resume(persisted) {
1099
+ if (!this._running) return;
1100
+ const was = this._suspended;
1101
+ this._suspended = null;
1102
+ if (was) {
1103
+ for (const win of was) {
1104
+ if (!this._windows.has(win.canvas)) continue; // removed while we were away
1105
+ if (this._lazy && this._observer) {
1106
+ this._observer.unobserve(win.observeEl);
1107
+ this._observer.observe(win.observeEl);
1108
+ } else {
1109
+ this._activate(win);
1110
+ }
1111
+ }
1112
+ }
1113
+ this._lastChromeScan = 0; // the 1 s throttle must not swallow the restore rescan
1114
+ this._scanChrome();
1115
+ this._armDprWatch(); // the restore may be on a different-scale display
1116
+ if (persisted) this._watchForStalledFrames();
1117
+ }
1118
+
1119
+ /**
1120
+ * A bfcache restore can hand back a session whose pending animation frame never arrives —
1121
+ * the loop was suspended between request and callback, and nothing re-issues it. The
1122
+ * manager then looks alive (`_running`) while no window ever paints again. Give it a second
1123
+ * to prove otherwise, then start a fresh loop (which retires the stalled one by id).
1124
+ */
1125
+ _watchForStalledFrames() {
1126
+ if (this._frameWatchdog || typeof setTimeout !== 'function') return;
1127
+ const before = this._frameCount;
1128
+ this._frameWatchdog = setTimeout(() => {
1129
+ this._frameWatchdog = null;
1130
+ if (!this._running || this._frameCount !== before) return; // frames arrived
1131
+ this._requestFrame(/*force*/ true);
1132
+ }, 1000);
1133
+ }
1134
+
1135
+ _teardown() {
1136
+ if (!this._running) return;
1137
+ this._running = false;
1138
+ if (liveManager === this) liveManager = null;
1139
+ this._unbindLifecycle();
1140
+ this._disarmDprWatch();
1141
+ if (this._observer) this._observer.disconnect();
1142
+ for (const win of this._windows.values()) {
1143
+ this._stopOverlayScan(win);
1144
+ this._stopSizeWatch(win);
1145
+ if (win.layer) {
1146
+ try {
1147
+ win.layer.close();
1148
+ } catch {
1149
+ /* ignore */
1150
+ }
1151
+ win.layer = null;
1152
+ }
1153
+ }
1154
+ this._windows.clear();
1155
+ }
1156
+ }
1157
+
1158
+ // ── small helpers ─────────────────────────────────────────────────────────────────────
1159
+
1160
+ function loadImage(source) {
1161
+ if (typeof source !== 'string') return Promise.resolve(source); // element/bitmap/canvas
1162
+ return new Promise((resolve, reject) => {
1163
+ const img = new Image();
1164
+ img.decoding = 'async';
1165
+ img.onload = () => resolve(img);
1166
+ img.onerror = reject;
1167
+ img.src = source;
1168
+ });
1169
+ }
1170
+
1171
+ // Draw one eye region with optional baked rounded corners. Corners are left transparent so
1172
+ // the canvas's page background shows through (as a CSS radius would have).
1173
+ function drawEye(ctx, src, sx, sy, sw, sh, dx, dy, dw, dh, radius, feather) {
1174
+ if (radius > 0 && ctx.roundRect) {
1175
+ ctx.save();
1176
+ ctx.beginPath();
1177
+ ctx.roundRect(dx, dy, dw, dh, radius);
1178
+ ctx.clip();
1179
+ ctx.drawImage(src, sx, sy, sw, sh, dx, dy, dw, dh);
1180
+ ctx.restore();
1181
+ } else {
1182
+ ctx.drawImage(src, sx, sy, sw, sh, dx, dy, dw, dh);
1183
+ }
1184
+ if (feather > 0) {
1185
+ featherEye(ctx, dx, dy, dw, dh, feather);
1186
+ }
1187
+ }
1188
+
1189
+ // Fade this EYE's outer edges to transparent, so the 3D window dissolves into the page
1190
+ // instead of ending at a hard rectangle. Same spirit as the runtime feathering a 3D zone's
1191
+ // edge — but note that is the hardware WISH MASK (lens control, never content); this is the
1192
+ // content-side equivalent, and the two are independent.
1193
+ //
1194
+ // Per-eye, like cornerRadius, and for the same reason: the weave splits the element's rect
1195
+ // down the middle, so anything applied across the whole (side-by-side) buffer gets halved —
1196
+ // each eye would get an inner fade along the split line that must not exist, and only half
1197
+ // its outer edge. A CSS mask/filter on the canvas has exactly that bug.
1198
+ //
1199
+ // destination-out with an alpha ramp erases toward transparent, so it works on top of
1200
+ // whatever was just drawn (image, video frame) without knowing the content.
1201
+ function featherEye(ctx, x, y, w, h, px) {
1202
+ const f = Math.min(px, Math.floor(Math.min(w, h) / 2));
1203
+ if (f <= 0) return;
1204
+ ctx.save();
1205
+ ctx.globalCompositeOperation = 'destination-out';
1206
+ const edges = [
1207
+ // [x, y, w, h, gradient-from, gradient-to]
1208
+ [x, y, w, f, [x, y], [x, y + f]], // top
1209
+ [x, y + h - f, w, f, [x, y + h], [x, y + h - f]], // bottom
1210
+ [x, y, f, h, [x, y], [x + f, y]], // left
1211
+ [x + w - f, y, f, h, [x + w, y], [x + w - f, y]], // right
1212
+ ];
1213
+ for (const [ex, ey, ew, eh, from, to] of edges) {
1214
+ const g = ctx.createLinearGradient(from[0], from[1], to[0], to[1]);
1215
+ g.addColorStop(0, 'rgba(0,0,0,1)'); // fully erased at the outer edge
1216
+ g.addColorStop(1, 'rgba(0,0,0,0)'); // untouched inside
1217
+ ctx.fillStyle = g;
1218
+ ctx.fillRect(ex, ey, ew, eh);
1219
+ }
1220
+ ctx.restore();
1221
+ }