@displayxr/inline3d 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,161 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.4.0 — 2026-09-06
9
+
10
+ ### Added
11
+
12
+ - **Display modes — the page can read what the panel IS, and ask it to change.** Until now a page
13
+ could describe its own framing (a view rig) but knew nothing about the display it was framing
14
+ *for*: not its size in metres, not its pixel count, not which of the runtime's rendering modes it
15
+ was in, and it had no way to ask for a different one. Three pass-throughs close that:
16
+ **`getDisplayInfo()`** (physical size, pixel size, recommended view scale; `null` where there is
17
+ no glasses-free display), **`getRenderingModes()`** (every mode the runtime can put the display
18
+ in — view count, tile grid, per-view pixels, `hardwareDisplay3D`, `isActive`, `isRequestable`)
19
+ and **`requestRenderingMode(i)`**. They sit on the **`createInline3D()` result** — the panel is
20
+ the document's, not a tile's — and under the same names on every tile handle, routed to whichever
21
+ window currently holds a live layer. Gate the group with
22
+ **`inline3dDisplayModesSupported()`**, which requires all three methods on
23
+ `XRDisplayLayer.prototype` — a browser shipping half the set is one mid-implementation, and
24
+ calling it supported would surface as a `not a function` inside a click handler.
25
+ *(core tier — additive)*
26
+
27
+ - **The 2D/3D hardware state is a consequence of the mode, not a control.** There is deliberately
28
+ **no page-facing request** for it. Asking for a mode with `viewCount === 1` puts the panel in its
29
+ 2D state and the browser reports that mode active — the runtime carries on weaving the same fixed
30
+ two-view atlas — and asking for the 2-view mode puts it back. Tying the two together makes the
31
+ one bad state (a flat panel showing a stereo atlas, i.e. a blurry double image rather than 2D)
32
+ unreachable. **`requestDisplayMode('2d'|'3d')` is gone**, and so is every mention of a "lens".
33
+
34
+ - **The SDK collapses the stereo rig automatically.** When a 1-view mode goes **active** every
35
+ window's `ipdFactor`/`parallaxFactor` is pushed to 0 (both eyes render from one place), and a
36
+ 2-view mode going active restores them. This is driven by the `renderingmodechange` event — plus
37
+ the first `getRenderingModes()` read, so a page that OPENS with the panel already flat is
38
+ collapsed too — and **not** by the request. So it happens however the mode changed (this page,
39
+ another one, the shell), the page's own render loop is untouched, and **a refused request changes
40
+ nothing in either direction**, which is now structural rather than a rollback.
41
+
42
+ **The restore is exact, and that is a design property, not luck.** The flattening is a *copy*
43
+ pushed at the layer — a page driving a rig every frame reuses one descriptor object, so zeroing
44
+ it in place would write the flattening into the page's own state and the restore would restore
45
+ 0. So each window keeps what the page asked for, untouched, and the flat rig is derived on the
46
+ way out. That latch also means a per-frame `setViewRig` loop cannot walk the page out of 2D, a
47
+ lazy tile that scrolls away and rebuilds its layer comes back flat rather than in 3D, and a
48
+ window that never set a rig at all is flattened (and restored) via the exact rig equivalent of
49
+ its `virtualDisplayHeight`. *(core tier — additive)*
50
+
51
+ - **`setStereoEnabled(bool)` is now SUGAR, and only sugar** — `false` requests the first mode with
52
+ `viewCount === 1 && isRequestable`, `true` the first with `viewCount === 2 && isRequestable`. It
53
+ touches neither the hardware state (nothing can) nor the rig (the event does that). It rejects
54
+ rather than inventing a mode when the panel lists none. *(core tier — behaviour change on an
55
+ unreleased API)*
56
+
57
+ - **`on(type, cb)` / `off(type, cb)`** — the two events, which fire on the **XRSession** and not on
58
+ the layer, re-emitted on the wall and on every handle: `renderingmodechange`
59
+ `{type, modeIndex, viewCount, mode, detail}` and `hardwaredisplaystatechange`
60
+ `{type, state:'2d'|'3d', detail}`. `on` returns an unsubscribe function;
61
+ **`onDisplayModeChange(cb)`** remains as the both-events-one-callback shape.
62
+ `wall.hardwareDisplayState`, `wall.activeMode` and `wall.stereoCollapsed` expose the last
63
+ **reported** state — never the last requested one. *(core tier — additive)*
64
+
65
+ - **Undock — lift a window's asset into a floating native viewer over the desktop.**
66
+ **`wall.undock`** is `{model, splat}` on a browser with `XRDisplayLayer.undock` and **`null`** on
67
+ one without (plain Chrome, or an older DisplayXR browser) — that null is what a page branches on;
68
+ **`wall.refreshUndock()`** re-reads it, **`inline3dUndockSupported()`** is the sync probe. The
69
+ action is **`await undock(element, {src, type, env?, pose?, margin?, title?})`** (new module
70
+ `js/inline3d-undock.js`, re-exported from the main entry): API-first through `layer.undock()`,
71
+ falling back to the `displayxr-view:` OS protocol (hidden-iframe navigation, Chrome's one-time
72
+ "Open DisplayXR…?" prompt) where the layer method is absent. **Call it synchronously inside the
73
+ click** — both paths need the transient activation. Resolves to `{ended, viewer, detached}`;
74
+ rejects with an Error named `not-installed` | `src-not-allowed` | `no-activation` | `busy`.
75
+ *(core tier — additive)*
76
+
77
+ - **[`samples/display-modes/`](samples/display-modes/)** — the whole surface on one page: the
78
+ `getDisplayInfo()` fields, the `getRenderingModes()` table with the active row marked, a request
79
+ button on every requestable row (`viewCount` 1 **or** 2) and the rows needing more than two views
80
+ greyed with the reason, one `setStereoEnabled` convenience button, a **read-only** hardware
81
+ display state badge fed by the event, and a live event log. Every action prints a greppable
82
+ `[display-modes] …` line so a harness can drive it from the console.
83
+
84
+ ### Notes
85
+
86
+ - **Advisory scales.** `viewScaleX/Y` and `recommendedViewScaleX/Y` are what the runtime would like
87
+ the per-view resolution to be. The browser cannot resize a page's canvas, so nothing applies them
88
+ for you — a page honours them by sizing its own backing store. Ignoring them costs sharpness or
89
+ fill rate, never correctness.
90
+ - **The browser is fixed at two views.** No view synthesis exists anywhere in this stack, so a mode
91
+ needing MORE than two is listed (the panel really can do it) and refused. Show those rows; mark
92
+ them. A **one**-view mode is requestable — the browser still submits two views and the runtime
93
+ still weaves them; it is the panel that goes flat.
94
+
95
+ ## 1.3.0 — 2026-09-04
96
+
97
+ ### Added
98
+
99
+ - **View rigs — the page can hand the runtime its own CAMERA, not just a virtual-display height.**
100
+ Every inline-3D frame is located against a *view rig*, and until now there was exactly one: a
101
+ display rig with an identity pose whose only knob was `virtualDisplayHeight`. That is the right
102
+ model for a portal — the canvas is a window onto a scene authored to fit it — and the wrong one
103
+ for a scene that owns a camera. An orbit, a walkthrough, a game has a pose and a field of view
104
+ already; it does not want to be told where the eyes are, it wants its own frustum perturbed by
105
+ them. That could not be expressed at all, so those pages either fought the display rig or
106
+ re-derived stereo themselves.
107
+
108
+ `handle.setViewRig(rig)` and `addScene`'s `viewRig` option send the whole descriptor:
109
+ `{type:'display'|'camera', position, orientation, virtualDisplayHeight, ipdFactor,
110
+ parallaxFactor, perspectiveFactor, convergenceDiopters, verticalFov, metersToVirtual}`. A rig
111
+ applies per-locate, so animating one is just sending new values each frame — nothing to tween,
112
+ nothing to tear down. **No projection math lands in the SDK**: it fills in a descriptor and the
113
+ off-axis (Kooima) frustum stays in the runtime, which is the same code the native apps consume.
114
+ *(core tier — additive)*
115
+
116
+ - **`inline3dViewRigSupported()`**, and a fallback that actually falls back. The gate reads a
117
+ capability (`XRDisplayLayer.prototype.setViewRig` being present), never a version or UA string.
118
+ Without it `setViewRig()` warns once and returns `false` while the window keeps weaving — so a
119
+ page that merely wants the extra control where it exists can call it unconditionally. And a
120
+ `viewRig` is only put in the layer init on a browser that *has* rigs: an older one would take
121
+ the init, find no member it recognised, and drop to its **own** default height, so passing a
122
+ camera rig alongside a `virtualDisplayHeight` now genuinely names the older browser's framing.
123
+ *(core tier — additive)*
124
+
125
+ - **`cameraRigFromCamera(THREE, camera, opts)` and `displayRig(opts)`** in
126
+ `@displayxr/inline3d/three` — descriptor builders, both accepting an `out` object so a per-frame
127
+ call allocates nothing. `cameraRigFromCamera` decomposes the pose from `matrixWorld` rather than
128
+ reading `.position`/`.quaternion` (those are local, and an app camera parented under a dolly —
129
+ the usual way to build an orbit — would otherwise report a pose in the wrong space), converts
130
+ three's degrees to the descriptor's radians, and turns a convergence *distance* into diopters so
131
+ "infinity" is a finite `0`. *(core tier — additive)*
132
+
133
+ - **`EyeCamera.setLocalFromView(view)` / `setLocalFromMatrices(proj, transform)`, for the attach
134
+ pattern.** The browser locates views **before** the page's rAF, so a rig set during frame N
135
+ drives the views delivered in frame N+1. On a slider that is invisible; on a camera moving under
136
+ the pointer it reads as a soft, swimming misalignment. The fix is not prediction: send an
137
+ identity-posed camera rig and parent the eye cameras under the app camera, and three's scene
138
+ graph composes *this* frame's world pose with no lag. These setters write `camera.matrix` and
139
+ leave `matrixWorld` to three's traversal, which is the whole of it — a scene-graph parent, not
140
+ projection math. `setFromView` (world) is unchanged. *(core tier — additive)*
141
+
142
+ - **`samples/camera-rig/`** — an orbiting scene on a camera rig, with sliders for FOV, convergence
143
+ and distance, an `attach` toggle, a live comfort readout, and `C` to cut between the camera rig
144
+ and a display rig framed to match it at the home angle. `samples/hello-cube/?debug` gains the
145
+ display rig's knobs; `samples/windows/`'s live scene tile moves to a camera rig in the attach
146
+ pattern. The comfort rule the readout prints is the runtime's own
147
+ (`ipdFactor × metersToVirtual × convergenceDiopters × N`, N ≈ 0.5 m): at 1 the viewer's eyes are
148
+ parallel on infinitely far content and past it they diverge. The SDK documents it and never
149
+ enforces it — the runtime clamps its own inputs, once, with a warning.
150
+
151
+ ### Docs
152
+
153
+ - **[`docs/porting-three-js-apps.md`](docs/porting-three-js-apps.md) — porting an existing three.js
154
+ app (WebXR or plain) to inline 3D.** The rig work above closed the gap that made this guide
155
+ possible: an app that owns a camera can now hand it over, so "port your WebXR app" stops meaning
156
+ "re-author it as a portal". The guide is the WebXR→inline-3d mapping table (what each of
157
+ `isSessionSupported`, `renderer.xr`, `setAnimationLoop`, `XRWebGLLayer`, reference spaces,
158
+ offset-reference-space locomotion, `ArrayCamera`, controllers and `updateRenderState` becomes, and
159
+ why), the whole render loop with validate-before-clear and last-good replay, the Spark
160
+ double-sort, DOM UI over a woven canvas, picking, a hardware checklist, and a 24-item pitfalls
161
+ register. Linked from the README and from the top of the authoring guide.
162
+
8
163
  ## 1.2.1 — 2026-08-25
