@displayxr/inline3d 1.37.1 → 1.39.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/index.d.ts CHANGED
@@ -24,27 +24,93 @@ export interface TileOptions {
24
24
  * you know is not fresh; 0 means "the first stereo frame".
25
25
  */
26
26
  firstWovenHoldMs?: number;
27
+ /**
28
+ * Cover this canvas across a rect change, owned by the SDK. Default `'off'` on `addImage` /
29
+ * `addVideo` / `addScene` (frozen defaults); `'auto'` on `./splat` and `./model`.
30
+ *
31
+ * Once the window has woven, a change of the canvas's CSS size, devicePixelRatio or PAGE position
32
+ * (scrolling the document is not a move) raises a sibling element over the canvas on the frame
33
+ * the change is seen, and cuts it when {@link TileHandle.rewoven} settles; a further change
34
+ * while it is up restarts the wait. The cover is a 2D canvas, never a second woven canvas,
35
+ * placed as the canvas's next sibling (`pointer-events: none`, `data-inline3d-cover`). It is
36
+ * filled with `color` and, with `snapshot`, the last frame: the left eye, cover-fit to the new
37
+ * box, captured once at the change with one `drawImage` (never `toDataURL`).
38
+ *
39
+ * - `'auto'` — `{ color: '#000', snapshot: true }`.
40
+ * - `'off'` — nothing; a page that runs its own cover passes this.
41
+ * - `{ color?, snapshot? }` — `color` any CSS color (default `'#000'`); `snapshot` default true.
42
+ *
43
+ * Before the first join nothing is raised: cover a fresh canvas yourself until `firstWoven`
44
+ * (rule 5). A pure move is seen at the next session frame, so a move made after that frame's
45
+ * callback can show for one frame.
46
+ */
47
+ rectCover?: RectCoverOption;
27
48
  }
28
49
 
50
+ /** See {@link TileOptions.rectCover}. */
51
+ export type RectCoverOption = 'auto' | 'off' | { color?: string; snapshot?: boolean };
52
+
53
+ /**
54
+ * The browser's per-frame report for one layer (`XRDisplayLayer.wovenState`, DisplayXR Browser
55
+ * builds that carry it; absent elsewhere). A level, never latched.
56
+ *
57
+ * - `'pending'` — no presented frame has reported this layer yet (from construction, again after
58
+ * close(), and whenever the frame's reply does not list it).
59
+ * - `'woven'` — the latest swapped frame put this layer's rect into the weave.
60
+ * - `'withheld'` — it did not; {@link WovenWithheldReason} says why.
61
+ */
62
+ export type WovenState = 'pending' | 'woven' | 'withheld';
63
+
64
+ /**
65
+ * Why a layer reads `'withheld'` (`XRDisplayLayer.withheldReason`). The same tokens the browser
66
+ * logs; what each means and whose fix it is: `docs/woven-canvas-rules.md` §3. A future browser may
67
+ * add tokens, so treat an unknown string as "withheld, reason not listed".
68
+ */
69
+ export type WovenWithheldReason =
70
+ | 'no-quad'
71
+ | 'no-identity'
72
+ | 'no-join'
73
+ | 'cross-pass:mono'
74
+ | 'cross-pass:mono(cover,2:1)'
75
+ | 'resolve-dropped'
76
+ | (string & {});
77
+
29
78
  /**
30
79
  * What {@link TileHandle.firstWoven} resolves to. Settles once and never rejects.
31
80
  *
32
- * - `woven: true, reason: 'hold-elapsed'` — a stereo frame is on a layer that has existed for
33
- * `firstWovenHoldMs`. Drop the poster covering the canvas.
34
- * - `woven: true, reason: 'hold-capped'` — `rewoven()` only: the canvas kept resizing, so it
35
- * stopped waiting four holds after the call. Drop the cover; the rect may still be settling.
36
- * - `woven: false` — the window will not weave (`'layer-failed'`, `'session-ended'`,
37
- * `'removed'`; the subpaths add `'unsupported'`). The canvas is already flat (image/video) or
38
- * its `onLayerLost` has run (scene). Drop the poster onto the 2D fallback.
81
+ * - `woven: true, reason: 'woven'` (`confirmed: true`) — the BROWSER reported the layer woven
82
+ * (`XRDisplayLayer.wovenState`), after a stereo frame was drawn. No hold. Drop the poster.
83
+ * - `woven: true, reason: 'hold-elapsed'` (`confirmed: false`) — a browser without that report:
84
+ * a stereo frame is on a layer that has existed for `firstWovenHoldMs`. Drop the poster.
85
+ * - `woven: true, reason: 'hold-capped'` — `rewoven()`: the canvas kept resizing or moving, so
86
+ * it stopped waiting four holds after the call; with the report, also a 'woven' read that never
87
+ * qualified (see {@link TileHandle.rewoven}). Drop the cover; the rect may still be settling.
88
+ * - `woven: false, confirmed: true`, `reason` a {@link WovenWithheldReason} or `'pending'` — the
89
+ * browser kept reporting the layer withheld (or never listed it) for the whole cap: four holds,
90
+ * at least 4.8 s. A safety release, not a loss: the canvas is NOT taken flat and `rewoven()`
91
+ * keeps working. Drop the cover; what shows is what the browser draws for that reason
92
+ * (`cross-pass:mono` is flat in place; `no-identity` can be the raw pair).
93
+ * - `woven: false, confirmed: false` — the window will not weave (`'layer-failed'`,
94
+ * `'session-ended'`, `'removed'`; the subpaths add `'unsupported'`). The canvas is already flat
95
+ * (image/video) or its `onLayerLost` has run (scene). Drop the poster onto the 2D fallback.
39
96
  */
