From a8e7969697ac8b4bbd702b09a9560959d9b10897 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 8 Oct 2026 00:11:47 -0700 Subject: [PATCH 1/9] feat(core): firstWoven/rewoven settle on XRDisplayLayer.wovenState where the browser reports it (1.38.0) Feature-detected on the prototype; absent attribute keeps the hold path unchanged. firstWoven: confirmed:true, reason 'woven' on the first woven read after a stereo frame. rewoven: needs a withheld/pending read after the call, or 3 frames after a box change, so a stale woven from the old rect never settles it. The cap stays as a safety: withheld forever settles woven:false, confirmed:true, reason . New read-only handle.wovenState / handle.withheldReason; WovenState / WovenWithheldReason types. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 24 +++ docs/authoring-inline-3d.md | 6 +- docs/sdk-stability.md | 3 +- docs/woven-canvas-rules.md | 18 ++- index.d.ts | 81 ++++++++-- js/inline3d.js | 168 ++++++++++++++++++-- test/woven-state.test.mjs | 306 ++++++++++++++++++++++++++++++++++++ 7 files changed, 572 insertions(+), 34 deletions(-) create mode 100644 test/woven-state.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c3fb8d..af885a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,30 @@ methods. No export changes; `SplatHandle` gains two methods in `splat.d.ts`. - Both came from a kiosk demo's fast screen switching (black box in the moved canvas, a stalled stage queue), vendored there as a local patch first. +### Core: `firstWoven` / `rewoven()` settle on the browser's join report (`.`, additive) + +- **`XRDisplayLayer.wovenState`** (`'pending' | 'woven' | 'withheld'`, plus `withheldReason`) is + the browser's per-frame report of whether a layer's rect went into the weave (DisplayXR Browser + draft PR #258, Windows, feature-flagged). Detected with `'wovenState' in + XRDisplayLayer.prototype`. Where it is absent (mac, Linux, Android, Windows builds without it) + nothing changes: the hold, `confirmed: false`, byte for byte. +- Where it is present: + - **`firstWoven`** settles `{ woven: true, confirmed: true, reason: 'woven' }` on the first + `'woven'` read once a stereo frame is drawn, with no hold. + - **`rewoven()`** settles on a `'woven'` read that follows a `'withheld'`/`'pending'` read seen + after the call, or three session frames after the last box change. The report trails the join + by 1–3 frames, so a `'woven'` from the old rect never settles it. With no change and no gap it + waits a hold of steady `'woven'` reads, as before. + - **The cap stays, as a safety.** A layer that never leaves `'withheld'` (a CSS effect on an + ancestor) settles `{ woven: false, confirmed: true, reason: }` (or + `'pending'`) four holds after the call, never sooner than 4.8 s. That is a level, not a loss: + the canvas is not taken flat and `rewoven()` keeps working. +- **New read-only `handle.wovenState` / `handle.withheldReason`** on the `TileHandle`, read live + (`null` where the browser does not report it, `'pending'` while the window has no layer). Types: + `WovenState`, `WovenWithheldReason`; `FirstWovenResult.reason` gains `'woven'`, `'pending'` and + the withheld tokens. +- "Woven" means submitted to the weave. A GPU-stage failure after submit is not reflected. + ## 1.37.1 — 2026-10-07 ### Depth cursor: hybrid anchor keeps the line of sight, not the foot diff --git a/docs/authoring-inline-3d.md b/docs/authoring-inline-3d.md index 4a6c556f..77ea8c54 100644 --- a/docs/authoring-inline-3d.md +++ b/docs/authoring-inline-3d.md @@ -1450,9 +1450,9 @@ poster.remove(); // cut, never fade `firstWoven` resolves once and never rejects. `woven: false` means the window will not weave (the layer failed, the session ended, the window was removed), and the canvas is already flat. -Until a browser reports joins, `firstWoven` holds for the measured worst case -(`firstWovenHoldMs`, default 1200) and `confirmed` is `false`. When a browser does report joins, -it will settle on the report with no change to your page. +On a browser that reports joins (`XRDisplayLayer.wovenState`), `firstWoven` settles on the report +(`confirmed: true`, no hold). Elsewhere it holds for the measured worst case (`firstWovenHoldMs`, +default 1200) and `confirmed` is `false`. Your page is the same on both. `handle.rewoven()` asks the same question again, measured from the call, for a canvas that is already woven but whose rect is about to change (rule 11). Same result shape; a box change while diff --git a/docs/sdk-stability.md b/docs/sdk-stability.md index cb6ae276..52a153a9 100644 --- a/docs/sdk-stability.md +++ b/docs/sdk-stability.md @@ -20,7 +20,8 @@ The **exported** surface of the package entry points: settles once, never rejects, and has the `{ woven, confirmed, reason, ms }` shape. `woven: true` means "safe to reveal the canvas". *When* it settles is deliberately not frozen: today it is a worst-case hold (`confirmed: false`), and it moves to the browser's own join report - (`confirmed: true`, earlier) when a browser provides one. That is a timing change, not a + (`confirmed: true`, earlier) where a browser provides one (1.38: `XRDisplayLayer.wovenState`, + with the read-only `handle.wovenState` / `withheldReason`). That is a timing change, not a contract change ([proposal](proposals/layer-joined-signal.md)). `rewoven()` is the same contract measured from the call: it settles once, never rejects, same shape; a change of the canvas's CSS size or dpr while pending restarts it, and it settles `'hold-capped'` after at most diff --git a/docs/woven-canvas-rules.md b/docs/woven-canvas-rules.md index ab11f69b..b2bb4640 100644 --- a/docs/woven-canvas-rules.md +++ b/docs/woven-canvas-rules.md @@ -117,16 +117,20 @@ poster.remove(); // cut, never fade | result | meaning | what to do | |---|---|---| -| `woven: true`, `reason: 'hold-elapsed'` | the window has drawn a real stereo frame on a layer that has existed for `firstWovenHoldMs` (default **1200**, the browser's measured worst case) | drop the cover | +| `woven: true`, `reason: 'woven'`, `confirmed: true` | the browser reported the layer woven (`XRDisplayLayer.wovenState`), after a real stereo frame | drop the cover | +| `woven: true`, `reason: 'hold-elapsed'` | a browser without that report: the window has drawn a real stereo frame on a layer that has existed for `firstWovenHoldMs` (default **1200**, the browser's measured worst case) | drop the cover | +| `woven: false`, `confirmed: true`, `reason` a withheld token (or `'pending'`) | the browser kept reporting the layer withheld for the whole safety cap (four holds, at least 4.8 s). The canvas is not taken flat | drop the cover, then read the token in §3: it names the page fix | | `woven: false`, `reason: 'layer-failed'` / `'session-ended'` / `'removed'` | this window will not weave. An image or video canvas has already been painted flat, and a scene's `onLayerLost` has already run | drop the cover onto the 2D fallback | | `woven: false`, `reason: 'unsupported'` (`addSplat` / `addModel` only) | no inline-3D session: the viewer is on its mono path | same | -**`confirmed` is `false` today, always.** No shipping browser reports the join (§1), so -`firstWoven` is the documented worst case, measured by the SDK so that each page does not measure -it separately. When a browser can report the join, `firstWoven` settles on the report -(`confirmed: true`, no hold) and **the page does not change**. That is the reason to code -against it now rather than keep a local `setTimeout`. The browser ask is in -[`proposals/layer-joined-signal.md`](proposals/layer-joined-signal.md). +**`confirmed` says which of the two you got.** A DisplayXR Browser build with the join report +(`XRDisplayLayer.wovenState`, Windows, behind a flag; detected with `'wovenState' in +XRDisplayLayer.prototype`) settles `firstWoven` on the report: `confirmed: true`, no hold. On +every other browser `firstWoven` is the documented worst case, measured by the SDK so that each +page does not measure it separately (`confirmed: false`). **The page code is the same on both**, +which is the reason to code against it rather than keep a local `setTimeout`. To see the report +yourself, read `handle.wovenState` (`'pending'`, `'woven'`, `'withheld'`, or `null` where absent) +and `handle.withheldReason`. Background: [`proposals/layer-joined-signal.md`](proposals/layer-joined-signal.md). What counts as a "real stereo frame": for a scene, an `onFrame` that received two or more views and did not throw. A frame with a short view list is the load fallback, and a frame that threw diff --git a/index.d.ts b/index.d.ts index 35a21e32..df64af20 100644 --- a/index.d.ts +++ b/index.d.ts @@ -26,25 +26,67 @@ export interface TileOptions { firstWovenHoldMs?: number; } +/** + * The browser's per-frame report for one layer (`XRDisplayLayer.wovenState`, DisplayXR Browser + * builds that carry it; absent elsewhere). A level, never latched. + * + * - `'pending'` — no presented frame has reported this layer yet (from construction, again after + * close(), and whenever the frame's reply does not list it). + * - `'woven'` — the latest swapped frame put this layer's rect into the weave. + * - `'withheld'` — it did not; {@link WovenWithheldReason} says why. + */ +export type WovenState = 'pending' | 'woven' | 'withheld'; + +/** + * Why a layer reads `'withheld'` (`XRDisplayLayer.withheldReason`). The same tokens the browser + * logs; what each means and whose fix it is: `docs/woven-canvas-rules.md` §3. A future browser may + * add tokens, so treat an unknown string as "withheld, reason not listed". + */ +export type WovenWithheldReason = + | 'no-quad' + | 'no-identity' + | 'no-join' + | 'cross-pass:mono' + | 'cross-pass:mono(cover,2:1)' + | 'resolve-dropped' + | (string & {}); + /** * What {@link TileHandle.firstWoven} resolves to. Settles once and never rejects. * - * - `woven: true, reason: 'hold-elapsed'` — a stereo frame is on a layer that has existed for - * `firstWovenHoldMs`. Drop the poster covering the canvas. - * - `woven: true, reason: 'hold-capped'` — `rewoven()` only: the canvas kept resizing, so it - * stopped waiting four holds after the call. Drop the cover; the rect may still be settling. - * - `woven: false` — the window will not weave (`'layer-failed'`, `'session-ended'`, - * `'removed'`; the subpaths add `'unsupported'`). The canvas is already flat (image/video) or - * its `onLayerLost` has run (scene). Drop the poster onto the 2D fallback. + * - `woven: true, reason: 'woven'` (`confirmed: true`) — the BROWSER reported the layer woven + * (`XRDisplayLayer.wovenState`), after a stereo frame was drawn. No hold. Drop the poster. + * - `woven: true, reason: 'hold-elapsed'` (`confirmed: false`) — a browser without that report: + * a stereo frame is on a layer that has existed for `firstWovenHoldMs`. Drop the poster. + * - `woven: true, reason: 'hold-capped'` — `rewoven()`: the canvas kept resizing or moving, so + * it stopped waiting four holds after the call; with the report, also a 'woven' read that never + * qualified (see {@link TileHandle.rewoven}). Drop the cover; the rect may still be settling. + * - `woven: false, confirmed: true`, `reason` a {@link WovenWithheldReason} or `'pending'` — the + * browser kept reporting the layer withheld (or never listed it) for the whole cap: four holds, + * at least 4.8 s. A safety release, not a loss: the canvas is NOT taken flat and `rewoven()` + * keeps working. Drop the cover; what shows is what the browser draws for that reason + * (`cross-pass:mono` is flat in place; `no-identity` can be the raw pair). + * - `woven: false, confirmed: false` — the window will not weave (`'layer-failed'`, + * `'session-ended'`, `'removed'`; the subpaths add `'unsupported'`). The canvas is already flat + * (image/video) or its `onLayerLost` has run (scene). Drop the poster onto the 2D fallback. */ export interface FirstWovenResult { readonly woven: boolean; /** - * `true` only when the BROWSER reported the join. Always `false` today: no browser exposes - * that, so the result is the SDK's worst-case hold rather than a report. + * `true` only when the BROWSER reported the state (`XRDisplayLayer.wovenState`). `false` on a + * browser without that report: the result is then the SDK's worst-case hold. */ readonly confirmed: boolean; - readonly reason: 'hold-elapsed' | 'hold-capped' | 'layer-failed' | 'session-ended' | 'removed' | 'unsupported'; + readonly reason: + | 'woven' + | 'hold-elapsed' + | 'hold-capped' + | 'layer-failed' + | 'session-ended' + | 'removed' + | 'unsupported' + | 'pending' + | WovenWithheldReason; /** Milliseconds from the add*() call to settling. */ readonly ms: number; } @@ -422,10 +464,20 @@ export interface TileHandle { * Scene windows only — image/video windows always report `{ frames: 0, monoFrames: 0 }`. */ stats(): { frames: number; monoFrames: number }; + /** + * The browser's report for this window's layer, read live ({@link WovenState}); `'pending'` + * while the window has no live layer; `null` on a browser that does not report it (the + * attribute is absent on mac, Linux, Android and Windows builds without it). Diagnostics: + * `firstWoven` / `rewoven()` already settle on it. + */ + readonly wovenState: WovenState | null; + /** The why-token while {@link TileHandle.wovenState} is `'withheld'`, else `null`. */ + readonly withheldReason: WovenWithheldReason | null; /** * Resolves once, when it is safe to reveal this canvas: see {@link FirstWovenResult}. THE way to * release a poster held over a woven canvas — `await Promise.all([ready, handle.firstWoven])` - * and cut, never fade. Approximate until a browser reports joins (`confirmed` stays `false`). + * and cut, never fade. On a browser that reports `wovenState` it settles on that report + * (`confirmed: true`, no hold); elsewhere it is the worst-case hold (`confirmed: false`). */ readonly firstWoven: Promise; /** Callback form of {@link TileHandle.firstWoven}: called once, asynchronously. Returns an unsubscribe. */ @@ -438,6 +490,13 @@ export interface TileHandle { * 'hold-capped'`, four holds after the call, so a size that never stops animating cannot hold a * cover up for good. A second call while pending returns the same promise, restarted. Before the * first join it is `firstWoven`; on a window that will not weave it is that `woven: false` result. + * + * On a browser that reports `wovenState` it settles `confirmed: true, reason: 'woven'` on a + * 'woven' read that follows a 'withheld'/'pending' read seen after the call, or three session + * frames after the last box change (the report trails the join by 1–3 frames, so a 'woven' from + * the old rect never settles it), or after a hold of steady 'woven' reads when nothing changed. + * The cap stays: still 'withheld' four holds after the call (at least 4.8 s), it settles `woven: + * false, confirmed: true, reason: `. */ rewoven(): Promise; } diff --git a/js/inline3d.js b/js/inline3d.js index 61941170..1ebaaeba 100644 --- a/js/inline3d.js +++ b/js/inline3d.js @@ -422,6 +422,55 @@ function nowMs() { // report. Per-window override: `firstWovenHoldMs` on any add*() call. const FIRST_WOVEN_HOLD_MS = 1200; +// The browser's own join report (DisplayXR Browser PR #258, Windows, behind a feature flag): +// `XRDisplayLayer.wovenState` — 'pending' (no presented frame has reported this layer yet: from +// construction, again after close(), and whenever the reply does not list it), 'woven' (the +// latest swapped frame put this layer's rect into the weave) or 'withheld' (with a why-token in +// `withheldReason`: no-quad / no-identity / no-join / cross-pass:mono / cross-pass:mono(cover,2:1) +// / resolve-dropped). It is a per-frame LEVEL, nothing latched, read off the GetLatestViews reply, +// and the layer is not an EventTarget — so polling it once per session frame is the whole API. +// +// Where it exists, firstWoven/rewoven settle on it (`confirmed: true`) instead of on the hold. +// Where it does not (mac, Linux, Android, and every Windows build without the patch: the +// attribute is deliberately absent there, so `in` on the prototype is an honest feature test) +// the hold below is used exactly as before. +const WOVEN_STATES = new Set(['pending', 'woven', 'withheld']); +// The report trails the join by 1-3 renderer frames. After a rect change that never reads +// anything but 'woven' (a move the browser rejoins without a gap), a 'woven' read only means the +// NEW rect once this many session frames have passed since the change was seen; before that it +// can still be the old rect's. +const WOVEN_STATE_LAG_FRAMES = 3; + +/** Does this browser build report `XRDisplayLayer.wovenState`? Feature-tested on the prototype. */ +function hasWovenState() { + try { + const L = typeof XRDisplayLayer === 'function' ? XRDisplayLayer : null; + return !!(L && L.prototype && 'wovenState' in L.prototype); + } catch { + return false; + } +} + +/** This layer's woven state, or null when the read fails or returns something not in the contract. */ +function readWovenState(layer) { + try { + const s = layer ? layer.wovenState : null; + return WOVEN_STATES.has(s) ? s : null; + } catch { + return null; + } +} + +/** The why-token while 'withheld', else null. */ +function readWithheldReason(layer) { + try { + const r = layer ? layer.withheldReason : null; + return typeof r === 'string' && r ? r : null; + } catch { + return null; + } +} + // The easing option, validated here rather than in the state machine: the sequencer falls back // silently (it has no opinion about a caller's config), but a typo in `createInline3D` is worth // exactly one warning — a page that asked for 'ease-in-out' and got smoothstep should know. @@ -1220,15 +1269,36 @@ class Inline3D { // Read-only counters, for pages that want to see the load-induced mono fallback rather // than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere. stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }), + /** + * The browser's own report for this window's layer, read live: 'pending' | 'woven' | + * 'withheld', or null on a browser that does not report it (the attribute is absent: mac, + * Linux, Android, Windows builds without it). 'pending' too while the window has no live + * layer (lazy, scrolled away), matching what the browser says of a closed layer. For + * diagnostics and pages; firstWoven/rewoven already settle on it. + */ + get wovenState() { + if (!hasWovenState()) return null; + if (!win.layer) return 'pending'; + return readWovenState(win.layer); + }, + /** The browser's why-token while `wovenState` is 'withheld' (e.g. 'no-identity'), else null. */ + get withheldReason() { + if (!hasWovenState() || !win.layer) return null; + return readWovenState(win.layer) === 'withheld' ? readWithheldReason(win.layer) : null; + }, /** * Resolves ONCE, never rejects: `{ woven, confirmed, reason, ms }`. * * `woven: true` — the window has drawn a stereo frame on a layer that has existed for * `firstWovenHoldMs` (default 1200). That is the moment to drop a poster covering the - * canvas. `confirmed` is `false` today, always: no browser reports when its compositor - * actually joined a canvas, so this is the browser's worst case, measured by the SDK so - * pages stop measuring it themselves. It becomes a reported fact (`confirmed: true`, no - * hold) when a browser can say so, with no change to the page. + * canvas. On a browser without `XRDisplayLayer.wovenState` this is the browser's worst + * case, measured by the SDK (`confirmed: false`, reason 'hold-elapsed'). On one that has it, + * it is the browser's report: `confirmed: true`, reason 'woven', on the first 'woven' read + * once a stereo frame is drawn, with no hold. Same page code on both. + * + * On the report, a layer that stays 'withheld' still settles after a cap (four holds, at + * least 4.8 s): `woven: false, confirmed: true, reason: ` (or 'pending'). + * That is a level, not a loss: the canvas is NOT taken flat, and `rewoven()` keeps working. * * `woven: false` — this window will not weave: `reason` is `'layer-failed'`, * `'session-ended'` or `'removed'`. The SDK has already taken an image/video canvas flat, @@ -1270,6 +1340,12 @@ class Inline3D { * animating cannot keep a cover up. Calling it again while pending returns the same * promise, restarted. Before the first join it IS `firstWoven`; on a window that will not * weave it resolves that `woven: false` result. Same shape, never rejects. + * + * On a browser with `XRDisplayLayer.wovenState` it settles on the report (`confirmed: true`, + * reason 'woven'): on a 'woven' read that follows a 'withheld'/'pending' read seen after the + * call, or three session frames after the last box change, so a stale 'woven' from the old + * rect never settles it. The cap stays: a layer that never leaves 'withheld' settles `woven: + * false, confirmed: true, reason: ` four holds after the call. */ rewoven: () => this._rewoven(win), }; @@ -2054,6 +2130,9 @@ class Inline3D { // firstWoven's two halves again, counted from the call (and from each later box change). rw: null, rwGone: null, // the reason this window will never weave again, once one arrived + // Whether the CURRENT layer is on a browser that reports `wovenState` (set per layer in + // _activate). False keeps firstWoven/rewoven on the hold, exactly as before the report. + wsLive: false, }; win.fwPromise = new Promise((resolve) => { win.fwResolve = resolve; @@ -2148,6 +2227,7 @@ class Inline3D { win.fwLayerAt = nowMs(); win.fwStereo = false; } + win.wsLive = hasWovenState(); this._restartRewoven(win); win.layerLostSent = false; // a live layer again: a future loss is worth reporting again // Nothing about the hardware state is re-asserted here, and that is the point: the panel's @@ -2920,6 +3000,13 @@ class Inline3D { * which knows nothing about whether the compositor has matched this canvas yet. */ _tickFirstWoven(win) { + // The browser's report, where it has one. An unreadable or out-of-contract value falls back + // to the hold for this frame rather than guessing. + const ws = win.wsLive && win.layer ? readWovenState(win.layer) : null; + if (ws) { + this._tickWovenState(win, ws); + return; + } if (win.rw) { // Every frame, for every window kind: the ResizeObserver only watches SDK-sized buffers, and // no observer sees a dpr-only change. @@ -2939,16 +3026,67 @@ class Inline3D { this._settleFirstWoven(win, true, 'hold-elapsed'); } - /** One-shot: the first call wins, later ones are ignored. */ - _settleFirstWoven(win, woven, reason) { + /** + * firstWoven / rewoven on the browser's report (`wovenState`, see WOVEN_STATES). Per session + * frame, per live window, one attribute read. + * + * firstWoven: the first 'woven' read once the layer has carried a stereo frame — no hold. + * + * rewoven(): a 'woven' read is only trusted as the NEW rect's once it follows a 'withheld' or + * 'pending' read seen after the call (the rect left the weave and came back), or once + * WOVEN_STATE_LAG_FRAMES frames have passed since the last box change (a move rejoined without + * a gap). A call with no box change and no gap settles after a hold of steady 'woven' reads, + * the same wait as without the report. Either way it needs a stereo frame since the last + * restart, as on the hold path. + * + * The cap stays, as a safety: the report is a level, and a layer that never leaves 'withheld' + * (a CSS effect on an ancestor, rule 7) must not hold a cover up for good. Four holds (never + * fewer than four default holds, so `firstWovenHoldMs: 0` does not mean "give up at once") + * after the call, it settles anyway: `woven: false` with the browser's reason while withheld, + * or `'hold-capped'` if the last read was 'woven' but never qualified. + */ + _tickWovenState(win, state) { + const t = nowMs(); + const cap = REWOVEN_MAX_HOLDS * Math.max(win.fwHoldMs, FIRST_WOVEN_HOLD_MS); + if (win.rw) { + this._noteRewovenBox(win); // first, so a change seen this frame is not credited to this read + const rw = win.rw; + if (state !== 'woven') rw.gap = true; + else if ( + rw.stereo && + (rw.gap || + (rw.boxFrame !== null && this._frameCount - rw.boxFrame >= WOVEN_STATE_LAG_FRAMES) || + (rw.boxFrame === null && t - rw.at >= win.fwHoldMs)) + ) { + this._settleRewoven(win, true, 'woven', true); + } + if (win.rw && t - rw.calledAt >= cap) { + if (state === 'woven') this._settleRewoven(win, true, 'hold-capped', true); + else this._settleRewoven(win, false, readWithheldReason(win.layer) || state, true); + } + } + if (win.fwResult || win.fwLayerAt === null) return; + if (win.fwStereo && state === 'woven') this._settleFirstWoven(win, true, 'woven', true); + else if (t - win.fwLayerAt >= cap) { + if (state === 'woven') this._settleFirstWoven(win, true, 'hold-capped', true); + else this._settleFirstWoven(win, false, readWithheldReason(win.layer) || state, true); + } + } + + /** + * One-shot: the first call wins, later ones are ignored. `confirmed` marks a result read off + * the browser's report; a confirmed `woven: false` is a capped 'withheld' level, which can still + * recover, so it is NOT a terminal "will not weave" and leaves rewoven() usable. + */ + _settleFirstWoven(win, woven, reason, confirmed = false) { // Every "will not weave" path lands here, so a pending rewoven() is released with it, and a // later one answers at once (firstWoven may have settled woven:true long before). - if (!woven) { + if (!woven && !confirmed) { if (!win.rwGone) win.rwGone = reason; this._settleRewoven(win, false, reason); } if (win.fwResult) return; - win.fwResult = Object.freeze({ woven, confirmed: false, reason, ms: Math.round(nowMs() - win.fwRegAt) }); + win.fwResult = Object.freeze({ woven, confirmed, reason, ms: Math.round(nowMs() - win.fwRegAt) }); win.fwResolve(win.fwResult); win.fwResolve = null; } @@ -2956,7 +3094,9 @@ class Inline3D { /** handle.rewoven(): see the handle's doc comment. */ _rewoven(win) { if (!win.fwResult) return win.fwPromise; // still on the first join: the same question - if (!win.fwResult.woven) return Promise.resolve(win.fwResult); + // A terminal no-weave answers at once. A CONFIRMED woven:false (the first join capped while + // the browser reported 'withheld') is a level that can recover, so it is measured again. + if (!win.fwResult.woven && !win.fwResult.confirmed) return Promise.resolve(win.fwResult); if (win.rwGone) return Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: win.rwGone, ms: 0 })); if (win.rw) { this._restartRewoven(win); @@ -2967,7 +3107,9 @@ class Inline3D { resolve = r; }); const t = nowMs(); - win.rw = { promise, resolve, calledAt: t, at: t, stereo: false, box: boxKeyOf(win.canvas) }; + // `gap` / `boxFrame` are the wovenState path's (see _tickWovenState): a 'withheld'/'pending' + // read since the last restart, and the session frame of the last box change (null = none). + win.rw = { promise, resolve, calledAt: t, at: t, stereo: false, box: boxKeyOf(win.canvas), gap: false, boxFrame: null }; return promise; } @@ -2986,13 +3128,15 @@ class Inline3D { if (!win.rw) return; win.rw.at = nowMs(); win.rw.stereo = false; + win.rw.gap = false; + win.rw.boxFrame = this._frameCount; } - _settleRewoven(win, woven, reason) { + _settleRewoven(win, woven, reason, confirmed = false) { const rw = win.rw; if (!rw) return; win.rw = null; - rw.resolve(Object.freeze({ woven, confirmed: false, reason, ms: Math.round(nowMs() - rw.calledAt) })); + rw.resolve(Object.freeze({ woven, confirmed, reason, ms: Math.round(nowMs() - rw.calledAt) })); } // ── page lifecycle: bfcache, freeze, restore (browser#87) ─────────────────────────── diff --git a/test/woven-state.test.mjs b/test/woven-state.test.mjs new file mode 100644 index 00000000..3a167d45 --- /dev/null +++ b/test/woven-state.test.mjs @@ -0,0 +1,306 @@ +// Tests for firstWoven / rewoven on the browser's own join report, `XRDisplayLayer.wovenState` +// (DisplayXR Browser PR #258; Windows, feature-flagged). What is pinned: +// - feature detection is `'wovenState' in XRDisplayLayer.prototype`; without it the hold path +// runs exactly as before (confirmed:false, 'hold-elapsed'); +// - with it, firstWoven settles confirmed:true, reason 'woven', on the first 'woven' read once a +// stereo frame is drawn — no hold; +// - rewoven never settles on a stale 'woven' from the old rect: it needs a 'withheld'/'pending' +// read after the call, or WOVEN_STATE_LAG_FRAMES frames after the box change; +// - the cap still releases a layer stuck in 'withheld', as woven:false with the browser's reason; +// - handle.wovenState / withheldReason read the report live, null where it is absent. +// +// Same recording-stub approach as first-woven.test.mjs (no jsdom, injected clock). + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +let clock = 0; +Object.defineProperty(globalThis, 'performance', { + value: { now: () => clock }, + configurable: true, + writable: true, +}); + +function makeCanvas(w = 300, h = 150) { + const ctx = { save() {}, restore() {}, clearRect() {}, drawImage() {} }; + return { + style: {}, + width: 0, + height: 0, + clientWidth: w, + clientHeight: h, + parentElement: null, + contains: () => false, + getBoundingClientRect() { + return { x: 0, y: 0, left: 0, top: 0, width: this.clientWidth, height: this.clientHeight, right: this.clientWidth, bottom: this.clientHeight }; + }, + getContext: () => ctx, + }; +} + +/** `report: true` gives the layer class a `wovenState` / `withheldReason` pair, as the patched browser does. */ +function installEnv({ report = true } = {}) { + const created = []; + class FakeDisplayLayer { + constructor(session, canvas) { + this.canvas = canvas; + this._state = 'pending'; + this._reason = null; + created.push(this); + } + getViewport() { + return { x: 0, y: 0, width: 150, height: 150 }; + } + close() { + this.closed = true; + this._state = 'pending'; + } + } + if (report) { + Object.defineProperty(FakeDisplayLayer.prototype, 'wovenState', { get() { return this._state; }, configurable: true }); + Object.defineProperty(FakeDisplayLayer.prototype, 'withheldReason', { get() { return this._state === 'withheld' ? this._reason : null; }, configurable: true }); + } + const listeners = new Map(); + let frameCb = null; + const session = { + addEventListener(type, fn) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type).add(fn); + }, + removeEventListener(type, fn) { + listeners.get(type)?.delete(fn); + }, + requestReferenceSpace: async () => ({}), + requestAnimationFrame(cb) { + frameCb = cb; + return 1; + }, + end() {}, + }; + Object.defineProperty(globalThis, 'navigator', { + value: { xr: { requestSession: async () => session } }, + configurable: true, + writable: true, + }); + globalThis.window = { XRDisplayLayer: FakeDisplayLayer, devicePixelRatio: 1, addEventListener() {}, removeEventListener() {} }; + globalThis.XRDisplayLayer = FakeDisplayLayer; + globalThis.document = undefined; + globalThis.IntersectionObserver = undefined; + return { + created, + /** Set what the browser reports for the (latest) layer from the next read on. */ + report(state, reason = null) { + const l = created[created.length - 1]; + l._state = state; + l._reason = reason; + }, + runFrame(n = 2) { + const cb = frameCb; + frameCb = null; + assert.ok(cb, 'the manager should have a session frame armed'); + const views = [{ eye: 'left' }, { eye: 'right' }].slice(0, n); + cb(clock, { getViewerPose: () => (n ? { views } : null) }); + }, + }; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); +async function peek(p) { + const PENDING = Symbol('pending'); + const v = await Promise.race([p, flush().then(() => PENDING)]); + return v === PENDING ? 'pending' : v; +} + +installEnv(); +const { createInline3D } = await import('../js/inline3d.js'); +const newWall = () => createInline3D({ lazy: false, autoChrome: false }); + +test('no wovenState on the prototype: the hold path, byte for byte (confirmed:false, hold-elapsed)', async () => { + clock = 0; + const env = installEnv({ report: false }); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + assert.equal(h.wovenState, null, 'absent attribute reads null, not a guess'); + assert.equal(h.withheldReason, null); + env.runFrame(2); + clock = 1199; + env.runFrame(2); + assert.equal(await peek(h.firstWoven), 'pending'); + clock = 1200; + env.runFrame(2); + assert.deepEqual({ ...(await peek(h.firstWoven)) }, { woven: true, confirmed: false, reason: 'hold-elapsed', ms: 1200 }); + wall.close(); +}); + +test('firstWoven: pending → woven settles confirmed on the first woven read, no hold', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + assert.equal(h.wovenState, 'pending'); + env.runFrame(2); + clock = 16; + env.runFrame(2); + assert.equal(await peek(h.firstWoven), 'pending', 'still pending: nothing to settle on'); + clock = 50; + env.report('woven'); + env.runFrame(2); + assert.equal(h.wovenState, 'woven'); + assert.deepEqual({ ...(await peek(h.firstWoven)) }, { woven: true, confirmed: true, reason: 'woven', ms: 50 }); + wall.close(); +}); + +test('firstWoven: a woven read needs a stereo frame drawn too', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + const quietDebug = console.debug; + console.debug = () => {}; + try { + const h = wall.addScene(makeCanvas(), () => {}); + env.report('woven'); + env.runFrame(1); // a mono (load-fallback) frame: not a stereo frame + assert.equal(await peek(h.firstWoven), 'pending'); + env.runFrame(2); + assert.equal((await peek(h.firstWoven)).reason, 'woven'); + } finally { + console.debug = quietDebug; + } + wall.close(); +}); + +test('firstWoven: a layer withheld forever is released at the cap, woven:false with the browser reason', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}, { firstWovenHoldMs: 100 }); + env.report('withheld', 'cross-pass:mono'); + assert.equal(h.withheldReason, 'cross-pass:mono'); + env.runFrame(2); + clock = 4799; // the cap never drops below four DEFAULT holds, whatever firstWovenHoldMs says + env.runFrame(2); + assert.equal(await peek(h.firstWoven), 'pending'); + clock = 4800; + env.runFrame(2); + assert.deepEqual({ ...(await peek(h.firstWoven)) }, { woven: false, confirmed: true, reason: 'cross-pass:mono', ms: 4800 }); + // A confirmed withheld is a level, not a loss: rewoven() measures again rather than replaying it. + const p = h.rewoven(); + assert.equal(await peek(p), 'pending', 'measured again, not answered with the withheld result'); + env.runFrame(2); // still withheld after the call: the gap + env.report('woven'); + env.runFrame(2); + assert.deepEqual({ ...(await peek(p)) }, { woven: true, confirmed: true, reason: 'woven', ms: 0 }); + wall.close(); +}); + +async function wovenWall(env, canvas, opts = {}) { + const wall = await newWall(); + const h = wall.addScene(canvas, () => {}, opts); + env.report('woven'); + env.runFrame(2); + assert.equal((await peek(h.firstWoven)).confirmed, true); + return { wall, h }; +} + +test('rewoven: a stale woven from the old rect does not settle it; withheld → woven after the change does', async () => { + clock = 0; + const env = installEnv(); + const canvas = makeCanvas(400, 200); + const { wall, h } = await wovenWall(env, canvas); + clock = 1000; + const p = h.rewoven(); + canvas.clientWidth = 800; // the rect changes; the browser still reports the OLD rect as woven + canvas.clientHeight = 400; + env.runFrame(2); + assert.equal(await peek(p), 'pending', 'a woven read on the frame the change was seen is the old rect'); + clock = 1016; + env.report('withheld', 'no-identity'); + env.runFrame(2); + assert.equal(h.wovenState, 'withheld'); + assert.equal(h.withheldReason, 'no-identity'); + assert.equal(await peek(p), 'pending'); + clock = 1300; + env.report('woven'); + env.runFrame(2); + assert.deepEqual({ ...(await peek(p)) }, { woven: true, confirmed: true, reason: 'woven', ms: 300 }); + wall.close(); +}); + +test('rewoven: a move rejoined without a gap settles three frames after the change, not before', async () => { + clock = 0; + const env = installEnv(); + const canvas = makeCanvas(400, 200); + const { wall, h } = await wovenWall(env, canvas); + const p = h.rewoven(); + canvas.clientWidth = 500; + env.runFrame(2); // change seen (frame N) + env.runFrame(2); // N+1 + env.runFrame(2); // N+2 + assert.equal(await peek(p), 'pending', 'the report can trail the join by up to three frames'); + env.runFrame(2); // N+3 + assert.equal((await peek(p)).reason, 'woven'); + wall.close(); +}); + +test('rewoven: a woven read before the call does not count as the gap', async () => { + clock = 0; + const env = installEnv(); + const canvas = makeCanvas(400, 200); + const { wall, h } = await wovenWall(env, canvas); + env.report('withheld', 'no-join'); // a gap BEFORE the call + env.runFrame(2); + env.report('woven'); + env.runFrame(2); + const p = h.rewoven(); + canvas.clientWidth = 640; + env.runFrame(2); + assert.equal(await peek(p), 'pending'); + wall.close(); +}); + +test('rewoven: no change and no gap settles after a hold of steady woven reads', async () => { + clock = 0; + const env = installEnv(); + const { wall, h } = await wovenWall(env, makeCanvas()); + clock = 100; + const p = h.rewoven(); + clock = 1299; + env.runFrame(2); + assert.equal(await peek(p), 'pending'); + clock = 1300; + env.runFrame(2); + assert.deepEqual({ ...(await peek(p)) }, { woven: true, confirmed: true, reason: 'woven', ms: 1200 }); + wall.close(); +}); + +test('rewoven: withheld forever is capped, woven:false with withheldReason', async () => { + clock = 0; + const env = installEnv(); + const canvas = makeCanvas(); + const { wall, h } = await wovenWall(env, canvas); + clock = 1000; + const p = h.rewoven(); + canvas.clientWidth = 777; + env.report('withheld', 'no-quad'); + for (clock = 1000; clock < 5800; clock += 100) env.runFrame(2); + assert.equal(await peek(p), 'pending'); + clock = 5800; + env.runFrame(2); + assert.deepEqual({ ...(await peek(p)) }, { woven: false, confirmed: true, reason: 'no-quad', ms: 4800 }); + // Not terminal: the next call measures again. + const q = h.rewoven(); + env.report('woven'); + env.runFrame(2); + assert.equal(await peek(q), 'pending', 'a fresh call with no change waits a hold of woven reads'); + wall.close(); +}); + +test('rewoven: removal still settles woven:false, unconfirmed (the terminal path is unchanged)', async () => { + clock = 0; + const env = installEnv(); + const { h } = await wovenWall(env, makeCanvas()); + const p = h.rewoven(); + h.remove(); + assert.deepEqual({ ...(await peek(p)) }, { woven: false, confirmed: false, reason: 'removed', ms: 0 }); + assert.equal(h.wovenState, 'pending', 'no live layer reads pending'); +}); From ecd6283370f15eb61f86dcfe563ce7462e551adc Mon Sep 17 00:00:00 2001 From: David Date: Thu, 8 Oct 2026 00:16:31 -0700 Subject: [PATCH 2/9] feat: SDK-owned cover across a rect change (rectCover), default 'auto' on ./splat and ./model (1.38.0) Core primitive on every add*(): after the first join, a change of the canvas's CSS size, dpr or page position raises a sibling 2D cover (color + one left-eye snapshot via drawImage at the change) and cuts it when rewoven() settles; a second change while up restarts; remove() and session end tear it down. 'off' in the frozen core, 'auto' on the scene subpaths. rewoven()'s box key gains the page position (document scroll excluded), so a pure move now restarts it. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 23 ++ docs/woven-canvas-rules.md | 24 +- index.d.ts | 31 ++- js/inline3d-model-playcanvas.js | 3 + js/inline3d-model.js | 3 + js/inline3d-splat-playcanvas.js | 3 + js/inline3d-splat.js | 3 + js/inline3d.js | 239 ++++++++++++++++++- model.d.ts | 9 +- splat.d.ts | 9 +- test/rect-cover.test.mjs | 392 ++++++++++++++++++++++++++++++++ 11 files changed, 724 insertions(+), 15 deletions(-) create mode 100644 test/rect-cover.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index af885a35..993615bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,29 @@ methods. No export changes; `SplatHandle` gains two methods in `splat.d.ts`. the withheld tokens. - "Woven" means submitted to the weave. A GPU-stage failure after submit is not reflected. +### Cover across a rect change, owned by the SDK (`rectCover`; core primitive, subpath default) + +- **New `rectCover` option on every `add*()`**: `'auto' | 'off' | { color?, snapshot? }`. Once a + window has woven, a change of its canvas's CSS size, dpr or page position raises a cover on the + frame the change is seen, and cuts it when `rewoven()` settles. A further change while it is up + restarts the wait. `remove()` and session end take it out of the DOM. + - **What the cover is:** a 2D canvas placed as the canvas's next sibling, never a second woven + canvas. It has `pointer-events: none` and `data-inline3d-cover`, and it is filled with `color` + (default `#000`). + - **The snapshot:** with `snapshot` (the default), the cover also shows the last frame: the left + eye, cover-fit to the new box. It is taken once per change, with one `drawImage` into a canvas + the size of the CSS box. Never `toDataURL`, never per frame. + - **Not before the first join:** cover a fresh canvas yourself until `firstWoven` (rule 5). +- **Defaults.** `'off'` on the core `addScene` / `addImage` / `addVideo` (frozen tier, unchanged). + **`'auto'` on `./splat` and `./model`, all engines.** That is a default change in the preview + tier: a page that already runs its own cover passes `rectCover: 'off'`. +- **`rewoven()` now also restarts on a pure move** (core, timing only). The box it watches gained + the canvas's page position (viewport rect plus document scroll, whole CSS px). A canvas moved + to another place at the same size goes through the same identity gap. Scrolling the document is + not a move. A scroll inside a nested scroller does read as one. +- From a kiosk demo that keeps one woven canvas and moves it between screens: a black box at the + new rect for 0.4–1.2 s, covered by hand on timers until now. + ## 1.37.1 — 2026-10-07 ### Depth cursor: hybrid anchor keeps the line of sight, not the foot diff --git a/docs/woven-canvas-rules.md b/docs/woven-canvas-rules.md index b2bb4640..ed3b93f7 100644 --- a/docs/woven-canvas-rules.md +++ b/docs/woven-canvas-rules.md @@ -221,12 +221,24 @@ cover.hidden = true; // cut, never fade ``` `rewoven()` is `firstWoven` measured from the call, with the same result shape. A change of the -canvas's CSS size or devicePixelRatio while it is pending restarts the hold (checked every frame, -for every window kind), so a resize that settles over several frames is covered until the last -one. A scroll or layout shift that moves the tile without resizing it is deliberately not -detected. So that a size that never stops animating cannot hold the cover up for good, it settles -anyway (`reason: 'hold-capped'`) four holds after the call. Before the first join it is `firstWoven` itself. The SDK's player -(`addPlayer`) does this for its own fullscreen button with `fullscreenCover: true`. +canvas's CSS size, devicePixelRatio or page position while it is pending restarts the hold +(checked every frame, for every window kind), so a resize or a move that settles over several +frames is covered until the last one. Position is measured in page coordinates, so scrolling the +document is not a move (since 1.38; before, a move without a resize was not detected at all). So +that a size that never stops animating cannot hold the cover up for good, it settles anyway +(`reason: 'hold-capped'`) four holds after the call. Before the first join it is `firstWoven` +itself. The SDK's player (`addPlayer`) does this for its own fullscreen button with +`fullscreenCover: true`. + +**The SDK covers rect changes for you on `./splat` and `./model`** (1.38, `rectCover: 'auto'` by +default there). After the first join, a move or resize of the canvas raises a cover over it on +the frame the change is seen: a solid color with the last frame's left eye, cover-fit. The cover +comes down when `rewoven()` settles. Pass `rectCover: 'off'` if the page runs its own cover. On +core windows (`addScene` / `addImage` / `addVideo`) the same primitive is opt-in: pass +`rectCover: 'auto'` (or `{ color, snapshot }`), or keep your own cover and release it on +`handle.rewoven()`. A move is seen at the next session frame, so a move your page makes after that +frame's callback can show for one frame. Move the canvas from an event handler or before the frame, +not from a later animation callback. **Measured on the Leia panel (26 September, browser test build with patch 0195, blind A/B, one observer):** entering and leaving the player's fullscreen showed **no** raw side-by-side pair, diff --git a/index.d.ts b/index.d.ts index df64af20..d1d5a026 100644 --- a/index.d.ts +++ b/index.d.ts @@ -24,8 +24,32 @@ export interface TileOptions { * you know is not fresh; 0 means "the first stereo frame". */ firstWovenHoldMs?: number; + /** + * Cover this canvas across a rect change, owned by the SDK. Default `'off'` on `addImage` / + * `addVideo` / `addScene` (frozen defaults); `'auto'` on `./splat` and `./model`. + * + * Once the window has woven, a change of the canvas's CSS size, devicePixelRatio or PAGE position + * (scrolling the document is not a move) raises a sibling element over the canvas on the frame + * the change is seen, and cuts it when {@link TileHandle.rewoven} settles; a further change + * while it is up restarts the wait. The cover is a 2D canvas, never a second woven canvas, + * placed as the canvas's next sibling (`pointer-events: none`, `data-inline3d-cover`). It is + * filled with `color` and, with `snapshot`, the last frame: the left eye, cover-fit to the new + * box, captured once at the change with one `drawImage` (never `toDataURL`). + * + * - `'auto'` — `{ color: '#000', snapshot: true }`. + * - `'off'` — nothing; a page that runs its own cover passes this. + * - `{ color?, snapshot? }` — `color` any CSS color (default `'#000'`); `snapshot` default true. + * + * Before the first join nothing is raised: cover a fresh canvas yourself until `firstWoven` + * (rule 5). A pure move is seen at the next session frame, so a move made after that frame's + * callback can show for one frame. + */ + rectCover?: RectCoverOption; } +/** See {@link TileOptions.rectCover}. */ +export type RectCoverOption = 'auto' | 'off' | { color?: string; snapshot?: boolean }; + /** * The browser's per-frame report for one layer (`XRDisplayLayer.wovenState`, DisplayXR Browser * builds that carry it; absent elsewhere). A level, never latched. @@ -484,9 +508,10 @@ export interface TileHandle { onFirstWoven(cb: (result: FirstWovenResult) => void): () => void; /** * {@link TileHandle.firstWoven}, measured from NOW. Cover an already-woven canvas across a rect - * change (fullscreen, a layout resize), then release on this. A change of the canvas's CSS size - * or devicePixelRatio while pending restarts the hold (checked every frame, any window kind; a - * move without a resize is not detected). It settles anyway, `woven: true, reason: + * change (fullscreen, a layout resize, a move), then release on this ({@link TileOptions.rectCover} + * does both for you). A change of the canvas's CSS size, devicePixelRatio or page position (since + * 1.38; document scroll excluded) while pending restarts the hold (checked every frame, any + * window kind). It settles anyway, `woven: true, reason: * 'hold-capped'`, four holds after the call, so a size that never stops animating cannot hold a * cover up for good. A second call while pending returns the same promise, restarted. Before the * first join it is `firstWoven`; on a window that will not weave it is that `woven: false` result. diff --git a/js/inline3d-model-playcanvas.js b/js/inline3d-model-playcanvas.js index 2d78cb26..e291199e 100644 --- a/js/inline3d-model-playcanvas.js +++ b/js/inline3d-model-playcanvas.js @@ -531,6 +531,9 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = [] bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context ...(observe ? { observe } : {}), ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}), + // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the + // preview tier; 'off' for a page that runs its own cover. + rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover, }); } else { viewer.startMono(); diff --git a/js/inline3d-model.js b/js/inline3d-model.js index e405a399..5fb52419 100644 --- a/js/inline3d-model.js +++ b/js/inline3d-model.js @@ -426,6 +426,9 @@ export function addModel(wall, canvas, src, opts = {}) { bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context ...(observe ? { observe } : {}), ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}), + // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the + // preview tier; 'off' for a page that runs its own cover. + rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover, }); } else { viewer.startMono(); diff --git a/js/inline3d-splat-playcanvas.js b/js/inline3d-splat-playcanvas.js index 36200aae..b32cdefd 100644 --- a/js/inline3d-splat-playcanvas.js +++ b/js/inline3d-splat-playcanvas.js @@ -3599,6 +3599,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = [] bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context ...(observe ? { observe } : {}), ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}), + // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the + // preview tier; 'off' for a page that runs its own cover. + rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover, }); } else { viewer.startMono(); diff --git a/js/inline3d-splat.js b/js/inline3d-splat.js index b5b21a49..7c4cd3a9 100644 --- a/js/inline3d-splat.js +++ b/js/inline3d-splat.js @@ -487,6 +487,9 @@ export function addSplat(wall, canvas, src, opts = {}) { bufferClamped: true, // this viewer clamps its own buffer; the core must never touch its context ...(observe ? { observe } : {}), ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}), + // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the + // preview tier; 'off' for a page that runs its own cover. + rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover, }); } else { viewer.startMono(); diff --git a/js/inline3d.js b/js/inline3d.js index 1ebaaeba..d6f372fa 100644 --- a/js/inline3d.js +++ b/js/inline3d.js @@ -742,10 +742,45 @@ function chromeTextPlates(root) { /** A pending rewoven() settles anyway ('hold-capped') after this many holds from the call. */ const REWOVEN_MAX_HOLDS = 4; -/** The CSS size + dpr of a window's canvas: what a pending rewoven() restarts on. */ +/** + * The CSS size + dpr + PAGE position of a window's canvas: what a pending rewoven() restarts on, + * and what a rect cover goes up on. + * + * Position joined the key in 1.38: a canvas moved to another place on the page, at the same size, + * goes through the same identity gap as a resized one (a kiosk demo moving ONE woven canvas between + * screens measured 0.4-1.2 s of black at the new rect). It is measured in PAGE coordinates + * (viewport rect + document scroll) so that scrolling the document is not a move: a scrolled + * canvas keeps its identity (rule 11). Rounded to whole CSS px so sub-pixel layout jitter is not + * a move either. A scroll inside a nested scroller still reads as one; that is the price of one + * getBoundingClientRect per frame instead of walking the scroll ancestors. + */ function boxKeyOf(canvas) { const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1; - return `${canvas.clientWidth}x${canvas.clientHeight}@${dpr}`; + let pos = ''; + if (typeof canvas.getBoundingClientRect === 'function') { + const r = canvas.getBoundingClientRect() || {}; + const sx = (typeof window !== 'undefined' && window.scrollX) || 0; + const sy = (typeof window !== 'undefined' && window.scrollY) || 0; + pos = `+${Math.round((r.left ?? r.x ?? 0) + sx)},${Math.round((r.top ?? r.y ?? 0) + sy)}`; + } + return `${canvas.clientWidth}x${canvas.clientHeight}@${dpr}${pos}`; +} + +/** + * The `rectCover` option of every add*(): null (off) or `{ color, snapshot }`. Off by default in + * the core (frozen defaults); `./splat` and `./model` pass 'auto'. A bad value throws at the call: + * a typo here would otherwise silently leave a page without the cover it asked for. + */ +function resolveRectCover(v) { + if (v === undefined || v === null || v === false || v === 'off') return null; + if (v === 'auto' || v === true) return { color: '#000', snapshot: true }; + if (typeof v === 'object') { + if (v.color !== undefined && typeof v.color !== 'string') { + throw new TypeError('[inline3d] rectCover.color must be a CSS color string.'); + } + return { color: v.color || '#000', snapshot: v.snapshot !== false }; + } + throw new TypeError(`[inline3d] rectCover: expected 'auto', 'off' or { color?, snapshot? }; got ${JSON.stringify(v)}.`); } class Inline3D { @@ -995,6 +1030,8 @@ class Inline3D { * @param {number} [opts.cornerRadius=0] round each eye's corners in buffer px (CSS * border-radius can't: it would round the packed SBS square's outer corners and * come out lopsided after the eye-split). + * @param {'auto'|'off'|{color?:string, snapshot?:boolean}} [opts.rectCover='off'] (every add*()) + * cover the canvas across a later move/resize until rewoven() settles; see _rectCoverCheck. * @param {number} [opts.feather=0] fade each eye's outer edges to transparent over this * many buffer px, so the 3D window dissolves into the page instead of ending at a * hard rectangle. Same reason CSS can't do it: a mask/filter on the canvas applies @@ -1334,8 +1371,9 @@ class Inline3D { * already woven but whose rect is about to change (fullscreen, a layout resize): the * browser re-registers the moved rect and the same identity gap as a fresh canvas * applies, so cover the canvas across the change and release on this. A change of the - * canvas's CSS size or devicePixelRatio while it is pending restarts the hold (checked every - * frame, for every window kind; a move without a resize is not detected), and it settles + * canvas's CSS size, devicePixelRatio or page position (since 1.38: a moved canvas goes + * through the same gap; document scroll is not a move) while it is pending restarts the + * hold (checked every frame, for every window kind), and it settles * anyway with `reason: 'hold-capped'` four holds after the call, so a size that never stops * animating cannot keep a cover up. Calling it again while pending returns the same * promise, restarted. Before the first join it IS `firstWoven`; on a window that will not @@ -2133,7 +2171,16 @@ class Inline3D { // Whether the CURRENT layer is on a browser that reports `wovenState` (set per layer in // _activate). False keeps firstWoven/rewoven on the hold, exactly as before the report. wsLive: false, + // The rect cover (opts.rectCover; see _rectCoverCheck), or null when off. + rc: null, }; + const rcCfg = resolveRectCover(opts.rectCover); + if (rcCfg) { + // box: the last key seen (null = not looked yet); el: the cover element, made on first use; + // up: showing; snapped/snapWanted: this cycle's one snapshot; waiting: the rewoven promise + // that takes it down; w/h: the CSS box at the last look (the snapshot's source aspect). + win.rc = { cfg: rcCfg, box: null, el: null, up: false, snapped: false, snapWanted: false, waiting: null, w: 0, h: 0 }; + } win.fwPromise = new Promise((resolve) => { win.fwResolve = resolve; }); @@ -2153,6 +2200,7 @@ class Inline3D { this._deactivate(win); this._windows.delete(canvas); this._settleFirstWoven(win, false, 'removed'); + this._dropCover(win); } _onIntersect(entries) { @@ -2922,6 +2970,8 @@ class Inline3D { this._trackBakedStereo(t); for (const win of this._windows.values()) { if (!win.layer) continue; + // Before the draw: a moved/resized canvas is covered before anything else happens to it. + if (win.rc) this._rectCoverCheck(win); if (win.kind === 'scene') { if (views && win.onFrame) { // Count the SHORT view lists and hand them over unchanged. Under GPU load the session @@ -2985,6 +3035,10 @@ class Inline3D { if (win.rw) win.rw.stereo = true; } } + // After the draw: the one snapshot of this cover cycle reads the frame just committed to + // the backing store, in the same task, which is the one moment a WebGL drawing buffer is + // guaranteed readable without preserveDrawingBuffer. + if (win.rc && win.rc.snapWanted) this._rectCoverSnap(win); this._tickFirstWoven(win); } } @@ -3139,6 +3193,182 @@ class Inline3D { rw.resolve(Object.freeze({ woven, confirmed, reason, ms: Math.round(nowMs() - rw.calledAt) })); } + // ── rect cover (opts.rectCover) ────────────────────────────────────────────────────── + // + // A canvas that is already woven and then moves or resizes goes back through the identity gap + // (rule 11): until the browser rejoins it at the new rect, the screen shows the page's own + // raster of it — black, or the squeezed pair. A kiosk demo that moves ONE woven canvas between + // screens measured 0.4-1.2 s of that, and every page that hit it hand-rolled the same cover. + // This is that cover, owned by the SDK: a sibling element over the canvas (never a second woven + // canvas: two 2:1 candidates in one region are refused as ambiguous, rule 6), raised on the + // frame the change is seen and cut when rewoven() settles. + + /** Per frame, before the draw: has the box (size, dpr, page position) changed since the last look? */ + _rectCoverCheck(win) { + const rc = win.rc; + const key = boxKeyOf(win.canvas); + if (key === rc.box) return; + const first = rc.box === null; + const prevW = rc.w; + const prevH = rc.h; + rc.box = key; + rc.w = win.canvas.clientWidth || 0; + rc.h = win.canvas.clientHeight || 0; + if (first) return; // the first look is the baseline, not a change + if (rc.up) this._placeCover(win); // already up: follow the canvas + // Before the first join the page's own poster covers the canvas (rule 5), and a window that + // will not weave has nothing to wait for. A confirmed withheld first result still covers. + const fw = win.fwResult; + if (!fw || win.rwGone || !(fw.woven || fw.confirmed)) return; + if (!rc.up) { + if (!rc.snapped) rc.aspect = prevH > 0 ? prevW / prevH : 0; // what the eye looked like on screen + this._raiseCover(win); + } + if (rc.cfg.snapshot && !rc.snapped && rc.w > 0 && rc.h > 0) rc.snapWanted = true; + let p; + if (win.rw) { + this._noteRewovenBox(win); // a second change while pending restarts it + p = win.rw.promise; + } else { + p = this._rewoven(win); + if (win.rw) win.rw.boxFrame = this._frameCount; // the change was seen THIS frame + } + if (rc.waiting !== p) { + rc.waiting = p; + p.then(() => { + if (rc.waiting !== p) return; + rc.waiting = null; + this._lowerCover(win); + }); + } + } + + _raiseCover(win) { + const rc = win.rc; + if (!rc.el) { + if (typeof document === 'undefined' || !document || typeof document.createElement !== 'function') return; + if (!win.canvas.parentElement) return; + const el = document.createElement('canvas'); + el.setAttribute?.('aria-hidden', 'true'); + if (el.dataset) el.dataset.inline3dCover = ''; + const st = el.style; + st.position = 'absolute'; + st.pointerEvents = 'none'; // the canvas under it keeps its input (orbit, clicks) + st.margin = '0'; + st.border = '0'; + st.padding = '0'; + st.background = rc.cfg.color; + st.display = 'none'; + // Stack with the canvas, not under it: a canvas the page raised gets the same z-index. + try { + const z = typeof getComputedStyle === 'function' ? getComputedStyle(win.canvas).zIndex : 'auto'; + if (z && z !== 'auto') st.zIndex = z; + } catch { + /* no computed style: DOM order alone puts it on top */ + } + rc.el = el; + } + this._placeCover(win); + rc.el.style.display = 'block'; + rc.up = true; + // Declared through the existing exclusion path. On a draw-order browser that is a no-op and + // the cover is crisp 2D anyway (it sits over the tile in draw order). On an older one the + // full-tile guard refuses it, on purpose: a congruent plate would stage the CANVAS as the + // overlay. Pre-marked as warned so the SDK's own cover does not trip the page-facing warning. + this._fullTileWarned.add(rc.el); + this._applyExclusion(win, rc.el); + } + + /** Put the cover exactly over the canvas, as its next sibling (the canvas may have been re-parented). */ + _placeCover(win) { + const rc = win.rc; + const el = rc.el; + const c = win.canvas; + if (!el) return; + const parent = c.parentElement; + if (parent && (el.parentElement !== parent || el.previousSibling !== c)) { + parent.insertBefore(el, c.nextSibling); + } + const st = el.style; + if (c.offsetParent) { + // Same containing block as the canvas (its offsetParent), so offsets are directly usable. + st.position = 'absolute'; + st.left = `${c.offsetLeft}px`; + st.top = `${c.offsetTop}px`; + st.width = `${c.offsetWidth}px`; + st.height = `${c.offsetHeight}px`; + } else { + // A fixed canvas (no offsetParent): cover its viewport rect. + const r = c.getBoundingClientRect(); + st.position = 'fixed'; + st.left = `${r.left ?? r.x ?? 0}px`; + st.top = `${r.top ?? r.y ?? 0}px`; + st.width = `${r.width}px`; + st.height = `${r.height}px`; + } + } + + /** + * The last frame, once per cover cycle: the LEFT eye of the side-by-side buffer, cover-fit into a + * 2D canvas the size of the new CSS box (CSS px, not device px: it is on screen for about a second + * and must not cost a full-resolution copy). The eye's on-screen aspect was the OLD box's (each + * eye is stretched to the box by the weave), so the crop is computed from that aspect, not from + * the buffer's. Never toDataURL: one drawImage, at this moment only. Any failure (a lost + * context, a tainted source) leaves the solid color. + */ + _rectCoverSnap(win) { + const rc = win.rc; + rc.snapWanted = false; + const el = rc.el; + const src = win.canvas; + if (!el || !rc.up || !(src.width > 1) || !(src.height > 0)) return; + const bw = Math.max(1, Math.round(rc.w)); + const bh = Math.max(1, Math.round(rc.h)); + const target = bw / bh; + // A canvas that was hidden (0x0) before the change has no old box to read: the eye half of the + // buffer, still at its old size at this point, has the same aspect. + const aspect = rc.aspect > 0 ? rc.aspect : src.width / 2 / src.height; + let fw = 1; + let fh = 1; + if (target > aspect) fh = aspect / target; // wider box: keep the width, crop height + else fw = target / aspect; // taller box: keep the height, crop width + const sw = (src.width / 2) * fw; + const sh = src.height * fh; + const sx = (src.width / 2 - sw) / 2; + const sy = (src.height - sh) / 2; + try { + el.width = bw; + el.height = bh; + const ctx = el.getContext('2d'); + if (!ctx) return; + ctx.drawImage(src, sx, sy, sw, sh, 0, 0, bw, bh); + rc.snapped = true; + } catch { + /* the solid color stays */ + } + } + + _lowerCover(win) { + const rc = win.rc; + if (!rc) return; + rc.up = false; + rc.snapped = false; // the next cycle takes its own snapshot + rc.snapWanted = false; + if (!rc.el) return; + rc.el.style.display = 'none'; + this._dropExclusion(win, rc.el); + } + + /** remove() / session end: the cover leaves the DOM with its window. */ + _dropCover(win) { + const rc = win.rc; + if (!rc) return; + rc.waiting = null; + this._lowerCover(win); + if (rc.el && rc.el.parentElement) rc.el.parentElement.removeChild(rc.el); + rc.el = null; + } + // ── page lifecycle: bfcache, freeze, restore (browser#87) ─────────────────────────── // // A weaved window's rect reaches the compositor from the session's own rAF: every frame the @@ -3271,6 +3501,7 @@ class Inline3D { this._paintMono(win); this._notifyLayerLost(win); this._settleFirstWoven(win, false, 'session-ended'); + this._dropCover(win); } this._windows.clear(); // Nobody is tracked through a session that has ended: say so ONCE, before the listeners are diff --git a/model.d.ts b/model.d.ts index 4a8f1521..398bde0b 100644 --- a/model.d.ts +++ b/model.d.ts @@ -3,7 +3,7 @@ import type { CursorOptions } from './cursor-depth.js'; import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js'; -import type { FirstWovenResult, ViewerEaseOption } from './index.js'; +import type { FirstWovenResult, RectCoverOption, ViewerEaseOption } from './index.js'; import type { PlayCanvasSplatViewer, SplatCameraPose, SplatFrameInfo } from './splat.js'; export interface ModelOptions { @@ -164,6 +164,13 @@ export interface ModelOptions { observe?: Element; /** Forwarded to the core window: see `TileOptions.firstWovenHoldMs`. */ firstWovenHoldMs?: number; + /** + * The SDK's cover across a move or resize of this canvas (see `TileOptions.rectCover`). + * **Default `'auto'` here** (the core default is `'off'`): a solid cover showing the last frame, + * raised when the canvas's size, dpr or page position changes and cut when `rewoven()` settles. + * Pass `'off'` if the page runs its own cover. + */ + rectCover?: RectCoverOption; } /** What {@link addModel} returns — the same shape as addSplat's handle. */ diff --git a/splat.d.ts b/splat.d.ts index f705ca96..4eef1b41 100644 --- a/splat.d.ts +++ b/splat.d.ts @@ -3,7 +3,7 @@ import type { CursorOptions } from './cursor-depth.js'; import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js'; -import type { FirstWovenResult, ViewerEaseOption, DisplayMetrics } from './index.js'; +import type { FirstWovenResult, RectCoverOption, ViewerEaseOption, DisplayMetrics } from './index.js'; /** * The knobs behind `SplatOptions.perf`. Every one is a Spark 2.1.0 setting except `alphaRadius`, @@ -515,6 +515,13 @@ export interface SplatOptions { observe?: Element; /** Forwarded to the core window: see `TileOptions.firstWovenHoldMs`. */ firstWovenHoldMs?: number; + /** + * The SDK's cover across a move or resize of this canvas (see `TileOptions.rectCover`). + * **Default `'auto'` here** (the core default is `'off'`): a solid cover showing the last frame, + * raised when the canvas's size, dpr or page position changes and cut when `rewoven()` settles. + * Pass `'off'` if the page runs its own cover. + */ + rectCover?: RectCoverOption; /** * Who owns the camera. `'viewer'` (the default): the SDK's orbit, idle spin, auto-fit and focus * gestures. `'page'` (`engine: 'playcanvas'` only; throws on Spark): the page drives the camera diff --git a/test/rect-cover.test.mjs b/test/rect-cover.test.mjs new file mode 100644 index 00000000..f2073197 --- /dev/null +++ b/test/rect-cover.test.mjs @@ -0,0 +1,392 @@ +// Tests for `rectCover` — the SDK-owned cover over a woven canvas across a move or resize, released +// on rewoven() (core primitive; default 'off' in the core, 'auto' on ./splat and ./model). +// +// The state machine is what is pinned: change → cover up (on the frame the change is seen) → +// rewoven settles → cover down; a second change while it is up restarts the wait; remove() takes +// the element out of the DOM; nothing happens before the first join, or with the option off. Plus +// the box key: a pure MOVE now restarts a pending rewoven(), a document scroll does not. +// +// Recording DOM stubs and an injected clock, as first-woven.test.mjs. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +let clock = 0; +Object.defineProperty(globalThis, 'performance', { + value: { now: () => clock }, + configurable: true, + writable: true, +}); + +function makeParent() { + const parent = { + children: [], + insertBefore(el, ref) { + const i = this.children.indexOf(el); + if (i >= 0) this.children.splice(i, 1); + if (el.parentElement && el.parentElement !== this) el.parentElement.removeChild(el); + const at = ref ? this.children.indexOf(ref) : -1; + if (at >= 0) this.children.splice(at, 0, el); + else this.children.push(el); + el.parentElement = this; + }, + removeChild(el) { + const i = this.children.indexOf(el); + if (i >= 0) this.children.splice(i, 1); + el.parentElement = null; + }, + querySelectorAll: () => [], + }; + return parent; +} +const sibling = (el, d) => { + const p = el.parentElement; + if (!p) return null; + return p.children[p.children.indexOf(el) + d] || null; +}; + +function makeCanvas(w = 400, h = 200, parent = makeParent()) { + const ctx = { save() {}, restore() {}, clearRect() {}, drawImage() {} }; + const c = { + tagName: 'CANVAS', + style: {}, + width: 800, + height: 200, + clientWidth: w, + clientHeight: h, + left: 0, + top: 0, + parentElement: null, + contains: () => false, + get offsetParent() { + return this.parentElement; + }, + get offsetLeft() { + return this.left; + }, + get offsetTop() { + return this.top; + }, + get offsetWidth() { + return this.clientWidth; + }, + get offsetHeight() { + return this.clientHeight; + }, + get nextSibling() { + return sibling(this, 1); + }, + getBoundingClientRect() { + const sy = globalThis.window?.scrollY || 0; + return { x: this.left, y: this.top - sy, left: this.left, top: this.top - sy, width: this.clientWidth, height: this.clientHeight, right: this.left + this.clientWidth, bottom: this.top - sy + this.clientHeight }; + }, + getContext: () => ctx, + }; + parent.insertBefore(c, null); + return c; +} + +function installEnv() { + const made = []; + globalThis.document = { + createElement(tag) { + const draws = []; + const el = { + tagName: tag.toUpperCase(), + style: {}, + dataset: {}, + attrs: {}, + width: 300, + height: 150, + parentElement: null, + draws, + setAttribute(k, v) { + this.attrs[k] = v; + }, + get previousSibling() { + return sibling(this, -1); + }, + getContext: () => ({ drawImage: (...a) => draws.push(a) }), + }; + made.push(el); + return el; + }, + }; + class FakeDisplayLayer { + constructor(session, canvas) { + this.canvas = canvas; + } + getViewport() { + return { x: 0, y: 0, width: 400, height: 200 }; + } + close() {} + } + const listeners = new Map(); + let frameCb = null; + const session = { + addEventListener(type, fn) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type).add(fn); + }, + removeEventListener() {}, + requestReferenceSpace: async () => ({}), + requestAnimationFrame(cb) { + frameCb = cb; + return 1; + }, + end() {}, + }; + Object.defineProperty(globalThis, 'navigator', { + value: { xr: { requestSession: async () => session } }, + configurable: true, + writable: true, + }); + globalThis.window = { XRDisplayLayer: FakeDisplayLayer, devicePixelRatio: 1, scrollX: 0, scrollY: 0, addEventListener() {}, removeEventListener() {} }; + globalThis.XRDisplayLayer = FakeDisplayLayer; + globalThis.IntersectionObserver = undefined; + globalThis.MutationObserver = undefined; + return { + made, + covers: () => made.filter((e) => e.dataset && 'inline3dCover' in e.dataset), + fire(type) { + for (const fn of listeners.get(type) ?? []) fn({ type }); + }, + runFrame(n = 2) { + const cb = frameCb; + frameCb = null; + assert.ok(cb, 'the manager should have a session frame armed'); + const views = [{ eye: 'left' }, { eye: 'right' }].slice(0, n); + cb(clock, { getViewerPose: () => (n ? { views } : null) }); + }, + }; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); +async function peek(p) { + const PENDING = Symbol('pending'); + const v = await Promise.race([p, flush().then(() => PENDING)]); + return v === PENDING ? 'pending' : v; +} + +installEnv(); +const { createInline3D } = await import('../js/inline3d.js'); +const newWall = () => createInline3D({ lazy: false, autoChrome: false }); + +/** A scene window, woven (hold 100 ms), with a recording onFrame. */ +async function wovenScene(env, opts = {}) { + const wall = await newWall(); + const canvas = makeCanvas(); + const h = wall.addScene(canvas, () => {}, { firstWovenHoldMs: 100, ...opts }); + env.runFrame(2); + clock += 100; + env.runFrame(2); + assert.equal((await peek(h.firstWoven)).woven, true); + return { wall, canvas, h }; +} + +test('off by default in the core: a moved canvas gets no cover', async () => { + clock = 0; + const env = installEnv(); + const { wall, canvas } = await wovenScene(env); + canvas.left = 500; + env.runFrame(2); + assert.equal(env.covers().length, 0); + wall.close(); +}); + +test('change → cover up on that frame → rewoven settles → cover down', async () => { + clock = 0; + const env = installEnv(); + const { wall, canvas, h } = await wovenScene(env, { rectCover: 'auto' }); + env.runFrame(2); // baseline look, nothing moved + assert.equal(env.covers().length, 0, 'made lazily, on the first change'); + + clock = 1000; + canvas.left = 640; // the screen switch moves the canvas + canvas.top = 40; + env.runFrame(2); + const [cover] = env.covers(); + assert.ok(cover, 'raised on the frame the change was seen'); + assert.equal(cover.style.display, 'block'); + assert.equal(cover.parentElement, canvas.parentElement); + assert.equal(cover.previousSibling, canvas, 'the canvas’s next sibling'); + assert.equal(cover.style.pointerEvents, 'none'); + assert.equal(cover.style.background, '#000'); + assert.deepEqual([cover.style.left, cover.style.top, cover.style.width, cover.style.height], ['640px', '40px', '400px', '200px']); + assert.equal(cover.draws.length, 1, 'one snapshot, at the change'); + + const p = h.rewoven(); // the page can await the same wait + clock = 1099; + env.runFrame(2); + assert.equal(cover.style.display, 'block'); + clock = 1100; + env.runFrame(2); + assert.equal((await peek(p)).woven, true); + assert.equal(cover.style.display, 'none', 'cut, never faded, when rewoven settles'); + assert.equal(cover.draws.length, 1, 'never re-snapshotted per frame'); + wall.close(); +}); + +test('the snapshot is the LEFT eye, cover-fit from the old on-screen aspect into the new box', async () => { + clock = 0; + const env = installEnv(); + const { wall, canvas } = await wovenScene(env, { rectCover: { snapshot: true, color: 'rgb(1, 2, 3)' } }); + env.runFrame(2); + canvas.clientWidth = 200; // 2:1 → 1:1 + canvas.clientHeight = 200; + env.runFrame(2); + const [cover] = env.covers(); + assert.equal(cover.style.background, 'rgb(1, 2, 3)'); + assert.equal(cover.width, 200); + assert.equal(cover.height, 200); + const [src, sx, sy, sw, sh, dx, dy, dw, dh] = cover.draws[0]; + assert.equal(src, canvas); + // Left eye = 400x200 of the 800x200 buffer, shown at 2:1; a 1:1 box keeps the middle half. + assert.deepEqual([sx, sy, sw, sh, dx, dy, dw, dh], [100, 0, 200, 200, 0, 0, 200, 200]); + wall.close(); +}); + +test('snapshot:false is a solid color only', async () => { + clock = 0; + const env = installEnv(); + const { wall, canvas } = await wovenScene(env, { rectCover: { snapshot: false } }); + env.runFrame(2); + canvas.left = 10; + env.runFrame(2); + const [cover] = env.covers(); + assert.equal(cover.style.display, 'block'); + assert.equal(cover.draws.length, 0); + wall.close(); +}); + +test('a second change while the cover is up restarts the wait and moves the cover', async () => { + clock = 0; + const env = installEnv(); + const { wall, canvas } = await wovenScene(env, { rectCover: 'auto' }); + env.runFrame(2); + clock = 1000; + canvas.left = 300; + env.runFrame(2); + const [cover] = env.covers(); + clock = 1060; + canvas.clientWidth = 600; // the layout settles over two frames + env.runFrame(2); + assert.equal(cover.style.width, '600px', 'follows the canvas'); + clock = 1100; + env.runFrame(2); + assert.equal(cover.style.display, 'block', 'restarted at 1060, not settled at 1100'); + clock = 1160; + env.runFrame(2); + await flush(); + assert.equal(cover.style.display, 'none'); + assert.equal(cover.draws.length, 1, 'one snapshot per cover cycle'); + // A later change is a new cycle: up again, a fresh snapshot. + clock = 2000; + canvas.left = 0; + env.runFrame(2); + assert.equal(cover.style.display, 'block'); + assert.equal(cover.draws.length, 2); + wall.close(); +}); + +test('a re-parented canvas takes its cover with it', async () => { + clock = 0; + const env = installEnv(); + const { wall, canvas } = await wovenScene(env, { rectCover: 'auto' }); + env.runFrame(2); + const other = makeParent(); + other.insertBefore(canvas, null); // the kiosk moves the ONE canvas into the next screen's box + canvas.left = 50; + env.runFrame(2); + const [cover] = env.covers(); + assert.equal(cover.parentElement, other); + assert.equal(cover.previousSibling, canvas); + wall.close(); +}); + +test('remove() tears the cover down and out of the DOM', async () => { + clock = 0; + const env = installEnv(); + const { wall, canvas, h } = await wovenScene(env, { rectCover: 'auto' }); + env.runFrame(2); + canvas.left = 5; + env.runFrame(2); + const [cover] = env.covers(); + const parent = cover.parentElement; + h.remove(); + await flush(); + assert.equal(cover.parentElement, null); + assert.equal(parent.children.includes(cover), false); + wall.close(); +}); + +test('session end tears the cover down too', async () => { + clock = 0; + const env = installEnv(); + const { canvas } = await wovenScene(env, { rectCover: 'auto' }); + env.runFrame(2); + canvas.left = 5; + env.runFrame(2); + const [cover] = env.covers(); + env.fire('end'); + await flush(); + assert.equal(cover.parentElement, null); +}); + +test('nothing is raised before the first join: the page’s own poster covers that', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + const canvas = makeCanvas(); + wall.addScene(canvas, () => {}, { rectCover: 'auto' }); + env.runFrame(2); + canvas.left = 99; + env.runFrame(2); + assert.equal(env.covers().length, 0); + wall.close(); +}); + +test('a bad rectCover throws at the call', async () => { + clock = 0; + installEnv(); + const wall = await newWall(); + assert.throws(() => wall.addScene(makeCanvas(), () => {}, { rectCover: 'on' }), /rectCover: expected 'auto', 'off'/); + assert.throws(() => wall.addScene(makeCanvas(), () => {}, { rectCover: { color: 3 } }), /rectCover\.color/); + wall.close(); +}); + +test('rewoven: a pure move now restarts a pending hold; a document scroll does not', async () => { + clock = 0; + const env = installEnv(); + const { wall, canvas, h } = await wovenScene(env); + clock = 1000; + const p = h.rewoven(); + globalThis.window.scrollY = 300; // scrolling: the page position is unchanged + env.runFrame(2); + clock = 1100; + env.runFrame(2); + assert.equal((await peek(p)).reason, 'hold-elapsed', 'a scroll is not a move'); + + clock = 2000; + const q = h.rewoven(); + canvas.left = 200; // a move at the same size + clock = 2050; + env.runFrame(2); + clock = 2100; + env.runFrame(2); + assert.equal(await peek(q), 'pending', 'restarted at the move (2050)'); + clock = 2150; + env.runFrame(2); + assert.equal((await peek(q)).woven, true); + wall.close(); +}); + +test('./splat and ./model default rectCover to auto at every addScene call site', () => { + for (const f of ['inline3d-splat.js', 'inline3d-splat-playcanvas.js', 'inline3d-model.js', 'inline3d-model-playcanvas.js']) { + const src = fs.readFileSync(new URL(`../js/${f}`, import.meta.url), 'utf8'); + const calls = src.split('wall.addScene(').length - 1; + assert.equal(calls, 1, `${f}: one addScene call`); + assert.match(src, /rectCover: opts\.rectCover === undefined \? 'auto' : opts\.rectCover,/, f); + } +}); From 676cd270dcab2df6fd42d5d97291127881692061 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 8 Oct 2026 00:18:45 -0700 Subject: [PATCH 3/9] feat: resize and draw in the same task (PlayCanvas viewer), core addScene onResize (1.38.0) The PlayCanvas viewer (./splat playcanvas, ./model default) resizes its store and replays the last frame inside the ResizeObserver callback, before the paint, with the cached frustum's x row scaled by the aspect change so the replay keeps proportions; a callback during a draw or a re-entrant one falls back to the one-frame debounce. Core addScene gains an optional onResize(box) run in the same callback for page-drawn windows. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 17 +++ index.d.ts | 9 ++ js/inline3d-splat-playcanvas.js | 66 +++++++++- js/inline3d.js | 51 ++++++++ test/resize-sync.test.mjs | 224 ++++++++++++++++++++++++++++++++ 5 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 test/resize-sync.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 993615bb..9e8ca1a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,23 @@ methods. No export changes; `SplatHandle` gains two methods in `splat.d.ts`. - From a kiosk demo that keeps one woven canvas and moves it between screens: a black box at the new rect for 0.4–1.2 s, covered by hand on timers until now. +### Resize and draw in the same task (`./splat`, `./model`; core `onResize`, additive) + +- **The PlayCanvas viewer** (`./splat` `engine: 'playcanvas'`, `./model`'s default engine) now + resizes its backing store and replays the last frame inside the ResizeObserver callback, which + runs before the paint. Before, it waited for the next animation frame, and for one paint the + old store was shown stretched onto the new box. + - **The replay keeps its proportions.** The cached frustum's x row is scaled by the aspect + change, until the runtime's next views carry the true one. + - **Guards.** A callback that arrives while the viewer is drawing, or re-entrantly while it is + resizing, takes the old one-frame debounce instead. + - **Not covered:** `./splat` on Spark and `./model` `engine: 'three'` (`SceneViewer`) keep the + one-frame debounce for now. +- **Core `addScene(…, { onResize(box) })`**, optional. It is called in the same ResizeObserver + callback with `{ width, height, dpr }`, so a page-drawn window can resize and draw before the + paint too. It runs only on a real change, and a throw is caught. Without it, nothing changes: + no observer is attached to a scene canvas. + ## 1.37.1 — 2026-10-07 ### Depth cursor: hybrid anchor keeps the line of sight, not the foot diff --git a/index.d.ts b/index.d.ts index d1d5a026..d71f3bba 100644 --- a/index.d.ts +++ b/index.d.ts @@ -355,6 +355,15 @@ export interface SceneOptions extends TileOptions { */ bufferClamped?: boolean; onLayerLost?: () => void; + /** + * Called INSIDE the ResizeObserver callback when the canvas's CSS box or devicePixelRatio + * changes — after the frame's animation callbacks, before its paint. Resize your backing store + * and draw one frame here (replay your last views) and the old store is never shown stretched + * onto the new box, which is what waiting for the next frame shows. Called only on a real + * change, never re-entrantly; a throw is caught and warned about once. Default: none, and then + * no observer is attached to a scene canvas (unchanged). + */ + onResize?: (box: { width: number; height: number; dpr: number }) => void; } /** The per-frame render callback passed to {@link Inline3D.addScene}. */ diff --git a/js/inline3d-splat-playcanvas.js b/js/inline3d-splat-playcanvas.js index b32cdefd..11f4a580 100644 --- a/js/inline3d-splat-playcanvas.js +++ b/js/inline3d-splat-playcanvas.js @@ -1002,7 +1002,11 @@ export class PlayCanvasSplatViewer { this._tileBudget = undefined; // undefined = the engine's own default this._budgetViews = 1; - this._onResize = () => this._scheduleResize(); + // Resize AND draw inside the observer callback (it runs after the frame's animation callbacks + // and before the paint). Deferring to the next animation frame, as this used to, let one + // paint show the old backing store stretched onto the new box: a visible jolt on every + // CSS resize of a woven canvas. See _resizeNow for the two cases that still defer. + this._onResize = () => this._resizeNow(); this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null; if (this._ro) this._ro.observe(canvas); else if (typeof addEventListener === 'function') addEventListener('resize', this._onResize); @@ -2010,6 +2014,17 @@ export class PlayCanvasSplatViewer { * engine tick. Every entry is {proj, pose, x, y, width, height} in BUFFER pixels. */ _drawEntries(entries, cache) { + // `_drawing` is what _resizeNow checks: never reallocate the buffer under a running render. + if (this._drawing) return this._drawEntriesInner(entries, cache); + this._drawing = true; + try { + return this._drawEntriesInner(entries, cache); + } finally { + this._drawing = false; + } + } + + _drawEntriesInner(entries, cache) { const app = this.app; if (!app || !this.pc) return false; const pc = this.pc; @@ -2139,6 +2154,7 @@ export class PlayCanvasSplatViewer { } g.bufW = el.width || 0; g.bufH = el.height || 0; + g.boxAspect = this.boxAspect || 0; // the CSS shape these views were located for (see _replayLastGood) g.rig = this._rigSnap ? { ...this._rigSnap } : null; // a replay re-uses the rig of its views g.rigAt = this._rigAtPull ?? null; for (let i = 0; i < views.length; i++) { @@ -2156,7 +2172,53 @@ export class PlayCanvasSplatViewer { _replayLastGood() { const g = this._lastGood; if (!g || this._disposed) return false; - return this._drawEntries(g.entries, g); + // A box whose aspect changed since the cache (a CSS resize replayed before the runtime has + // located views for the new rect): the cached frustum was built for the OLD shape, and the + // viewports map onto the new buffer proportionally, so the picture would be stretched by + // exactly the aspect ratio change. Widen (or narrow) the frustum by the same factor — + // dividing the projection's x row by it — so the replayed frame keeps its proportions. One + // frame's approximation: the next located views carry the true frustum for the new window. + const f = g.boxAspect > 0 && this.boxAspect > 0 ? this.boxAspect / g.boxAspect : 1; + if (Math.abs(f - 1) < 1e-6) return this._drawEntries(g.entries, g); + const fixed = (this._aspectFixed ||= []); + fixed.length = g.entries.length; + for (let i = 0; i < g.entries.length; i++) { + const e = g.entries[i]; + const o = (fixed[i] ||= { proj: new Float32Array(16), pose: null, x: 0, y: 0, width: 0, height: 0 }); + o.proj.set(e.proj); + o.proj[0] /= f; + o.proj[4] /= f; + o.proj[8] /= f; + o.proj[12] /= f; + o.pose = e.pose; + o.x = e.x; + o.y = e.y; + o.width = e.width; + o.height = e.height; + } + return this._drawEntries(fixed, g); + } + + /** + * The observer's resize, synchronous. Two cases still take the debounced path instead: a call + * that arrives while this viewer is already resizing (re-entrancy: setting canvas.width can + * queue more observer work), and one that arrives while it is drawing (a nested dispatch from + * inside the engine's tick). Reallocating the drawing buffer under a running render would + * leave that render half on a cleared store. + */ + _resizeNow() { + if (this._disposed) return; + if (this._inResize || this._drawing) { + this._scheduleResize(); + return; + } + this._resizePending = false; // this run supersedes a debounced one already queued + this._inResize = true; + try { + this._resize(); + } finally { + this._inResize = false; + } } _scheduleResize() { diff --git a/js/inline3d.js b/js/inline3d.js index d6f372fa..11116477 100644 --- a/js/inline3d.js +++ b/js/inline3d.js @@ -766,6 +766,12 @@ function boxKeyOf(canvas) { return `${canvas.clientWidth}x${canvas.clientHeight}@${dpr}${pos}`; } +/** A scene canvas's CSS box and dpr, as addScene's `onResize` receives it. */ +function sceneBoxOf(canvas) { + const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1; + return { width: canvas.clientWidth || 0, height: canvas.clientHeight || 0, dpr }; +} + /** * The `rectCover` option of every add*(): null (off) or `{ color, snapshot }`. Off by default in * the core (frozen defaults); `./splat` and `./model` pass 'auto'. A bad value throws at the call: @@ -1085,6 +1091,10 @@ class Inline3D { * falls back to `virtualDisplayHeight` if one was given (that pair is the one reason * to pass both) — either way the window still weaves. * @param {Element} [opts.observe=canvas] element whose visibility gates lazy create/close. + * @param {(box:{width:number, height:number, dpr:number}) => void} [opts.onResize] called + * inside the ResizeObserver callback when the canvas's CSS box or dpr changes, i.e. + * before the paint: resize your backing store and draw one frame here and the old store + * is never shown stretched onto the new box. Only on a real change; errors are caught. * @param {() => void} [opts.onLayerLost] called once when this window's weave layer goes away * for good — the session ended, or the layer could not be created. YOU own a scene * canvas's pixels, so this is the SDK's only way to tell you that the side-by-side pair @@ -2116,6 +2126,12 @@ class Inline3D { // therefore already fail to build its layer) inside this call. onLayerLost: typeof opts.onLayerLost === 'function' ? opts.onLayerLost : null, layerLostSent: false, + // Scene windows only (addScene's `onResize`): called inside the ResizeObserver callback, so + // a page-drawn window can resize its store and draw before the paint. Read here for the same + // reason as onLayerLost: a non-lazy window starts its size watch inside this call. + onResize: kind === 'scene' && typeof opts.onResize === 'function' ? opts.onResize : null, + resizeBox: null, + inResize: false, ready: null, ownsBuffer: kind !== 'scene', cornerRadius: opts.cornerRadius || 0, @@ -2698,6 +2714,14 @@ class Inline3D { _startSizeWatch(win) { if (typeof ResizeObserver !== 'function') return; if (win.sizeObserver) return; + if (win.kind === 'scene') { + // The SDK never sizes a scene canvas; it only hands the page the moment to (opts.onResize). + if (!win.onResize) return; + win.resizeBox = sceneBoxOf(win.canvas); + win.sizeObserver = new ResizeObserver(() => this._onSceneResize(win)); + win.sizeObserver.observe(win.canvas); + return; + } // Scene canvases are the app's (ownsBuffer false) — never touch their width/height. // An explicit {width, height} is box-independent by definition, so nothing to watch. if (!win.ownsBuffer || (win.reqW && win.reqH)) return; @@ -2705,6 +2729,33 @@ class Inline3D { win.sizeObserver.observe(win.canvas); } + /** + * addScene's `onResize(box)`, run INSIDE the ResizeObserver callback. That callback runs after + * the frame's animation callbacks and before its paint, so a page that resizes its store and + * draws here never shows the old backing store stretched onto the new box for a frame — which + * is what deferring to the next animation frame shows. Only on a real change of the CSS box or + * dpr (an observer fires on plenty that moves neither), never re-entrantly, and a throw is + * contained and warned about once: this is a browser callback and must not take the page down. + */ + _onSceneResize(win) { + if (!win.layer || !win.onResize || win.inResize) return; + const box = sceneBoxOf(win.canvas); + const last = win.resizeBox; + if (last && last.width === box.width && last.height === box.height && last.dpr === box.dpr) return; + win.resizeBox = box; + win.inResize = true; + try { + win.onResize(box); + } catch (err) { + if (!win.resizeThrewWarned) { + win.resizeThrewWarned = true; + console.warn("[inline3d] a scene window's onResize threw; further throws from it are silent.", err); + } + } finally { + win.inResize = false; + } + } + _stopSizeWatch(win) { if (win.sizeObserver) { win.sizeObserver.disconnect(); diff --git a/test/resize-sync.test.mjs b/test/resize-sync.test.mjs new file mode 100644 index 00000000..c8e5c10d --- /dev/null +++ b/test/resize-sync.test.mjs @@ -0,0 +1,224 @@ +// Resize and draw in the same task (1.38): the PlayCanvas viewer (./splat engine:'playcanvas', +// ./model's default engine) resizes its backing store and replays the last frame INSIDE the +// ResizeObserver callback, which runs before the paint — so the old store is never shown +// stretched onto the new box. And the core's addScene `onResize(box)` hands a page-drawn window +// the same moment. Mocked ResizeObserver (test/stubs.mjs), recording draws, no engine. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { installDom, makeCanvas } from './stubs.mjs'; + +const dom = installDom(); +const { PlayCanvasSplatViewer } = await import('../js/inline3d-splat-playcanvas.js'); + +function gl(canvas) { + return { + MAX_TEXTURE_SIZE: 1, + MAX_RENDERBUFFER_SIZE: 2, + MAX_VIEWPORT_DIMS: 3, + getParameter: (p) => (p === 3 ? Int32Array.from([16384, 16384]) : 16384), + get drawingBufferWidth() { + return canvas.width; + }, + get drawingBufferHeight() { + return canvas.height; + }, + }; +} + +/** A viewer with a stand-in engine: draws are recorded, with the buffer size they drew into. */ +function pcViewer(box = [400, 200]) { + const canvas = makeCanvas(...box); + const viewer = new PlayCanvasSplatViewer(canvas, { orbit: false }); + viewer.app = { graphicsDevice: { gl: gl(canvas) }, destroy() {} }; + viewer.pc = {}; + const draws = []; + viewer._drawEntriesInner = (entries, cache) => { + draws.push({ w: canvas.width, h: canvas.height, proj: entries.map((e) => Array.from(e.proj)), cache, drawing: viewer._drawing }); + return true; + }; + viewer._resize(); // the constructor sized before the stand-in GL existed + return { canvas, viewer, draws }; +} + +const P = [2, 0, 0, 0, 0, 2, 0, 0, 0.25, 0, -1, -1, 0, 0, -0.5, 0]; // exact in float32 +function seedGood(viewer, canvas) { + const views = [0, 1].map(() => ({ projectionMatrix: Float32Array.from(P), transform: { matrix: new Float32Array(16) } })); + const half = canvas.width / 2; + viewer._cacheGood(views, [ + { x: 0, y: 0, width: half, height: canvas.height }, + { x: half, y: 0, width: half, height: canvas.height }, + ]); +} + +test('PlayCanvas viewer: a CSS resize resizes and replays INSIDE the observer callback, no rAF', () => { + const { canvas, viewer, draws } = pcViewer(); + assert.equal(canvas.width, 800); + seedGood(viewer, canvas); + draws.length = 0; + dom.flushRaf(); + canvas.setBox(600, 200); + dom.fireResizeObservers(); + assert.equal(canvas.width, 1200, 'resized in the callback, before the paint'); + assert.equal(draws.length, 1, 'and drawn in the same task'); + assert.equal(draws[0].w, 1200, 'into the NEW store'); + assert.equal(dom.pendingRaf(), 0, 'nothing deferred to the next frame'); + viewer.dispose(); +}); + +test('PlayCanvas viewer: the replay keeps proportions across an aspect change (x row / f)', () => { + const { canvas, viewer, draws } = pcViewer([400, 200]); // 2:1 per eye + seedGood(viewer, canvas); + draws.length = 0; + canvas.setBox(800, 200); // 4:1: f = 2 + dom.fireResizeObservers(); + const proj = draws[0].proj[0]; + assert.equal(proj[0], 1, 'P[0] / 2'); + assert.equal(proj[8], 0.125, 'the off-axis term scales with it'); + assert.equal(proj[5], 2, 'the vertical frustum is untouched'); + // The cache itself is untouched: the next located views replace it, a second replay redoes the fix. + assert.equal(viewer._lastGood.entries[0].proj[0], 2); + viewer.dispose(); +}); + +test('PlayCanvas viewer: same-aspect resize replays the cached projection unchanged', () => { + const { canvas, viewer, draws } = pcViewer([400, 200]); + seedGood(viewer, canvas); + draws.length = 0; + canvas.setBox(800, 400); + dom.fireResizeObservers(); + assert.deepEqual(draws[0].proj[0], P); + viewer.dispose(); +}); + +test('PlayCanvas viewer: an observer callback during a draw, or a re-entrant one, defers to one rAF', () => { + const { canvas, viewer, draws } = pcViewer(); + seedGood(viewer, canvas); + draws.length = 0; + dom.flushRaf(); + viewer._drawing = true; // a nested dispatch from inside the engine's tick + canvas.setBox(500, 200); + dom.fireResizeObservers(); + assert.equal(canvas.width, 800, 'never reallocated under a running render'); + assert.equal(dom.pendingRaf(), 1); + viewer._drawing = false; + dom.flushRaf(); + assert.equal(canvas.width, 1000); + + // Re-entrancy: a resize whose own work fires the observer again. + let inner = 0; + const real = viewer._resize.bind(viewer); + viewer._resize = () => { + inner++; + if (inner === 1) { + canvas.setBox(520, 200); + dom.fireResizeObservers(); + } + real(); + }; + canvas.setBox(510, 200); + dom.fireResizeObservers(); + assert.equal(inner, 1, 'the nested callback did not resize re-entrantly'); + assert.equal(dom.pendingRaf(), 1, 'it was deferred instead'); + dom.flushRaf(); + assert.equal(inner, 2); + assert.equal(canvas.width, 1040); + viewer.dispose(); +}); + +test('PlayCanvas viewer: _drawing is set for exactly the duration of a draw', () => { + const { canvas, viewer, draws } = pcViewer(); + seedGood(viewer, canvas); + draws.length = 0; + viewer._replayLastGood(); + assert.equal(draws[0].drawing, true); + assert.equal(viewer._drawing, false); + viewer.dispose(); +}); + +// ── core: addScene({ onResize }) ───────────────────────────────────────────────────────────── + +test('core addScene onResize: called in the observer callback on a real box change only', async () => { + const observers = []; + globalThis.ResizeObserver = class { + constructor(cb) { + this.cb = cb; + observers.push(this); + } + observe() {} + disconnect() { + this.gone = true; + } + }; + let frameCb = null; + const session = { + addEventListener() {}, + removeEventListener() {}, + requestReferenceSpace: async () => ({}), + requestAnimationFrame(cb) { + frameCb = cb; + return 1; + }, + end() {}, + }; + Object.defineProperty(globalThis, 'navigator', { value: { xr: { requestSession: async () => session } }, configurable: true, writable: true }); + class L { + getViewport() { + return { x: 0, y: 0, width: 1, height: 1 }; + } + close() {} + } + globalThis.window = { XRDisplayLayer: L, devicePixelRatio: 1, addEventListener() {}, removeEventListener() {} }; + globalThis.XRDisplayLayer = L; + const { createInline3D } = await import('../js/inline3d.js'); + const wall = await createInline3D({ lazy: false, autoChrome: false }); + const canvas = { style: {}, width: 0, height: 0, clientWidth: 300, clientHeight: 150, parentElement: null, getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 150 }) }; + const seen = []; + const h = wall.addScene(canvas, () => {}, { onResize: (box) => seen.push(box) }); + const ro = observers.at(-1); + ro.cb([]); // the initial observe() fire: nothing changed + assert.equal(seen.length, 0); + canvas.clientWidth = 600; + ro.cb([]); + assert.deepEqual(seen, [{ width: 600, height: 150, dpr: 1 }]); + ro.cb([]); + assert.equal(seen.length, 1, 'same box: not called again'); + // A throwing callback is contained, warned once. + const warns = []; + const realWarn = console.warn; + console.warn = (...a) => warns.push(a.join(' ')); + try { + const h2c = { ...canvas, clientWidth: 100 }; + wall.addScene(h2c, () => {}, { onResize: () => { throw new Error('boom'); } }); + const ro2 = observers.at(-1); + h2c.clientWidth = 200; + ro2.cb([]); + h2c.clientWidth = 300; + ro2.cb([]); + } finally { + console.warn = realWarn; + } + assert.equal(warns.filter((w) => /onResize threw/.test(w)).length, 1); + h.remove(); + assert.equal(ro.gone, true, 'the observer goes with the window'); + void frameCb; + wall.close(); +}); + +test('core addScene without onResize: no observer on a scene canvas (unchanged)', async () => { + const observers = []; + globalThis.ResizeObserver = class { + constructor() { + observers.push(this); + } + observe() {} + disconnect() {} + }; + const { createInline3D } = await import('../js/inline3d.js'); + const wall = await createInline3D({ lazy: false, autoChrome: false }); + const canvas = { style: {}, width: 0, height: 0, clientWidth: 300, clientHeight: 150, parentElement: null, getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 150 }) }; + wall.addScene(canvas, () => {}); + assert.equal(observers.length, 0); + wall.close(); +}); From a231ee87a07061718164fb7c57ea655cc87de74c Mon Sep 17 00:00:00 2001 From: David Date: Thu, 8 Oct 2026 00:25:03 -0700 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20double-attach=20guard=20=E2=80=94?= =?UTF-8?q?=20add*()=20on=20a=20live=20canvas=20warns=20once=20and=20retur?= =?UTF-8?q?ns=20the=20existing=20handle=20(1.38.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addScene/addImage/addVideo and the addSplat/addModel entries: a canvas still registered on the same wall is answered with its first handle and no second layer/renderer (was: close and rebuild the layer). The subpath entries claim the canvas at the call (their renderer exists before the core window) and pass a claim token to their own addScene; remove() releases it. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 19 ++++ docs/woven-canvas-rules.md | 7 +- index.d.ts | 8 +- js/inline3d-model-entry.js | 7 ++ js/inline3d-model-playcanvas.js | 2 + js/inline3d-model.js | 9 ++ js/inline3d-splat-pc-entry.js | 8 +- js/inline3d-splat-playcanvas.js | 2 + js/inline3d-splat-shared.js | 18 ++++ js/inline3d-splat.js | 9 ++ js/inline3d.js | 95 +++++++++++++++++++- test/double-attach.test.mjs | 151 ++++++++++++++++++++++++++++++++ 12 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 test/double-attach.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8ca1a8..b8dac2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,25 @@ methods. No export changes; `SplatHandle` gains two methods in `splat.d.ts`. paint too. It runs only on a real change, and a throw is caught. Without it, nothing changes: no observer is attached to a scene canvas. +### Double-attach guard (core, and the `./splat` / `./model` entries) + +- **One registration per canvas per manager.** `addScene` / `addImage` / `addVideo` / + `addSplat` / `addModel` on a canvas that is still registered on the same wall (not yet + `remove()`d) now: + - warns once per canvas, naming the method and the first registration that is still live; + - returns the **existing** handle, and creates no second layer or renderer. +- **Behavior change in the core.** Before, a second call closed the window's layer and built a + new one. That started a fresh 0.4–1.2 s identity gap every time, and on `./splat` / `./model` it + put a second renderer on the same context. + - A page that re-called `addImage(canvas, newSource)` to swap pictures now keeps the first + picture, with the warning saying why. + - To swap content, redraw a source canvas or use `setSource`. To re-register on purpose, + `remove()` first. +- `./splat` and `./model` check at their entry, not only in `addScene`: their renderer exists + before their core window. A core `add*()` on a canvas that `addSplat` / `addModel` holds returns + that subpath handle. A subpath handle whose load failed keeps the canvas until its `remove()`. +- From a kiosk demo that attached its stage twice per screen visit. + ## 1.37.1 — 2026-10-07 ### Depth cursor: hybrid anchor keeps the line of sight, not the foot diff --git a/docs/woven-canvas-rules.md b/docs/woven-canvas-rules.md index ed3b93f7..d623f0dd 100644 --- a/docs/woven-canvas-rules.md +++ b/docs/woven-canvas-rules.md @@ -68,8 +68,11 @@ re-renders over, and change what is **in** it: - **Scenes:** swap the content inside your `onFrame`, never the canvas. - **Splats on the PlayCanvas backend:** `handle.setSource(src, { fadeMs, resetPose })`. -Calling `add*()` again on the same canvas is not a content swap. It closes the window's layer and -builds a new one. +Calling `add*()` again on the same canvas is not a content swap. Since 1.38 the SDK refuses +it: while the first registration is live (not `remove()`d), a second `addScene` / `addImage` / +`addVideo` / `addSplat` / `addModel` on that canvas warns once and returns the **existing** handle, +and no second layer or renderer is created. Before 1.38 it closed the window's layer and built a +new one: a fresh identity gap on every call. To re-register on purpose, `remove()` first. ### 3. Prefer one persistent canvas for the whole app diff --git a/index.d.ts b/index.d.ts index d71f3bba..6fd3d4fa 100644 --- a/index.d.ts +++ b/index.d.ts @@ -373,7 +373,13 @@ export type SceneFrameCallback = ( frame: XRFrame, ) => void; -/** The handle returned by every add*() call. */ +/** + * The handle returned by every add*() call. + * + * One registration per canvas per manager: an add*() on a canvas that is still registered (not + * `remove()`d) warns once per canvas and returns the EXISTING handle, creating no second layer + * (1.38; before, it closed and rebuilt the layer). `remove()` first to re-register. + */ export interface TileHandle { /** Remove this window: close its weave layer and stop driving it. */ remove(): void; diff --git a/js/inline3d-model-entry.js b/js/inline3d-model-entry.js index 7c0573a1..09b29f4d 100644 --- a/js/inline3d-model-entry.js +++ b/js/inline3d-model-entry.js @@ -21,6 +21,7 @@ // engine import fails, one warning names what to install, and the tile renders on three. With // neither, `ready` rejects naming both. An explicit `engine` never falls back. +import { guardedAttach } from './inline3d-splat-shared.js'; /** The engines `addModel` renders with; the first is the default. */ export const MODEL_ENGINES = Object.freeze(['playcanvas', 'three']); @@ -98,6 +99,12 @@ export function validateModelCall(canvas, src, opts, route) { * fill in as the backend loads. */ export function addModel(wall, canvas, src, opts = {}) { + // One registration per canvas per wall: a second call on a live canvas warns once and returns + // the first handle (see guardedAttach). + return guardedAttach(wall, canvas, 'addModel', opts, (o) => addModelUnguarded(wall, canvas, src, o)); +} + +function addModelUnguarded(wall, canvas, src, opts = {}) { const route = resolveModelEngine(opts); validateModelCall(canvas, src, opts, route); if (route.engine === 'playcanvas') { diff --git a/js/inline3d-model-playcanvas.js b/js/inline3d-model-playcanvas.js index e291199e..2b0c2f85 100644 --- a/js/inline3d-model-playcanvas.js +++ b/js/inline3d-model-playcanvas.js @@ -534,6 +534,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = [] // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the // preview tier; 'off' for a page that runs its own cover. rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover, + // The entry's double-attach claim (guardedAttach): lets THIS registration through. + _dxrClaim: opts._dxrClaim, }); } else { viewer.startMono(); diff --git a/js/inline3d-model.js b/js/inline3d-model.js index 5fb52419..13bb7658 100644 --- a/js/inline3d-model.js +++ b/js/inline3d-model.js @@ -38,6 +38,7 @@ // your own origin (`/draco/`, `/basis/`) and `opts.decoderPath` moves that. See // docs/authoring-inline-3d.md#compressed-gltf. +import { guardedAttach } from './inline3d-splat-shared.js'; import * as THREE from 'three'; import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js'; import { resolveCursorOption } from './inline3d-cursor-depth.js'; @@ -345,6 +346,12 @@ function kindFromMessage(msg) { * `setPose`, `resetPose`, `frame`, and `ready`. */ export function addModel(wall, canvas, src, opts = {}) { + // One registration per canvas per wall: a second call on a live canvas warns once and returns + // the first handle (see guardedAttach). + return guardedAttach(wall, canvas, 'addModel', opts, (o) => addModelUnguarded(wall, canvas, src, o)); +} + +function addModelUnguarded(wall, canvas, src, opts = {}) { const { virtualDisplayHeight = 0.24, frame = null, @@ -429,6 +436,8 @@ export function addModel(wall, canvas, src, opts = {}) { // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the // preview tier; 'off' for a page that runs its own cover. rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover, + // The entry's double-attach claim (guardedAttach): lets THIS registration through. + _dxrClaim: opts._dxrClaim, }); } else { viewer.startMono(); diff --git a/js/inline3d-splat-pc-entry.js b/js/inline3d-splat-pc-entry.js index 454ccd70..b4abe76e 100644 --- a/js/inline3d-splat-pc-entry.js +++ b/js/inline3d-splat-pc-entry.js @@ -16,7 +16,7 @@ // Not here: measureSplatBounds (it measures a Spark mesh — import it from ./splat). import { resolveRevealOption } from './inline3d-splat-effects.js'; -import { CAPTURE_FITS, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js'; +import { CAPTURE_FITS, guardedAttach, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js'; import { addSplatDeferred } from './inline3d-splat-deferred.js'; export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js'; @@ -25,6 +25,12 @@ export { resolveRig } from './inline3d-splat-rig.js'; /** addSplat with `engine: 'playcanvas'`; the same validation, at the call, as ./splat's. */ export function addSplat(wall, canvas, src, opts = {}) { + // One registration per canvas per wall: a second call on a live canvas warns once and returns + // the first handle (see guardedAttach). + return guardedAttach(wall, canvas, 'addSplat', opts, (o) => addSplatUnguarded(wall, canvas, src, o)); +} + +function addSplatUnguarded(wall, canvas, src, opts = {}) { if (opts.engine !== undefined && opts.engine !== 'playcanvas') { throw new Error( `@displayxr/inline3d/splat/playcanvas: engine "${opts.engine}" — this entry is PlayCanvas ` + diff --git a/js/inline3d-splat-playcanvas.js b/js/inline3d-splat-playcanvas.js index 11f4a580..b2dc40b1 100644 --- a/js/inline3d-splat-playcanvas.js +++ b/js/inline3d-splat-playcanvas.js @@ -3664,6 +3664,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = [] // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the // preview tier; 'off' for a page that runs its own cover. rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover, + // The entry's double-attach claim (guardedAttach): lets THIS registration through. + _dxrClaim: opts._dxrClaim, }); } else { viewer.startMono(); diff --git a/js/inline3d-splat-shared.js b/js/inline3d-splat-shared.js index bc825187..8e17bf00 100644 --- a/js/inline3d-splat-shared.js +++ b/js/inline3d-splat-shared.js @@ -12,6 +12,24 @@ // apart on how a drag, a wheel notch, an idle turntable or a focus change feels. Pinned by // test/splat-playcanvas.test.mjs (values, and a behavioural trace of both viewers side by side). +/** + * The double-attach guard around a scene subpath's public entry (`addSplat`, `addModel`). + * + * The check has to run HERE, at the entry, and not only in the core's addScene: the subpaths build + * a renderer on the canvas BEFORE the core window exists (asynchronously, once the engine module + * has loaded), so a second `addSplat` on a live canvas would already have put a second engine on + * its context by the time addScene could refuse it. `wall._attachGuard` (the core manager) does + * the bookkeeping; a wall without it (no session, or an older core) attaches unguarded. + */ +export function guardedAttach(wall, canvas, method, opts, attach) { + const g = wall && typeof wall._attachGuard === 'function' ? wall._attachGuard(canvas, method, opts) : null; + if (!g) return attach(opts); + if (g.existing) return g.existing; + const out = attach(g.opts); + g.own(out); + return out; +} + /** * Backstop on total subject depth, as a multiple of the display height. Generous on purpose: * depth placement is a z decision (see fitTo), not a scale one, so this only catches the diff --git a/js/inline3d-splat.js b/js/inline3d-splat.js index 7c4cd3a9..3b6c35d3 100644 --- a/js/inline3d-splat.js +++ b/js/inline3d-splat.js @@ -39,6 +39,7 @@ import { STREAMED_NEEDS_PLAYCANVAS, resolveControls, declareViewRig, + guardedAttach, } from './inline3d-splat-shared.js'; import { resolveRig, @@ -162,6 +163,12 @@ function sniffFileType(bytes) { * good enough for a clean, isolated capture, weaker on a scene with a background wall. */ export function addSplat(wall, canvas, src, opts = {}) { + // One registration per canvas per wall: a second call on a live canvas warns once and returns + // the first handle (see guardedAttach). + return guardedAttach(wall, canvas, 'addSplat', opts, (o) => addSplatUnguarded(wall, canvas, src, o)); +} + +function addSplatUnguarded(wall, canvas, src, opts = {}) { if (opts.captureFit !== undefined && !CAPTURE_FITS.includes(opts.captureFit)) { throw new Error( `@displayxr/inline3d/splat: captureFit "${opts.captureFit}" — expected ` + @@ -490,6 +497,8 @@ export function addSplat(wall, canvas, src, opts = {}) { // The SDK covers this canvas across a move/resize (core rectCover): on by default here, the // preview tier; 'off' for a page that runs its own cover. rectCover: opts.rectCover === undefined ? 'auto' : opts.rectCover, + // The entry's double-attach claim (guardedAttach): lets THIS registration through. + _dxrClaim: opts._dxrClaim, }); } else { viewer.startMono(); diff --git a/js/inline3d.js b/js/inline3d.js index 11116477..548be436 100644 --- a/js/inline3d.js +++ b/js/inline3d.js @@ -799,6 +799,11 @@ class Inline3D { this.session = session; this.refSpace = refSpace; this._windows = new Map(); // canvas -> window record + // Double-attach guard. `_claims`: canvases a scene subpath entry (addSplat / addModel) has + // taken before its core window exists (canvas -> { token, method, handle }); `_doubleWarned`: + // canvases already warned about, so a page calling add*() per render warns once. + this._claims = new Map(); + this._doubleWarned = new WeakSet(); this._globalOverlays = new Set(); // page-global overlays excluded from EVERY window // el -> Set(window) currently excluding it. Isolation (will-change) is a GLOBAL // property of the element while exclusion is PER-WINDOW, so the promotion has to @@ -1046,12 +1051,15 @@ class Inline3D { * @returns {{remove():void}} */ addImage(canvas, source, opts = {}) { + const dup = this._existingAttachment(canvas, opts._dxrClaim); + if (dup) return this._refuseDoubleAttach(canvas, 'addImage', dup); const win = this._register(canvas, 'image', opts); + win.method = 'addImage'; win.ready = loadImage(source).then((img) => { win.img = img; win.repaint(); }); - return this._handle(canvas, win); + return (win.handle = this._handle(canvas, win)); } /** @@ -1062,9 +1070,12 @@ class Inline3D { * @returns {{remove():void}} */ addVideo(canvas, video, opts = {}) { + const dup = this._existingAttachment(canvas, opts._dxrClaim); + if (dup) return this._refuseDoubleAttach(canvas, 'addVideo', dup); const win = this._register(canvas, 'video', opts); + win.method = 'addVideo'; win.video = video; - return this._handle(canvas, win); + return (win.handle = this._handle(canvas, win)); } /** @@ -1107,14 +1118,91 @@ class Inline3D { // The rig and the height describe the same one slot in the layer init, so warn where a // caller has said it twice — silently dropping one of two things the page explicitly asked // for is how a scene ends up framed at a scale nobody chose. + const dup = this._existingAttachment(canvas, opts._dxrClaim); + if (dup) return this._refuseDoubleAttach(canvas, 'addScene', dup); if (opts.viewRig && opts.virtualDisplayHeight !== undefined) noteRigWinsOverHeight(); const win = this._register(canvas, 'scene', { virtualDisplayHeight: 0.24, ...opts }); + win.method = 'addScene'; win.onFrame = onFrame; win.ownsBuffer = false; // the app sizes a scene canvas; we never touch canvas.width/height // The SDK's own renderers (./viewer, ./splat, ./model) clamp their buffers before sizing and // say so, so the core never inspects (or creates!) their GL context — see _checkSceneBuffer. if (opts.bufferClamped === true) win.warnedBufMismatch = true; - return this._handle(canvas, win); + return (win.handle = this._handle(canvas, win)); + } + + // ── double-attach guard ─────────────────────────────────────────────────────────────── + // + // A second add*() on a canvas that is still registered used to close its layer and build a new + // one: a fresh identity gap on purpose (rule 2), and for ./splat and ./model a second renderer + // on the same context. A kiosk demo attached its stage twice per screen visit this way, without + // noticing. The first registration is what the page is actually looking at, so the second call + // is answered with it: one warning per canvas, the EXISTING handle, no new layer. + + /** The live registration on `canvas` other than the one `token` belongs to: { method, handle } or null. */ + _existingAttachment(canvas, token) { + const claim = this._claims.get(canvas); + if (claim && claim.token !== token) return claim; + const win = this._windows.get(canvas); + if (win && win.handle) return { method: win.method || 'add*', handle: win.handle }; + return null; + } + + _refuseDoubleAttach(canvas, method, dup) { + if (!this._doubleWarned.has(canvas)) { + this._doubleWarned.add(canvas); + console.warn( + `[inline3d] ${method}() on a canvas that is already registered on this inline-3D session ` + + `(by ${dup.method}(), not yet removed): the first registration is still live, so this ` + + 'call returns ITS handle and creates no second layer. Re-registering would close and ' + + "rebuild the canvas's layer (a fresh 0.4-1.2 s identity gap) or put a second renderer on " + + 'its context. Change what is IN the canvas instead (setSource, your onFrame, a redrawn ' + + 'source canvas), or call handle.remove() first.', + canvas + ); + } + return dup.handle; + } + + /** + * For the scene subpath entries (via guardedAttach in ./inline3d-splat-shared.js). Returns + * `{ existing }` for a canvas already registered (warned), else `{ opts, own }`: attach with + * `opts` (it carries the claim token down to this entry's own addScene), then `own(handle)`. + * A nested entry (./model's engine:'three' route calls ./model/three's addModel) inherits the + * claim through the same token. + */ + _attachGuard(canvas, method, opts = {}) { + const claim = this._claims.get(canvas); + if (opts && opts._dxrClaim && claim && claim.token === opts._dxrClaim) return { existing: null, opts, own: () => {} }; + const dup = this._existingAttachment(canvas, null); + if (dup) return { existing: this._refuseDoubleAttach(canvas, method, dup), opts, own: () => {} }; + const token = {}; + return { existing: null, opts: { ...opts, _dxrClaim: token }, own: (out) => this._claim(canvas, method, out, token) }; + } + + /** + * Hold `canvas` for a subpath handle until that handle's remove(). The adapters REASSIGN + * `out.remove` when their module loads (a queued stub first, the real one later), so the release + * rides an accessor that wraps whatever is assigned. A handle whose load failed stays claimed + * until its remove(), like any registration: "not yet removed" is the rule. + */ + _claim(canvas, method, out, token) { + if (!this._running || !out || typeof out !== 'object') return; + const claim = { token, method, handle: out }; + this._claims.set(canvas, claim); + let impl = out.remove; + const remove = (...args) => { + if (this._claims.get(canvas) === claim) this._claims.delete(canvas); + return typeof impl === 'function' ? impl.apply(out, args) : undefined; + }; + Object.defineProperty(out, 'remove', { + configurable: true, + enumerable: true, + get: () => remove, + set: (fn) => { + impl = fn; + }, + }); } /** @@ -3555,6 +3643,7 @@ class Inline3D { this._dropCover(win); } this._windows.clear(); + this._claims.clear(); // Nobody is tracked through a session that has ended: say so ONCE, before the listeners are // dropped, so a page that gated its UI on 'tracking' is released rather than left latched. A // browser that never reported one is already 'unknown' and this is silent. diff --git a/test/double-attach.test.mjs b/test/double-attach.test.mjs new file mode 100644 index 00000000..a08d06b0 --- /dev/null +++ b/test/double-attach.test.mjs @@ -0,0 +1,151 @@ +// The double-attach guard (1.38): add*() on a canvas that is still registered on the same wall +// warns once per canvas, naming the method and the live first registration, and returns the +// EXISTING handle — no second layer, no second renderer. A kiosk demo attached its stage twice per +// screen visit before this. Core methods and the ./splat entry (engine:'playcanvas', whose +// renderer exists before its core window, which is why the check sits at the entry). + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { installDom, makeCanvas as makeViewerCanvas } from './stubs.mjs'; + +const dom = installDom(); + +function makeCanvas(w = 300, h = 150) { + const ctx = { save() {}, restore() {}, clearRect() {}, drawImage() {} }; + return { + style: {}, + width: 0, + height: 0, + clientWidth: w, + clientHeight: h, + parentElement: null, + contains: () => false, + getBoundingClientRect: () => ({ x: 0, y: 0, left: 0, top: 0, width: w, height: h }), + getContext: () => ctx, + }; +} + +const created = []; +class FakeDisplayLayer { + constructor(session, canvas) { + this.canvas = canvas; + created.push(this); + } + getViewport() { + return { x: 0, y: 0, width: 1, height: 1 }; + } + close() { + this.closed = true; + } +} +const session = { + addEventListener() {}, + removeEventListener() {}, + requestReferenceSpace: async () => ({}), + requestAnimationFrame: () => 1, + end() {}, +}; +Object.defineProperty(globalThis, 'navigator', { value: { xr: { requestSession: async () => session } }, configurable: true, writable: true }); +Object.assign(globalThis.window, { XRDisplayLayer: FakeDisplayLayer, addEventListener() {}, removeEventListener() {} }); +globalThis.XRDisplayLayer = FakeDisplayLayer; +globalThis.Image = class {}; + +const { createInline3D } = await import('../js/inline3d.js'); +const { addSplat } = await import('../js/inline3d-splat-pc-entry.js'); + +function captureWarnings(fn) { + const real = console.warn; + const warns = []; + console.warn = (...a) => warns.push(a.map(String).join(' ')); + try { + return { result: fn(), warns }; + } finally { + console.warn = real; + } +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +test('core: a second addScene on a live canvas returns the first handle, warns once, builds no layer', async () => { + const wall = await createInline3D({ lazy: false, autoChrome: false }); + const canvas = makeCanvas(); + created.length = 0; + const first = wall.addScene(canvas, () => {}); + const { result: second, warns } = captureWarnings(() => [wall.addScene(canvas, () => {}), wall.addScene(canvas, () => {})]); + assert.equal(second[0], first); + assert.equal(second[1], first); + assert.equal(created.length, 1, 'no second layer'); + assert.equal(created[0].closed, undefined, 'the first layer was not closed and rebuilt'); + assert.equal(warns.length, 1, 'once per canvas'); + assert.match(warns[0], /addScene\(\) on a canvas that is already registered .*by addScene\(\), not yet removed/); + wall.close(); +}); + +test('core: across methods (addImage, then addVideo) — the live image handle comes back', async () => { + const wall = await createInline3D({ lazy: false, autoChrome: false }); + const canvas = makeCanvas(); + const img = wall.addImage(canvas, makeCanvas()); + const { result, warns } = captureWarnings(() => wall.addVideo(canvas, { readyState: 0 })); + assert.equal(result, img); + assert.match(warns[0], /^\[inline3d\] addVideo\(\) .*by addImage\(\)/); + wall.close(); +}); + +test('core: after remove() the canvas registers again, silently, as a new window', async () => { + const wall = await createInline3D({ lazy: false, autoChrome: false }); + const canvas = makeCanvas(); + const a = wall.addScene(canvas, () => {}); + a.remove(); + const { result: b, warns } = captureWarnings(() => wall.addScene(canvas, () => {})); + assert.notEqual(b, a); + assert.equal(warns.length, 0); + wall.close(); +}); + +test('core: the guard is per wall — a closed wall does not block a new one', async () => { + const w1 = await createInline3D({ lazy: false, autoChrome: false }); + const canvas = makeCanvas(); + w1.addScene(canvas, () => {}); + w1.close(); + const w2 = await createInline3D({ lazy: false, autoChrome: false }); + const { warns } = captureWarnings(() => w2.addScene(canvas, () => {})); + assert.equal(warns.length, 0); + w2.close(); +}); + +test('./splat (playcanvas): a second addSplat returns the first handle; a core add*() on it does too', async () => { + const wall = await createInline3D({ lazy: false, autoChrome: false }); + const canvas = makeViewerCanvas(320, 180); + const pc = { createGraphicsDevice: () => new Promise(() => {}) }; // the engine never arrives + const first = addSplat(wall, canvas, 'a.sog', { playcanvas: pc }); + const { result, warns } = captureWarnings(() => addSplat(wall, canvas, 'b.sog', { playcanvas: pc })); + assert.equal(result, first); + assert.match(warns[0], /addSplat\(\) .*by addSplat\(\), not yet removed/); + // Its own core window goes through (the entry's claim), and is the canvas's one layer. + const { warns: w2 } = await (async () => { + const real = console.warn; + const ws = []; + console.warn = (...a) => ws.push(a.map(String).join(' ')); + try { + await flush(); + await flush(); + } finally { + console.warn = real; + } + return { warns: ws }; + })(); + assert.equal(w2.filter((w) => /already registered/.test(w)).length, 0, 'the subpath’s own addScene was let through'); + assert.equal(wall._windows.has(canvas), true); + assert.equal(captureWarnings(() => wall.addScene(canvas, () => {})).result, first, 'the page-facing handle, not the core tile'); + // remove() releases the canvas. + first.remove(); + await flush(); + const { result: again, warns: w3 } = captureWarnings(() => addSplat(wall, canvas, 'c.sog', { playcanvas: pc })); + assert.notEqual(again, first); + assert.equal(w3.filter((w) => /already registered/.test(w)).length, 0); + again.remove(); + await flush(); + wall.close(); + void dom; +}); From 75322fc4eb45ba54bf5e63475f450b0d4c045f9e Mon Sep 17 00:00:00 2001 From: David Date: Thu, 8 Oct 2026 00:27:49 -0700 Subject: [PATCH 5/9] fix(viewers): present a frame on every session frame, even before the first good one (1.38.0) PlayCanvas viewer and SceneViewer drew nothing on a bad frame (short view list, missing or degenerate viewport) before any good frame existed; they now draw the mono camera into both halves (flat subject, or a cleared frame with no content). Rules doc: page-drawn addScene windows must draw every onFrame; a transparent woven frame weaving black is browser-pvt #255. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 18 +++++++ docs/porting-three-js-apps.md | 5 +- docs/woven-canvas-rules.md | 10 +++- js/inline3d-splat-playcanvas.js | 38 +++++++++++++- js/inline3d-viewer.js | 40 +++++++++++++-- test/undrawn-frames.test.mjs | 88 +++++++++++++++++++++++++++++++++ test/viewer-blink.test.mjs | 50 ++++++++++++------- 7 files changed, 223 insertions(+), 26 deletions(-) create mode 100644 test/undrawn-frames.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b8dac2e1..b7d80a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,24 @@ methods. No export changes; `SplatHandle` gains two methods in `splat.d.ts`. that subpath handle. A subpath handle whose load failed keeps the canvas until its `remove()`. - From a kiosk demo that attached its stage twice per screen visit. +### Every session frame presents (`./splat`, `./model`, `./viewer`) + +- The viewers behind the scene subpaths now draw on **every** session frame they are handed. This + covers the PlayCanvas viewer (`./splat` `engine: 'playcanvas'`, `./model`'s default) and + `SceneViewer` (`./splat` on Spark, `./model` `engine: 'three'`, `./viewer`). + - **Before the first good frame,** a frame they cannot draw (a short view list, a missing or + degenerate viewport) used to draw nothing. It now draws the mono camera into both halves: the + subject flat if it is loaded, a cleared frame if not. Afterwards they replay the last good + frame, as before. + - **No source, or nothing visible,** still draws a frame. + - **Before the engine has created its GL context,** nothing can be presented, and creating one + early would give it the wrong attributes. + - The dark-blink rule holds: no clear without a draw. +- **Note for `addScene` pages:** the SDK cannot enforce this for a window your page draws. Draw on + every `onFrame` ([`woven-canvas-rules.md`](docs/woven-canvas-rules.md), "redraw every frame"). + A fully transparent woven frame weaves **black**: a browser bug (browser-pvt #255), listed there + as a known limitation. + ## 1.37.1 — 2026-10-07 ### Depth cursor: hybrid anchor keeps the line of sight, not the foot diff --git a/docs/porting-three-js-apps.md b/docs/porting-three-js-apps.md index b557c2d8..86a01dd3 100644 --- a/docs/porting-three-js-apps.md +++ b/docs/porting-three-js-apps.md @@ -293,7 +293,10 @@ function onFrame(views, layer) { } function replayLastGood() { - if (!haveGood) return; // before the first good frame do NOTHING — not even clear + if (!haveGood) { // before the first good frame: still PRESENT a frame — an + renderer.clear(); // undrawn canvas can drop out of the weave. A cleared frame + return; // counts (the SDK's viewers draw the mono camera flat here). + } renderer.clear(); renderer.setScissorTest(true); for (let i = 0; i < 2; i++) { diff --git a/docs/woven-canvas-rules.md b/docs/woven-canvas-rules.md index d623f0dd..35b978a7 100644 --- a/docs/woven-canvas-rules.md +++ b/docs/woven-canvas-rules.md @@ -261,7 +261,15 @@ A scroll is not a resize. The canvas keeps its size and its identity, and nothin last frame). A scene must draw in every `onFrame`, and on a frame it cannot draw it must **replay the last good one** rather than skip ([validate before you clear](authoring-inline-3d.md#3-live-scene-threejs--webgl--addscenecanvas-onframe-opts)). - `./viewer`, `./splat` and `./model` already do this. + `./viewer`, `./splat` and `./model` already do this, and since 1.38 they also draw on the frames + before the first good one: the mono camera into both halves, the subject if it is loaded, or a + cleared frame if it is not. The only undrawn frames left are the ones before the engine has + created its context. The SDK cannot enforce this for an `addScene` window your page draws: draw + something on every `onFrame`, even with no content yet. + - **Known limitation: a transparent canvas weaves black.** A cleared (fully transparent) woven + frame comes out black on the panel, not see-through: a browser bug (browser-pvt #255), not + the SDK's. Until it is fixed, a frame with nothing to show is drawn but dark. If the page + behind the tile is not black, cover the tile until its content is in (rule 5). - **Large scene canvases: consider `preserveDrawingBuffer: true`.** On older DisplayXR Browser builds, the weave's zero-copy read of a full-window WebGL canvas can race the page's next write, and the frame is dropped. Keep the SBS buffer no wider than the panel, and try diff --git a/js/inline3d-splat-playcanvas.js b/js/inline3d-splat-playcanvas.js index b2dc40b1..cbc1379c 100644 --- a/js/inline3d-splat-playcanvas.js +++ b/js/inline3d-splat-playcanvas.js @@ -1235,7 +1235,7 @@ export class PlayCanvasSplatViewer { this._beforeFrame?.(views || null); this._tick(); if (!views || views.length < 2) { - this._replayLastGood(); + this._replayOrFlat(); return; } const vps = this._vps; @@ -1243,7 +1243,7 @@ export class PlayCanvasSplatViewer { for (const view of views) { const vp = layer && typeof layer.getViewport === 'function' ? layer.getViewport(view) : null; if (!vp || !(vp.width > 0) || !(vp.height > 0)) { - this._replayLastGood(); + this._replayOrFlat(); return; } vps.push(vp); @@ -2169,6 +2169,40 @@ export class PlayCanvasSplatViewer { } } + /** + * A session frame that cannot draw its own views still PRESENTS: the last good frame, or — + * before there is one — the mono camera into both halves (flat, the subject if loaded, else + * the clear colour). A woven canvas that is not redrawn can drop out of the frame the + * compositor aggregates (woven-canvas-rules, "redraw every frame"), and a load-fallback frame + * before the first stereo one is exactly when no cache exists. The only frame this cannot + * cover is one before the engine has attached: there is no GL context yet, and creating one + * here would create it with the wrong attributes. + */ + _replayOrFlat() { + return this._replayLastGood() || this._drawFlatPair(); + } + + _drawFlatPair() { + if (this._disposed || !this.app || !this.pc) return false; + const b = this._bufScale(); + const W = b.w || this.canvas.width; + const H = b.h || this.canvas.height; + if (!(W > 1) || !(H > 0)) return false; + const half = Math.floor(W / 2); + const v = this._monoView(); + const pair = (this._flatPair ||= [0, 1].map(() => ({ proj: null, pose: null, x: 0, y: 0, width: 0, height: 0 }))); + for (let i = 0; i < 2; i++) { + const e = pair[i]; + e.proj = v.proj; + e.pose = v.pose; + e.x = i * half; + e.y = 0; + e.width = half; + e.height = H; + } + return this._drawEntries(pair, null); + } + _replayLastGood() { const g = this._lastGood; if (!g || this._disposed) return false; diff --git a/js/inline3d-viewer.js b/js/inline3d-viewer.js index 16765854..0524e4f2 100644 --- a/js/inline3d-viewer.js +++ b/js/inline3d-viewer.js @@ -699,7 +699,7 @@ export class SceneViewer { // 1. A short view list is the load-induced mono fallback. Stereo needs two. if (!views || views.length < 2) { - this._replayLastGood(); + this._replayOrFlat(); return; } @@ -725,7 +725,7 @@ export class SceneViewer { for (const view of views) { const vp = layer && typeof layer.getViewport === 'function' ? layer.getViewport(view) : null; if (!vp || !(vp.width > 0) || !(vp.height > 0)) { - this._replayLastGood(); + this._replayOrFlat(); return; } vps.push(vp); @@ -891,11 +891,41 @@ export class SceneViewer { } } + /** + * A session frame that cannot draw its own views still PRESENTS (1.38): the last good frame, + * or, before there is one, the mono camera into both halves — flat, but drawn. An undrawn + * woven canvas can drop out of the frame the compositor aggregates (woven-canvas-rules, + * "redraw every frame"), and this viewer used to leave exactly the frames before the first + * stereo one undrawn. Every clear here is followed by a draw, so the dark-blink rule (web#12) + * still holds. + */ + _replayOrFlat() { + return this._replayLastGood() || this._drawFlatPair(); + } + + _drawFlatPair() { + if (this._disposed) return false; + const r = this.renderer; + const b = this._bufScale(); + if (!(b.w > 1) || !(b.h > 0)) return false; + const half = Math.floor(b.w / 2); + r.clear(); + r.setScissorTest(true); + for (const x of [0, half]) { + const vp = { x, y: 0, width: half, height: b.h }; + r.setViewport(vp.x, vp.y, vp.width, vp.height); + r.setScissor(vp.x, vp.y, vp.width, vp.height); + r.render(this.scene, this.monoCamera); + if (this._feather) this._feather.render(r, vp); + } + r.setScissorTest(false); + return true; + } + /** * Re-render the last good frame from the cached matrices. Returns false when there is no - * cache yet — and the caller must then do NOTHING, not clear: before the first good frame - * the canvas holds either the page's own initial state or the mono fallback's output, both - * of which are better than black. + * cache yet (onFrame then draws the flat pair, see _replayOrFlat; a resize leaves the + * freshly cleared store to the next session frame). */ _replayLastGood() { const g = this._lastGood; diff --git a/test/undrawn-frames.test.mjs b/test/undrawn-frames.test.mjs new file mode 100644 index 00000000..ab9fc9d2 --- /dev/null +++ b/test/undrawn-frames.test.mjs @@ -0,0 +1,88 @@ +// Every session frame presents (1.38, woven-canvas-rules "redraw every frame"): the PlayCanvas +// viewer behind ./splat (engine:'playcanvas') and ./model (default engine) draws on EVERY frame it +// is handed — its views, the last good frame, or, before there is one, the mono camera into both +// halves — with or without content loaded. (SceneViewer, the Spark / three engines' viewer, is +// pinned the same way in viewer-blink.test.mjs.) Stand-in engine: draws are recorded. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { installDom, makeCanvas } from './stubs.mjs'; + +installDom(); +const { PlayCanvasSplatViewer } = await import('../js/inline3d-splat-playcanvas.js'); + +function gl(canvas) { + return { + getParameter: (p) => (p === 3 ? Int32Array.from([16384, 16384]) : 16384), + MAX_TEXTURE_SIZE: 1, + MAX_RENDERBUFFER_SIZE: 2, + MAX_VIEWPORT_DIMS: 3, + get drawingBufferWidth() { + return canvas.width; + }, + get drawingBufferHeight() { + return canvas.height; + }, + }; +} + +function viewerWithEngine() { + const canvas = makeCanvas(400, 200); + const viewer = new PlayCanvasSplatViewer(canvas, { orbit: false }); + viewer.app = { graphicsDevice: { gl: gl(canvas) }, destroy() {} }; + viewer.pc = {}; + viewer._resize(); + const draws = []; + viewer._drawEntriesInner = (entries, cache) => { + draws.push({ n: entries.length, rects: entries.map((e) => [e.x, e.y, e.width, e.height]), cached: !!cache }); + return true; + }; + return { canvas, viewer, draws }; +} + +const views2 = () => + [0, 1].map(() => ({ projectionMatrix: new Float32Array([2, 0, 0, 0, 0, 2, 0, 0, 0, 0, -1, -1, 0, 0, -0.5, 0]), transform: { matrix: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) } })); +const layer = (canvas) => ({ getViewport: () => ({ x: 0, y: 0, width: canvas.width / 2, height: canvas.height }) }); + +test('before any good frame, a short view list draws the flat pair (both halves), not nothing', () => { + const { canvas, viewer, draws } = viewerWithEngine(); + viewer.onFrame([], layer(canvas), {}); + viewer.onFrame(null, layer(canvas), {}); + assert.equal(draws.length, 2, 'one presented frame per session frame'); + for (const d of draws) { + assert.equal(d.n, 2); + assert.deepEqual(d.rects, [ + [0, 0, 400, 200], + [400, 0, 400, 200], + ]); + assert.equal(d.cached, false); + } + viewer.dispose(); +}); + +test('before any good frame, a frame with no usable viewport draws the flat pair', () => { + const { viewer, draws } = viewerWithEngine(); + viewer.onFrame(views2(), null, {}); + viewer.onFrame(views2(), { getViewport: () => ({ x: 0, y: 0, width: 0, height: 0 }) }, {}); + assert.equal(draws.length, 2); + viewer.dispose(); +}); + +test('every session frame presents: good frames, load-fallback frames, with no content loaded', () => { + const { canvas, viewer, draws } = viewerWithEngine(); + assert.equal(viewer.splat, null, 'no source: nothing loaded on this viewer'); + const seq = [2, 1, 0, 2, 2, 1, 2]; + for (const n of seq) viewer.onFrame(n === 2 ? views2() : n === 1 ? views2().slice(0, 1) : [], layer(canvas), {}); + assert.equal(draws.length, seq.length, 'a draw for every frame, whatever it carried'); + assert.equal(draws[1].cached, true, 'after a good frame, a bad one replays it'); + viewer.dispose(); +}); + +test('before the engine attaches there is no context to present into: no draw, no throw', () => { + const canvas = makeCanvas(400, 200); + const viewer = new PlayCanvasSplatViewer(canvas, { orbit: false }); + assert.doesNotThrow(() => viewer.onFrame([], layer(canvas), {})); + assert.equal(viewer._drawFlatPair(), false); + viewer.dispose(); +}); diff --git a/test/viewer-blink.test.mjs b/test/viewer-blink.test.mjs index d370229e..9e58b835 100644 --- a/test/viewer-blink.test.mjs +++ b/test/viewer-blink.test.mjs @@ -38,39 +38,54 @@ function withCapturedWarnings(fn) { } // ── 1. validate before clear ──────────────────────────────────────────────────────────── +// +// Before any good frame there is nothing to replay. Until 1.38 such a frame drew nothing at all; +// it now draws the FLAT pair (the mono camera into both halves) so that every session frame +// presents (woven-canvas-rules, "redraw every frame"). What these pin is the blink rule itself: +// no clear is ever left without a draw, and the eye path never runs on a frame that failed. + +/** The flat pair: one clear, the mono camera into the left and right halves, nothing else. */ +function assertFlatPair(log, viewer, canvas) { + assert.equal(log.clear, 1, 'one clear, followed by a draw'); + assert.equal(log.render.length, 2); + for (const r of log.render) assert.equal(r.camera, viewer.monoCamera, 'never the eye camera on a failed frame'); + const half = canvas.width / 2; + assert.deepEqual(log.render.map((r) => r.viewport), [ + { x: 0, y: 0, width: half, height: canvas.height }, + { x: half, y: 0, width: half, height: canvas.height }, + ]); + assert.equal(log.clearsWithoutDraw, 0); +} -test('an empty view list never clears the canvas', () => { +test('an empty view list before any good frame draws the flat pair, never a bare clear', () => { const { log, canvas, viewer } = setup(); viewer.onFrame([], makeLayer(canvas)); - assert.equal(log.clear, 0, 'cleared on a frame it could not draw'); - assert.equal(log.render.length, 0); + assertFlatPair(log, viewer, canvas); }); -test('a one-eye view list never clears the canvas', () => { +test('a one-eye view list before any good frame draws the flat pair', () => { const { log, canvas, viewer } = setup(); viewer.onFrame(makeViews(1), makeLayer(canvas)); - assert.equal(log.clear, 0, 'the load-induced mono fallback must not blank the tile'); - assert.equal(log.render.length, 0); + assertFlatPair(log, viewer, canvas); }); -test('a null viewport never clears the canvas', () => { +test('a null viewport before any good frame draws the flat pair', () => { const { log, canvas, viewer } = setup(); viewer.onFrame(makeViews(2), makeLayer(canvas, { nullFor: 1 })); - assert.equal(log.clear, 0, 'cleared even though the right eye had nowhere to go'); - assert.equal(log.render.length, 0); + assertFlatPair(log, viewer, canvas); }); -test('a degenerate (zero-width) viewport never clears the canvas', () => { +test('a degenerate (zero-width) viewport before any good frame draws the flat pair', () => { const { log, canvas, viewer } = setup(); const layer = { getViewport: () => ({ x: 0, y: 0, width: 0, height: 0 }) }; viewer.onFrame(makeViews(2), layer); - assert.equal(log.clear, 0); + assertFlatPair(log, viewer, canvas); }); -test('a missing layer never clears the canvas', () => { - const { log, viewer } = setup(); +test('a missing layer before any good frame draws the flat pair', () => { + const { log, canvas, viewer } = setup(); viewer.onFrame(makeViews(2), null); - assert.equal(log.clear, 0); + assertFlatPair(log, viewer, canvas); }); test('without useEyeCamera it warns ONCE and draws the mono camera instead of clearing', () => { @@ -143,11 +158,12 @@ test('the replay cache COPIES the matrices — an XRView is only valid in its ow ); }); -test('a bad frame before any good one does nothing at all', () => { +test('bad frames before any good one present every frame (flat pair), never an undrawn frame', () => { const { log, canvas, viewer } = setup(); for (let i = 0; i < 3; i++) viewer.onFrame(makeViews(1), makeLayer(canvas)); - assert.equal(log.clear, 0); - assert.equal(log.render.length, 0); + assert.equal(log.clear, 3, 'one presented frame per session frame'); + assert.equal(log.render.length, 6); + assert.equal(log.clearsWithoutDraw, 0); }); test('a sustained bad run keeps replaying rather than degrading', () => { From 7ca1805e6e52a7c536d702083dae5d347f4dd487 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 8 Oct 2026 00:30:20 -0700 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20double-attach=20is=20a=20warning=20o?= =?UTF-8?q?nly=20=E2=80=94=20add*()=20on=20a=20live=20canvas=20proceeds=20?= =?UTF-8?q?as=20before=20(1.38.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: returning the existing handle changed frozen-core behavior (a page re-adding to swap pictures would keep the old one). Detection stays (including the subpath entry claim, so a second addSplat/addModel during the first one's load is caught), warned once per canvas; the call then closes and rebuilds the layer / attaches a new renderer and returns its own new handle, exactly as before. Drops the return-existing machinery (win.handle, the refusal's return value). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 28 ++++----- docs/woven-canvas-rules.md | 9 ++- index.d.ts | 7 ++- js/inline3d-model-entry.js | 4 +- js/inline3d-model.js | 4 +- js/inline3d-splat-pc-entry.js | 4 +- js/inline3d-splat-shared.js | 9 ++- js/inline3d-splat.js | 4 +- js/inline3d.js | 95 ++++++++++++++--------------- test/double-attach.test.mjs | 110 ++++++++++++++++++---------------- 10 files changed, 134 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d80a57..19f22b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,23 +96,19 @@ methods. No export changes; `SplatHandle` gains two methods in `splat.d.ts`. paint too. It runs only on a real change, and a throw is caught. Without it, nothing changes: no observer is attached to a scene canvas. -### Double-attach guard (core, and the `./splat` / `./model` entries) - -- **One registration per canvas per manager.** `addScene` / `addImage` / `addVideo` / - `addSplat` / `addModel` on a canvas that is still registered on the same wall (not yet - `remove()`d) now: - - warns once per canvas, naming the method and the first registration that is still live; - - returns the **existing** handle, and creates no second layer or renderer. -- **Behavior change in the core.** Before, a second call closed the window's layer and built a - new one. That started a fresh 0.4–1.2 s identity gap every time, and on `./splat` / `./model` it - put a second renderer on the same context. - - A page that re-called `addImage(canvas, newSource)` to swap pictures now keeps the first - picture, with the warning saying why. - - To swap content, redraw a source canvas or use `setSource`. To re-register on purpose, - `remove()` first. +### Double-attach warning (core, and the `./splat` / `./model` entries) + +- `addScene` / `addImage` / `addVideo` / `addSplat` / `addModel` on a canvas that is still + registered on the same wall (not yet `remove()`d) now **warns**, once per canvas. The warning + names the method and the live registration, and says the call is rebuilding the layer (a fresh + 0.4–1.2 s identity gap) or putting a second renderer on the context. +- **It is a warning only, so no page changes behavior.** The call proceeds exactly as before: the + core closes and rebuilds the layer, the subpaths attach a new renderer, and the call returns its + own new handle. A page that re-adds to swap pictures keeps working. +- To swap content, redraw a source canvas or use `setSource`. To re-register on purpose, + `remove()` first. - `./splat` and `./model` check at their entry, not only in `addScene`: their renderer exists - before their core window. A core `add*()` on a canvas that `addSplat` / `addModel` holds returns - that subpath handle. A subpath handle whose load failed keeps the canvas until its `remove()`. + before their core window, so a second call during the first one's load is caught too. - From a kiosk demo that attached its stage twice per screen visit. ### Every session frame presents (`./splat`, `./model`, `./viewer`) diff --git a/docs/woven-canvas-rules.md b/docs/woven-canvas-rules.md index 35b978a7..13c2f632 100644 --- a/docs/woven-canvas-rules.md +++ b/docs/woven-canvas-rules.md @@ -68,11 +68,10 @@ re-renders over, and change what is **in** it: - **Scenes:** swap the content inside your `onFrame`, never the canvas. - **Splats on the PlayCanvas backend:** `handle.setSource(src, { fadeMs, resetPose })`. -Calling `add*()` again on the same canvas is not a content swap. Since 1.38 the SDK refuses -it: while the first registration is live (not `remove()`d), a second `addScene` / `addImage` / -`addVideo` / `addSplat` / `addModel` on that canvas warns once and returns the **existing** handle, -and no second layer or renderer is created. Before 1.38 it closed the window's layer and built a -new one: a fresh identity gap on every call. To re-register on purpose, `remove()` first. +Calling `add*()` again on the same canvas is not a content swap. It closes the window's layer and +builds a new one (a fresh identity gap), and on `./splat` / `./model` it puts a second renderer on +the context. Since 1.38 the SDK warns about it, once per canvas, while the first registration is +live (not `remove()`d), then proceeds as before. To re-register on purpose, `remove()` first. ### 3. Prefer one persistent canvas for the whole app diff --git a/index.d.ts b/index.d.ts index 6fd3d4fa..56734305 100644 --- a/index.d.ts +++ b/index.d.ts @@ -376,9 +376,10 @@ export type SceneFrameCallback = ( /** * The handle returned by every add*() call. * - * One registration per canvas per manager: an add*() on a canvas that is still registered (not - * `remove()`d) warns once per canvas and returns the EXISTING handle, creating no second layer - * (1.38; before, it closed and rebuilt the layer). `remove()` first to re-register. + * An add*() on a canvas that is still registered (not `remove()`d) on the same manager warns once + * per canvas (1.38) and then proceeds as it always has: the layer is closed and rebuilt (a fresh + * identity gap), or a subpath puts a second renderer on the context, and the call returns its own + * new handle. Change what is in the canvas instead, or `remove()` first. */ export interface TileHandle { /** Remove this window: close its weave layer and stop driving it. */ diff --git a/js/inline3d-model-entry.js b/js/inline3d-model-entry.js index 09b29f4d..5cfa7335 100644 --- a/js/inline3d-model-entry.js +++ b/js/inline3d-model-entry.js @@ -99,8 +99,8 @@ export function validateModelCall(canvas, src, opts, route) { * fill in as the backend loads. */ export function addModel(wall, canvas, src, opts = {}) { - // One registration per canvas per wall: a second call on a live canvas warns once and returns - // the first handle (see guardedAttach). + // A second call on a canvas still registered on this wall warns once, then proceeds as before + // (see guardedAttach). return guardedAttach(wall, canvas, 'addModel', opts, (o) => addModelUnguarded(wall, canvas, src, o)); } diff --git a/js/inline3d-model.js b/js/inline3d-model.js index 13bb7658..af2de8f5 100644 --- a/js/inline3d-model.js +++ b/js/inline3d-model.js @@ -346,8 +346,8 @@ function kindFromMessage(msg) { * `setPose`, `resetPose`, `frame`, and `ready`. */ export function addModel(wall, canvas, src, opts = {}) { - // One registration per canvas per wall: a second call on a live canvas warns once and returns - // the first handle (see guardedAttach). + // A second call on a canvas still registered on this wall warns once, then proceeds as before + // (see guardedAttach). return guardedAttach(wall, canvas, 'addModel', opts, (o) => addModelUnguarded(wall, canvas, src, o)); } diff --git a/js/inline3d-splat-pc-entry.js b/js/inline3d-splat-pc-entry.js index b4abe76e..6ef0a318 100644 --- a/js/inline3d-splat-pc-entry.js +++ b/js/inline3d-splat-pc-entry.js @@ -25,8 +25,8 @@ export { resolveRig } from './inline3d-splat-rig.js'; /** addSplat with `engine: 'playcanvas'`; the same validation, at the call, as ./splat's. */ export function addSplat(wall, canvas, src, opts = {}) { - // One registration per canvas per wall: a second call on a live canvas warns once and returns - // the first handle (see guardedAttach). + // A second call on a canvas still registered on this wall warns once, then proceeds as before + // (see guardedAttach). return guardedAttach(wall, canvas, 'addSplat', opts, (o) => addSplatUnguarded(wall, canvas, src, o)); } diff --git a/js/inline3d-splat-shared.js b/js/inline3d-splat-shared.js index 8e17bf00..d2fa0348 100644 --- a/js/inline3d-splat-shared.js +++ b/js/inline3d-splat-shared.js @@ -13,18 +13,17 @@ // test/splat-playcanvas.test.mjs (values, and a behavioural trace of both viewers side by side). /** - * The double-attach guard around a scene subpath's public entry (`addSplat`, `addModel`). + * Double-attach detection around a scene subpath's public entry (`addSplat`, `addModel`). * * The check has to run HERE, at the entry, and not only in the core's addScene: the subpaths build * a renderer on the canvas BEFORE the core window exists (asynchronously, once the engine module - * has loaded), so a second `addSplat` on a live canvas would already have put a second engine on - * its context by the time addScene could refuse it. `wall._attachGuard` (the core manager) does - * the bookkeeping; a wall without it (no session, or an older core) attaches unguarded. + * has loaded), so a second `addSplat` during the first one's load would never reach the core's + * check in time. `wall._attachGuard` (the core manager) warns once per canvas and claims it; the + * call then proceeds exactly as before. A wall without it (no session, an older core) attaches as is. */ export function guardedAttach(wall, canvas, method, opts, attach) { const g = wall && typeof wall._attachGuard === 'function' ? wall._attachGuard(canvas, method, opts) : null; if (!g) return attach(opts); - if (g.existing) return g.existing; const out = attach(g.opts); g.own(out); return out; diff --git a/js/inline3d-splat.js b/js/inline3d-splat.js index 3b6c35d3..a7a0e8f1 100644 --- a/js/inline3d-splat.js +++ b/js/inline3d-splat.js @@ -163,8 +163,8 @@ function sniffFileType(bytes) { * good enough for a clean, isolated capture, weaker on a scene with a background wall. */ export function addSplat(wall, canvas, src, opts = {}) { - // One registration per canvas per wall: a second call on a live canvas warns once and returns - // the first handle (see guardedAttach). + // A second call on a canvas still registered on this wall warns once, then proceeds as before + // (see guardedAttach). return guardedAttach(wall, canvas, 'addSplat', opts, (o) => addSplatUnguarded(wall, canvas, src, o)); } diff --git a/js/inline3d.js b/js/inline3d.js index 548be436..0850479a 100644 --- a/js/inline3d.js +++ b/js/inline3d.js @@ -799,8 +799,8 @@ class Inline3D { this.session = session; this.refSpace = refSpace; this._windows = new Map(); // canvas -> window record - // Double-attach guard. `_claims`: canvases a scene subpath entry (addSplat / addModel) has - // taken before its core window exists (canvas -> { token, method, handle }); `_doubleWarned`: + // Double-attach detection. `_claims`: canvases a scene subpath entry (addSplat / addModel) has + // taken before its core window exists (canvas -> { token, method }); `_doubleWarned`: // canvases already warned about, so a page calling add*() per render warns once. this._claims = new Map(); this._doubleWarned = new WeakSet(); @@ -1051,15 +1051,14 @@ class Inline3D { * @returns {{remove():void}} */ addImage(canvas, source, opts = {}) { - const dup = this._existingAttachment(canvas, opts._dxrClaim); - if (dup) return this._refuseDoubleAttach(canvas, 'addImage', dup); + this._warnDoubleAttach(canvas, 'addImage', opts._dxrClaim); const win = this._register(canvas, 'image', opts); win.method = 'addImage'; win.ready = loadImage(source).then((img) => { win.img = img; win.repaint(); }); - return (win.handle = this._handle(canvas, win)); + return this._handle(canvas, win); } /** @@ -1070,12 +1069,11 @@ class Inline3D { * @returns {{remove():void}} */ addVideo(canvas, video, opts = {}) { - const dup = this._existingAttachment(canvas, opts._dxrClaim); - if (dup) return this._refuseDoubleAttach(canvas, 'addVideo', dup); + this._warnDoubleAttach(canvas, 'addVideo', opts._dxrClaim); const win = this._register(canvas, 'video', opts); win.method = 'addVideo'; win.video = video; - return (win.handle = this._handle(canvas, win)); + return this._handle(canvas, win); } /** @@ -1118,8 +1116,7 @@ class Inline3D { // The rig and the height describe the same one slot in the layer init, so warn where a // caller has said it twice — silently dropping one of two things the page explicitly asked // for is how a scene ends up framed at a scale nobody chose. - const dup = this._existingAttachment(canvas, opts._dxrClaim); - if (dup) return this._refuseDoubleAttach(canvas, 'addScene', dup); + this._warnDoubleAttach(canvas, 'addScene', opts._dxrClaim); if (opts.viewRig && opts.virtualDisplayHeight !== undefined) noteRigWinsOverHeight(); const win = this._register(canvas, 'scene', { virtualDisplayHeight: 0.24, ...opts }); win.method = 'addScene'; @@ -1128,67 +1125,63 @@ class Inline3D { // The SDK's own renderers (./viewer, ./splat, ./model) clamp their buffers before sizing and // say so, so the core never inspects (or creates!) their GL context — see _checkSceneBuffer. if (opts.bufferClamped === true) win.warnedBufMismatch = true; - return (win.handle = this._handle(canvas, win)); + return this._handle(canvas, win); } - // ── double-attach guard ─────────────────────────────────────────────────────────────── + // ── double-attach detection ─────────────────────────────────────────────────────────── // - // A second add*() on a canvas that is still registered used to close its layer and build a new - // one: a fresh identity gap on purpose (rule 2), and for ./splat and ./model a second renderer - // on the same context. A kiosk demo attached its stage twice per screen visit this way, without - // noticing. The first registration is what the page is actually looking at, so the second call - // is answered with it: one warning per canvas, the EXISTING handle, no new layer. - - /** The live registration on `canvas` other than the one `token` belongs to: { method, handle } or null. */ - _existingAttachment(canvas, token) { + // A second add*() on a canvas that is still registered closes its layer and builds a new one: a + // fresh identity gap (rule 2), and for ./splat and ./model a second renderer on the same + // context. A kiosk demo attached its stage twice per screen visit this way without noticing. + // The SDK says so, once per canvas, and then does exactly what it always did: a WARNING, so no + // page changes behavior (a page that re-adds to swap pictures keeps working). + + /** The live registration on `canvas` other than the one `token` belongs to: its method, or null. */ + _liveRegistration(canvas, token) { const claim = this._claims.get(canvas); - if (claim && claim.token !== token) return claim; + if (claim && claim.token !== token) return claim.method; const win = this._windows.get(canvas); - if (win && win.handle) return { method: win.method || 'add*', handle: win.handle }; - return null; + return win ? win.method || 'add*' : null; } - _refuseDoubleAttach(canvas, method, dup) { - if (!this._doubleWarned.has(canvas)) { - this._doubleWarned.add(canvas); - console.warn( - `[inline3d] ${method}() on a canvas that is already registered on this inline-3D session ` + - `(by ${dup.method}(), not yet removed): the first registration is still live, so this ` + - 'call returns ITS handle and creates no second layer. Re-registering would close and ' + - "rebuild the canvas's layer (a fresh 0.4-1.2 s identity gap) or put a second renderer on " + - 'its context. Change what is IN the canvas instead (setSource, your onFrame, a redrawn ' + - 'source canvas), or call handle.remove() first.', - canvas - ); - } - return dup.handle; + _warnDoubleAttach(canvas, method, token) { + const live = this._liveRegistration(canvas, token); + if (!live || this._doubleWarned.has(canvas)) return; + this._doubleWarned.add(canvas); + console.warn( + `[inline3d] ${method}() on a canvas that is already registered on this inline-3D session ` + + `(by ${live}(), not yet removed). This call is rebuilding the canvas's layer (a fresh ` + + '0.4-1.2 s identity gap) or putting a second renderer on its context. Change what is IN ' + + 'the canvas instead (setSource, your onFrame, a redrawn source canvas), or call ' + + 'handle.remove() on the first registration first. (Warned once per canvas.)', + canvas + ); } /** - * For the scene subpath entries (via guardedAttach in ./inline3d-splat-shared.js). Returns - * `{ existing }` for a canvas already registered (warned), else `{ opts, own }`: attach with - * `opts` (it carries the claim token down to this entry's own addScene), then `own(handle)`. - * A nested entry (./model's engine:'three' route calls ./model/three's addModel) inherits the - * claim through the same token. + * For the scene subpath entries (via guardedAttach in ./inline3d-splat-shared.js), which build + * their renderer BEFORE the core window exists, so the core's own check would come too late. + * Warns on a live registration, then claims the canvas for this call either way: returns + * `{ opts, own }` — attach with `opts` (it carries the claim token down to this entry's own + * addScene, which then does not warn a second time), then `own(handle)`. A nested entry + * (./model's engine:'three' route calls ./model/three's addModel) inherits the claim. */ _attachGuard(canvas, method, opts = {}) { const claim = this._claims.get(canvas); - if (opts && opts._dxrClaim && claim && claim.token === opts._dxrClaim) return { existing: null, opts, own: () => {} }; - const dup = this._existingAttachment(canvas, null); - if (dup) return { existing: this._refuseDoubleAttach(canvas, method, dup), opts, own: () => {} }; + if (opts && opts._dxrClaim && claim && claim.token === opts._dxrClaim) return { opts, own: () => {} }; + this._warnDoubleAttach(canvas, method, null); const token = {}; - return { existing: null, opts: { ...opts, _dxrClaim: token }, own: (out) => this._claim(canvas, method, out, token) }; + return { opts: { ...opts, _dxrClaim: token }, own: (out) => this._claim(canvas, method, out, token) }; } /** - * Hold `canvas` for a subpath handle until that handle's remove(). The adapters REASSIGN - * `out.remove` when their module loads (a queued stub first, the real one later), so the release - * rides an accessor that wraps whatever is assigned. A handle whose load failed stays claimed - * until its remove(), like any registration: "not yet removed" is the rule. + * Remember that a subpath handle holds `canvas` until that handle's remove() (the latest call + * wins). The adapters REASSIGN `out.remove` when their module loads, so the release rides an + * accessor that wraps whatever is assigned. */ _claim(canvas, method, out, token) { if (!this._running || !out || typeof out !== 'object') return; - const claim = { token, method, handle: out }; + const claim = { token, method }; this._claims.set(canvas, claim); let impl = out.remove; const remove = (...args) => { diff --git a/test/double-attach.test.mjs b/test/double-attach.test.mjs index a08d06b0..3f6fa1fd 100644 --- a/test/double-attach.test.mjs +++ b/test/double-attach.test.mjs @@ -1,15 +1,15 @@ -// The double-attach guard (1.38): add*() on a canvas that is still registered on the same wall -// warns once per canvas, naming the method and the live first registration, and returns the -// EXISTING handle — no second layer, no second renderer. A kiosk demo attached its stage twice per -// screen visit before this. Core methods and the ./splat entry (engine:'playcanvas', whose -// renderer exists before its core window, which is why the check sits at the entry). +// Double-attach detection (1.38): add*() on a canvas that is still registered on the same wall +// WARNS once per canvas, naming the method and the live registration, and then proceeds exactly +// as before — the layer is closed and rebuilt, the call returns its own new handle. A warning +// only, so no page changes behavior. Core methods and the ./splat entry (engine:'playcanvas', +// whose renderer exists before its core window, which is why the check also sits at the entry). import test from 'node:test'; import assert from 'node:assert/strict'; import { installDom, makeCanvas as makeViewerCanvas } from './stubs.mjs'; -const dom = installDom(); +installDom(); function makeCanvas(w = 300, h = 150) { const ctx = { save() {}, restore() {}, clearRect() {}, drawImage() {} }; @@ -54,98 +54,104 @@ globalThis.Image = class {}; const { createInline3D } = await import('../js/inline3d.js'); const { addSplat } = await import('../js/inline3d-splat-pc-entry.js'); -function captureWarnings(fn) { +/** Run fn (sync or async) with console.warn captured; only the double-attach warnings are kept. */ +async function warnsOf(fn) { const real = console.warn; const warns = []; console.warn = (...a) => warns.push(a.map(String).join(' ')); try { - return { result: fn(), warns }; + const result = await fn(); + return { result, warns: warns.filter((w) => /already registered/.test(w)) }; } finally { console.warn = real; } } const flush = () => new Promise((r) => setTimeout(r, 0)); +const flushFirstWoven = (h) => Promise.race([h.firstWoven, flush().then(() => 'pending')]); -test('core: a second addScene on a live canvas returns the first handle, warns once, builds no layer', async () => { +test('core: a second addScene on a live canvas warns once, then rebuilds the layer and returns a NEW handle', async () => { const wall = await createInline3D({ lazy: false, autoChrome: false }); const canvas = makeCanvas(); created.length = 0; const first = wall.addScene(canvas, () => {}); - const { result: second, warns } = captureWarnings(() => [wall.addScene(canvas, () => {}), wall.addScene(canvas, () => {})]); - assert.equal(second[0], first); - assert.equal(second[1], first); - assert.equal(created.length, 1, 'no second layer'); - assert.equal(created[0].closed, undefined, 'the first layer was not closed and rebuilt'); + const { result: [second, third], warns } = await warnsOf(() => [wall.addScene(canvas, () => {}), wall.addScene(canvas, () => {})]); + assert.notEqual(second, first, 'its own new handle, as before'); + assert.notEqual(third, second); + assert.equal(created.length, 3, 'a new layer per call, as before'); + assert.equal(created[0].closed, true, 'the first layer was closed and rebuilt, as before'); + assert.equal(created[1].closed, true); + assert.equal((await flushFirstWoven(first)).reason, 'removed', 'the first window is removed, as before'); assert.equal(warns.length, 1, 'once per canvas'); - assert.match(warns[0], /addScene\(\) on a canvas that is already registered .*by addScene\(\), not yet removed/); + assert.match(warns[0], /^\[inline3d\] addScene\(\) on a canvas that is already registered .*\(by addScene\(\), not yet removed\)/); + assert.match(warns[0], /rebuilding the canvas's layer \(a fresh 0\.4-1\.2 s identity gap\) or putting a second renderer/); + assert.match(warns[0], /handle\.remove\(\)/); wall.close(); }); -test('core: across methods (addImage, then addVideo) — the live image handle comes back', async () => { +test('core: across methods (addImage, then addVideo) — warned with both names, the video window replaces it', async () => { const wall = await createInline3D({ lazy: false, autoChrome: false }); const canvas = makeCanvas(); const img = wall.addImage(canvas, makeCanvas()); - const { result, warns } = captureWarnings(() => wall.addVideo(canvas, { readyState: 0 })); - assert.equal(result, img); + const { result: vid, warns } = await warnsOf(() => wall.addVideo(canvas, { readyState: 0 })); + assert.notEqual(vid, img); + assert.equal(wall._windows.get(canvas).kind, 'video'); + assert.equal(warns.length, 1); assert.match(warns[0], /^\[inline3d\] addVideo\(\) .*by addImage\(\)/); wall.close(); }); -test('core: after remove() the canvas registers again, silently, as a new window', async () => { +test('core: after remove() the canvas registers again, silently', async () => { const wall = await createInline3D({ lazy: false, autoChrome: false }); const canvas = makeCanvas(); - const a = wall.addScene(canvas, () => {}); - a.remove(); - const { result: b, warns } = captureWarnings(() => wall.addScene(canvas, () => {})); - assert.notEqual(b, a); + wall.addScene(canvas, () => {}).remove(); + const { warns } = await warnsOf(() => wall.addScene(canvas, () => {})); assert.equal(warns.length, 0); wall.close(); }); -test('core: the guard is per wall — a closed wall does not block a new one', async () => { +test('core: the detection is per wall — a closed wall does not warn on a new one', async () => { const w1 = await createInline3D({ lazy: false, autoChrome: false }); const canvas = makeCanvas(); w1.addScene(canvas, () => {}); w1.close(); const w2 = await createInline3D({ lazy: false, autoChrome: false }); - const { warns } = captureWarnings(() => w2.addScene(canvas, () => {})); + const { warns } = await warnsOf(() => w2.addScene(canvas, () => {})); assert.equal(warns.length, 0); w2.close(); }); -test('./splat (playcanvas): a second addSplat returns the first handle; a core add*() on it does too', async () => { +test('./splat (playcanvas): a second addSplat during the first one’s load warns once and attaches its own handle', async () => { const wall = await createInline3D({ lazy: false, autoChrome: false }); const canvas = makeViewerCanvas(320, 180); const pc = { createGraphicsDevice: () => new Promise(() => {}) }; // the engine never arrives - const first = addSplat(wall, canvas, 'a.sog', { playcanvas: pc }); - const { result, warns } = captureWarnings(() => addSplat(wall, canvas, 'b.sog', { playcanvas: pc })); - assert.equal(result, first); + const { result, warns } = await warnsOf(async () => { + const first = addSplat(wall, canvas, 'a.sog', { playcanvas: pc }); + // Caught at the entry, before either core window exists. + assert.equal(wall._windows.has(canvas), false); + const second = addSplat(wall, canvas, 'b.sog', { playcanvas: pc }); + await flush(); + await flush(); + return { first, second }; + }); + assert.notEqual(result.second, result.first, 'a new handle, as before'); + assert.equal(warns.length, 1, 'once per canvas, including both subpaths’ own addScene calls'); assert.match(warns[0], /addSplat\(\) .*by addSplat\(\), not yet removed/); - // Its own core window goes through (the entry's claim), and is the canvas's one layer. - const { warns: w2 } = await (async () => { - const real = console.warn; - const ws = []; - console.warn = (...a) => ws.push(a.map(String).join(' ')); - try { - await flush(); - await flush(); - } finally { - console.warn = real; - } - return { warns: ws }; - })(); - assert.equal(w2.filter((w) => /already registered/.test(w)).length, 0, 'the subpath’s own addScene was let through'); assert.equal(wall._windows.has(canvas), true); - assert.equal(captureWarnings(() => wall.addScene(canvas, () => {})).result, first, 'the page-facing handle, not the core tile'); - // remove() releases the canvas. - first.remove(); - await flush(); - const { result: again, warns: w3 } = captureWarnings(() => addSplat(wall, canvas, 'c.sog', { playcanvas: pc })); - assert.notEqual(again, first); - assert.equal(w3.filter((w) => /already registered/.test(w)).length, 0); - again.remove(); + // remove() releases the claim: a later addSplat on the removed canvas is silent. + result.first.remove(); + result.second.remove(); await flush(); + const wall2 = await createInline3D({ lazy: false, autoChrome: false }); + const c2 = makeViewerCanvas(320, 180); + const { warns: w3 } = await warnsOf(async () => { + const a = addSplat(wall2, c2, 'a.sog', { playcanvas: pc }); + a.remove(); + await flush(); + addSplat(wall2, c2, 'c.sog', { playcanvas: pc }).remove(); + await flush(); + }); + assert.equal(w3.length, 0); wall.close(); - void dom; + wall2.close(); }); From b7f463c8f07fc1e455e08612670813742e462a15 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 8 Oct 2026 16:19:47 -0700 Subject: [PATCH 7/9] fix: display reads wait for the layer's first frame instead of answering null/[] early getDisplayInfo() / getRenderingModes() / requestRenderingMode() called before a window's layer has delivered its first frame (right after add*()) got null / [] / "not forwarded": the browser answers only once the layer has a weave session, and null is documented as "no glasses-free display". The core now waits for that layer's first frame and asks then. - Released by the layer's first stereo frame (the firstWoven signal, no hold), or where the browser reports wovenState, the first non-'pending' read. - Capped at the first-woven cap (REWOVEN_MAX_HOLDS x max(firstWovenHoldMs, 1200) from the layer's construction), checked per session frame and backed by a real timer; a layer that never frames resolves with what it answers then. - No session / no live layer / failed layer: settles at once, as before. Removal or session end mid-wait releases the read, which re-checks and rejects as before; a rebuilt layer waits anew. - index.d.ts documents null/empty as "the layer answered", never "not ready yet"; CHANGELOG Unreleased gains the entry and an intro covering the whole Unreleased set. - test/display-reads.test.mjs (8 tests); display-modes and mode-switch walls now deliver a stereo frame before their display calls. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 27 +++- index.d.ts | 31 +++- js/inline3d.js | 94 ++++++++++++ test/display-modes.test.mjs | 27 +++- test/display-reads.test.mjs | 291 ++++++++++++++++++++++++++++++++++++ test/mode-switch.test.mjs | 18 ++- 6 files changed, 474 insertions(+), 14 deletions(-) create mode 100644 test/display-reads.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f22b35..c7af3011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,15 @@ which tier they touch, because that is what tells you whether an upgrade can mov ## Unreleased -Touches the **preview tier** (`./call`, `./splat`) only: one default and two additive handle -methods. No export changes; `SplatHandle` gains two methods in `splat.d.ts`. +Core (`.`) changes are **additive**, with every existing default unchanged: `firstWoven` and +`rewoven()` settle on the browser's `wovenState` report where it exists (the hold path elsewhere, +byte for byte), the optional `onResize` on `addScene`, a warning on a double attach (the call +proceeds as before), and the display reads (`getDisplayInfo()` / `getRenderingModes()` / +`requestRenderingMode()`) waiting for a layer's first frame instead of answering `null` / `[]` +before it. The **preview tier** has one default change, `rectCover: 'auto'` on `./splat` and +`./model` (the core keeps `'off'`), plus additive handle methods (`finishSwap()`, `rewoven()` on +the splat handle) and the call's self view following `autoConverge`. New types only +(`WovenState`, `WovenWithheldReason`, `RectCoverOption`); no export is removed or renamed. ### `./call` — the stereo self view auto-converges too @@ -96,6 +103,22 @@ methods. No export changes; `SplatHandle` gains two methods in `splat.d.ts`. paint too. It runs only on a real change, and a throw is caught. Without it, nothing changes: no observer is attached to a scene canvas. +### Display reads wait for the layer's first frame (core `.`, additive, no new API) + +- **`getDisplayInfo()` / `getRenderingModes()` / `requestRenderingMode()`**, on a tile handle or + the wall, called before the window's layer has delivered its first frame now **wait for it** and + ask the layer then. Before, a page calling them right after `add*()` got `null` / `[]` (and a + request answered "not forwarded"): the browser only answers once the layer has a weave session, + and `null` is documented as "no glasses-free display". Found by the samples panel test (#151), + which worked around it page-side with `await handle.firstWoven`; that is no longer needed. + - **Released by** the layer's first stereo frame, or, where the browser reports `wovenState`, + the first read that is not `'pending'`. No first-woven hold. + - **Capped** at the first-woven cap: four holds from the layer's construction, never under + 4.8 s. A layer that never frames still resolves, with whatever it answers then (`null` / `[]` + is a real absence). + - **Unchanged:** a call after the first frame goes straight through; no session, no live layer, + a failed layer, or a window removed / session ended mid-wait settles at once, as before. + ### Double-attach warning (core, and the `./splat` / `./model` entries) - `addScene` / `addImage` / `addVideo` / `addSplat` / `addModel` on a canvas that is still diff --git a/index.d.ts b/index.d.ts index 56734305..1fd50c95 100644 --- a/index.d.ts +++ b/index.d.ts @@ -424,6 +424,14 @@ export interface TileHandle { /** * The panel this window weaves on, or `null` where there is no glasses-free display. * + * `null` is returned only once the layer has answered, never for "not ready yet". Called before + * this window's layer has delivered its first frame (right after `add*()`, the natural place), + * the call waits for that frame — the first stereo frame, or the first non-`'pending'` + * {@link TileHandle.wovenState} read where the browser reports one; no first-woven hold — and asks then. + * The wait is capped at the first-woven cap (four holds from the layer's construction, never + * under 4.8 s): a layer that never frames still resolves, with whatever it answers then. + * Without a live layer it settles at once, as below. + * * Rejects with an `Error` on a browser without the display-mode API * ({@link inline3dDisplayModesSupported}) or while this window has no live layer (lazy mode, * tile off screen). @@ -436,7 +444,13 @@ export interface TileHandle { * The canvas size is read at the call: call again after a resize. */ displayMetrics(): Promise; - /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */ + /** + * Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. + * + * An empty list is returned only once the layer has answered, never for "not ready yet": like + * {@link getDisplayInfo}, a call made before this window's layer has delivered its first frame + * waits for it (same release, same cap) and asks then. Rejects as {@link getDisplayInfo} does. + */ getRenderingModes(): Promise>; /** * Ask the runtime to switch the display to `modeIndex`. A thin pass-through — it resolves and @@ -450,6 +464,10 @@ export interface TileHandle { * * A `viewCount === 1` mode is requestable and is how a page goes flat. * + * Made before this window's layer has delivered its first frame, the request waits for it (the + * same release and cap as {@link getDisplayInfo}): before then the browser has no weave session + * to forward it to. + * * With the eased transition on (the default — see {@link ModeSwitchOptions}) a going-flat * request is HELD while the disparity ramps out, so the promise resolves when the request has * been forwarded rather than on the call; a request dropped by a reversal in that window @@ -555,9 +573,16 @@ export interface Inline3D { // The panel is the DOCUMENT's, not a tile's, so the display API lives here; the same names are // on every tile handle, routed to whichever window currently holds a live layer. - /** The panel, or `null` where there is no glasses-free display. See {@link TileHandle.getDisplayInfo}. */ + /** + * The panel, or `null` where there is no glasses-free display — returned only once the layer + * has answered, never for "not ready yet" (a call before the live window's first frame waits + * for it, capped). See {@link TileHandle.getDisplayInfo}. + */ getDisplayInfo(): Promise; - /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */ + /** + * Every rendering mode the display can be put in; an empty list only once the layer has + * answered, never for "not ready yet". See {@link TileHandle.getRenderingModes}. + */ getRenderingModes(): Promise>; /** Switch the display to `modeIndex`. See {@link TileHandle.requestRenderingMode}. */ requestRenderingMode(modeIndex: number): Promise; diff --git a/js/inline3d.js b/js/inline3d.js index 0850479a..aa0d0d2f 100644 --- a/js/inline3d.js +++ b/js/inline3d.js @@ -441,6 +441,12 @@ const WOVEN_STATES = new Set(['pending', 'woven', 'withheld']); // can still be the old rect's. const WOVEN_STATE_LAG_FRAMES = 3; +// The display calls that wait for a layer's first frame (see Inline3D._layerCall). All three +// answer from the browser's weave session, which only exists once a layer has delivered a frame: +// before that getDisplayInfo() is null, getRenderingModes() is [] and requestRenderingMode() is +// `false` ("not forwarded"). +const LAYER_CALLS_AFTER_FIRST_FRAME = new Set(['getDisplayInfo', 'getRenderingModes', 'requestRenderingMode']); + /** Does this browser build report `XRDisplayLayer.wovenState`? Feature-tested on the prototype. */ function hasWovenState() { try { @@ -1687,6 +1693,16 @@ class Inline3D { ) ); } + // A layer that has not delivered a frame yet answers getDisplayInfo() with null and + // getRenderingModes() with an empty list, and null is documented as "no glasses-free + // display". A page that asks right after add*() (the natural place) would be told there is + // no display. So wait for this layer's first frame (see _tickLayerFramed) and ask then. + // Capped at the first-woven cap, so a layer that never frames still answers (null/empty, as + // a real absence). Re-entered after the wait: the layer may have closed (-> the rejection + // above) or been rebuilt (-> wait for the new one) meanwhile. + if (LAYER_CALLS_AFTER_FIRST_FRAME.has(method) && this._layerReadPending(win)) { + return this._whenLayerFramed(win).then(() => this._layerCall(win, method, label, args)); + } // Wrapped so a SYNCHRONOUS throw from the browser (requestRenderingMode raises TypeError // that way for a non-2-view mode) arrives as a rejection like every other failure. try { @@ -1696,6 +1712,65 @@ class Inline3D { } } + /** The first-frame wait cap: the first-woven cap (four holds, never under four default holds). */ + _layerFramedCap(win) { + return REWOVEN_MAX_HOLDS * Math.max(win.fwHoldMs, FIRST_WOVEN_HOLD_MS); + } + + /** Does a display read on `win` still have to wait for its current layer's first frame? */ + _layerReadPending(win) { + if (!win.layer || win.layerFramed || win.lfCapped) return false; + if (nowMs() - win.lfAt >= this._layerFramedCap(win)) { + win.lfCapped = true; + return false; + } + return true; + } + + /** + * Resolves once `win`'s current layer has delivered its first frame, when the layer goes away + * or is rebuilt, or at the cap (measured from the layer's construction, as firstWoven's is). + * Never rejects. The cap is checked per session frame on the same clock as firstWoven, and + * backed by a real timer for a session whose frames have stopped altogether. + */ + _whenLayerFramed(win) { + return new Promise((resolve) => { + const layer = win.layer; + const waiter = { resolve, timer: null }; + const left = Math.max(0, this._layerFramedCap(win) - (nowMs() - win.lfAt)); + waiter.timer = setTimeout(() => { + const i = win.framedWaiters.indexOf(waiter); + if (i >= 0) win.framedWaiters.splice(i, 1); + if (win.layer === layer) win.lfCapped = true; + resolve(); + }, left); + if (waiter.timer && typeof waiter.timer.unref === 'function') waiter.timer.unref(); + win.framedWaiters.push(waiter); + }); + } + + /** + * Per session frame, after the draw, until the current layer has framed. The release rule: + * where the browser reports `wovenState`, the first read that is not 'pending' (it has seen the + * layer); elsewhere, the first stereo frame this layer has carried (the firstWoven signal, with + * no hold). Either way capped at _layerFramedCap from the layer's construction. + */ + _tickLayerFramed(win) { + const ws = win.wsLive ? readWovenState(win.layer) : null; + if (ws ? ws !== 'pending' : win.lfStereo) this._noteLayerFramed(win, true); + else if (win.framedWaiters.length && !this._layerReadPending(win)) this._noteLayerFramed(win, false); + } + + /** The layer framed (mark = true), or went away / capped (false): release every waiting read. */ + _noteLayerFramed(win, mark = true) { + if (mark) win.layerFramed = true; + const ws = win.framedWaiters.splice(0); + for (const w of ws) { + clearTimeout(w.timer); + w.resolve(); + } + } + /** The first window currently holding a live layer, or null. Every display call needs one. */ _liveWindow(preferred) { if (preferred && preferred.layer) return preferred; @@ -2270,6 +2345,14 @@ class Inline3D { wsLive: false, // The rect cover (opts.rectCover; see _rectCoverCheck), or null when off. rc: null, + // Display reads wait for the current layer's first frame (see _layerCall). `layerFramed`: + // it has delivered one; `lfStereo`: it has carried a stereo frame; `lfAt`: when it was + // built; `lfCapped`: the wait cap ran out for it; `framedWaiters`: reads queued meanwhile. + layerFramed: false, + lfStereo: false, + lfAt: 0, + lfCapped: false, + framedWaiters: [], }; const rcCfg = resolveRectCover(opts.rectCover); if (rcCfg) { @@ -2373,6 +2456,12 @@ class Inline3D { win.fwStereo = false; } win.wsLive = hasWovenState(); + // A new layer has delivered nothing yet (see _layerCall). Reads queued on the previous one + // were released by _deactivate and re-enter against this one. + win.layerFramed = false; + win.lfStereo = false; + win.lfCapped = false; + win.lfAt = nowMs(); this._restartRewoven(win); win.layerLostSent = false; // a live layer again: a future loss is worth reporting again // Nothing about the hardware state is re-asserted here, and that is the point: the panel's @@ -2414,6 +2503,7 @@ class Inline3D { } _deactivate(win) { + this._noteLayerFramed(win, false); // queued display reads re-check and fail as before this._stopOverlayScan(win); this._stopSizeWatch(win); if (win.layer) { @@ -3139,6 +3229,7 @@ class Inline3D { // fallback (a mono frame), which is not what a poster is waiting for. if (views.length >= 2) { win.fwStereo = true; + win.lfStereo = true; if (!win.warnedBufMismatch) this._checkSceneBuffer(win); if (win.rw) win.rw.stereo = true; } @@ -3164,6 +3255,7 @@ class Inline3D { // video has never had a frame) the tile holds nothing worth revealing yet. if (win.sbs && (win.kind === 'video' ? ((win.video && win.video.readyState) || 0) >= 2 : !!win.img)) { win.fwStereo = true; + win.lfStereo = true; if (win.rw) win.rw.stereo = true; } } @@ -3171,6 +3263,7 @@ class Inline3D { // the backing store, in the same task, which is the one moment a WebGL drawing buffer is // guaranteed readable without preserveDrawingBuffer. if (win.rc && win.rc.snapWanted) this._rectCoverSnap(win); + if (!win.layerFramed) this._tickLayerFramed(win); this._tickFirstWoven(win); } } @@ -3634,6 +3727,7 @@ class Inline3D { this._notifyLayerLost(win); this._settleFirstWoven(win, false, 'session-ended'); this._dropCover(win); + this._noteLayerFramed(win, false); } this._windows.clear(); this._claims.clear(); diff --git a/test/display-modes.test.mjs b/test/display-modes.test.mjs index 2f32cb69..582afc27 100644 --- a/test/display-modes.test.mjs +++ b/test/display-modes.test.mjs @@ -163,6 +163,7 @@ function installEnv(opts = {}) { const { FakeDisplayLayer, created, modes } = makeLayerClass(opts); const observers = []; const listeners = new Map(); + let pendingFrame = null; const session = { addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, new Set()); @@ -172,7 +173,10 @@ function installEnv(opts = {}) { listeners.get(type)?.delete(fn); }, requestReferenceSpace: async () => ({}), - requestAnimationFrame: () => 1, + requestAnimationFrame(cb) { + pendingFrame = cb; + return 1; + }, end() {}, }; Object.defineProperty(globalThis, 'navigator', { @@ -221,6 +225,16 @@ function installEnv(opts = {}) { for (const m of modes) m.isActive = m.modeIndex === i; this.fire('renderingmodechange', { modeIndex: i }); }, + /** + * One stereo session frame: every live layer has delivered its first frame, which is what + * the display calls wait for (test/display-reads.test.mjs pins that wait). + */ + async stereoFrame() { + const cb = pendingFrame; + pendingFrame = null; + if (cb) cb(0, { getViewerPose: () => ({ views: [{ eye: 'left' }, { eye: 'right' }] }) }); + await flush(); + }, intersect(el, isIntersecting) { for (const o of observers) o.cb([{ target: el, isIntersecting }]); }, @@ -263,7 +277,8 @@ const { createInline3D, inline3dDisplayModesSupported, inline3dUndockSupported, const CAM_RIG = { type: 'camera', position: { x: 0, y: 0, z: 0 }, verticalFov: 0.8, ipdFactor: 2, parallaxFactor: 2 }; /** - * A live, non-lazy wall with one scene window whose first mode read has already landed. + * A live, non-lazy wall with one scene window whose layer has delivered its first frame and + * whose first mode read has already landed. * * `wallOpts` reaches `createInline3D`. Two tests below pass `{modeSwitch:{enabled:false}}`: they * assert that a REQUEST touches no rig, which is the structural invariant of this file, and the @@ -274,7 +289,7 @@ async function makeWall(env, opts = {}, wallOpts = {}) { const wall = await createInline3D({ lazy: false, autoChrome: false, ...wallOpts }); const canvas = makeCanvas(); const handle = wall.addScene(canvas, () => {}, opts); - await flush(); + await env.stereoFrame(); return { wall, handle, canvas, layer: env.created[0] }; } @@ -463,7 +478,7 @@ test('EVERY window collapses, not just the one that was asked — the mode is th const wall = await createInline3D({ lazy: false, autoChrome: false }); const a = wall.addScene(makeCanvas(), () => {}); const b = wall.addScene(makeCanvas(), () => {}); - await flush(); + await env.stereoFrame(); a.setViewRig({ ...CAM_RIG }); b.setViewRig({ ...CAM_RIG }); await quietInfo(async () => { @@ -481,7 +496,7 @@ test('a lazy tile that rebuilds its layer while flat comes back FLAT, not in 3D' const canvas = makeCanvas(); const handle = wall.addScene(canvas, () => {}); env.intersect(canvas, true); - await flush(); + await env.stereoFrame(); handle.setViewRig(CAM_RIG); await quietInfo(async () => { env.goActive(0); @@ -505,7 +520,7 @@ test('a page that OPENS with a 1-view mode already active is collapsed by the fi let layer; await quietInfo(async () => { wall.addScene(makeCanvas(), () => {}, { virtualDisplayHeight: 0.4 }); - await flush(); + await env.stereoFrame(); layer = env.created[0]; }); assert.equal(wall.stereoCollapsed, true, 'no event ever fired — the first mode read is the source'); diff --git a/test/display-reads.test.mjs b/test/display-reads.test.mjs new file mode 100644 index 00000000..37313346 --- /dev/null +++ b/test/display-reads.test.mjs @@ -0,0 +1,291 @@ +// Tests for the display reads waiting on a layer's FIRST FRAME: getDisplayInfo(), +// getRenderingModes() and requestRenderingMode() all answer from the browser's weave session, +// which only exists once the layer has delivered a frame. Before that the browser answers null / +// [] / false, and null is documented as "no glasses-free display" — so a page asking right after +// add*() was told there is no display (found by the samples panel test, web#151). What is pinned: +// - a call made before the first frame waits, and is asked only once the layer has framed: the +// first stereo frame, or (where the browser reports wovenState) the first non-'pending' read — +// no 1200 ms hold either way; +// - a layer that never frames still answers, at the first-woven cap (4 holds from the layer's +// construction), with whatever the layer says then (null / []: a real absence); +// - no session, no layer, a failed layer, a removed window: settles at once, as before; +// - a call made after the first frame goes straight through, no deferral. +// +// Same recording-stub approach as woven-state.test.mjs (no jsdom, injected clock). + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +let clock = 0; +Object.defineProperty(globalThis, 'performance', { + value: { now: () => clock }, + configurable: true, + writable: true, +}); + +const INFO = Object.freeze({ + displayWidthMeters: 0.6, + displayHeightMeters: 0.34, + displayPixelWidth: 3840, + displayPixelHeight: 2160, + recommendedViewScaleX: 0.5, + recommendedViewScaleY: 1, +}); +const MODES = Object.freeze([ + Object.freeze({ modeIndex: 0, name: '2D', viewCount: 1, isActive: false, isRequestable: true }), + Object.freeze({ modeIndex: 1, name: '3D', viewCount: 2, isActive: true, isRequestable: true }), +]); + +function makeCanvas(w = 300, h = 150) { + const ctx = { save() {}, restore() {}, clearRect() {}, drawImage() {} }; + return { + style: {}, + width: 0, + height: 0, + clientWidth: w, + clientHeight: h, + parentElement: null, + contains: () => false, + getBoundingClientRect() { + return { x: 0, y: 0, left: 0, top: 0, width: this.clientWidth, height: this.clientHeight, right: this.clientWidth, bottom: this.clientHeight }; + }, + getContext: () => ctx, + }; +} + +/** + * A layer that behaves as the browser does: until it has a weave session (`answers`, which the + * test flips when the layer "delivers" its first frame) getDisplayInfo is null, getRenderingModes + * is [] and requestRenderingMode is false. `report: true` adds wovenState; `failCtor` throws. + */ +function installEnv({ report = false, failCtor = false } = {}) { + const created = []; + class FakeDisplayLayer { + constructor(session, canvas) { + if (failCtor) throw new Error('refused'); + this.canvas = canvas; + this._state = 'pending'; + this.answers = false; + this.calls = []; + created.push(this); + } + getViewport() { + return { x: 0, y: 0, width: 150, height: 150 }; + } + close() { + this.closed = true; + } + } + const P = FakeDisplayLayer.prototype; + P.getDisplayInfo = async function () { + this.calls.push(['getDisplayInfo', this.answers]); + return this.answers ? INFO : null; + }; + P.getRenderingModes = async function () { + this.calls.push(['getRenderingModes', this.answers]); + return this.answers ? MODES.map((m) => ({ ...m })) : []; + }; + P.requestRenderingMode = async function (i) { + this.calls.push(['requestRenderingMode', this.answers, i]); + return this.answers; + }; + if (report) { + Object.defineProperty(P, 'wovenState', { get() { return this._state; }, configurable: true }); + Object.defineProperty(P, 'withheldReason', { get() { return null; }, configurable: true }); + } + const listeners = new Map(); + let frameCb = null; + const session = { + addEventListener(type, fn) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type).add(fn); + }, + removeEventListener(type, fn) { + listeners.get(type)?.delete(fn); + }, + requestReferenceSpace: async () => ({}), + requestAnimationFrame(cb) { + frameCb = cb; + return 1; + }, + end() {}, + }; + Object.defineProperty(globalThis, 'navigator', { + value: { xr: { requestSession: async () => session } }, + configurable: true, + writable: true, + }); + globalThis.window = { XRDisplayLayer: FakeDisplayLayer, devicePixelRatio: 1, addEventListener() {}, removeEventListener() {} }; + globalThis.XRDisplayLayer = FakeDisplayLayer; + globalThis.document = undefined; + globalThis.IntersectionObserver = undefined; + return { + created, + get layer() { + return created[created.length - 1]; + }, + /** The browser now has a weave session for the latest layer: the methods answer for real. */ + goLive() { + this.layer.answers = true; + }, + report(state) { + this.layer._state = state; + }, + runFrame(n = 2) { + const cb = frameCb; + frameCb = null; + assert.ok(cb, 'the manager should have a session frame armed'); + const views = [{ eye: 'left' }, { eye: 'right' }].slice(0, n); + cb(clock, { getViewerPose: () => (n ? { views } : null) }); + }, + }; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); +async function peek(p) { + const PENDING = Symbol('pending'); + const v = await Promise.race([p, flush().then(() => PENDING)]); + return v === PENDING ? 'pending' : v; +} + +installEnv(); +const { createInline3D } = await import('../js/inline3d.js'); +const newWall = (opts = {}) => createInline3D({ lazy: false, autoChrome: false, modeSwitch: { enabled: false }, ...opts }); + +test('a read made right after add*() waits for the first stereo frame, then gets the real answer', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + const info = h.getDisplayInfo(); + const modes = h.getRenderingModes(); + const wallInfo = wall.getDisplayInfo(); + const req = h.requestRenderingMode(1); + assert.equal(await peek(info), 'pending', 'not answered null while the layer has not framed'); + assert.equal(await peek(modes), 'pending'); + assert.equal(await peek(wallInfo), 'pending', 'the wall-level read waits too'); + assert.equal(await peek(req), 'pending', 'requestRenderingMode has the same early "false" shape'); + assert.deepEqual(env.layer.calls, [], 'the layer is not even asked before it has framed'); + env.runFrame(1); // a mono (load-fallback) frame is not the first stereo frame + assert.equal(await peek(info), 'pending'); + clock = 16; // the first stereo frame: no hold + env.goLive(); + env.runFrame(2); + assert.deepEqual(await info, INFO); + assert.deepEqual(await modes, MODES); + assert.deepEqual(await wallInfo, INFO); + assert.equal(await req, true, 'forwarded once the layer had a session to forward to'); + assert.ok(env.layer.calls.every(([, live]) => live), 'every call reached a layer that answers'); + wall.close(); +}); + +test('with wovenState: released by the first non-pending read, not by a stereo frame while pending', async () => { + clock = 0; + const env = installEnv({ report: true }); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + const info = h.getDisplayInfo(); + env.runFrame(2); + assert.equal(await peek(info), 'pending', "a stereo frame the browser still calls 'pending' does not release"); + clock = 50; + env.goLive(); + env.report('withheld'); // the browser has seen the layer (withheld is not pending) + env.runFrame(2); + assert.deepEqual(await info, INFO); + wall.close(); +}); + +test('a layer that never frames: the read resolves null / [] at the first-woven cap, a real absence', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + const info = h.getDisplayInfo(); + const modes = h.getRenderingModes(); + clock = 4799; // 4 x the 1200 ms hold, from the layer's construction + env.runFrame(0); + assert.equal(await peek(info), 'pending'); + clock = 4800; + env.runFrame(0); + assert.equal(await info, null, "the layer's own null, asked at the cap"); + assert.deepEqual(await modes, []); + // Once capped, a later read on the same layer is asked at once. + assert.equal(await peek(h.getDisplayInfo()), null); + wall.close(); +}); + +test('the cap follows firstWovenHoldMs (four holds), never under four default holds', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + const long = wall.addScene(makeCanvas(), () => {}, { firstWovenHoldMs: 2000 }); + const short = wall.addScene(makeCanvas(), () => {}, { firstWovenHoldMs: 0 }); + const a = long.getDisplayInfo(); + const b = short.getDisplayInfo(); + clock = 4800; + env.runFrame(0); + assert.equal(await peek(a), 'pending', 'a 2000 ms hold caps at 8000 ms'); + assert.equal(await b, null, 'firstWovenHoldMs: 0 still waits four default holds, not zero'); + clock = 8000; + env.runFrame(0); + assert.equal(await a, null); + wall.close(); +}); + +test('a read made after the first frame goes straight through, no deferral', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + env.goLive(); + env.runFrame(2); + await flush(); + const before = env.layer.calls.length; + const info = h.getDisplayInfo(); + assert.equal(env.layer.calls.length, before + 1, 'asked synchronously, in the same call'); + assert.deepEqual(await info, INFO); + wall.close(); +}); + +test('no session (unsupported browser): nothing to wait on, the unsupported shape at once', async () => { + Object.defineProperty(globalThis, 'navigator', { value: {}, configurable: true, writable: true }); + globalThis.window = { devicePixelRatio: 1 }; + globalThis.XRDisplayLayer = undefined; + const wall = await createInline3D(); + assert.equal(wall.supported, false); + assert.equal(typeof wall.getDisplayInfo, 'undefined', 'no manager, so no read to defer'); +}); + +// (No layer at all — a lazy tile off screen — rejects at once exactly as before; pinned in +// test/display-modes.test.mjs.) +test('a window whose layer failed: settles at once, as before', async () => { + clock = 0; + installEnv({ failCtor: true }); + const warn = console.warn; + console.warn = () => {}; + try { + const wall = await newWall(); + const f = wall.addScene(makeCanvas(), () => {}); + await assert.rejects(() => f.getDisplayInfo(), /live weave layer/); + wall.close(); + } finally { + console.warn = warn; + } +}); + +test('a window removed, or a session ended, while a read waits: the read settles at once', async () => { + clock = 0; + installEnv(); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + const p = h.getDisplayInfo().catch((e) => e); + assert.equal(await peek(p), 'pending'); + h.remove(); + assert.match(String((await peek(p)).message), /live weave layer/); + + const h2 = wall.addScene(makeCanvas(), () => {}); + const q = h2.getRenderingModes().catch((e) => e); + assert.equal(await peek(q), 'pending'); + wall.close(); + assert.match(String((await peek(q)).message), /live weave layer/); +}); diff --git a/test/mode-switch.test.mjs b/test/mode-switch.test.mjs index 340aca05..20af8342 100644 --- a/test/mode-switch.test.mjs +++ b/test/mode-switch.test.mjs @@ -202,6 +202,8 @@ function makeModes() { } const FRAME = { getViewerPose: () => null }; +/** A frame with two views: what releases the display reads waiting on a layer's first frame. */ +const STEREO_FRAME = { getViewerPose: () => ({ views: [{ eye: 'left' }, { eye: 'right' }] }) }; const flush = () => new Promise((r) => setTimeout(r, 0)); /** @@ -296,6 +298,13 @@ function installEnv({ refuse = [] } = {}) { for (const m of modes) m.isActive = m.modeIndex === i; for (const fn of listeners.get('renderingmodechange') ?? []) fn({ type: 'renderingmodechange', detail: { modeIndex: i } }); }, + /** One stereo session frame at the current clock: the layer has delivered its first frame. */ + async stereoFrame() { + const cb = pendingFrame; + pendingFrame = null; + if (cb) cb(clock.now, STEREO_FRAME); + await flush(); + }, /** Advance the wall clock by `ms` and run one session frame with it. */ async step(ms) { clock.now += ms; @@ -335,11 +344,14 @@ function quietInfo(fn) { }); } -/** A live, non-lazy wall with one scene window whose first mode read has already landed. */ +/** + * A live, non-lazy wall with one scene window whose layer has delivered its first frame (display + * calls wait for it, see test/display-reads.test.mjs) and whose first mode read has landed. + */ async function makeWall(env, wallOpts = {}) { const wall = await createInline3D({ lazy: false, autoChrome: false, ...wallOpts }); const handle = wall.addScene(makeCanvas(), () => {}, {}); - await flush(); + await env.stereoFrame(); return { wall, handle, layer: env.created[0] }; } @@ -594,7 +606,7 @@ test('the transition is skipped on a browser with no setViewRig — there is not try { const wall = await createInline3D({ lazy: false, autoChrome: false }); const handle = wall.addScene(makeCanvas(), () => {}, {}); - await flush(); + await env.stereoFrame(); await handle.requestRenderingMode(0); assert.deepEqual(env.created[0].modeRequests, [0], 'forwarded at once, exactly as before rigs existed'); wall.close(); From 0748845137924648a98df1e8dd4a30523efc753d Mon Sep 17 00:00:00 2001 From: David Date: Fri, 9 Oct 2026 13:16:01 -0700 Subject: [PATCH 8/9] fix: display reads release on the first 'woven' read and never return an early null On a browser that reports wovenState the release was "first read that is not 'pending'", but the layer reads 'withheld' right after creation, before the weave session can answer: getDisplayInfo() asked at 1.41 s answered null at 1.55 s while the layer only read 'woven' at ~3.9 s (Chromium 156 panel build). - wovenState path: released by the first 'woven' read; 'withheld' does not count. - Both paths: a getDisplayInfo() / getRenderingModes() answer that is null / [] after the release and before the cap is not returned; the read waits for the next release signal (next 'woven' read, or next session frame once a stereo frame has been drawn) and is asked again, resolving with the first non-empty answer. Only past the cap (4 x max(fwHoldMs, 1200) from layer construction) is null / [] returned. requestRenderingMode() is forwarded once, not retried. - Once a read on the layer answered for real, calls go straight through. No session / no layer / failed layer / removed window: unchanged. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 21 ++++++---- index.d.ts | 39 ++++++++++------- js/inline3d.js | 60 ++++++++++++++++++++------ test/display-reads.test.mjs | 84 ++++++++++++++++++++++++++++++++++--- 4 files changed, 163 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7af3011..8a645461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Core (`.`) changes are **additive**, with every existing default unchanged: `fir byte for byte), the optional `onResize` on `addScene`, a warning on a double attach (the call proceeds as before), and the display reads (`getDisplayInfo()` / `getRenderingModes()` / `requestRenderingMode()`) waiting for a layer's first frame instead of answering `null` / `[]` -before it. The **preview tier** has one default change, `rectCover: 'auto'` on `./splat` and +before it, and asking again while it still answers `null` / `[]`, until the cap. The **preview tier** has one default change, `rectCover: 'auto'` on `./splat` and `./model` (the core keeps `'off'`), plus additive handle methods (`finishSwap()`, `rewoven()` on the splat handle) and the call's self view following `autoConverge`. New types only (`WovenState`, `WovenWithheldReason`, `RectCoverOption`); no export is removed or renamed. @@ -111,13 +111,20 @@ the splat handle) and the call's self view following `autoConverge`. New types o request answered "not forwarded"): the browser only answers once the layer has a weave session, and `null` is documented as "no glasses-free display". Found by the samples panel test (#151), which worked around it page-side with `await handle.firstWoven`; that is no longer needed. - - **Released by** the layer's first stereo frame, or, where the browser reports `wovenState`, - the first read that is not `'pending'`. No first-woven hold. + - **Released by** the first `'woven'` read where the browser reports `wovenState`, or the + layer's first stereo frame elsewhere. No first-woven hold. A `'withheld'` read does not + release: the layer reads it right after creation, before the weave session can answer + (measured on a Chromium 156 panel build: released on it, `getDisplayInfo()` answered `null` + at 1.55 s while the layer only read `'woven'` at ~3.9 s). + - **A `null` / `[]` answer before the cap is not returned.** The read is asked again on each + later release signal (the next `'woven'` read, or the next session frame once a stereo frame + has been drawn) and resolves with the first non-empty answer, on both paths. + `requestRenderingMode()` is forwarded once on the release, not retried. - **Capped** at the first-woven cap: four holds from the layer's construction, never under - 4.8 s. A layer that never frames still resolves, with whatever it answers then (`null` / `[]` - is a real absence). - - **Unchanged:** a call after the first frame goes straight through; no session, no live layer, - a failed layer, or a window removed / session ended mid-wait settles at once, as before. + 4.8 s. Only past it is the layer's `null` / `[]` returned, as a real absence. + - **Unchanged:** once a read on the layer has answered for real, later calls go straight + through; no session, no live layer, a failed layer, or a window removed / session ended + mid-wait settles at once, as before. ### Double-attach warning (core, and the `./splat` / `./model` entries) diff --git a/index.d.ts b/index.d.ts index 1fd50c95..3548265f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -424,13 +424,18 @@ export interface TileHandle { /** * The panel this window weaves on, or `null` where there is no glasses-free display. * - * `null` is returned only once the layer has answered, never for "not ready yet". Called before - * this window's layer has delivered its first frame (right after `add*()`, the natural place), - * the call waits for that frame — the first stereo frame, or the first non-`'pending'` - * {@link TileHandle.wovenState} read where the browser reports one; no first-woven hold — and asks then. - * The wait is capped at the first-woven cap (four holds from the layer's construction, never - * under 4.8 s): a layer that never frames still resolves, with whatever it answers then. - * Without a live layer it settles at once, as below. + * `null` is returned only once the layer has answered for real or the cap has passed, never for + * "not ready yet". Called before this window's layer has delivered its first frame (right after + * `add*()`, the natural place), the call waits for the release signal — where the browser + * reports {@link TileHandle.wovenState}, the first `'woven'` read (a `'withheld'` read right + * after the layer is built does not count: it comes before the weave session can answer); + * elsewhere, the layer's first stereo frame; no first-woven hold — and asks then. If the layer + * still answers `null` before the cap, that answer is not returned: the call is asked again on + * each later release signal (the next `'woven'` read, or the next session frame once a stereo + * frame has been drawn) and resolves with the first non-`null` answer. Once a read on this layer + * has answered for real, later calls go straight to the layer. The cap is the first-woven cap + * (four holds from the layer's construction, never under 4.8 s): past it the layer's `null` is + * returned as a real absence. Without a live layer it settles at once, as below. * * Rejects with an `Error` on a browser without the display-mode API * ({@link inline3dDisplayModesSupported}) or while this window has no live layer (lazy mode, @@ -447,9 +452,10 @@ export interface TileHandle { /** * Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. * - * An empty list is returned only once the layer has answered, never for "not ready yet": like + * An empty list is returned only once the cap has passed, never for "not ready yet": like * {@link getDisplayInfo}, a call made before this window's layer has delivered its first frame - * waits for it (same release, same cap) and asks then. Rejects as {@link getDisplayInfo} does. + * waits for the same release signal, an empty answer before the cap is asked again on the next + * one, and the cap is the same. Rejects as {@link getDisplayInfo} does. */ getRenderingModes(): Promise>; /** @@ -465,8 +471,8 @@ export interface TileHandle { * A `viewCount === 1` mode is requestable and is how a page goes flat. * * Made before this window's layer has delivered its first frame, the request waits for it (the - * same release and cap as {@link getDisplayInfo}): before then the browser has no weave session - * to forward it to. + * same release signal and cap as {@link getDisplayInfo}; forwarded once, not retried): before + * then the browser has no weave session to forward it to. * * With the eased transition on (the default — see {@link ModeSwitchOptions}) a going-flat * request is HELD while the disparity ramps out, so the promise resolves when the request has @@ -574,14 +580,15 @@ export interface Inline3D { // on every tile handle, routed to whichever window currently holds a live layer. /** - * The panel, or `null` where there is no glasses-free display — returned only once the layer - * has answered, never for "not ready yet" (a call before the live window's first frame waits - * for it, capped). See {@link TileHandle.getDisplayInfo}. + * The panel, or `null` where there is no glasses-free display — `null` only once the cap has + * passed, never for "not ready yet" (a call before the live window has answered waits for its + * release signal and is asked again while it answers `null`, capped). See + * {@link TileHandle.getDisplayInfo}. */ getDisplayInfo(): Promise; /** - * Every rendering mode the display can be put in; an empty list only once the layer has - * answered, never for "not ready yet". See {@link TileHandle.getRenderingModes}. + * Every rendering mode the display can be put in; an empty list only once the cap has passed, + * never for "not ready yet". See {@link TileHandle.getRenderingModes}. */ getRenderingModes(): Promise>; /** Switch the display to `modeIndex`. See {@link TileHandle.requestRenderingMode}. */ diff --git a/js/inline3d.js b/js/inline3d.js index aa0d0d2f..beaf3323 100644 --- a/js/inline3d.js +++ b/js/inline3d.js @@ -446,6 +446,15 @@ const WOVEN_STATE_LAG_FRAMES = 3; // before that getDisplayInfo() is null, getRenderingModes() is [] and requestRenderingMode() is // `false` ("not forwarded"). const LAYER_CALLS_AFTER_FIRST_FRAME = new Set(['getDisplayInfo', 'getRenderingModes', 'requestRenderingMode']); +// The two READS among them. A read that comes back null / empty before the cap is not an answer +// yet (the weave session can lag the release signal by a few frames): it is asked again on the +// next release signal. requestRenderingMode resolves void, so it is forwarded once, not retried. +const LAYER_READS_RETRIED_WHEN_EMPTY = new Set(['getDisplayInfo', 'getRenderingModes']); + +/** A display read's "nothing yet" shape: null / undefined, or an empty list. */ +function isEmptyLayerAnswer(r) { + return r === null || r === undefined || (Array.isArray(r) && r.length === 0); +} /** Does this browser build report `XRDisplayLayer.wovenState`? Feature-tested on the prototype. */ function hasWovenState() { @@ -1696,20 +1705,37 @@ class Inline3D { // A layer that has not delivered a frame yet answers getDisplayInfo() with null and // getRenderingModes() with an empty list, and null is documented as "no glasses-free // display". A page that asks right after add*() (the natural place) would be told there is - // no display. So wait for this layer's first frame (see _tickLayerFramed) and ask then. + // no display. So wait for this layer's release signal (see _tickLayerFramed) and ask then. // Capped at the first-woven cap, so a layer that never frames still answers (null/empty, as // a real absence). Re-entered after the wait: the layer may have closed (-> the rejection // above) or been rebuilt (-> wait for the new one) meanwhile. - if (LAYER_CALLS_AFTER_FIRST_FRAME.has(method) && this._layerReadPending(win)) { + const deferred = LAYER_CALLS_AFTER_FIRST_FRAME.has(method); + if (deferred && !win.layerFramed && this._layerReadPending(win)) { return this._whenLayerFramed(win).then(() => this._layerCall(win, method, label, args)); } // Wrapped so a SYNCHRONOUS throw from the browser (requestRenderingMode raises TypeError // that way for a non-2-view mode) arrives as a rejection like every other failure. + let p; try { - return Promise.resolve(win.layer[method](...args)); + p = Promise.resolve(win.layer[method](...args)); } catch (e) { return Promise.reject(e); } + if (!LAYER_READS_RETRIED_WHEN_EMPTY.has(method) || !this._layerReadPending(win)) return p; + // Released, but not answered for real yet on this layer. The weave session can trail the + // release signal, so a null / empty answer before the cap is "not ready", never returned: + // wait for the next release signal (the next 'woven' read, or the next stereo frame) and ask + // again. The first real answer marks the layer answered, and later calls go straight through. + const layer = win.layer; + return p.then((r) => { + if (win.layer !== layer) return r; // that layer's own answer; the window moved on + if (!isEmptyLayerAnswer(r)) { + win.lfAnswered = true; + return r; + } + if (!this._layerReadPending(win)) return r; // at the cap: null / [] is a real absence + return this._whenLayerFramed(win).then(() => this._layerCall(win, method, label, args)); + }); } /** The first-frame wait cap: the first-woven cap (four holds, never under four default holds). */ @@ -1717,9 +1743,12 @@ class Inline3D { return REWOVEN_MAX_HOLDS * Math.max(win.fwHoldMs, FIRST_WOVEN_HOLD_MS); } - /** Does a display read on `win` still have to wait for its current layer's first frame? */ + /** + * Is a display read on `win` still owed a real answer by its current layer — not yet answered + * non-empty, and the cap not passed? (Whether it waits for the FIRST release is `layerFramed`.) + */ _layerReadPending(win) { - if (!win.layer || win.layerFramed || win.lfCapped) return false; + if (!win.layer || win.lfAnswered || win.lfCapped) return false; if (nowMs() - win.lfAt >= this._layerFramedCap(win)) { win.lfCapped = true; return false; @@ -1750,14 +1779,16 @@ class Inline3D { } /** - * Per session frame, after the draw, until the current layer has framed. The release rule: - * where the browser reports `wovenState`, the first read that is not 'pending' (it has seen the - * layer); elsewhere, the first stereo frame this layer has carried (the firstWoven signal, with - * no hold). Either way capped at _layerFramedCap from the layer's construction. + * Per session frame, after the draw, until the current layer has framed and while reads wait. + * The release signal: where the browser reports `wovenState`, a 'woven' read (a 'withheld' + * read right after construction comes before the weave session can answer, so it does not + * count); elsewhere, a frame once this layer has carried a stereo frame (the firstWoven signal, + * with no hold). Every such frame releases the waiting reads, so a read that came back empty + * is asked again on the next one. Capped at _layerFramedCap from the layer's construction. */ _tickLayerFramed(win) { const ws = win.wsLive ? readWovenState(win.layer) : null; - if (ws ? ws !== 'pending' : win.lfStereo) this._noteLayerFramed(win, true); + if (ws ? ws === 'woven' : win.lfStereo) this._noteLayerFramed(win, true); else if (win.framedWaiters.length && !this._layerReadPending(win)) this._noteLayerFramed(win, false); } @@ -2346,9 +2377,11 @@ class Inline3D { // The rect cover (opts.rectCover; see _rectCoverCheck), or null when off. rc: null, // Display reads wait for the current layer's first frame (see _layerCall). `layerFramed`: - // it has delivered one; `lfStereo`: it has carried a stereo frame; `lfAt`: when it was - // built; `lfCapped`: the wait cap ran out for it; `framedWaiters`: reads queued meanwhile. + // its release signal has been seen; `lfAnswered`: a read got a real (non-empty) answer from + // it; `lfStereo`: it has carried a stereo frame; `lfAt`: when it was built; `lfCapped`: the + // wait cap ran out for it; `framedWaiters`: reads queued meanwhile. layerFramed: false, + lfAnswered: false, lfStereo: false, lfAt: 0, lfCapped: false, @@ -2459,6 +2492,7 @@ class Inline3D { // A new layer has delivered nothing yet (see _layerCall). Reads queued on the previous one // were released by _deactivate and re-enter against this one. win.layerFramed = false; + win.lfAnswered = false; win.lfStereo = false; win.lfCapped = false; win.lfAt = nowMs(); @@ -3263,7 +3297,7 @@ class Inline3D { // the backing store, in the same task, which is the one moment a WebGL drawing buffer is // guaranteed readable without preserveDrawingBuffer. if (win.rc && win.rc.snapWanted) this._rectCoverSnap(win); - if (!win.layerFramed) this._tickLayerFramed(win); + if (!win.layerFramed || win.framedWaiters.length) this._tickLayerFramed(win); this._tickFirstWoven(win); } } diff --git a/test/display-reads.test.mjs b/test/display-reads.test.mjs index 37313346..0748796f 100644 --- a/test/display-reads.test.mjs +++ b/test/display-reads.test.mjs @@ -4,8 +4,10 @@ // [] / false, and null is documented as "no glasses-free display" — so a page asking right after // add*() was told there is no display (found by the samples panel test, web#151). What is pinned: // - a call made before the first frame waits, and is asked only once the layer has framed: the -// first stereo frame, or (where the browser reports wovenState) the first non-'pending' read — -// no 1200 ms hold either way; +// first stereo frame, or (where the browser reports wovenState) the first 'woven' read ('withheld' +// right after creation comes before the weave session can answer) — no 1200 ms hold either way; +// - a read that answers null / [] after that release, before the cap, is not returned: it is asked +// again on each later release signal (woven read / stereo frame) until it answers for real; // - a layer that never frames still answers, at the first-woven cap (4 holds from the layer's // construction), with whatever the layer says then (null / []: a real absence); // - no session, no layer, a failed layer, a removed window: settles at once, as before; @@ -179,7 +181,7 @@ test('a read made right after add*() waits for the first stereo frame, then gets wall.close(); }); -test('with wovenState: released by the first non-pending read, not by a stereo frame while pending', async () => { +test("with wovenState: 'withheld' right after creation does not release; the first 'woven' read does", async () => { clock = 0; const env = installEnv({ report: true }); const wall = await newWall(); @@ -187,11 +189,83 @@ test('with wovenState: released by the first non-pending read, not by a stereo f const info = h.getDisplayInfo(); env.runFrame(2); assert.equal(await peek(info), 'pending', "a stereo frame the browser still calls 'pending' does not release"); - clock = 50; + // Measured on the panel: the layer reads 'withheld' right after creation, before the weave + // session can answer. Releasing there answered null at 1.55 s; 'woven' came at ~3.9 s. + env.report('withheld'); + for (let i = 0; i < 5; i++) { + clock += 16; + env.runFrame(2); + } + assert.equal(await peek(info), 'pending', "'withheld' is not a release"); + assert.deepEqual(env.layer.calls, [], 'the layer is not asked while it reads withheld'); + clock += 16; env.goLive(); - env.report('withheld'); // the browser has seen the layer (withheld is not pending) + env.report('woven'); + env.runFrame(2); + assert.deepEqual(await info, INFO, 'answered on the first woven read, with the real panel'); + wall.close(); +}); + +test("with wovenState: a null on the first 'woven' read is not returned; the read is asked again on later woven reads", async () => { + clock = 0; + const env = installEnv({ report: true }); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + const info = h.getDisplayInfo(); + const modes = h.getRenderingModes(); + clock = 16; + env.report('woven'); // woven, but the weave session does not answer yet + env.runFrame(2); + assert.equal(await peek(info), 'pending', 'a null right after the release is "not ready", not "no display"'); + assert.equal(await peek(modes), 'pending', 'an empty list likewise'); + clock = 32; + env.runFrame(2); + assert.equal(await peek(info), 'pending'); + clock = 48; + env.goLive(); // two frames later the session answers env.runFrame(2); assert.deepEqual(await info, INFO); + assert.deepEqual(await modes, MODES); + assert.equal(env.layer.calls.filter(([m]) => m === 'getDisplayInfo').length, 3, 'asked on each woven read until it answered'); + // Answered for real: a later read goes straight through, synchronously. + const before = env.layer.calls.length; + const again = h.getDisplayInfo(); + assert.equal(env.layer.calls.length, before + 1); + assert.deepEqual(await again, INFO); + wall.close(); +}); + +test('timer path: a null after the first stereo frame is asked again on the next stereo frame', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + const info = h.getDisplayInfo(); + clock = 16; + env.runFrame(2); // the release, but the layer still answers null + assert.equal(await peek(info), 'pending'); + clock = 32; + env.goLive(); + env.runFrame(2); + assert.deepEqual(await info, INFO); + wall.close(); +}); + +test("with wovenState: a layer that stays null after 'woven' resolves null at the cap, not before", async () => { + clock = 0; + const env = installEnv({ report: true }); + const wall = await newWall(); + const h = wall.addScene(makeCanvas(), () => {}); + const info = h.getDisplayInfo(); + env.report('woven'); + for (const t of [16, 1000, 3000, 4799]) { + clock = t; + env.runFrame(2); + assert.equal(await peek(info), 'pending', `still waiting at ${t} ms`); + } + clock = 4800; + env.runFrame(2); + assert.equal(await info, null, 'past the cap the null is a real absence'); wall.close(); }); From 9591a7db0037082b9ded846026e901b59edd7f59 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 9 Oct 2026 13:17:19 -0700 Subject: [PATCH 9/9] fix: rewoven() on wovenState settles three frames after the call, not after a hold A page that moves its canvas and THEN calls rewoven() never shows a box change or a withheld read after the call, so the confirmed path fell to "a hold of steady 'woven' reads": a kiosk panel run measured 46/46 confirmed at median 1206 ms, the same as the timer path. The no-change rule is now: settle on a 'woven' read at least WOVEN_STATE_LAG_FRAMES (3) session frames after the call. The report is a live per-frame level with at most 3 frames of lag, so such a read reflects the rect as it was at the call. Unchanged: settle after a withheld/pending read seen after the call; 3 frames after a box change seen after the call; never on the frame of the call; a stereo frame since the restart is required; the cap. The timer path (no wovenState) is untouched. Expected ~50 ms instead of ~1200 ms. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 12 +++++-- index.d.ts | 13 ++++--- js/inline3d.js | 30 ++++++++++++----- test/woven-state.test.mjs | 71 ++++++++++++++++++++++++++++++++++----- 4 files changed, 102 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a645461..8995eb14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,9 +50,15 @@ the splat handle) and the call's self view following `autoConverge`. New types o - **`firstWoven`** settles `{ woven: true, confirmed: true, reason: 'woven' }` on the first `'woven'` read once a stereo frame is drawn, with no hold. - **`rewoven()`** settles on a `'woven'` read that follows a `'withheld'`/`'pending'` read seen - after the call, or three session frames after the last box change. The report trails the join - by 1–3 frames, so a `'woven'` from the old rect never settles it. With no change and no gap it - waits a hold of steady `'woven'` reads, as before. + after the call, or three session frames after the last box change seen after the call, or, + when no box change is seen after the call, three session frames after the call. The report + is a live per-frame level trailing the join by 1–3 frames, so a read three frames on reflects + the rect as it was at the call; a `'woven'` from the old rect, or one read on the frame of the + call, never settles it. A stereo frame since the call is still required, and the cap stays. + - The no-change case is the common one: a page that moves the canvas, then calls + `rewoven()`. It used to wait a full hold of steady `'woven'` reads (a kiosk panel run: + 46/46 calls confirmed, median 1206 ms, the same as the timer path). It now settles about + three frames after the call (~50 ms). The timer path (no `wovenState`) is unchanged. - **The cap stays, as a safety.** A layer that never leaves `'withheld'` (a CSS effect on an ancestor) settles `{ woven: false, confirmed: true, reason: }` (or `'pending'`) four holds after the call, never sooner than 4.8 s. That is a level, not a loss: diff --git a/index.d.ts b/index.d.ts index 3548265f..be5449e9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -556,10 +556,15 @@ export interface TileHandle { * cover up for good. A second call while pending returns the same promise, restarted. Before the * first join it is `firstWoven`; on a window that will not weave it is that `woven: false` result. * - * On a browser that reports `wovenState` it settles `confirmed: true, reason: 'woven'` on a - * 'woven' read that follows a 'withheld'/'pending' read seen after the call, or three session - * frames after the last box change (the report trails the join by 1–3 frames, so a 'woven' from - * the old rect never settles it), or after a hold of steady 'woven' reads when nothing changed. + * On a browser that reports `wovenState` it settles `confirmed: true, reason: 'woven'` on the + * first 'woven' read (with a stereo frame drawn since the call) that either follows a + * 'withheld'/'pending' read seen after the call, or comes three session frames after the last + * box change seen after the call, or — when no box change is seen after the call, the usual + * case of a page that moves the canvas and THEN calls this — comes three session frames after + * the call. The report is a live per-frame level trailing the join by 1–3 frames, so a read + * three frames on reflects the rect as it was at the call, and a 'woven' from the old rect + * never settles it; a read on the frame of the call never does. Expect ~50 ms instead of the + * 1.2 s hold. * The cap stays: still 'withheld' four holds after the call (at least 4.8 s), it settles `woven: * false, confirmed: true, reason: `. */ diff --git a/js/inline3d.js b/js/inline3d.js index beaf3323..5d7908fd 100644 --- a/js/inline3d.js +++ b/js/inline3d.js @@ -3347,10 +3347,13 @@ class Inline3D { * * rewoven(): a 'woven' read is only trusted as the NEW rect's once it follows a 'withheld' or * 'pending' read seen after the call (the rect left the weave and came back), or once - * WOVEN_STATE_LAG_FRAMES frames have passed since the last box change (a move rejoined without - * a gap). A call with no box change and no gap settles after a hold of steady 'woven' reads, - * the same wait as without the report. Either way it needs a stereo frame since the last - * restart, as on the hold path. + * WOVEN_STATE_LAG_FRAMES frames have passed since the last box change seen after the call (a + * move rejoined without a gap). A call that sees no box change and no gap settles on a 'woven' + * read WOVEN_STATE_LAG_FRAMES session frames after the call: the report is a live per-frame + * level trailing the join by at most that many frames, so such a read reflects the rect as it + * was at the call (the usual case: the page moved the canvas, THEN called rewoven()). Never on + * the frame of the call itself. Either way it needs a stereo frame since the last restart, as + * on the hold path. * * The cap stays, as a safety: the report is a level, and a layer that never leaves 'withheld' * (a CSS effect on an ancestor, rule 7) must not hold a cover up for good. Four holds (never @@ -3369,7 +3372,7 @@ class Inline3D { rw.stereo && (rw.gap || (rw.boxFrame !== null && this._frameCount - rw.boxFrame >= WOVEN_STATE_LAG_FRAMES) || - (rw.boxFrame === null && t - rw.at >= win.fwHoldMs)) + (rw.boxFrame === null && this._frameCount - rw.callFrame >= WOVEN_STATE_LAG_FRAMES)) ) { this._settleRewoven(win, true, 'woven', true); } @@ -3420,9 +3423,20 @@ class Inline3D { resolve = r; }); const t = nowMs(); - // `gap` / `boxFrame` are the wovenState path's (see _tickWovenState): a 'withheld'/'pending' - // read since the last restart, and the session frame of the last box change (null = none). - win.rw = { promise, resolve, calledAt: t, at: t, stereo: false, box: boxKeyOf(win.canvas), gap: false, boxFrame: null }; + // `gap` / `boxFrame` / `callFrame` are the wovenState path's (see _tickWovenState): a + // 'withheld'/'pending' read since the last restart, the session frame of the last box change + // (null = none), and the session frame the call was made in. + win.rw = { + promise, + resolve, + calledAt: t, + at: t, + stereo: false, + box: boxKeyOf(win.canvas), + gap: false, + boxFrame: null, + callFrame: this._frameCount, + }; return promise; } diff --git a/test/woven-state.test.mjs b/test/woven-state.test.mjs index 3a167d45..6cbb2567 100644 --- a/test/woven-state.test.mjs +++ b/test/woven-state.test.mjs @@ -5,7 +5,9 @@ // - with it, firstWoven settles confirmed:true, reason 'woven', on the first 'woven' read once a // stereo frame is drawn — no hold; // - rewoven never settles on a stale 'woven' from the old rect: it needs a 'withheld'/'pending' -// read after the call, or WOVEN_STATE_LAG_FRAMES frames after the box change; +// read after the call, or WOVEN_STATE_LAG_FRAMES frames after a box change seen after the +// call, or (no such change) WOVEN_STATE_LAG_FRAMES frames after the call — never a hold, and +// never on the frame of the call; // - the cap still releases a layer stuck in 'withheld', as woven:false with the browser's reason; // - handle.wovenState / withheldReason read the report live, null where it is absent. // @@ -258,18 +260,69 @@ test('rewoven: a woven read before the call does not count as the gap', async () wall.close(); }); -test('rewoven: no change and no gap settles after a hold of steady woven reads', async () => { +test('rewoven: called after the move, steady woven reads settle three frames after the call, not after a hold', async () => { clock = 0; const env = installEnv(); - const { wall, h } = await wovenWall(env, makeCanvas()); + const canvas = makeCanvas(400, 200); + const { wall, h } = await wovenWall(env, canvas); + // The kiosk case: the page moves the canvas, a frame sees the new box (no rewoven pending + // yet), THEN the page calls rewoven(). The browser never reads withheld. + canvas.clientWidth = 640; clock = 100; - const p = h.rewoven(); - clock = 1299; env.runFrame(2); - assert.equal(await peek(p), 'pending'); - clock = 1300; + const p = h.rewoven(); // call frame C + clock = 116; + env.runFrame(2); // C+1 + clock = 133; + env.runFrame(2); // C+2 + assert.equal(await peek(p), 'pending', 'a woven read under three frames after the call can still be the old rect'); + clock = 150; + env.runFrame(2); // C+3 + assert.deepEqual({ ...(await peek(p)) }, { woven: true, confirmed: true, reason: 'woven', ms: 50 }); + wall.close(); +}); + +test('rewoven: a stale woven read on the frame of the call does not settle it', async () => { + clock = 0; + const env = installEnv(); + const wall = await newWall(); + let p = null; + let calls = 0; + // rewoven() called from inside the frame callback: that frame's own read is not after the call. + const h = wall.addScene(makeCanvas(), () => { + if (++calls === 3) p = h.rewoven(); + }); + env.report('woven'); + env.runFrame(2); // frame 1: firstWoven + assert.equal((await peek(h.firstWoven)).confirmed, true); + env.runFrame(2); // frame 2 + clock = 100; + env.runFrame(2); // frame 3: the call, then this frame's woven read + assert.ok(p, 'the call was made in the frame callback'); + assert.equal(await peek(p), 'pending', 'never settled on the frame of the call'); + env.runFrame(2); + env.runFrame(2); + assert.equal(await peek(p), 'pending', 'nor two frames after it'); + clock = 150; env.runFrame(2); - assert.deepEqual({ ...(await peek(p)) }, { woven: true, confirmed: true, reason: 'woven', ms: 1200 }); + assert.deepEqual({ ...(await peek(p)) }, { woven: true, confirmed: true, reason: 'woven', ms: 50 }); + wall.close(); +}); + +test('rewoven: a box change after the call still counts three frames from the change', async () => { + clock = 0; + const env = installEnv(); + const canvas = makeCanvas(400, 200); + const { wall, h } = await wovenWall(env, canvas); + const p = h.rewoven(); // call frame C + env.runFrame(2); // C+1 + canvas.clientWidth = 520; + env.runFrame(2); // C+2: change seen (frame N) + env.runFrame(2); // C+3 = N+1: three frames after the call, but not after the change + env.runFrame(2); // N+2 + assert.equal(await peek(p), 'pending', 'the change restarts the count; the call frame no longer rules'); + env.runFrame(2); // N+3 + assert.equal((await peek(p)).reason, 'woven'); wall.close(); }); @@ -291,7 +344,7 @@ test('rewoven: withheld forever is capped, woven:false with withheldReason', asy const q = h.rewoven(); env.report('woven'); env.runFrame(2); - assert.equal(await peek(q), 'pending', 'a fresh call with no change waits a hold of woven reads'); + assert.equal(await peek(q), 'pending', 'a fresh call with no change waits three frames of woven reads'); wall.close(); });