9
164
 
10
165
  ### Fixed
package/README.md CHANGED
@@ -65,6 +65,19 @@ if (!wall.supported) {
65
65
  The browser weaves each element's stereo pair at its on-screen rect; the surrounding DOM stays flat.
66
66
  The runtime batches every visible window into one weave per frame, so it scales to a wall of elements.
67
67
 
68
+ A scene with its own camera can hand that camera to the runtime instead of being told where the eyes
69
+ are — a **camera rig** — and let eye tracking perturb its frustum:
70
+
71
+ ```js
72
+ import { cameraRigFromCamera } from '@displayxr/inline3d/three';
73
+ const handle = wall.addScene(canvas, onFrame, { viewRig: cameraRigFromCamera(THREE, cam, { convergence: 1.2 }) });
74
+ handle.setViewRig(cameraRigFromCamera(THREE, cam, { convergence: 1.2, out: rig })); // per frame
75
+ ```
76
+
77
+ No projection math lands in your page or in the SDK — the off-axis frustum stays in the runtime. See
78
+ [view rigs](docs/authoring-inline-3d.md#view-rigs-display-vs-camera) and
79
+ [`samples/camera-rig/`](samples/camera-rig/).
80
+
68
81
  > **Detection:** call `createInline3D()` and check `wall.supported` — do **not** gate on
69
82
  > `navigator.xr.isSessionSupported('inline-3d')`. That async probe resolves `false` if it runs before the
70
83
  > OS weave service has bound (typically at page load), a false-negative that silently drops you to 2D.
@@ -78,6 +91,8 @@ Three.js glue (an off-axis `EyeCamera`) in [`js/inline3d-three.js`](js/inline3d-
78
91
  ```
79
92
  index.html landing (Pages entry point)
80
93
  samples/
94
+ camera-rig/ the camera rig — an orbiting scene that sends its OWN camera each frame;
95
+ convergence + comfort, the attach pattern, C to A/B a display rig
81
96
  windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
82
97
  each woven with one SDK call, all on one session
83
98
  splat/ a 3D Gaussian splat in a tile, auto-framed, with a 2D price plate over it
@@ -95,6 +110,11 @@ js/
95
110
  from what the asset declares (you serve the decoder files — see the guide)
96
111
  docs/
97
112
  authoring-inline-3d.md the authoring guide
113
+ authoring-motion-and-effects.md
114
+ motion, transitions and per-eye effects — the half of authoring
115
+ that is not the API
116
+ porting-three-js-apps.md porting an existing three.js app (WebXR or plain) to inline 3D —
117
+ the WebXR→inline-3d mapping table and the whole render loop
98
118
  ```
99
119
 
100
120
  ## The inline-3D model (under the SDK)
package/index.d.ts CHANGED
@@ -13,6 +13,153 @@ export interface TileOptions {
13
13
  feather?: number;
14
14
  }
15
15
 
16
+ /**
17
+ * A view-rig descriptor — what the runtime locates the eye views against. Two rigs, one shape:
18
+ *
19
+ * - **`"display"`** — the canvas is a PORTAL. Its plane is world `z = 0` and the viewer looks
20
+ * through it at a virtual display `virtualDisplayHeight` tall. This is the default rig and
21
+ * what `SceneOptions.virtualDisplayHeight` is shorthand for (identity pose, all factors 1).
22
+ * - **`"camera"`** — an APP CAMERA whose frustum eye tracking perturbs. The runtime keeps your
23
+ * `verticalFov`, offsets the eyes, and skews each frustum so `convergenceDiopters` lands on
24
+ * the zero-disparity plane.
25
+ *
26
+ * Every field is optional; the runtime **clamps** an out-of-range value (once, with a warning)
27
+ * rather than rejecting the rig. A descriptor applies per-locate, so animating one means sending
28
+ * new values each frame — see {@link TileHandle.setViewRig} for the one-frame latency that
29
+ * implies. Build one with `cameraRigFromCamera` / `displayRig` from
30
+ * `@displayxr/inline3d/three`, or by hand.
31
+ */
32
+ export interface XRViewRigInit {
33
+ /** `"display"` (portal) or `"camera"` (app camera). */
34
+ type?: 'display' | 'camera';
35
+ /** Rig pose in the app's world units (default 0,0,0). */
36
+ position?: DOMPointInit;
37
+ /** Rig orientation as a quaternion (default identity). */
38
+ orientation?: DOMPointInit;
39
+ /** **Display rig.** Metres of virtual display; `m2v = this / the element's physical height`. */
40
+ virtualDisplayHeight?: number;
41
+ /** Eye separation — display rig: relative `[0,1]`; camera rig: absolute `>= 0`. */
42
+ ipdFactor?: number;
43
+ /** Head-tracking response — display rig: `[0,1]`; camera rig: absolute `>= 0`. */
44
+ parallaxFactor?: number;
45
+ /** **Display rig only**, `[0.1,10]`: exaggerates or flattens the off-axis skew. */
46
+ perspectiveFactor?: number;
47
+ /** **Camera rig.** `1 / (convergence distance in world units)`; `0` = infinity. */
48
+ convergenceDiopters?: number;
49
+ /** **Camera rig.** The FULL vertical angle, in RADIANS (three's `camera.fov` is degrees). */
50
+ verticalFov?: number;
51
+ /** **Camera rig.** Metres → world units on the eye; `0`/unset means 1. */
52
+ metersToVirtual?: number;
53
+ }
54
+
55
+ /**
56
+ * The panel's hardware display state, as reported by `hardwaredisplaystatechange`.
57
+ *
58
+ * There is no page-facing request for it: it is a CONSEQUENCE of the active rendering mode —
59
+ * a `viewCount === 1` mode puts the panel flat, the 2-view mode puts it back.
60
+ */
61
+ export type XRHardwareDisplayMode = '2d' | '3d';
62
+
63
+ /**
64
+ * What the glasses-free display physically is. Resolved by {@link TileHandle.getDisplayInfo};
65
+ * `null` there means the machine has no such display.
66
+ *
67
+ * `recommendedViewScaleX/Y` are **advisory**. The browser cannot resize a page's canvas, so
68
+ * nothing applies them for you — a page honours them by sizing its own backing store. Ignoring
69
+ * them costs sharpness or fill rate, never correctness.
70
+ */
71
+ export interface XRDisplayInfo {
72
+ displayWidthMeters: number;
73
+ displayHeightMeters: number;
74
+ displayPixelWidth: number;
75
+ displayPixelHeight: number;
76
+ recommendedViewScaleX: number;
77
+ recommendedViewScaleY: number;
78
+ }
79
+
80
+ /**
81
+ * One rendering mode the DISPLAY can be put in, as reported by the runtime.
82
+ *
83
+ * The list is the display's, not the browser's. The DisplayXR Browser renders exactly **two**
84
+ * views — no view synthesis exists anywhere in the stack — so a mode needing MORE than two is
85
+ * reported with `isRequestable: false` and {@link TileHandle.requestRenderingMode} refuses it.
86
+ * Show such rows (they are what the panel can do) but mark them unavailable. A `viewCount === 1`
87
+ * mode IS requestable: the browser still submits two views and the runtime still weaves them —
88
+ * it is the PANEL that goes flat, which is how a page reaches the 2D hardware state.
89
+ */
90
+ export interface XRDisplayRenderingMode {
91
+ modeIndex: number;
92
+ name: string;
93
+ /** @deprecated the browser reports `name`; kept for pages written against the earlier build. */
94
+ modeName?: string;
95
+ viewCount: number;
96
+ /** Per-view render scale the runtime recommends for this mode — advisory, like the display's. */
97
+ viewScaleX: number;
98
+ viewScaleY: number;
99
+ tileColumns: number;
100
+ tileRows: number;
101
+ viewWidthPixels: number;
102
+ viewHeightPixels: number;
103
+ /** True for a glasses-free 3D mode; false for a flat one. */
104
+ hardwareDisplay3D: boolean;
105
+ isActive: boolean;
106
+ /** False when the browser cannot drive it — in practice, `viewCount > 2`. */
107
+ isRequestable: boolean;
108
+ }
109
+
110
+ /** A rendering mode went active. Re-emitted on the wall and on every tile handle. */
111
+ export interface RenderingModeChange {
112
+ type: 'renderingmodechange';
113
+ /** The mode now active, or -1 if the browser named none and the list could not be read. */
114
+ modeIndex: number;
115
+ /** Its view count — the thing the automatic rig collapse turns on. Null if unknown. */
116
+ viewCount: number | null;
117
+ /** The full mode row, when it could be read back. */
118
+ mode: XRDisplayRenderingMode | null;
119
+ /** The browser's own event payload, unreshaped. */
120
+ detail: unknown;
121
+ }
122
+
123
+ /** The panel's hardware display state changed. */
124
+ export interface HardwareDisplayStateChange {
125
+ type: 'hardwaredisplaystatechange';
126
+ state: XRHardwareDisplayMode | null;
127
+ detail: unknown;
128
+ }
129
+
130
+ export type DisplayModeChange = RenderingModeChange | HardwareDisplayStateChange;
131
+
132
+ /** What a page may lift into the floating native viewer. `null` = no `XRDisplayLayer.undock`. */
133
+ export interface UndockCapabilities {
134
+ model: boolean;
135
+ splat: boolean;
136
+ }
137
+
138
+ export interface UndockOptions {
139
+ /** Absolute https URL (or http on loopback) of the asset the NATIVE viewer loads. */
140
+ src: string;
141
+ type: 'model' | 'splat';
142
+ /** Lighting the page rendered with, so the viewer can match it. */
143
+ env?: 'room' | 'studio' | 'sky' | 'none';
144
+ /** The angle the page opened the asset at, so the undocked view opens at the same one. */
145
+ pose?: { yaw: number; pitch?: number; zoom?: number };
146
+ /** The page's fit margin, when it overrides the default. */
147
+ margin?: number;
148
+ title?: string;
149
+ }
150
+
151
+ export interface UndockHandle {
152
+ /**
153
+ * Resolves when the viewer exits. On the protocol FALLBACK it resolves immediately and
154
+ * `detached` is true — a protocol launch is fire-and-forget and the page never hears back.
155
+ */
156
+ ended: Promise<void>;
157
+ viewer: 'model' | 'splat';
158
+ detached: boolean;
159
+ /** The `displayxr-view:` URL, on the fallback path only. */
160
+ url?: string;
161
+ }
162
+
16
163
  /** Extra options for {@link Inline3D.addScene}. */
17
164
  export interface SceneOptions extends TileOptions {
18
165
  /**
@@ -21,6 +168,15 @@ export interface SceneOptions extends TileOptions {
21
168
  * and render the reported views as-is. Halving it doubles how much of the window an object fills.
22
169
  */
23
170
  virtualDisplayHeight?: number;
171
+ /**
172
+ * A full {@link XRViewRigInit} instead of the scalar height — a posed display rig, or a camera
173
+ * rig. **Supersedes `virtualDisplayHeight`** (which is one particular display rig); passing
174
+ * both warns once and the rig wins **where rigs are supported** — which is the one reason to
175
+ * pass the pair deliberately, since a browser without {@link inline3dViewRigSupported} then
176
+ * falls back to the height you named rather than to its own default. The window weaves either
177
+ * way. Replaceable per frame with {@link TileHandle.setViewRig}.
178
+ */
179
+ viewRig?: XRViewRigInit;
24
180
  /** Element whose visibility drives the lazy create/close lifecycle (defaults to the canvas). */
25
181
  observe?: Element;
26
182
  }
@@ -52,6 +208,78 @@ export interface TileHandle {
52
208
  * @deprecated See {@link TileHandle.exclude} — no-op on browsers with draw-order occlusion.
53
209
  */
54
210
  unexclude(el: Element): void;
211
+ /**
212
+ * Replace this window's view rig. Cheap enough to call every frame — a rig applies per-locate,
213
+ * so animating one means sending new values, not tweening anything.
214
+ *
215
+ * Returns whether the rig reached a **live** layer. `false` means it was stored and will build
216
+ * the next one (a window scrolled away in lazy mode), or that the browser has no rig support
217
+ * ({@link inline3dViewRigSupported}) — in which case it warns once and the window keeps
218
+ * weaving on the runtime's default display rig.
219
+ *
220
+ * **One frame of lag, by construction.** The browser locates views *before* the page's rAF, so
221
+ * a rig set during frame N drives the views delivered in frame N+1. Invisible for a slider or
222
+ * a settled camera; not for a camera that moves with the pointer — for that, send an
223
+ * identity-posed camera rig and parent your eye cameras under the app camera
224
+ * (`cameraRigFromCamera(THREE, cam, { attach: true })` + `EyeCamera.setLocalFromView`), so the
225
+ * scene graph supplies this frame's world pose with no lag at all.
226
+ *
227
+ * While a `viewCount === 1` mode is active the rig is stored **as given** and pushed **flat**
228
+ * (a copy with `ipdFactor`/`parallaxFactor` at 0), so a page driving a rig every frame cannot
229
+ * walk out of 2D, and the 2-view mode going active restores exactly this rig.
230
+ */
231
+ setViewRig(rig: XRViewRigInit): boolean;
232
+ /**
233
+ * The panel this window weaves on, or `null` where there is no glasses-free display.
234
+ *
235
+ * Rejects with an `Error` on a browser without the display-mode API
236
+ * ({@link inline3dDisplayModesSupported}) or while this window has no live layer (lazy mode,
237
+ * tile off screen).
238
+ */
239
+ getDisplayInfo(): Promise<XRDisplayInfo | null>;
240
+ /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
241
+ getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
242
+ /**
243
+ * Ask the runtime to switch the display to `modeIndex`. A thin pass-through — it resolves and
244
+ * rejects exactly as the browser does.
245
+ *
246
+ * Rejects with a `TypeError` for a mode whose `viewCount > 2` (the browser is fixed at two
247
+ * views) or an unknown index — the browser raises those synchronously, and this pass-through
248
+ * turns them into rejections so one `.catch()` covers every failure — and with a
249
+ * `NotSupportedError` `DOMException` when the request was not forwardable. Success is signalled
250
+ * by the session's `renderingmodechange` event, not by this promise.
251
+ *
252
+ * A `viewCount === 1` mode is requestable and is how a page goes flat.
253
+ */
254
+ requestRenderingMode(modeIndex: number): Promise<void>;
255
+ /**
256
+ * SUGAR over {@link TileHandle.requestRenderingMode}: `false` requests the first mode with
257
+ * `viewCount === 1 && isRequestable`, `true` the first with `viewCount === 2 && isRequestable`.
258
+ * It never touches the hardware display state directly (there is no such call) and never
259
+ * touches your rig.
260
+ *
261
+ * **The rig collapse is not part of this call.** When a 1-view mode actually goes ACTIVE the
262
+ * SDK zeroes every window's `ipdFactor`/`parallaxFactor` on the way to the layer and restores
263
+ * them when a 2-view mode does — driven by `renderingmodechange`, so it happens however the
264
+ * mode changed, and a **refused request changes nothing in either direction**. The flattening
265
+ * is a copy pushed at the layer, never a write into your descriptor, so the restore is literally
266
+ * the rig you last set — and it survives a per-frame `setViewRig` loop, a lazy tile rebuilding
267
+ * its layer, and a window that never set a rig at all.
268
+ *
269
+ * Rejects with an `Error` when no such mode is listed, otherwise as `requestRenderingMode` does.
270
+ */
271
+ setStereoEnabled(enabled: boolean): Promise<boolean>;
272
+ /** Listen for one display event, re-emitted on this handle. Returns an unsubscribe function. */
273
+ on(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): () => void;
274
+ on(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): () => void;
275
+ /** Drop a listener registered with {@link TileHandle.on}. */
276
+ off(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): void;
277
+ off(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): void;
278
+ /**
279
+ * Both display events through one callback — the older shape, still supported. Returns an
280
+ * unsubscribe function; inert (a no-op unsubscribe) on a browser without the API.
281
+ */
282
+ onDisplayModeChange(cb: (e: DisplayModeChange) => void): () => void;
55
283
  /**
56
284
  * Per-window frame counters, for diagnosing the load-induced mono fallback.
57
285
  *
@@ -76,6 +304,37 @@ export interface Inline3D {
76
304
  /** Number of currently-active (weaving) windows. */
77
305
  readonly liveCount: number;
78
306
 
307
+ // The panel is the DOCUMENT's, not a tile's, so the display API lives here; the same names are
308
+ // on every tile handle, routed to whichever window currently holds a live layer.
309
+
310
+ /** The panel, or `null` where there is no glasses-free display. See {@link TileHandle.getDisplayInfo}. */
311
+ getDisplayInfo(): Promise<XRDisplayInfo | null>;
312
+ /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
313
+ getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
314
+ /** Switch the display to `modeIndex`. See {@link TileHandle.requestRenderingMode}. */
315
+ requestRenderingMode(modeIndex: number): Promise<void>;
316
+ /** Sugar: `false` -> a 1-view mode, `true` -> the 2-view mode. See {@link TileHandle.setStereoEnabled}. */
317
+ setStereoEnabled(enabled: boolean): Promise<boolean>;
318
+ on(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): () => void;
319
+ on(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): () => void;
320
+ off(type: 'renderingmodechange', cb: (e: RenderingModeChange) => void): void;
321
+ off(type: 'hardwaredisplaystatechange', cb: (e: HardwareDisplayStateChange) => void): void;
322
+ /** As last REPORTED by `hardwaredisplaystatechange` — never what was last requested. */
323
+ readonly hardwareDisplayState: XRHardwareDisplayMode | null;
324
+ /** The active mode as last read/reported. `viewCount: 0` means "not read yet". */
325
+ readonly activeMode: { modeIndex: number; viewCount: number };
326
+ /** True while the SDK is holding every window's rig flat because a 1-view mode is active. */
327
+ readonly stereoCollapsed: boolean;
328
+
329
+ /**
330
+ * What this build can lift into the floating native viewer, or `null` on a browser with no
331
+ * `XRDisplayLayer.undock` — that null is what a page branches on. Read off the first live
332
+ * layer (`layer.getUndockCapabilities()`); `{model:false, splat:false}` is the pre-read value.
333
+ */
334
+ readonly undock: UndockCapabilities | null;
335
+ /** Re-read {@link Inline3D.undock} off a live layer. */
336
+ refreshUndock(): Promise<UndockCapabilities | null>;
337
+
79
338
  /** Weave a still side-by-side 3D photo from a URL or decoded image source. */
80
339
  addImage(
81
340
  canvas: HTMLCanvasElement,
@@ -194,6 +453,61 @@ export function inline3dOverlaySupported(): boolean;
194
453
  */
195
454
  export function inline3dOcclusionByDrawOrder(): boolean;
196
455
 
456
+ /**
457
+ * True when this browser accepts a full {@link XRViewRigInit} — {@link TileHandle.setViewRig} and
458
+ * {@link SceneOptions.viewRig}, i.e. a posed display rig or a camera rig, instead of only the
459
+ * scalar `virtualDisplayHeight`. Sync + cheap; implies {@link inline3DAvailable}.
460
+ *
461
+ * Reads a capability (the presence of `XRDisplayLayer.setViewRig`), never a version or UA string,
462
+ * and is `false` on every browser that predates the rig API — where `virtualDisplayHeight` still
463
+ * works. Branch on it only if a camera rig is load-bearing for your page: `setViewRig` no-ops
464
+ * (warning once) rather than throwing, so a page that merely wants the extra control where it
465
+ * exists can call it unconditionally.
466
+ */
467
+ export function inline3dViewRigSupported(): boolean;
468
+
469
+ /**
470
+ * True when this browser exposes the DISPLAY-MODE API — {@link TileHandle.getDisplayInfo},
471
+ * {@link TileHandle.getRenderingModes} and {@link TileHandle.requestRenderingMode}. Sync + cheap;
472
+ * implies {@link inline3DAvailable}.
473
+ *
474
+ * Reads a capability (all three methods present on `XRDisplayLayer.prototype`), never a version or
475
+ * UA string, and demands all three: a browser shipping half the set is one mid-implementation.
476
+ * Everything the API drives is optional enhancement, so branch on this only to decide whether to
477
+ * show display controls — the handle methods reject with a clear `Error` rather than throwing at
478
+ * import or create time.
479
+ */
480
+ export function inline3dDisplayModesSupported(): boolean;
481
+
482
+ /**
483
+ * True when this browser can undock through `XRDisplayLayer.undock()` — i.e. without the
484
+ * `displayxr-view:` protocol prompt the fallback needs. A page does not have to branch on it to
485
+ * undock (the helper falls back on its own); it is the probe for whether {@link Inline3D.undock}
486
+ * carries capabilities.
487
+ */
488
+ export function inline3dUndockSupported(): boolean;
489
+
490
+ /**
491
+ * Undock `target`'s asset into the floating native viewer over the desktop.
492
+ *
493
+ * **Call it synchronously inside the click.** Both paths need the transient user activation — the
494
+ * API path to be allowed at all, the fallback to get Chrome's protocol dialog — and an `await`
495
+ * before this call spends it.
496
+ *
497
+ * Rejects with an `Error` whose `name` is `'not-installed'`, `'src-not-allowed'`,
498
+ * `'no-activation'` or `'busy'`.
499
+ */
500
+ export function undock(target: Element, opts: UndockOptions): Promise<UndockHandle>;
501
+
502
+ /** True where a native DisplayXR viewer can exist at all (the viewers are Windows-only today). */
503
+ export function undockAvailable(): boolean;
504
+
505
+ /** The `displayxr-view:` URL the fallback path navigates to — exported for logging and tests. */
506
+ export function undockUrl(el: Element, opts: UndockOptions): string;
507
+
508
+ /** An element's rect in physical screen pixels — where the native viewer places its window. */
509
+ export function tileScreenRect(el: Element): { x: number; y: number; w: number; h: number; dpr: number };
510
+
197
511
  /** Open the page's inline-3D session and return a manager you add windows to. */
198
512
  export function createInline3D(
199
513
  opts?: CreateInline3DOptions,
@@ -229,5 +543,26 @@ export interface XRDisplayLayer {
229
543
  * browser shipped so far — the SDK treats absent as `false` and runs the legacy path.
230
544
  */
231
545
  readonly occlusionByDrawOrder?: boolean;
546
+ /**
547
+ * Replace the rig the runtime locates this layer's views against. Optional because it is
548
+ * absent on browsers that predate the rig API — its PRESENCE on the prototype is the
549
+ * capability signal ({@link inline3dViewRigSupported}), which is why the browser exposes it as
550
+ * a method: a Blink IDL attribute getter throws `Illegal invocation` when read off a prototype
551
+ * (see {@link XRDisplayLayer.occlusionByDrawOrder}), so an attribute could not be probed at
552
+ * all on the browser that has it.
553
+ */
554
+ setViewRig?(rig: XRViewRigInit): void;
555
+ /**
556
+ * The display-mode API. All three optional for the same reason as `setViewRig`: their presence
557
+ * on the prototype IS the capability signal ({@link inline3dDisplayModesSupported}), and the
558
+ * SDK demands all three before treating the browser as supporting any of them.
559
+ */
560
+ getDisplayInfo?(): Promise<XRDisplayInfo | null>;
561
+ getRenderingModes?(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
562
+ /** Throws `TypeError` **synchronously** for `viewCount > 2` or an unknown index. */
563
+ requestRenderingMode?(modeIndex: number): Promise<void>;
564
+ /** Undock this layer's asset into the floating native viewer. Optional; same probe rule. */
565
+ undock?(init: UndockOptions): Promise<unknown>;
566
+ getUndockCapabilities?(): Promise<UndockCapabilities>;
232
567
  close(): void;
233
568
  }
@@ -478,7 +478,7 @@ function boundsOf(object3d) {
478
478
  * This is the default because the alternative is silently wrong. `addStudioLights` is punctual
479
479
  * only, and a punctual light contributes a specular highlight without filling a metallic BRDF —
480
480
  * so a `metalness: 1` surface has nothing to reflect and resolves to BLACK. Chrome bells render as
481
- * a dark disc, glass lenses as opaque holes, and the result reads as a corrupt asset rather than a
481
+ * a dark disc, clear-glass optics as opaque holes, and the result reads as a corrupt asset rather than a
482
482
  * lighting choice. It has cost real debugging time more than once.
483
483
  *
484
484
  * RoomEnvironment is generated in memory — a small box of emissive panels — so this buys IBL with