40
97
  export interface FirstWovenResult {
41
98
  readonly woven: boolean;
42
99
  /**
43
- * `true` only when the BROWSER reported the join. Always `false` today: no browser exposes
44
- * that, so the result is the SDK's worst-case hold rather than a report.
100
+ * `true` only when the BROWSER reported the state (`XRDisplayLayer.wovenState`). `false` on a
101
+ * browser without that report: the result is then the SDK's worst-case hold.
45
102
  */
46
103
  readonly confirmed: boolean;
47
- readonly reason: 'hold-elapsed' | 'hold-capped' | 'layer-failed' | 'session-ended' | 'removed' | 'unsupported';
104
+ readonly reason:
105
+ | 'woven'
106
+ | 'hold-elapsed'
107
+ | 'hold-capped'
108
+ | 'layer-failed'
109
+ | 'session-ended'
110
+ | 'removed'
111
+ | 'unsupported'
112
+ | 'pending'
113
+ | WovenWithheldReason;
48
114
  /** Milliseconds from the add*() call to settling. */
49
115
  readonly ms: number;
50
116
  }
@@ -289,6 +355,15 @@ export interface SceneOptions extends TileOptions {
289
355
  */
290
356
  bufferClamped?: boolean;
291
357
  onLayerLost?: () => void;
358
+ /**
359
+ * Called INSIDE the ResizeObserver callback when the canvas's CSS box or devicePixelRatio
360
+ * changes — after the frame's animation callbacks, before its paint. Resize your backing store
361
+ * and draw one frame here (replay your last views) and the old store is never shown stretched
362
+ * onto the new box, which is what waiting for the next frame shows. Called only on a real
363
+ * change, never re-entrantly; a throw is caught and warned about once. Default: none, and then
364
+ * no observer is attached to a scene canvas (unchanged).
365
+ */
366
+ onResize?: (box: { width: number; height: number; dpr: number }) => void;
292
367
  }
293
368
 
294
369
  /** The per-frame render callback passed to {@link Inline3D.addScene}. */
@@ -298,7 +373,14 @@ export type SceneFrameCallback = (
298
373
  frame: XRFrame,
299
374
  ) => void;
300
375
 
