@displayxr/inline3d 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/inline3d.js CHANGED
@@ -24,6 +24,13 @@
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
+ // 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
+
27
34
  const hasWebXR = () => typeof navigator !== 'undefined' && !!navigator.xr;
28
35
  const hasLayer = () =>
29
36
  typeof window !== 'undefined' && typeof window.XRDisplayLayer === 'function';
@@ -34,6 +41,81 @@ const hasLayer = () =>
34
41
  // no-op — the page still works, the overlay just weaves like before.
35
42
  const hasExclusion = () =>
36
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
+ }
37
119
 
38
120
  /**
39
121
  * Cheap, synchronous "can this browser even attempt inline-3D?" gate — true only in the
@@ -49,14 +131,43 @@ export function inline3DAvailable() {
49
131
  }
50
132
 
51
133
  /**
52
- * True when this browser supports 2D-overlay exclusion (browser#18) putting a
53
- * 2D element ON a woven tile (hover plate, badge) so it composites as crisp 2D
54
- * over the woven 3D instead of being woven. Use it to choose the on-image
55
- * overlay path when available and a weave-safe fallback (e.g. a caption band
56
- * below the tile) otherwise. Implies inline3DAvailable(). Sync + cheap.
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.
57
141
  */
58
142
  export function inline3dOverlaySupported() {
59
- return hasExclusion();
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();
60
171
  }
61
172
 
62
173
  /**
@@ -70,10 +181,18 @@ export function inline3dOverlaySupported() {
70
181
  * @param {string} [opts.rootMargin='50% 0px'] IntersectionObserver margin for lazy mode;
71
182
  * the default pre-arms a window half a viewport early so a fast scroll never shows a
72
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.
73
192
  * @returns {Promise<Inline3D | {supported:false, error?:Error}>}
74
193
  */
75
194
  export async function createInline3D(opts = {}) {
76
- const { referenceSpace = 'viewer', lazy = true, rootMargin = '50% 0px' } = opts;
195
+ const { referenceSpace = 'viewer', lazy = true, rootMargin = '50% 0px', autoChrome = true } = opts;
77
196
  if (!inline3DAvailable()) return { supported: false };
78
197
  let session;
79
198
  try {
@@ -89,7 +208,7 @@ export async function createInline3D(opts = {}) {
89
208
  } catch {
90
209
  /* rAF still fires without a ref space; views are just null (fine for image/video). */
91
210
  }
92
- return new Inline3D(session, refSpace, { lazy, rootMargin });
211
+ return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome });
93
212
  }
94
213
 
95
214
  /**
@@ -110,8 +229,38 @@ export async function startInline3D(
110
229
  return { supported: true, wall, session: wall.session, close: () => wall.close() };
111
230
  }
112
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
+
113
262
  class Inline3D {
114
- constructor(session, refSpace, { lazy, rootMargin }) {
263
+ constructor(session, refSpace, { lazy, rootMargin, autoChrome = true }) {
115
264
  this.supported = true;
116
265
  this.session = session;
117
266
  this.refSpace = refSpace;
@@ -125,14 +274,46 @@ class Inline3D {
125
274
  // input and gets woven). Page-global overlays span many windows, so they are
126
275
  // exactly the case that breaks.
127
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;
128
289
  this._running = true;
129
290
  this._lazy = lazy;
130
291
  this._observer =
131
292
  lazy && typeof IntersectionObserver === 'function'
132
293
  ? new IntersectionObserver((entries) => this._onIntersect(entries), { rootMargin })
133
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;
134
312
  session.addEventListener('end', () => this._teardown());
135
- session.requestAnimationFrame((t, f) => this._frame(t, f));
313
+ this._scanChrome(); // page chrome usually exists before the session does
314
+ this._bindLifecycle();
315
+ this._armDprWatch();
316
+ this._requestFrame();
136
317
  }
137
318
 
138
319
  /** Number of windows whose weave layer is currently live (on-screen in lazy mode). */
