diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c3fb8..8995eb1 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, 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. ### `./call` — the stereo self view auto-converges too @@ -32,6 +39,132 @@ 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 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: + 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. + +### 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. + +### 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. + +### 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 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. 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) + +- `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, 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`) + +- 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/authoring-inline-3d.md b/docs/authoring-inline-3d.md index 4a6c556..77ea8c5 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/porting-three-js-apps.md b/docs/porting-three-js-apps.md index b557c2d..86a01dd 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/sdk-stability.md b/docs/sdk-stability.md index cb6ae27..52a153a 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 ab11f69..13c2f63 100644 --- a/docs/woven-canvas-rules.md +++ b/docs/woven-canvas-rules.md @@ -69,7 +69,9 @@ re-renders over, and change what is **in** it: - **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. +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 @@ -117,16 +119,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 @@ -217,12 +223,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, @@ -242,7 +260,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/index.d.ts b/index.d.ts index 35a21e3..be5449e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -24,27 +24,93 @@ 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. + * + * - `'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; } @@ -289,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}. */ @@ -298,7 +373,14 @@ export type SceneFrameCallback = ( frame: XRFrame, ) => void; -/** The handle returned by every add*() call. */ +/** + * The handle returned by every add*() call. + * + * 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. */ remove(): void; @@ -342,6 +424,19 @@ 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 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, * tile off screen). @@ -354,7 +449,14 @@ 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 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 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>; /** * Ask the runtime to switch the display to `modeIndex`. A thin pass-through — it resolves and @@ -368,6 +470,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 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 * been forwarded rather than on the call; a request dropped by a reversal in that window @@ -422,22 +528,45 @@ 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. */ 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. + * + * 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: `. */ rewoven(): Promise; } @@ -455,9 +584,17 @@ 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 — `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. See {@link XRDisplayRenderingMode}. */ + /** + * 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}. */ requestRenderingMode(modeIndex: number): Promise; diff --git a/js/inline3d-model-entry.js b/js/inline3d-model-entry.js index 7c0573a..5cfa733 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 = {}) { + // 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)); +} + +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 2d78cb2..2b0c2f8 100644 --- a/js/inline3d-model-playcanvas.js +++ b/js/inline3d-model-playcanvas.js @@ -531,6 +531,11 @@ 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, + // 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 e405a39..af2de8f 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 = {}) { + // 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)); +} + +function addModelUnguarded(wall, canvas, src, opts = {}) { const { virtualDisplayHeight = 0.24, frame = null, @@ -426,6 +433,11 @@ 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, + // 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 454ccd7..6ef0a31 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 = {}) { + // 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)); +} + +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 36200aa..cbc1379 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); @@ -1231,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; @@ -1239,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); @@ -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++) { @@ -2153,10 +2169,90 @@ 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; - 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() { @@ -3599,6 +3695,11 @@ 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, + // 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 bc82518..d2fa034 100644 --- a/js/inline3d-splat-shared.js +++ b/js/inline3d-splat-shared.js @@ -12,6 +12,23 @@ // 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). +/** + * 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` 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); + 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 b5b21a4..a7a0e8f 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 = {}) { + // 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)); +} + +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 ` + @@ -487,6 +494,11 @@ 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, + // The entry's double-attach claim (guardedAttach): lets THIS registration through. + _dxrClaim: opts._dxrClaim, }); } else { viewer.startMono(); diff --git a/js/inline3d-viewer.js b/js/inline3d-viewer.js index 1676585..0524e4f 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/js/inline3d.js b/js/inline3d.js index 6194117..5d7908f 100644 --- a/js/inline3d.js +++ b/js/inline3d.js @@ -422,6 +422,70 @@ 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; + +// 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']); +// 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() { + 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. @@ -693,10 +757,51 @@ 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}`; +} + +/** 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: + * 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 { @@ -709,6 +814,11 @@ class Inline3D { this.session = session; this.refSpace = refSpace; this._windows = new Map(); // canvas -> window record + // 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(); 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 @@ -946,6 +1056,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 @@ -954,7 +1066,9 @@ class Inline3D { * @returns {{remove():void}} */ addImage(canvas, source, opts = {}) { + 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(); @@ -970,7 +1084,9 @@ class Inline3D { * @returns {{remove():void}} */ addVideo(canvas, video, opts = {}) { + this._warnDoubleAttach(canvas, 'addVideo', opts._dxrClaim); const win = this._register(canvas, 'video', opts); + win.method = 'addVideo'; win.video = video; return this._handle(canvas, win); } @@ -999,6 +1115,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 @@ -1011,8 +1131,10 @@ 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. + 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'; 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 @@ -1021,6 +1143,76 @@ class Inline3D { return this._handle(canvas, win); } + // ── double-attach detection ─────────────────────────────────────────────────────────── + // + // 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.method; + const win = this._windows.get(canvas); + return win ? win.method || 'add*' : null; + } + + _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), 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 { opts, own: () => {} }; + this._warnDoubleAttach(canvas, method, null); + const token = {}; + return { opts: { ...opts, _dxrClaim: token }, own: (out) => this._claim(canvas, method, out, token) }; + } + + /** + * 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 }; + 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; + }, + }); + } + /** * The handle every add*() returns. `exclude(el)` marks 2D DOM painted over this window * (a hover plate, a play badge) so the weave leaves it crisp 2D instead of garbling it @@ -1220,15 +1412,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, @@ -1264,12 +1477,19 @@ 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 * 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), }; @@ -1482,13 +1702,104 @@ 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 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. + 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). */ + _layerFramedCap(win) { + return REWOVEN_MAX_HOLDS * Math.max(win.fwHoldMs, FIRST_WOVEN_HOLD_MS); + } + + /** + * 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.lfAnswered || 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 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 === 'woven' : 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. */ @@ -2002,6 +2313,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, @@ -2054,7 +2371,29 @@ 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, + // 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`: + // 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, + framedWaiters: [], }; + 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; }); @@ -2074,6 +2413,7 @@ class Inline3D { this._deactivate(win); this._windows.delete(canvas); this._settleFirstWoven(win, false, 'removed'); + this._dropCover(win); } _onIntersect(entries) { @@ -2148,6 +2488,14 @@ class Inline3D { win.fwLayerAt = nowMs(); 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.lfAnswered = 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 @@ -2189,6 +2537,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) { @@ -2570,6 +2919,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; @@ -2577,6 +2934,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(); @@ -2842,6 +3226,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 @@ -2877,6 +3263,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; } @@ -2902,9 +3289,15 @@ 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; } } + // 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); + if (!win.layerFramed || win.framedWaiters.length) this._tickLayerFramed(win); this._tickFirstWoven(win); } } @@ -2920,6 +3313,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 +3339,70 @@ 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 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 + * 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 && this._frameCount - rw.callFrame >= WOVEN_STATE_LAG_FRAMES)) + ) { + 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 +3410,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 +3423,20 @@ class Inline3D { resolve = r; }); const t = nowMs(); - win.rw = { promise, resolve, calledAt: t, at: t, stereo: false, box: boxKeyOf(win.canvas) }; + // `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; } @@ -2986,13 +3455,191 @@ 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) })); + } + + // ── 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) ─────────────────────────── @@ -3127,8 +3774,11 @@ class Inline3D { this._paintMono(win); this._notifyLayerLost(win); this._settleFirstWoven(win, false, 'session-ended'); + this._dropCover(win); + this._noteLayerFramed(win, false); } 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/model.d.ts b/model.d.ts index 4a8f152..398bde0 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 f705ca9..4eef1b4 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/display-modes.test.mjs b/test/display-modes.test.mjs index 2f32cb6..582afc2 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 0000000..0748796 --- /dev/null +++ b/test/display-reads.test.mjs @@ -0,0 +1,365 @@ +// 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 '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; +// - 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: 'withheld' right after creation does not release; the first 'woven' read does", 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"); + // 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('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(); +}); + +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/double-attach.test.mjs b/test/double-attach.test.mjs new file mode 100644 index 0000000..3f6fa1f --- /dev/null +++ b/test/double-attach.test.mjs @@ -0,0 +1,157 @@ +// 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'; + +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'); + +/** 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 { + 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 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, 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], /^\[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) — 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: 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', async () => { + const wall = await createInline3D({ lazy: false, autoChrome: false }); + const canvas = makeCanvas(); + wall.addScene(canvas, () => {}).remove(); + const { warns } = await warnsOf(() => wall.addScene(canvas, () => {})); + assert.equal(warns.length, 0); + wall.close(); +}); + +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 } = await warnsOf(() => w2.addScene(canvas, () => {})); + assert.equal(warns.length, 0); + w2.close(); +}); + +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 { 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/); + assert.equal(wall._windows.has(canvas), true); + // 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(); + wall2.close(); +}); diff --git a/test/mode-switch.test.mjs b/test/mode-switch.test.mjs index 340aca0..20af834 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(); diff --git a/test/rect-cover.test.mjs b/test/rect-cover.test.mjs new file mode 100644 index 0000000..f207319 --- /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); + } +}); diff --git a/test/resize-sync.test.mjs b/test/resize-sync.test.mjs new file mode 100644 index 0000000..c8e5c10 --- /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(); +}); diff --git a/test/undrawn-frames.test.mjs b/test/undrawn-frames.test.mjs new file mode 100644 index 0000000..ab9fc9d --- /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 d370229..9e58b83 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', () => { diff --git a/test/woven-state.test.mjs b/test/woven-state.test.mjs new file mode 100644 index 0000000..6cbb256 --- /dev/null +++ b/test/woven-state.test.mjs @@ -0,0 +1,359 @@ +// 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 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. +// +// 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: called after the move, steady woven reads settle three frames after the call, not after a hold', async () => { + clock = 0; + const env = installEnv(); + 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; + env.runFrame(2); + 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: 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(); +}); + +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 three frames 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'); +});