301
- /** The handle returned by every add*() call. */
376
+ /**
377
+ * The handle returned by every add*() call.
378
+ *
379
+ * An add*() on a canvas that is still registered (not `remove()`d) on the same manager warns once
380
+ * per canvas (1.38) and then proceeds as it always has: the layer is closed and rebuilt (a fresh
381
+ * identity gap), or a subpath puts a second renderer on the context, and the call returns its own
382
+ * new handle. Change what is in the canvas instead, or `remove()` first.
383
+ */
302
384
  export interface TileHandle {
303
385
  /** Remove this window: close its weave layer and stop driving it. */
304
386
  remove(): void;
@@ -342,6 +424,19 @@ export interface TileHandle {
342
424
  /**
343
425
  * The panel this window weaves on, or `null` where there is no glasses-free display.
344
426
  *
427
+ * `null` is returned only once the layer has answered for real or the cap has passed, never for
428
+ * "not ready yet". Called before this window's layer has delivered its first frame (right after
429
+ * `add*()`, the natural place), the call waits for the release signal — where the browser
430
+ * reports {@link TileHandle.wovenState}, the first `'woven'` read (a `'withheld'` read right
431
+ * after the layer is built does not count: it comes before the weave session can answer);
432
+ * elsewhere, the layer's first stereo frame; no first-woven hold — and asks then. If the layer
433
+ * still answers `null` before the cap, that answer is not returned: the call is asked again on
434
+ * each later release signal (the next `'woven'` read, or the next session frame once a stereo
435
+ * frame has been drawn) and resolves with the first non-`null` answer. Once a read on this layer
436
+ * has answered for real, later calls go straight to the layer. The cap is the first-woven cap
437
+ * (four holds from the layer's construction, never under 4.8 s): past it the layer's `null` is
438
+ * returned as a real absence. Without a live layer it settles at once, as below.
439
+ *
345
440
  * Rejects with an `Error` on a browser without the display-mode API
346
441
  * ({@link inline3dDisplayModesSupported}) or while this window has no live layer (lazy mode,
347
442
  * tile off screen).
@@ -354,7 +449,14 @@ export interface TileHandle {
354
449
  * The canvas size is read at the call: call again after a resize.
355
450
  */
356
451
  displayMetrics(): Promise<DisplayMetrics>;
357
- /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
452
+ /**
453
+ * Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}.
454
+ *
455
+ * An empty list is returned only once the cap has passed, never for "not ready yet": like
456
+ * {@link getDisplayInfo}, a call made before this window's layer has delivered its first frame
457
+ * waits for the same release signal, an empty answer before the cap is asked again on the next
458
+ * one, and the cap is the same. Rejects as {@link getDisplayInfo} does.
459
+ */
358
460
  getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
359
461
  /**
360
462
  * Ask the runtime to switch the display to `modeIndex`. A thin pass-through — it resolves and
@@ -368,6 +470,10 @@ export interface TileHandle {
368
470
  *
369
471
  * A `viewCount === 1` mode is requestable and is how a page goes flat.
370
472
  *
473
+ * Made before this window's layer has delivered its first frame, the request waits for it (the
474
+ * same release signal and cap as {@link getDisplayInfo}; forwarded once, not retried): before
475
+ * then the browser has no weave session to forward it to.
476
+ *
371
477
  * With the eased transition on (the default — see {@link ModeSwitchOptions}) a going-flat
372
478
  * request is HELD while the disparity ramps out, so the promise resolves when the request has
373
479
  * been forwarded rather than on the call; a request dropped by a reversal in that window
@@ -422,22 +528,45 @@ export interface TileHandle {
422
528
  * Scene windows only — image/video windows always report `{ frames: 0, monoFrames: 0 }`.
423
529
  */
424
530
  stats(): { frames: number; monoFrames: number };
531
+ /**
532
+ * The browser's report for this window's layer, read live ({@link WovenState}); `'pending'`
533
+ * while the window has no live layer; `null` on a browser that does not report it (the
534
+ * attribute is absent on mac, Linux, Android and Windows builds without it). Diagnostics:
535
+ * `firstWoven` / `rewoven()` already settle on it.
536
+ */
537
+ readonly wovenState: WovenState | null;
538
+ /** The why-token while {@link TileHandle.wovenState} is `'withheld'`, else `null`. */
539
+ readonly withheldReason: WovenWithheldReason | null;
425
540
  /**
426
541
  * Resolves once, when it is safe to reveal this canvas: see {@link FirstWovenResult}. THE way to
427
542
  * release a poster held over a woven canvas — `await Promise.all([ready, handle.firstWoven])`
428
- * and cut, never fade. Approximate until a browser reports joins (`confirmed` stays `false`).
543
+ * and cut, never fade. On a browser that reports `wovenState` it settles on that report
544
+ * (`confirmed: true`, no hold); elsewhere it is the worst-case hold (`confirmed: false`).
429
545
  */
430
546
  readonly firstWoven: Promise<FirstWovenResult>;
431
547
  /** Callback form of {@link TileHandle.firstWoven}: called once, asynchronously. Returns an unsubscribe. */
432
548
  onFirstWoven(cb: (result: FirstWovenResult) => void): () => void;
433
549
  /**
434
550
  * {@link TileHandle.firstWoven}, measured from NOW. Cover an already-woven canvas across a rect
435
- * change (fullscreen, a layout resize), then release on this. A change of the canvas's CSS size
436
- * or devicePixelRatio while pending restarts the hold (checked every frame, any window kind; a
437
- * move without a resize is not detected). It settles anyway, `woven: true, reason:
551
+ * change (fullscreen, a layout resize, a move), then release on this ({@link TileOptions.rectCover}
552
+ * does both for you). A change of the canvas's CSS size, devicePixelRatio or page position (since
553
+ * 1.38; document scroll excluded) while pending restarts the hold (checked every frame, any
554
+ * window kind). It settles anyway, `woven: true, reason:
438
555
  * 'hold-capped'`, four holds after the call, so a size that never stops animating cannot hold a
439
556
  * cover up for good. A second call while pending returns the same promise, restarted. Before the
440
557
  * first join it is `firstWoven`; on a window that will not weave it is that `woven: false` result.
558
+ *
559
+ * On a browser that reports `wovenState` it settles `confirmed: true, reason: 'woven'` on the
560
+ * first 'woven' read (with a stereo frame drawn since the call) that either follows a
561
+ * 'withheld'/'pending' read seen after the call, or comes three session frames after the last
562
+ * box change seen after the call, or — when no box change is seen after the call, the usual
563
+ * case of a page that moves the canvas and THEN calls this — comes three session frames after
564
+ * the call. The report is a live per-frame level trailing the join by 1–3 frames, so a read
565
+ * three frames on reflects the rect as it was at the call, and a 'woven' from the old rect
566
+ * never settles it; a read on the frame of the call never does. Expect ~50 ms instead of the
567
+ * 1.2 s hold.
568
+ * The cap stays: still 'withheld' four holds after the call (at least 4.8 s), it settles `woven:
569
+ * false, confirmed: true, reason: <withheldReason>`.
441
570
  */
442
571
  rewoven(): Promise<FirstWovenResult>;
443
572
  }
@@ -455,9 +584,17 @@ export interface Inline3D {
455
584
  // The panel is the DOCUMENT's, not a tile's, so the display API lives here; the same names are
456
585
  // on every tile handle, routed to whichever window currently holds a live layer.
457
586
 
458
- /** The panel, or `null` where there is no glasses-free display. See {@link TileHandle.getDisplayInfo}. */
587
+ /**
588
+ * The panel, or `null` where there is no glasses-free display — `null` only once the cap has
589
+ * passed, never for "not ready yet" (a call before the live window has answered waits for its
590
+ * release signal and is asked again while it answers `null`, capped). See
591
+ * {@link TileHandle.getDisplayInfo}.
592
+ */
459
593
  getDisplayInfo(): Promise<XRDisplayInfo | null>;
460
- /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
594
+ /**
595
+ * Every rendering mode the display can be put in; an empty list only once the cap has passed,
596
+ * never for "not ready yet". See {@link TileHandle.getRenderingModes}.
597
+ */
461
598
  getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
462
599
  /** Switch the display to `modeIndex`. See {@link TileHandle.requestRenderingMode}. */
463
600
  requestRenderingMode(modeIndex: number): Promise<void>;
@@ -1873,7 +1873,7 @@ class SelfTile {
1873
1873
  // will not weave (firstWoven → woven:false, web#131), and the badge must follow it.
1874
1874
  this.view = addCameraView(wall, this.canvas, cam, {
1875
1875
  mirror: true,
1876
- autoConverge: false,
1876
+ autoConverge: !!this.call.o.autoConverge, // the call option (default on) governs the self view too
1877
1877
  aspect: this.call.o.tileAspect,
1878
1878
  onRouteChange: (route, st) => {
1879
1879
  if (this.cam !== cam) return; // a view this tile already replaced
@@ -21,6 +21,7 @@
21
21
  // engine import fails, one warning names what to install, and the tile renders on three. With
22
22
  // neither, `ready` rejects naming both. An explicit `engine` never falls back.
23
23
 
24
+ import { guardedAttach } from './inline3d-splat-shared.js';
24
25
  /** The engines `addModel` renders with; the first is the default. */
25
26
  export const MODEL_ENGINES = Object.freeze(['playcanvas', 'three']);
26
27
 
@@ -98,6 +99,12 @@ export function validateModelCall(canvas, src, opts, route) {
98
99
  * fill in as the backend loads.
99
100
  */
100
101
  export function addModel(wall, canvas, src, opts = {}) {
102
+ // A second call on a canvas still registered on this wall warns once, then proceeds as before
103
+ // (see guardedAttach).
104
+ return guardedAttach(wall, canvas, 'addModel', opts, (o) => addModelUnguarded(wall, canvas, src, o));
105
+ }
106
+
107
+ function addModelUnguarded(wall, canvas, src, opts = {}) {
101
108
  const route = resolveModelEngine(opts);
102
109
  validateModelCall(canvas, src, opts, route);
103
110
  if (route.engine === 'playcanvas') {
@@ -531,6 +531,11 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
531
531
  bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context
532
532
  ...(observe ? { observe } : {}),
533
533
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
534
+ // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the
535
+ // preview tier; 'off' for a page that runs its own cover.
536
+ rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover,
537
+ // The entry's double-attach claim (guardedAttach): lets THIS registration through.
538
+ _dxrClaim: opts._dxrClaim,
534
539
  });
535
540
  } else {
536
541
  viewer.startMono();
@@ -38,6 +38,7 @@
38
38
  // your own origin (`/draco/`, `/basis/`) and `opts.decoderPath` moves that. See
39
39
  // docs/authoring-inline-3d.md#compressed-gltf.
40
40
 
41
+ import { guardedAttach } from './inline3d-splat-shared.js';
41
42
  import * as THREE from 'three';
42
43
  import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js';
43
44
  import { resolveCursorOption } from './inline3d-cursor-depth.js';
@@ -345,6 +346,12 @@ function kindFromMessage(msg) {
345
346
  * `setPose`, `resetPose`, `frame`, and `ready`.
346
347
  */
347
348
  export function addModel(wall, canvas, src, opts = {}) {
349
+ // A second call on a canvas still registered on this wall warns once, then proceeds as before
350
+ // (see guardedAttach).
351
+ return guardedAttach(wall, canvas, 'addModel', opts, (o) => addModelUnguarded(wall, canvas, src, o));
352
+ }
353
+
354
+ function addModelUnguarded(wall, canvas, src, opts = {}) {
348
355
  const {
349
356
  virtualDisplayHeight = 0.24,
350
357
  frame = null,
@@ -426,6 +433,11 @@ export function addModel(wall, canvas, src, opts = {}) {
426
433
  bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context
427
434
  ...(observe ? { observe } : {}),
428
435
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
436
+ // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the
437
+ // preview tier; 'off' for a page that runs its own cover.
438
+ rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover,
439
+ // The entry's double-attach claim (guardedAttach): lets THIS registration through.
440
+ _dxrClaim: opts._dxrClaim,
429
441
  });
430
442
  } else {
431
443
  viewer.startMono();
@@ -83,6 +83,11 @@ export function addSplatDeferred(wall, canvas, src, opts) {
83
83
  // setSource replaces this stub on the same object by then).
84
84
  setSource: (...args) => out.ready.then(() => out.setSource(...args)),
85
85
  prepareSource: (...args) => out.ready.then(() => out.prepareSource(...args)),
86
+ // Nothing can be mid-transition before the adapter has loaded; the adapter replaces this.
87
+ finishSwap: () => out,
88
+ // `rewoven()` before the adapter: the core window's answer once it exists, `firstWoven`'s
89
+ // result if the engine never arrives (same shape, never rejects).
90
+ rewoven: () => out.ready.then(() => out.rewoven(), () => out.firstWoven),
86
91
  // A rig switch before the first asset has landed: validated now (controls:'page' and a bad
87
92
  // type throw at the call's own line), applied once it has.
88
93
  setRig: page
@@ -16,7 +16,7 @@
16
16
  // Not here: measureSplatBounds (it measures a Spark mesh — import it from ./splat).
17
17
 
18
18
  import { resolveRevealOption } from './inline3d-splat-effects.js';
19
- import { CAPTURE_FITS, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js';
19
+ import { CAPTURE_FITS, guardedAttach, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js';
20
20
  import { addSplatDeferred } from './inline3d-splat-deferred.js';
21
21
 
22
22
  export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js';
@@ -25,6 +25,12 @@ export { resolveRig } from './inline3d-splat-rig.js';
25
25
 
26
26
  /** addSplat with `engine: 'playcanvas'`; the same validation, at the call, as ./splat's. */
27
27
  export function addSplat(wall, canvas, src, opts = {}) {
28
+ // A second call on a canvas still registered on this wall warns once, then proceeds as before
29
+ // (see guardedAttach).
30
+ return guardedAttach(wall, canvas, 'addSplat', opts, (o) => addSplatUnguarded(wall, canvas, src, o));
31
+ }
32
+
33
+ function addSplatUnguarded(wall, canvas, src, opts = {}) {
28
34
  if (opts.engine !== undefined && opts.engine !== 'playcanvas') {
29
35
  throw new Error(
30
36
  `@displayxr/inline3d/splat/playcanvas: engine "${opts.engine}" — this entry is PlayCanvas ` +
@@ -1002,7 +1002,11 @@ export class PlayCanvasSplatViewer {
1002
1002
  this._tileBudget = undefined; // undefined = the engine's own default
1003
1003
  this._budgetViews = 1;
1004
1004
 
1005
- this._onResize = () => this._scheduleResize();
1005
+ // Resize AND draw inside the observer callback (it runs after the frame's animation callbacks
1006
+ // and before the paint). Deferring to the next animation frame, as this used to, let one
1007
+ // paint show the old backing store stretched onto the new box: a visible jolt on every
1008
+ // CSS resize of a woven canvas. See _resizeNow for the two cases that still defer.
1009
+ this._onResize = () => this._resizeNow();
1006
1010
  this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null;
1007
1011
  if (this._ro) this._ro.observe(canvas);
1008
1012
  else if (typeof addEventListener === 'function') addEventListener('resize', this._onResize);
@@ -1231,7 +1235,7 @@ export class PlayCanvasSplatViewer {
1231
1235
  this._beforeFrame?.(views || null);
1232
1236
  this._tick();
1233
1237
  if (!views || views.length < 2) {
1234
- this._replayLastGood();
1238
+ this._replayOrFlat();
1235
1239
  return;
1236
1240
  }
1237
1241
  const vps = this._vps;
@@ -1239,7 +1243,7 @@ export class PlayCanvasSplatViewer {
1239
1243
  for (const view of views) {
1240
1244
  const vp = layer && typeof layer.getViewport === 'function' ? layer.getViewport(view) : null;
1241
1245
  if (!vp || !(vp.width > 0) || !(vp.height > 0)) {
1242
- this._replayLastGood();
1246
+ this._replayOrFlat();
1243
1247
  return;
1244
1248
  }
1245
1249
  vps.push(vp);
@@ -2010,6 +2014,17 @@ export class PlayCanvasSplatViewer {
2010
2014
  * engine tick. Every entry is {proj, pose, x, y, width, height} in BUFFER pixels.
2011
2015
  */
2012
2016
  _drawEntries(entries, cache) {
2017
+ // `_drawing` is what _resizeNow checks: never reallocate the buffer under a running render.
2018
+ if (this._drawing) return this._drawEntriesInner(entries, cache);
2019
+ this._drawing = true;
2020
+ try {
2021
+ return this._drawEntriesInner(entries, cache);
2022
+ } finally {
2023
+ this._drawing = false;
2024
+ }
2025
+ }
2026
+
2027
+ _drawEntriesInner(entries, cache) {
2013
2028
  const app = this.app;
2014
2029
  if (!app || !this.pc) return false;
2015
2030
  const pc = this.pc;
@@ -2139,6 +2154,7 @@ export class PlayCanvasSplatViewer {
2139
2154
  }
2140
2155
  g.bufW = el.width || 0;
2141
2156
  g.bufH = el.height || 0;
2157
+ g.boxAspect = this.boxAspect || 0; // the CSS shape these views were located for (see _replayLastGood)
2142
2158
  g.rig = this._rigSnap ? { ...this._rigSnap } : null; // a replay re-uses the rig of its views
2143
2159
  g.rigAt = this._rigAtPull ?? null;
2144
2160
  for (let i = 0; i < views.length; i++) {
@@ -2153,10 +2169,90 @@ export class PlayCanvasSplatViewer {
2153
2169
  }
2154
2170
  }
2155
2171
 
2172
+ /**
2173
+ * A session frame that cannot draw its own views still PRESENTS: the last good frame, or —
2174
+ * before there is one — the mono camera into both halves (flat, the subject if loaded, else
2175
+ * the clear colour). A woven canvas that is not redrawn can drop out of the frame the
2176
+ * compositor aggregates (woven-canvas-rules, "redraw every frame"), and a load-fallback frame
2177
+ * before the first stereo one is exactly when no cache exists. The only frame this cannot
2178
+ * cover is one before the engine has attached: there is no GL context yet, and creating one
2179
+ * here would create it with the wrong attributes.
2180
+ */
2181
+ _replayOrFlat() {
2182
+ return this._replayLastGood() || this._drawFlatPair();
2183
+ }
2184
+
2185
+ _drawFlatPair() {
2186
+ if (this._disposed || !this.app || !this.pc) return false;
2187
+ const b = this._bufScale();
2188
+ const W = b.w || this.canvas.width;
2189
+ const H = b.h || this.canvas.height;
2190
+ if (!(W > 1) || !(H > 0)) return false;
2191
+ const half = Math.floor(W / 2);
2192
+ const v = this._monoView();
2193
+ const pair = (this._flatPair ||= [0, 1].map(() => ({ proj: null, pose: null, x: 0, y: 0, width: 0, height: 0 })));
2194
+ for (let i = 0; i < 2; i++) {
2195
+ const e = pair[i];
2196
+ e.proj = v.proj;
2197
+ e.pose = v.pose;
2198
+ e.x = i * half;
2199
+ e.y = 0;
2200
+ e.width = half;
2201
+ e.height = H;
2202
+ }
2203
+ return this._drawEntries(pair, null);
2204
+ }
2205
+
2156
2206
  _replayLastGood() {
2157
2207
  const g = this._lastGood;
2158
2208
  if (!g || this._disposed) return false;
2159
- return this._drawEntries(g.entries, g);
2209
+ // A box whose aspect changed since the cache (a CSS resize replayed before the runtime has
2210
+ // located views for the new rect): the cached frustum was built for the OLD shape, and the
2211
+ // viewports map onto the new buffer proportionally, so the picture would be stretched by
2212
+ // exactly the aspect ratio change. Widen (or narrow) the frustum by the same factor —
2213
+ // dividing the projection's x row by it — so the replayed frame keeps its proportions. One
2214
+ // frame's approximation: the next located views carry the true frustum for the new window.
2215
+ const f = g.boxAspect > 0 && this.boxAspect > 0 ? this.boxAspect / g.boxAspect : 1;
2216
+ if (Math.abs(f - 1) < 1e-6) return this._drawEntries(g.entries, g);
2217
+ const fixed = (this._aspectFixed ||= []);
2218
+ fixed.length = g.entries.length;
2219
+ for (let i = 0; i < g.entries.length; i++) {
2220
+ const e = g.entries[i];
2221
+ const o = (fixed[i] ||= { proj: new Float32Array(16), pose: null, x: 0, y: 0, width: 0, height: 0 });
2222
+ o.proj.set(e.proj);
2223
+ o.proj[0] /= f;
2224
+ o.proj[4] /= f;
2225
+ o.proj[8] /= f;
2226
+ o.proj[12] /= f;
2227
+ o.pose = e.pose;
2228
+ o.x = e.x;
2229
+ o.y = e.y;
2230
+ o.width = e.width;
2231
+ o.height = e.height;
2232
+ }
2233
+ return this._drawEntries(fixed, g);
2234
+ }
2235
+
2236
+ /**
2237
+ * The observer's resize, synchronous. Two cases still take the debounced path instead: a call
2238
+ * that arrives while this viewer is already resizing (re-entrancy: setting canvas.width can
2239
+ * queue more observer work), and one that arrives while it is drawing (a nested dispatch from
2240
+ * inside the engine's tick). Reallocating the drawing buffer under a running render would
2241
+ * leave that render half on a cleared store.
2242
+ */
2243
+ _resizeNow() {
2244
+ if (this._disposed) return;
2245
+ if (this._inResize || this._drawing) {
2246
+ this._scheduleResize();
2247
+ return;
2248
+ }
2249
+ this._resizePending = false; // this run supersedes a debounced one already queued
2250
+ this._inResize = true;
2251
+ try {
2252
+ this._resize();
2253
+ } finally {
2254
+ this._inResize = false;
2255
+ }
2160
2256
  }
2161
2257
 
2162
2258
  _scheduleResize() {
@@ -3370,6 +3466,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3370
3466
  return pickModel(clientX, clientY);
3371
3467
  },
3372
3468
  setSource,
3469
+ /**
3470
+ * End a running setSource transition now (its end state at once); a no-op when none runs.
3471
+ * The same thing a newer setSource does to an older one in flight (`pendingSwap.finish()`),
3472
+ * exposed for a page that leaves the photo mid-crossfade and must not wait for it.
3473
+ */
3474
+ finishSwap() {
3475
+ pendingSwap?.finish();
3476
+ return out;
3477
+ },
3373
3478
  prepareSource,
3374
3479
  setRig,
3375
3480
  setStereo,
@@ -3590,6 +3695,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3590
3695
  bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context
3591
3696
  ...(observe ? { observe } : {}),
3592
3697
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
3698
+ // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the
3699
+ // preview tier; 'off' for a page that runs its own cover.
3700
+ rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover,
3701
+ // The entry's double-attach claim (guardedAttach): lets THIS registration through.
3702
+ _dxrClaim: opts._dxrClaim,
3593
3703
  });
3594
3704
  } else {
3595
3705
  viewer.startMono();
@@ -3633,6 +3743,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3633
3743
  out._resolveFirstWoven(handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 })));
3634
3744
  delete out._resolveFirstWoven;
3635
3745
  }
3746
+ // The core window's `rewoven()`, forwarded like `firstWoven`: a page covers the canvas across a
3747
+ // rect change (a shared canvas moved into a box, fullscreen) and releases on this. Without a
3748
+ // session it settles `woven: false, reason: 'unsupported'` at once, the same shape. Replaces the
3749
+ // deferred stub's forwarder, as `setSource` does.
3750
+ out.rewoven = () => (handle && typeof handle.rewoven === 'function' ? handle.rewoven() : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 })));
3636
3751
 
3637
3752
  // addSplat's `displayRigLayers` sugar: [layer, …] or { layers: [...], viewerDistance, gain }.
3638
3753
  if (opts.displayRigLayers !== undefined) {
@@ -12,6 +12,23 @@
12
12
  // apart on how a drag, a wheel notch, an idle turntable or a focus change feels. Pinned by
13
13
  // test/splat-playcanvas.test.mjs (values, and a behavioural trace of both viewers side by side).
14
14
 
15
+ /**
16
+ * Double-attach detection around a scene subpath's public entry (`addSplat`, `addModel`).
17
+ *
18
+ * The check has to run HERE, at the entry, and not only in the core's addScene: the subpaths build
19
+ * a renderer on the canvas BEFORE the core window exists (asynchronously, once the engine module
20
+ * has loaded), so a second `addSplat` during the first one's load would never reach the core's
21
+ * check in time. `wall._attachGuard` (the core manager) warns once per canvas and claims it; the
22
+ * call then proceeds exactly as before. A wall without it (no session, an older core) attaches as is.
23
+ */
24
+ export function guardedAttach(wall, canvas, method, opts, attach) {
25
+ const g = wall && typeof wall._attachGuard === 'function' ? wall._attachGuard(canvas, method, opts) : null;
26
+ if (!g) return attach(opts);
27
+ const out = attach(g.opts);
28
+ g.own(out);
29
+ return out;
30
+ }
31
+
15
32
  /**
16
33
  * Backstop on total subject depth, as a multiple of the display height. Generous on purpose:
17
34
  * depth placement is a z decision (see fitTo), not a scale one, so this only catches the
@@ -39,6 +39,7 @@ import {
39
39
  STREAMED_NEEDS_PLAYCANVAS,
40
40
  resolveControls,
41
41
  declareViewRig,
42
+ guardedAttach,
42
43
  } from './inline3d-splat-shared.js';
43
44
  import {
44
45
  resolveRig,
@@ -162,6 +163,12 @@ function sniffFileType(bytes) {
162
163
  * good enough for a clean, isolated capture, weaker on a scene with a background wall.
163
164
  */
164
165
  export function addSplat(wall, canvas, src, opts = {}) {
166
+ // A second call on a canvas still registered on this wall warns once, then proceeds as before
167
+ // (see guardedAttach).
168
+ return guardedAttach(wall, canvas, 'addSplat', opts, (o) => addSplatUnguarded(wall, canvas, src, o));
169
+ }
170
+
171
+ function addSplatUnguarded(wall, canvas, src, opts = {}) {
165
172
  if (opts.captureFit !== undefined && !CAPTURE_FITS.includes(opts.captureFit)) {
166
173
  throw new Error(
167
174
  `@displayxr/inline3d/splat: captureFit "${opts.captureFit}" — expected ` +
@@ -487,6 +494,11 @@ export function addSplat(wall, canvas, src, opts = {}) {
487
494
  bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context
488
495
  ...(observe ? { observe } : {}),
489
496
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
497
+ // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the
498
+ // preview tier; 'off' for a page that runs its own cover.
499
+ rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover,
500
+ // The entry's double-attach claim (guardedAttach): lets THIS registration through.
501
+ _dxrClaim: opts._dxrClaim,
490
502
  });
491
503
  } else {
492
504
  viewer.startMono();