@@ -153,9 +334,18 @@ class Inline3D {
153
334
  * but the per-tile present can still seam page-global chrome that spans tile gaps during
154
335
  * scroll — the systematic fix is the DP-composited whole-window present (browser#22).
155
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.
156
342
  */
157
343
  addGlobalOverlay(el) {
158
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();
159
349
  this._globalOverlays.add(el);
160
350
  for (const win of this._windows.values()) if (win.layer) this._applyExclusion(win, el);
161
351
  }
@@ -166,6 +356,85 @@ class Inline3D {
166
356
  for (const win of this._windows.values()) if (win.layer) this._dropExclusion(win, el);
167
357
  }
168
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
+
169
438
  /**
170
439
  * Weave a still side-by-side 3D image into `canvas`.
171
440
  * @param {HTMLCanvasElement} canvas a 2D canvas; the SDK owns its backing buffer.
@@ -240,12 +509,18 @@ class Inline3D {
240
509
  * like before — progressive enhancement, like the rest of this SDK). Prefer the
241
510
  * declarative `data-inline3d-overlay` attribute (see _startOverlayScan) unless you need
242
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).
243
515
  */
244
516
  _handle(canvas, win) {
245
517
  return {
246
518
  remove: () => this._remove(canvas),
247
519
  exclude: (el) => {
248
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();
249
524
  win.excluded.add(el);
250
525
  this._applyExclusion(win, el);
251
526
  },
@@ -253,6 +528,9 @@ class Inline3D {
253
528
  if (!el || !win.excluded.delete(el)) return;
254
529
  this._dropExclusion(win, el);
255
530
  },
531
+ // Read-only counters, for pages that want to see the load-induced mono fallback rather
532
+ // than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere.
533
+ stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }),
256
534
  };
257
535
  }
258
536
 
@@ -296,6 +574,13 @@ class Inline3D {
296
574
  excluded: new Set(),
297
575
  autoExcluded: new Set(),
298
576
  overlayObserver: null,
577
+ // Box/dpr watch, live only while the window is (see _startSizeWatch).
578
+ sizeObserver: null,
579
+ resizePending: false,
580
+ // Scene diagnostics (web#12), read back through the handle's stats(). frames counts
581
+ // onFrame deliveries; monoFrames counts the ones that carried fewer than two views.
582
+ frames: 0,
583
+ monoFrames: 0,
299
584
  };
300
585
  this._windows.set(canvas, win);
