@displayxr/inline3d 1.3.0 → 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 +87 -0
- package/index.d.ts +248 -0
- package/js/inline3d-model.js +1 -1
- package/js/inline3d-undock.js +251 -0
- package/js/inline3d.js +777 -22
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,93 @@ 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
|
+
|
|
8
95
|
## 1.3.0 — 2026-09-04
|
|
9
96
|
|
|
10
97
|
### Added
|
package/index.d.ts
CHANGED
|
@@ -52,6 +52,114 @@ export interface XRViewRigInit {
|
|
|
52
52
|
metersToVirtual?: number;
|
|
53
53
|
}
|
|
54
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
|
+
|
|
55
163
|
/** Extra options for {@link Inline3D.addScene}. */
|
|
56
164
|
export interface SceneOptions extends TileOptions {
|
|
57
165
|
/**
|
|
@@ -115,8 +223,63 @@ export interface TileHandle {
|
|
|
115
223
|
* identity-posed camera rig and parent your eye cameras under the app camera
|
|
116
224
|
* (`cameraRigFromCamera(THREE, cam, { attach: true })` + `EyeCamera.setLocalFromView`), so the
|
|
117
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.
|
|
118
230
|
*/
|
|
119
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;
|
|
120
283
|
/**
|
|
121
284
|
* Per-window frame counters, for diagnosing the load-induced mono fallback.
|
|
122
285
|
*
|
|
@@ -141,6 +304,37 @@ export interface Inline3D {
|
|
|
141
304
|
/** Number of currently-active (weaving) windows. */
|
|
142
305
|
readonly liveCount: number;
|
|
143
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
|
+
|
|
144
338
|
/** Weave a still side-by-side 3D photo from a URL or decoded image source. */
|
|
145
339
|
addImage(
|
|
146
340
|
canvas: HTMLCanvasElement,
|
|
@@ -272,6 +466,48 @@ export function inline3dOcclusionByDrawOrder(): boolean;
|
|
|
272
466
|
*/
|
|
273
467
|
export function inline3dViewRigSupported(): boolean;
|
|
274
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
|
+
|
|
275
511
|
/** Open the page's inline-3D session and return a manager you add windows to. */
|
|
276
512
|
export function createInline3D(
|
|
277
513
|
opts?: CreateInline3DOptions,
|
|
@@ -316,5 +552,17 @@ export interface XRDisplayLayer {
|
|
|
316
552
|
* all on the browser that has it.
|
|
317
553
|
*/
|
|
318
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>;
|
|
319
567
|
close(): void;
|
|
320
568
|
}
|
package/js/inline3d-model.js
CHANGED
|
@@ -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
|
|
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
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// inline3d-undock.js — lift a window's 3D asset OUT of the page into a floating, transparent,
|
|
2
|
+
// click-through native viewer over the desktop. Dependency-free, and usable on its own.
|
|
3
|
+
//
|
|
4
|
+
// TWO PATHS, ONE CONTRACT. Where the browser exposes `XRDisplayLayer.undock()` the request goes
|
|
5
|
+
// straight through it: the layer already knows its element's rect, and the viewer opens with no
|
|
6
|
+
// prompt. Everywhere else the page spawns the same viewer through the `displayxr-view:` OS
|
|
7
|
+
// protocol — the spawn primitive every browser hands a page (Chrome asks once, "Open DisplayXR
|
|
8
|
+
// …?", with an "Always allow" tick). The URL grammar below is the contract both paths share, and
|
|
9
|
+
// it is the one parsed by displayxr-common's `launch_args.h`.
|
|
10
|
+
//
|
|
11
|
+
// WHY A NATIVE PROCESS AND NOT A WINDOW. The browser's inline-3D weave is bound to ONE window per
|
|
12
|
+
// process and hands back opaque pixels into the page's own compositing, so no browser window can
|
|
13
|
+
// be the transparent floating one. The floating window is a native process; the page's only job
|
|
14
|
+
// is to spawn it with the asset URL and the tile's screen rect.
|
|
15
|
+
//
|
|
16
|
+
// NOTHING HERE TOUCHES THE TILE. Undock READS an element's rect and never writes to it, so it
|
|
17
|
+
// cannot disturb a woven window — the button that calls it lives in the page's own chrome.
|
|
18
|
+
|
|
19
|
+
/** @typedef {'model'|'splat'} UndockType */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How the API-first path finds the live `XRDisplayLayer` behind an element. `inline3d.js`
|
|
23
|
+
* registers one when it is imported (only it knows the canvas -> layer map); with no resolver —
|
|
24
|
+
* this module used standalone — every call takes the protocol fallback, which is the correct
|
|
25
|
+
* degradation rather than a failure.
|
|
26
|
+
*
|
|
27
|
+
* @param {(el: Element) => object|null} fn
|
|
28
|
+
*/
|
|
29
|
+
let layerResolver = null;
|
|
30
|
+
export function setUndockLayerResolver(fn) {
|
|
31
|
+
layerResolver = typeof fn === 'function' ? fn : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True where a native DisplayXR viewer can exist at all. The viewers are Windows-only today, so
|
|
36
|
+
* this is a PLATFORM probe, not a capability one — a page uses it to decide whether to show an
|
|
37
|
+
* undock affordance. It says nothing about whether the viewer is installed: that is only knowable
|
|
38
|
+
* when the launch is attempted (a `not-installed` Error on the API path; silently nothing on the
|
|
39
|
+
* protocol path, which is exactly why the API path is worth having).
|
|
40
|
+
*/
|
|
41
|
+
export function undockAvailable() {
|
|
42
|
+
if (typeof navigator === 'undefined') return false;
|
|
43
|
+
const uad = navigator.userAgentData;
|
|
44
|
+
if (uad && uad.platform) return uad.platform === 'Windows';
|
|
45
|
+
return /Windows/i.test(navigator.userAgent);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The element's rect in PHYSICAL SCREEN pixels — the space the viewer places its window in.
|
|
50
|
+
*
|
|
51
|
+
* `screenX/Y` and `outerWidth/Height` are CSS px in the browser's own DIP space; `devicePixelRatio`
|
|
52
|
+
* folds the OS scale AND the page zoom together. At 100 % zoom the arithmetic is exact; with page
|
|
53
|
+
* zoom it drifts by the zoom factor, and the viewer clamps the rect into the panel anyway. `dpr`
|
|
54
|
+
* travels along so a calibration session can read both numbers from the viewer's log instead of
|
|
55
|
+
* reverse-engineering the DIP space.
|
|
56
|
+
*
|
|
57
|
+
* @param {Element} el
|
|
58
|
+
* @returns {{x:number, y:number, w:number, h:number, dpr:number}}
|
|
59
|
+
*/
|
|
60
|
+
export function tileScreenRect(el) {
|
|
61
|
+
const r = el.getBoundingClientRect();
|
|
62
|
+
const dpr = window.devicePixelRatio || 1;
|
|
63
|
+
const chromeX = Math.max(0, (window.outerWidth - window.innerWidth) / 2);
|
|
64
|
+
const chromeY = Math.max(0, window.outerHeight - window.innerHeight);
|
|
65
|
+
return {
|
|
66
|
+
x: Math.round((window.screenX + chromeX + r.left) * dpr),
|
|
67
|
+
y: Math.round((window.screenY + chromeY + r.top) * dpr),
|
|
68
|
+
w: Math.max(64, Math.round(r.width * dpr)),
|
|
69
|
+
h: Math.max(64, Math.round(r.height * dpr)),
|
|
70
|
+
dpr,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `displayxr-view:` URL for undocking `opts` at `el`'s screen rect — the fallback path's
|
|
76
|
+
* whole payload, exported so a page can log or test it without launching anything.
|
|
77
|
+
*
|
|
78
|
+
* displayxr-view://open?src=<pct>&type=model|splat&rect=X,Y,W,H&dpr=<f>&title=<pct>
|
|
79
|
+
* &env=room&pose=<yaw>,<pitch>&margin=<f>&transparent=1&v=1
|
|
80
|
+
*
|
|
81
|
+
* `src` is resolved to an ABSOLUTE url here and must be https (or http on loopback): the viewer
|
|
82
|
+
* refuses file:/UNC/local paths from a protocol launch by design.
|
|
83
|
+
*
|
|
84
|
+
* NO `vh`. The grammar has one, and sending it is wrong: it is a raw pin that DISABLES the
|
|
85
|
+
* viewer's auto-fit, so an asset authored at 0.2 m arrives at native scale in a small window (far
|
|
86
|
+
* too zoomed in). Left unpinned the viewer auto-fits to ~80 % of the window height — the same
|
|
87
|
+
* rule a page's own fit applies to the same pixel box — so the apparent size matches. Apparent
|
|
88
|
+
* size is a property of the window rect, not of vH.
|
|
89
|
+
*
|
|
90
|
+
* @param {Element} el
|
|
91
|
+
* @param {object} opts {src, type, env?, pose?, margin?, title?}
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function undockUrl(el, opts) {
|
|
95
|
+
const src = new URL(opts.src, window.location.href).href;
|
|
96
|
+
const rect = tileScreenRect(el);
|
|
97
|
+
const pairs = [];
|
|
98
|
+
const put = (k, v) => pairs.push(`${k}=${encodeURIComponent(v)}`);
|
|
99
|
+
put('src', src);
|
|
100
|
+
put('type', opts.type);
|
|
101
|
+
put('rect', `${rect.x},${rect.y},${rect.w},${rect.h}`);
|
|
102
|
+
put('dpr', rect.dpr.toFixed(3));
|
|
103
|
+
if (opts.title) put('title', String(opts.title).slice(0, 64));
|
|
104
|
+
if (opts.env) put('env', opts.env);
|
|
105
|
+
// The page's opening angle: a model seen at yaw -40 has a very different silhouette from the
|
|
106
|
+
// same model face-on, and "it looks bigger undocked" is usually exactly that (the fit rules
|
|
107
|
+
// agree; the pose did not). Same convention as the SDK's setPose({yaw, pitch, zoom}).
|
|
108
|
+
if (opts.pose) {
|
|
109
|
+
const z = opts.pose.zoom !== undefined && opts.pose.zoom !== 1 ? `,${opts.pose.zoom}` : '';
|
|
110
|
+
put('pose', `${opts.pose.yaw},${opts.pose.pitch ?? 0}${z}`);
|
|
111
|
+
}
|
|
112
|
+
if (opts.margin !== undefined) put('margin', String(opts.margin));
|
|
113
|
+
// Transparent is the protocol's default; stated explicitly so the intent is visible in the URL.
|
|
114
|
+
put('transparent', '1');
|
|
115
|
+
put('v', '1');
|
|
116
|
+
// Built by hand rather than with URLSearchParams, which encodes a space as '+' — a form the
|
|
117
|
+
// viewer deliberately does NOT decode. encodeURIComponent is the spec form and never emits '+'.
|
|
118
|
+
return `displayxr-view://open?${pairs.join('&')}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fire the protocol from a user gesture. A hidden iframe rather than `location.href`: the page
|
|
123
|
+
* never unloads mid-demo, and a "no handler installed" outcome is contained in the frame — which
|
|
124
|
+
* is also why this path can never REPORT that outcome. Chrome only shows the external-protocol
|
|
125
|
+
* dialog under a transient user activation, so it has to run synchronously in the click.
|
|
126
|
+
*/
|
|
127
|
+
function launchProtocol(url) {
|
|
128
|
+
const frame = document.createElement('iframe');
|
|
129
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
130
|
+
frame.style.display = 'none';
|
|
131
|
+
frame.src = url;
|
|
132
|
+
document.body.appendChild(frame);
|
|
133
|
+
window.setTimeout(() => frame.remove(), 1500);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const UNDOCK_ERRORS = ['not-installed', 'src-not-allowed', 'no-activation', 'busy'];
|
|
137
|
+
|
|
138
|
+
/** Give a rejection one of the four contract names, keeping the browser's own where it has one. */
|
|
139
|
+
function undockError(e, fallbackName, message) {
|
|
140
|
+
const name = e && UNDOCK_ERRORS.includes(e.name) ? e.name : fallbackName;
|
|
141
|
+
const err = new Error(message || (e && e.message) || `[inline3d] undock failed (${name}).`);
|
|
142
|
+
err.name = name;
|
|
143
|
+
if (e) err.cause = e;
|
|
144
|
+
return err;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ONE LIVE UNDOCK AT A TIME. The viewer is a single floating window and the browser refuses a
|
|
148
|
+
// second request while one is in flight ('busy'); the fallback path has no such guard, so the
|
|
149
|
+
// module keeps its own — two protocol launches from one click would spawn two viewers.
|
|
150
|
+
let inFlight = false;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Undock `target`'s asset into the floating native viewer.
|
|
154
|
+
*
|
|
155
|
+
* CALL IT SYNCHRONOUSLY INSIDE THE CLICK. Both paths need the transient user activation — the
|
|
156
|
+
* API path to be allowed at all (`no-activation`), the fallback to get Chrome's protocol dialog —
|
|
157
|
+
* and an `await` before this call spends it. Nothing here awaits before the launch, so the
|
|
158
|
+
* activation is intact when it matters.
|
|
159
|
+
*
|
|
160
|
+
* @param {Element} target the element whose SCREEN RECT the viewer opens over (the tile).
|
|
161
|
+
* @param {object} opts
|
|
162
|
+
* @param {string} opts.src absolute https URL (or http on loopback) of the asset.
|
|
163
|
+
* @param {UndockType} opts.type
|
|
164
|
+
* @param {'room'|'studio'|'sky'|'none'} [opts.env] lighting the page rendered with.
|
|
165
|
+
* @param {{yaw:number, pitch?:number, zoom?:number}} [opts.pose] the angle the page opened at.
|
|
166
|
+
* @param {number} [opts.margin] the page's fit margin, when it overrides the default.
|
|
167
|
+
* @param {string} [opts.title]
|
|
168
|
+
* @returns {Promise<{ended:Promise<void>, viewer:UndockType, detached?:boolean}>}
|
|
169
|
+
* `ended` resolves when the viewer exits (fallback path: immediately, with
|
|
170
|
+
* `detached === true` — a protocol launch is fire-and-forget and the page never hears back).
|
|
171
|
+
* Rejects with an Error named `not-installed` | `src-not-allowed` | `no-activation` | `busy`.
|
|
172
|
+
*/
|
|
173
|
+
export function undock(target, opts) {
|
|
174
|
+
if (!target || typeof target.getBoundingClientRect !== 'function') {
|
|
175
|
+
return Promise.reject(new TypeError('[inline3d] undock() takes an Element and options.'));
|
|
176
|
+
}
|
|
177
|
+
if (!opts || typeof opts.src !== 'string' || !opts.src) {
|
|
178
|
+
return Promise.reject(new TypeError('[inline3d] undock() needs opts.src (an absolute URL).'));
|
|
179
|
+
}
|
|
180
|
+
if (opts.type !== 'model' && opts.type !== 'splat') {
|
|
181
|
+
return Promise.reject(
|
|
182
|
+
new TypeError(`[inline3d] undock() opts.type is 'model' or 'splat', got ${JSON.stringify(opts.type)}.`)
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (inFlight) return Promise.reject(undockError(null, 'busy', '[inline3d] an undock is already in flight.'));
|
|
186
|
+
|
|
187
|
+
const layer = layerResolver ? layerResolver(target) : null;
|
|
188
|
+
const viewer = opts.type;
|
|
189
|
+
|
|
190
|
+
// ── fallback: the OS protocol ──────────────────────────────────────────────────────────
|
|
191
|
+
if (!layer || typeof layer.undock !== 'function') {
|
|
192
|
+
let url;
|
|
193
|
+
try {
|
|
194
|
+
url = undockUrl(target, opts);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return Promise.reject(undockError(e, 'src-not-allowed'));
|
|
197
|
+
}
|
|
198
|
+
launchProtocol(url);
|
|
199
|
+
// Fire-and-forget by construction: the iframe swallows "no handler installed" and nothing
|
|
200
|
+
// comes back from a spawned process, so `ended` is honest only about THIS page's part being
|
|
201
|
+
// over. `detached` is how a caller tells the two paths apart.
|
|
202
|
+
return Promise.resolve({ ended: Promise.resolve(), viewer, detached: true, url });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── API path ───────────────────────────────────────────────────────────────────────────
|
|
206
|
+
// Synchronous, first thing, activation intact. The layer knows its own element rect, so only
|
|
207
|
+
// the content half of the contract travels.
|
|
208
|
+
const init = { src: opts.src, type: opts.type };
|
|
209
|
+
if (opts.env) init.env = opts.env;
|
|
210
|
+
if (opts.pose) init.pose = opts.pose;
|
|
211
|
+
if (opts.margin !== undefined) init.margin = opts.margin;
|
|
212
|
+
if (opts.title) init.title = opts.title;
|
|
213
|
+
|
|
214
|
+
let call;
|
|
215
|
+
try {
|
|
216
|
+
call = Promise.resolve(layer.undock(init));
|
|
217
|
+
} catch (e) {
|
|
218
|
+
// A synchronous throw is the same failure as a rejection; one .catch() should cover both.
|
|
219
|
+
return Promise.reject(undockError(e, 'src-not-allowed'));
|
|
220
|
+
}
|
|
221
|
+
inFlight = true;
|
|
222
|
+
const settled = call.finally(() => {
|
|
223
|
+
inFlight = false;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// TWO PROMISE SHAPES, ONE ANSWER. `undock()` either rejects PROMPTLY — every refusal
|
|
227
|
+
// (not-installed / src-not-allowed / no-activation / busy) is decided before any window
|
|
228
|
+
// exists — or it stays pending until the viewer exits. A short race tells them apart without
|
|
229
|
+
// inventing an event: whatever has not rejected by then launched. The launch has already
|
|
230
|
+
// happened synchronously above, so this wait costs the user nothing.
|
|
231
|
+
const LAUNCH_MS = 150;
|
|
232
|
+
const launchProbe = settled.then(
|
|
233
|
+
() => 'launched',
|
|
234
|
+
(e) => {
|
|
235
|
+
throw undockError(e, 'not-installed');
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
// The probe LOSES the race whenever the viewer stays open, and a rejection arriving after that
|
|
239
|
+
// would otherwise be an unhandled one. Marked handled here; the same rejection still reaches
|
|
240
|
+
// the caller through `ended`, which is where a late failure belongs.
|
|
241
|
+
launchProbe.catch(() => {});
|
|
242
|
+
return Promise.race([
|
|
243
|
+
launchProbe,
|
|
244
|
+
new Promise((resolve) => window.setTimeout(() => resolve('launched'), LAUNCH_MS)),
|
|
245
|
+
]).then(() => ({
|
|
246
|
+
// A rejection AFTER the launch window is the viewer failing later, and it belongs on `ended`.
|
|
247
|
+
ended: settled.then(() => undefined),
|
|
248
|
+
viewer,
|
|
249
|
+
detached: false,
|
|
250
|
+
}));
|
|
251
|
+
}
|