301
586
  if (this._lazy && this._observer) {
@@ -332,6 +617,9 @@ class Inline3D {
332
617
 
333
618
  _activate(win) {
334
619
  if (win.layer) return;
620
+ // Pick up page chrome (incl. late-mounted) before the exclusion loop below —
621
+ // layers churn with scroll, so activations double as cheap rescan points.
622
+ this._scanChrome();
335
623
  try {
336
624
  // virtualDisplayHeight (display-rig m2v) tells the runtime what scale this
337
625
  // window's scene is authored at, so it returns render-ready scaled views.
@@ -342,6 +630,10 @@ class Inline3D {
342
630
  win.layer = null;
343
631
  return;
344
632
  }
633
+ // First real layer: if the occlusion capability is per-instance, this is the earliest point
634
+ // it can be read (see sampleDrawOrderOcclusion) — and if it says the browser occludes by
635
+ // draw order, retire whatever legacy machinery already started before we could know.
636
+ if (sampleDrawOrderOcclusion(win.layer) === true) this._standDownLegacyOcclusion();
345
637
  // Re-apply overlay exclusions (browser#18): the browser's layer-side set died with
346
638
  // the previous layer (lazy close), so a re-activated window must re-declare its own
347
639
  // explicit exclusions, the page-global overlays, and the attribute-scanned overlays,
@@ -353,10 +645,12 @@ class Inline3D {
353
645
  this._sizeBuffer(win, /*sbs*/ true);
354
646
  this._paint(win, null); // first SBS paint (video will refresh each frame)
355
647
  }
648
+ this._startSizeWatch(win);
356
649
  }
357
650
 
358
651
  _deactivate(win) {
359
652
  this._stopOverlayScan(win);
653
+ this._stopSizeWatch(win);
360
654
  if (win.layer) {
361
655
  try {
362
656
  win.layer.close();
@@ -374,8 +668,60 @@ class Inline3D {
374
668
 
375
669
  // ── overlay exclusion (browser#18) ─────────────────────────────────────────────────
376
670
 
671
+ /**
672
+ * Refuse a FULL-TILE overlay — an element whose rect is (near-)congruent with its own
673
+ * window's canvas.
674
+ *
675
+ * The browser re-composites an excluded element by geometrically matching its rect to a
676
+ * composited-layer quad (>=70% area overlap, see _scanChrome). A plate that covers the whole
677
+ * tile matches the tile's OWN canvas quad, so the CANVAS gets staged as the overlay: it
678
+ * leaves the weave input entirely and the tile presents its raw side-by-side buffer —
679
+ * squished halves, no 3D. That is a destroyed tile, not a degraded one, so skip the
680
+ * exclusion and say why instead of honouring it.
681
+ *
682
+ * The test is MUTUAL (>=70% of both rects) so page-global chrome stays legal: a sticky
683
+ * header may cover a small tile completely, but the tile is a small fraction of the header,
684
+ * so the header never looks congruent with any one canvas.
685
+ *
686
+ * Limit: it judges the rect it can measure now. A plate that is display:none at
687
+ * registration measures empty (and excluding it is harmless while hidden), so a plate that
688
+ * only becomes full-tile once shown slips through — the authoring rule stands on its own
689
+ * (docs/authoring-inline-3d.md § 2D overlays ON a 3D window).
690
+ */
691
+ _isFullTileOverlay(win, el) {
692
+ if (!el || typeof el.getBoundingClientRect !== 'function') return false;
693
+ const e = el.getBoundingClientRect();
694
+ const c = win.canvas.getBoundingClientRect();
695
+ const eArea = e.width * e.height;
696
+ const cArea = c.width * c.height;
697
+ if (eArea <= 0 || cArea <= 0) return false; // hidden / detached — nothing to judge
698
+ const iw = Math.min(e.right, c.right) - Math.max(e.left, c.left);
699
+ const ih = Math.min(e.bottom, c.bottom) - Math.max(e.top, c.top);
700
+ if (iw <= 0 || ih <= 0) return false;
701
+ const inter = iw * ih;
702
+ if (inter / eArea < FULL_TILE_OVERLAP || inter / cArea < FULL_TILE_OVERLAP) return false;
703
+ if (!this._fullTileWarned.has(el)) {
704
+ this._fullTileWarned.add(el);
705
+ console.warn(
706
+ '[inline3d] Refusing a full-tile overlay: this element covers its own woven canvas, ' +
707
+ "and the browser's geometric matcher cannot tell the two apart — it would stage " +
708
+ 'the CANVAS as the overlay and the tile would show its raw side-by-side buffer ' +
709
+ 'instead of 3D. Make the overlay a PARTIAL region of the tile (a caption band, a ' +
710
+ 'badge, a corner plate), or move it outside the tile and register it with ' +
711
+ 'addGlobalOverlay().',
712
+ el
713
+ );
714
+ }
715
+ return true;
716
+ }
717
+
377
718
  _applyExclusion(win, el) {
719
+ // Automatic occlusion: nothing to declare, and nothing to promote. Returning here (before
720
+ // the full-tile guard) is also why a full-tile plate is legal on such a browser — there is
721
+ // no geometric matcher to confuse, so there is no refusal and no warning.
722
+ if (hasDrawOrderOcclusion()) return;
378
723
  if (!win.layer || !hasExclusion()) return;
724
+ if (this._isFullTileOverlay(win, el)) return;
379
725
  // Force the overlay onto its OWN composited layer so the browser can grab it
380
726
  // as an isolated resource (the element rastered on transparency) and
381
727
  // composite it OVER the woven 3D — final = plate + (1−plate.a)·woven, true
@@ -405,14 +751,13 @@ class Inline3D {
405
751
  }
406
752
 
407
753
  _dropExclusion(win, el) {
754
+ // Nothing was ever excluded or promoted, so there is nothing to undo — and in particular
755
+ // this must not touch the element's `will-change`, which is the page's own here.
756
+ if (hasDrawOrderOcclusion()) return;
408
757
  const refs = this._isolatedBy.get(el);
409
758
  if (refs) refs.delete(win);
410
759
  // Only un-promote once NO window needs this element isolated any more.
411
- if ((!refs || refs.size === 0) && el.dataset.inline3dIsolated) {
412
- el.style.willChange = el.dataset.inline3dPriorWillChange || '';
413
- delete el.dataset.inline3dPriorWillChange;
414
- delete el.dataset.inline3dIsolated;
415
- }
760
+ if (!refs || refs.size === 0) this._unpromote(el);
416
761
  if (!win.layer || !hasExclusion()) return;
417
762
  try {
418
763
  win.layer.unexcludeElement(el);
@@ -421,6 +766,53 @@ class Inline3D {
421
766
  }
422
767
  }
423
768
 
769
+ /**
770
+ * Undo the SDK's own compositing promotion on `el`, restoring the `will-change` the page had
771
+ * (which may be none). Only ever touches an element the SDK promoted — the marker dataset
772
+ * flag is what says so. Returns whether there was anything to undo.
773
+ */
774
+ _unpromote(el) {
775
+ if (!el || !el.dataset || !el.dataset.inline3dIsolated) return false;
776
+ el.style.willChange = el.dataset.inline3dPriorWillChange || '';
777
+ delete el.dataset.inline3dPriorWillChange;
778
+ delete el.dataset.inline3dIsolated;
779
+ return true;
780
+ }
781
+
782
+ /**
783
+ * Retire the legacy occlusion machinery, once, on learning the browser occludes by draw order.
784
+ *
785
+ * Only reachable when the capability could not be read until the first layer existed (the
786
+ * per-instance flag shape): by then one auto-chrome scan may have run and promoted page
787
+ * furniture. Where the flag is readable up front — the shape this SDK asks for — nothing has
788
+ * started and this finds nothing to do.
789
+ *
790
+ * Registrations the APP made are kept as API state (its `removeGlobalOverlay(el)` must still
791
+ * find `el` registered); only the SDK's own side effects on the page's DOM are reversed. The
792
+ * auto-chrome set is dropped entirely: it was never the app's, and nothing will re-add it.
793
+ */
794
+ _standDownLegacyOcclusion() {
795
+ if (this._stoodDown) return;
796
+ this._stoodDown = true;
797
+ let retired = false;
798
+ for (const win of this._windows.values()) {
799
+ if (win.overlayObserver) {
800
+ this._stopOverlayScan(win);
801
+ retired = true;
802
+ }
803
+ for (const el of win.excluded) if (this._unpromote(el)) retired = true;
804
+ }
805
+ for (const el of this._autoChromeEls) {
806
+ this._globalOverlays.delete(el);
807
+ this._unpromote(el);
808
+ retired = true;
809
+ }
810
+ this._autoChromeEls.clear();
811
+ for (const el of this._globalOverlays) if (this._unpromote(el)) retired = true;
812
+ this._isolatedBy = new WeakMap(); // every promotion is gone; the refcounts with them
813
+ if (retired) noteAutomaticOcclusion(); // only worth saying if work was actually thrown away
814
+ }
815
+
424
816
  // Declarative overlays: any element marked `data-inline3d-overlay` inside the window's
425
817
  // container (the canvas's parent — where an over-the-window plate must live to be
426
818
  // positioned over it) is auto-excluded while the window is live, and tracked through
@@ -430,6 +822,10 @@ class Inline3D {
430
822
  // with display, not opacity/visibility: those still report a full rect, so the weave
431
823
  // hole would stay punched under an invisible plate.)
432
824
  _startOverlayScan(win) {
825
+ // No observer at all where occlusion is automatic: `data-inline3d-overlay` needs no
826
+ // honouring, so a page keeps its attributes (harmless, portable) and pays no
827
+ // MutationObserver per live tile.
828
+ if (hasDrawOrderOcclusion()) return;
433
829
  if (!hasExclusion() || typeof MutationObserver !== 'function') return;
434
830
  const container = win.canvas.parentElement;
435
831
  if (!container) return;
@@ -468,10 +864,17 @@ class Inline3D {
468
864
  win.autoExcluded.clear();
469
865
  }
470
866
 
471
- _sizeBuffer(win, sbs) {
867
+ /** The per-eye buffer size this window should have right now (explicit, or box × dpr). */
868
+ _eyeSize(win) {
472
869
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
473
- const boxW = win.reqW || Math.round((win.canvas.clientWidth || 256) * dpr);
474
- const boxH = win.reqH || Math.round((win.canvas.clientHeight || 256) * dpr);
870
+ return {
871
+ w: win.reqW || Math.round((win.canvas.clientWidth || 256) * dpr),
872
+ h: win.reqH || Math.round((win.canvas.clientHeight || 256) * dpr),
873
+ };
874
+ }
875
+
876
+ _sizeBuffer(win, sbs) {
877
+ const { w: boxW, h: boxH } = this._eyeSize(win);
475
878
  win.eyeW = boxW;
476
879
  win.eyeH = boxH;
477
880
  win.canvas.width = sbs ? boxW * 2 : boxW; // SBS = two eye tiles wide
@@ -479,6 +882,92 @@ class Inline3D {
479
882
  win.sbs = sbs;
480
883
  }
481
884
 
885
+ // ── box / devicePixelRatio changes ──────────────────────────────────────────────────
886
+ //
887
+ // _sizeBuffer runs at activate and deactivate ONLY, so a live window whose CSS box or
888
+ // devicePixelRatio changes underneath it keeps its old backing store: the same SBS pixels
889
+ // are stretched onto a differently-shaped box and the two eyes come out mis-squeezed, with
890
+ // no error, until the tile happens to re-activate. A responsive reflow, a flex sibling
891
+ // appearing, a browser zoom or a drag to a different-scale monitor all do it. So: watch the
892
+ // box while the window is live, and re-derive the buffer when it actually moves.
893
+
894
+ _startSizeWatch(win) {
895
+ if (typeof ResizeObserver !== 'function') return;
896
+ if (win.sizeObserver) return;
897
+ // Scene canvases are the app's (ownsBuffer false) — never touch their width/height.
898
+ // An explicit {width, height} is box-independent by definition, so nothing to watch.
899
+ if (!win.ownsBuffer || (win.reqW && win.reqH)) return;
900
+ win.sizeObserver = new ResizeObserver(() => this._onBoxChange(win));
901
+ win.sizeObserver.observe(win.canvas);
902
+ }
903
+
904
+ _stopSizeWatch(win) {
905
+ if (win.sizeObserver) {
906
+ win.sizeObserver.disconnect();
907
+ win.sizeObserver = null;
908
+ }
909
+ win.resizePending = false;
910
+ }
911
+
912
+ /**
913
+ * Re-derive one live window's SBS buffer and repaint it. Debounced to one animation frame:
914
+ * ResizeObserver and a dpr flip both fire in bursts during a drag-resize or a zoom, and
915
+ * every resize reallocates the backing store and clears it.
916
+ */
917
+ _onBoxChange(win) {
918
+ if (!win.layer || !win.ownsBuffer || win.resizePending) return;
919
+ win.resizePending = true;
920
+ const run = () => {
921
+ if (!win.resizePending) return;
922
+ win.resizePending = false;
923
+ if (!win.layer || !win.ownsBuffer) return;
924
+ const { w, h } = this._eyeSize(win);
925
+ if (w === win.eyeW && h === win.eyeH) return; // observer fired, geometry didn't move
926
+ this._sizeBuffer(win, /*sbs*/ true);
927
+ this._paint(win, null); // repaint NOW: setting canvas.width cleared the buffer
928
+ };
929
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
930
+ else run();
931
+ }
932
+
933
+ /**
934
+ * devicePixelRatio is invisible to ResizeObserver — a browser zoom or a move to a
935
+ * different-scale monitor leaves the CSS box the same number of CSS px while the buffer
936
+ * that box deserves changes. A `(resolution: Ndppx)` query flips exactly when dpr leaves
937
+ * its current value, so arm one, and re-arm it on the new value each time.
938
+ */
939
+ _armDprWatch() {
940
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
941
+ this._disarmDprWatch();
942
+ let q;
943
+ try {
944
+ q = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
945
+ } catch {
946
+ return; // no resolution-query support: box changes are still covered
947
+ }
948
+ const onChange = () => {
949
+ if (!this._running) return;
950
+ this._armDprWatch(); // this query is stale the moment it fires
951
+ for (const win of this._windows.values()) if (win.layer) this._onBoxChange(win);
952
+ };
953
+ try {
954
+ q.addEventListener('change', onChange);
955
+ } catch {
956
+ return;
957
+ }
958
+ this._dprWatch = { q, onChange };
959
+ }
960
+
961
+ _disarmDprWatch() {
962
+ if (!this._dprWatch) return;
963
+ try {
964
+ this._dprWatch.q.removeEventListener('change', this._dprWatch.onChange);
965
+ } catch {
966
+ /* ignore */
967
+ }
968
+ this._dprWatch = null;
969
+ }
970
+
482
971
  _paint(win, _views) {
483
972
  if (win.kind === 'scene' || !win.ctx) return;
484
973
  const src = win.kind === 'video' ? win.video : win.img;
@@ -507,15 +996,63 @@ class Inline3D {
507
996
  }
508
997
  }
509
998
 
999
+ /**
1000
+ * Arm the next session frame. `force` starts a NEW loop even though one is nominally
1001
+ * pending: each loop carries an id and only the current id re-arms, so a stalled
1002
+ * predecessor (a bfcache restore whose callback never fired) is retired rather than
1003
+ * doubled if it ever does fire.
1004
+ */
1005
+ _requestFrame(force) {
1006
+ if (!this._running) return;
1007
+ if (this._framePending && !force) return;
1008
+ const id = force ? ++this._loopId : this._loopId;
1009
+ this._framePending = true;
1010
+ try {
1011
+ this.session.requestAnimationFrame((t, f) => {
1012
+ if (id !== this._loopId) return; // superseded loop — let it die here
1013
+ this._framePending = false;
1014
+ this._frameCount++;
1015
+ this._frame(t, f);
1016
+ });
1017
+ } catch {
1018
+ this._framePending = false; // session going away; 'end' → _teardown handles it
1019
+ }
1020
+ }
1021
+
510
1022
  _frame(t, f) {
511
1023
  if (!this._running) return;
512
- this.session.requestAnimationFrame((t2, f2) => this._frame(t2, f2));
1024
+ this._requestFrame();
513
1025
  const pose = this.refSpace ? f.getViewerPose(this.refSpace) : null;
514
1026
  const views = pose ? pose.views : null;
515
1027
  for (const win of this._windows.values()) {
516
1028
  if (!win.layer) continue;
517
1029
  if (win.kind === 'scene') {
518
- if (views && win.onFrame) win.onFrame(views, win.layer, f);
1030
+ if (views && win.onFrame) {
1031
+ // Count the SHORT view lists and hand them over unchanged. Under GPU load the session
1032
+ // can report a single view (a per-frame mono fallback) where it normally reports two,
1033
+ // and a renderer that clears before it validates turns that into a dark tile
1034
+ // (web#12 — ./viewer now validates first and replays its last good frame instead).
1035
+ //
1036
+ // The core deliberately does NOT filter or synthesise: the contract is "here is what
1037
+ // the frame reported", and a window that can do something sensible with one view
1038
+ // (a mono preview, say) must be allowed to. What the core owes you is VISIBILITY —
1039
+ // this is otherwise invisible from the page, since nothing throws and nothing logs.
1040
+ win.frames++;
1041
+ if (views.length < 2) {
1042
+ win.monoFrames++;
1043
+ // 1-in-300 so a sustained rate is reported without the log itself becoming the load;
1044
+ // `% 300 === 1` also names the FIRST one immediately.
1045
+ if (win.monoFrames % 300 === 1 && typeof console !== 'undefined' && console.debug) {
1046
+ const pct = ((100 * win.monoFrames) / Math.max(1, win.frames)).toFixed(1);
1047
+ console.debug(
1048
+ `[inline3d] scene window: ${win.monoFrames} non-stereo view lists in ` +
1049
+ `${win.frames} frames (${pct}%). The viewer replays its last good stereo ` +
1050
+ 'frame for these; a rising rate means the session is falling back under load.',
1051
+ );
1052
+ }
1053
+ }
1054
+ win.onFrame(views, win.layer, f);
1055
+ }
519
1056
  } else {
520
1057
  // Repaint image AND video every frame. The weave reads each window's
521
1058
  // composited canvas quad per frame; a canvas that isn't redrawn can have
@@ -527,12 +1064,116 @@ class Inline3D {
527
1064
  }
528
1065
  }
529
1066
 
1067
+ // ── page lifecycle: bfcache, freeze, restore (browser#87) ───────────────────────────
1068
+ //
1069
+ // A weaved window's rect reaches the compositor from the session's own rAF: every frame the
1070
+ // live session pushes the full list of rects to weave, and the ONLY way to clear a rect is
1071
+ // to push a list without it. So a page that simply stops running frames leaves its last
1072
+ // list standing — the rects keep weaving over whatever is on screen now. Back/forward
1073
+ // navigation does exactly that: bfcache freezes the page mid-loop, the woven tiles stay
1074
+ // pinned where they were, and the next page inherits ghost 3D windows (browser#87).
1075
+ //
1076
+ // The fix is to make the LAST frames before suspension report an empty list: deactivate
1077
+ // every live window while frames still run, remember which ones were live, and restore them
1078
+ // on the way back. pagehide covers bfcache entry and unload; freeze covers a discarded
1079
+ // background tab where pagehide does not fire.
1080
+
1081
+ _bindLifecycle() {
1082
+ if (typeof window === 'undefined') return;
1083
+ this._onPageHide = () => this._suspend();
1084
+ this._onPageShow = (e) => this._resume(!!(e && e.persisted));
1085
+ window.addEventListener('pagehide', this._onPageHide);
1086
+ window.addEventListener('pageshow', this._onPageShow);
1087
+ // Page Lifecycle API (Blink): a frozen tab never gets pagehide/pageshow.
1088
+ if (typeof document !== 'undefined' && 'onfreeze' in document) {
1089
+ this._onFreeze = () => this._suspend();
1090
+ this._onResume = () => this._resume(true);
1091
+ document.addEventListener('freeze', this._onFreeze);
1092
+ document.addEventListener('resume', this._onResume);
1093
+ }
1094
+ }
1095
+
1096
+ _unbindLifecycle() {
1097
+ if (typeof window === 'undefined') return;
1098
+ if (this._onPageHide) window.removeEventListener('pagehide', this._onPageHide);
1099
+ if (this._onPageShow) window.removeEventListener('pageshow', this._onPageShow);
1100
+ if (this._onFreeze && typeof document !== 'undefined') {
1101
+ document.removeEventListener('freeze', this._onFreeze);
1102
+ document.removeEventListener('resume', this._onResume);
1103
+ }
1104
+ this._onPageHide = this._onPageShow = this._onFreeze = this._onResume = null;
1105
+ if (this._frameWatchdog) {
1106
+ clearTimeout(this._frameWatchdog);
1107
+ this._frameWatchdog = null;
1108
+ }
1109
+ }
1110
+
1111
+ /** Close every live layer so the outgoing frames report an empty rect list. */
1112
+ _suspend() {
1113
+ if (!this._running || this._suspended) return;
1114
+ const was = [];
1115
+ for (const win of this._windows.values()) {
1116
+ if (win.layer) {
1117
+ was.push(win);
1118
+ this._deactivate(win);
1119
+ }
1120
+ }
1121
+ this._suspended = was;
1122
+ }
1123
+
1124
+ /**
1125
+ * Coming back: re-arm the windows that were live. In lazy mode the IntersectionObserver
1126
+ * owns that decision, and re-observing re-delivers the CURRENT intersection state — so a
1127
+ * tile the user scrolled away from before leaving stays dark, and only what is actually on
1128
+ * screen re-weaves. Chrome is rescanned because a restored page may have remounted it.
1129
+ */
1130
+ _resume(persisted) {
1131
+ if (!this._running) return;
1132
+ const was = this._suspended;
1133
+ this._suspended = null;
1134
+ if (was) {
1135
+ for (const win of was) {
1136
+ if (!this._windows.has(win.canvas)) continue; // removed while we were away
1137
+ if (this._lazy && this._observer) {
1138
+ this._observer.unobserve(win.observeEl);
1139
+ this._observer.observe(win.observeEl);
1140
+ } else {
1141
+ this._activate(win);
1142
+ }
1143
+ }
1144
+ }
1145
+ this._lastChromeScan = 0; // the 1 s throttle must not swallow the restore rescan
1146
+ this._scanChrome();
1147
+ this._armDprWatch(); // the restore may be on a different-scale display
1148
+ if (persisted) this._watchForStalledFrames();
1149
+ }
1150
+
1151
+ /**
1152
+ * A bfcache restore can hand back a session whose pending animation frame never arrives —
1153
+ * the loop was suspended between request and callback, and nothing re-issues it. The
1154
+ * manager then looks alive (`_running`) while no window ever paints again. Give it a second
1155
+ * to prove otherwise, then start a fresh loop (which retires the stalled one by id).
1156
+ */
1157
+ _watchForStalledFrames() {
1158
+ if (this._frameWatchdog || typeof setTimeout !== 'function') return;
1159
+ const before = this._frameCount;
1160
+ this._frameWatchdog = setTimeout(() => {
1161
+ this._frameWatchdog = null;
1162
+ if (!this._running || this._frameCount !== before) return; // frames arrived
1163
+ this._requestFrame(/*force*/ true);
1164
+ }, 1000);
1165
+ }
1166
+
530
1167
  _teardown() {
531
1168
  if (!this._running) return;
532
1169
  this._running = false;
1170
+ if (liveManager === this) liveManager = null;
1171
+ this._unbindLifecycle();
1172
+ this._disarmDprWatch();
533
1173
  if (this._observer) this._observer.disconnect();
534
1174
  for (const win of this._windows.values()) {
535
1175
  this._stopOverlayScan(win);
1176
+ this._stopSizeWatch(win);
536
1177
  if (win.layer) {
537
1178
  try {
538
1179
  win.layer.close();