@displayxr/inline3d 0.0.1 → 1.1.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 ADDED
@@ -0,0 +1,132 @@
1
+ # Changelog — `@displayxr/inline3d`
2
+
3
+ Versioning follows [`docs/sdk-stability.md`](docs/sdk-stability.md). Read that first: the core
4
+ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (`./viewer`,
5
+ `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
+ which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
+
8
+ ## 1.1.0 — 2026-08-19
9
+
10
+ ### Added
11
+
12
+ - **`inline3dOcclusionByDrawOrder()` — and the whole overlay-exclusion machinery turns itself off
13
+ where it's true.** The browser's Phase-2 compositor path composites ANY 2D content over woven
14
+ tiles per-pixel by draw order: a header, a badge, a dropdown, a translucent scrim, even a
15
+ full-tile plate occludes a tile with nothing declared. On such a browser this SDK stops working
16
+ around it — no auto-chrome DOM scan (a `querySelectorAll` + `getComputedStyle` sweep at every
17
+ layer activation), no `MutationObserver` per live tile for `data-inline3d-overlay`, and no
18
+ `will-change: transform` promotions written onto the page's own elements. `exclude()`,
19
+ `addGlobalOverlay()` and their `remove`/`unexclude` pairs still accept and store their argument
20
+ and simply do nothing, so ONE page runs unchanged on both browser generations; one
21
+ `console.info` says so the first time a page calls one.
22
+
23
+ The probe is a **capability, not a version**: the browser change is compositor-side and leaves
24
+ the JS API untouched, so `excludeElement` is present on both generations and only its effect
25
+ differs — its presence cannot tell them apart, and neither can `inline3dOverlaySupported()`,
26
+ whose question ("does 2D on a tile composite as crisp 2D?") is true on both. The gate is a
27
+ readonly capability flag the browser exposes on `XRDisplayLayer` —
28
+ `typeof XRDisplayLayer.occlusionByDrawOrder === 'boolean' ? XRDisplayLayer.occlusionByDrawOrder : …`,
29
+ falling back to the same-named per-layer attribute read off the first live layer if that is the
30
+ shape it lands in. **DisplayXR Browser 0.1.11 is the first build to expose it**, so on 0.1.11 and
31
+ newer this release stands the machinery down; on 0.1.10 and earlier the legacy path runs, byte for
32
+ byte as in 1.0 — verified by replaying one page against both SDK builds and diffing every
33
+ exclusion call, promotion, warning and registration. A user-agent or version gate was rejected: a
34
+ page pins an SDK for years, and a version string cannot describe a compositor behaviour that is
35
+ switch-gated — which is also why the flag reads `false` on a 0.1.11 launched with
36
+ `--disable-inline-3d-occlusion`, and the SDK correctly resumes the legacy path there.
37
+
38
+ Note what the *obvious* probe would have done.
39
+ `!!XRDisplayLayer.prototype.occlusionByDrawOrder` **throws** — a Blink IDL attribute getter
40
+ raises `TypeError: Illegal invocation` when its receiver is the prototype rather than an
41
+ instance — so the natural one-liner would have failed on precisely the browser it was looking
42
+ for. Presence is therefore probed with `in` (which calls no getter) and every value read has a
43
+ legal receiver: the interface object, or a real layer.
44
+
45
+ Effects on an element that overlaps a tile remain the exception on both generations: a
46
+ `backdrop-filter` (a function of what is behind it, and what is behind it is the woven buffer),
47
+ and — new small print for the Phase-2 path — a pixel-moving `filter`, a non-normal blend mode or
48
+ a 3D sorting context, none of which draw as the plain quad the split can lift. Plain chrome is
49
+ unaffected. *(core tier — additive: one new helper, no behaviour change on current browsers. The
50
+ exclusion APIs are deprecated-but-covered; see the stability policy.)*
51
+
52
+ - **`./viewer`, `./splat` and `./model` are now published exports.** 1.0.0 shipped `exports` for
53
+ `.` and `./three` only, so `import { addSplat } from '@displayxr/inline3d/splat'` failed on an
54
+ npm install even though the modules existed in the repo — vendoring the files was the only way to
55
+ use a splat or a mesh tile. Additive, so nothing in 1.0.0 changes.
56
+ *(preview tier — see the stability policy before depending on their option shapes)*
57
+ - `boundsFromPositions` takes `expand` (default 2.5), the width of the outlier-rejection window in
58
+ core extents. `expand: 0` restores the 1.0-era percentile-only box. *(preview)*
59
+ - `addSplat` checks `THREE.REVISION` and throws a named error when three is older than 0.180,
60
+ Spark's floor. npm cannot express a peer range per export, so the manifest states the
61
+ package-wide `>=0.150` and an install on 0.16x succeeds; the failure used to surface from inside
62
+ a Spark worker as something unrelated to versions. *(preview)*
63
+ - **A live window now tracks its own box and `devicePixelRatio`.** `addImage`/`addVideo` windows
64
+ get a `ResizeObserver` while active, plus a `(resolution: Ndppx)` media query for the changes a
65
+ `ResizeObserver` cannot see (browser zoom, a drag to a different-scale monitor); the
66
+ side-by-side buffer is re-derived and repainted on the next animation frame. `addScene`
67
+ canvases and windows given an explicit `{ width, height }` are box-independent and untouched.
68
+ *(core tier — additive; no API change)*
69
+ - **Creating a second manager while one is live warns.** The browser's element-rect channel is a
70
+ whole-widget setter, so two live sessions in one document overwrite each other's rect list
71
+ every frame and neither one's tiles hold still. One `console.warn` says so; nothing is refused,
72
+ because a route change that closes one manager and opens the next is the normal case.
73
+ *(core tier)*
74
+ - **A full-tile overlay is refused with an explanation instead of destroying the tile.** The
75
+ browser matches an excluded element to a composited quad by ≥70% area overlap, so a plate
76
+ congruent with its own canvas matches the **canvas** — which then leaves the weave input and
77
+ presents its raw side-by-side buffer. Both the imperative `exclude()` and the
78
+ `data-inline3d-overlay` scan now measure mutual overlap and skip such an element. The test is
79
+ mutual, so page-global chrome that fully covers a small tile is unaffected. Make the overlay a
80
+ partial region of the tile, or page chrome via `addGlobalOverlay()`. *(core tier)*
81
+
82
+ ### Changed — this moves existing pixels
83
+
84
+ - **A splat's framing changes: subjects that were 10–15% too large now render smaller.**
85
+ `boundsFromPositions` returned a percentile-trimmed box as the subject's extent. Trimming is
86
+ essential on captured content — one floater a hundred metres out and the subject is a speck — but
87
+ the tail it drops on a DENSE subject is that subject's own outer shell, so the box came back
88
+ small and the fit faithfully turned that into a subject overflowing its tile. A uniform cube of
89
+ 20k points measured 0.899 of its real size with no outliers present at all.
90
+
91
+ Percentiles now bound a rejection window and the returned extent is the true min/max inside it;
92
+ the same cube measures 1.000 and the floater is still rejected. Measured across seven scanned
93
+ products, rendered silhouettes went from 0.849–0.980 of the tile to 0.739–0.880, against 0.856
94
+ for the `./model` path whose `Box3` bounds were always exact.
95
+
96
+ If a page compensated for the old behaviour with a reduced `margin`, remove that compensation.
97
+ *(preview tier — `./viewer`, and `./splat` through it. `./model` is unaffected: its bounds were
98
+ never percentile-based.)*
99
+
100
+ ### Fixed
101
+
102
+ - **Back-navigation left ghost 3D windows woven over the next page.** A window's rect reaches the
103
+ compositor from the session's own animation frames, and the only way to clear a rect is to push
104
+ a list without it — so a page frozen into the bfcache mid-loop leaves its last list standing and
105
+ its tiles keep weaving over whatever is on screen now (context:
106
+ [displayxr-browser#87](https://github.com/DisplayXR/displayxr-browser/issues/87)). Every live
107
+ window is now released on `pagehide` (and `freeze`, for a tab frozen without one) while frames
108
+ still run, so the outgoing frames report an empty list, and re-armed on `pageshow`/`resume`
109
+ through the existing lazy logic — re-observing re-delivers the current intersection state, so a
110
+ tile scrolled away before leaving stays dark. Page chrome is rescanned on restore. *(core tier)*
111
+ - **A restored page could come back alive but never paint.** A bfcache restore can hand back a
112
+ session whose pending animation frame never arrives, leaving the manager nominally running with
113
+ a dead loop. A persisted `pageshow` now gives it a second to prove otherwise and then starts a
114
+ fresh loop; loops carry an id and only the current one re-arms, so a stalled predecessor cannot
115
+ double the loop if it later fires. *(core tier)*
116
+ - `addSplat` threw a `ReferenceError` on the **URL path** — every ordinary page — because the
117
+ loader assigned `out.mesh` before `const out` was initialised. An async body runs synchronously
118
+ to its first `await`, and the URL path has none. The throw escaped into `ready` *after* the mesh
119
+ had joined the scene, so the splat rendered at raw model scale and never got framed: the symptom
120
+ was "the fit is wrong" when the fit had never run. The Blob path awaited `arrayBuffer()` and so
121
+ was unaffected, which is how it survived a commit about the bytes path. *(preview)*
122
+ - A rejected splat load now detaches its mesh, so a failed tile is empty as documented rather than
123
+ an unframed subject spilling out of the window under the caller's error state. *(preview)*
124
+ - `addSplat` warns when no usable bounds could be measured, instead of silently drawing at model
125
+ scale. *(preview)*
126
+
127
+ ## 1.0.0 — 2026-07-20
128
+
129
+ First published release. Freezes the imperative authoring API — `createInline3D`, the `Inline3D`
130
+ manager (`addImage` / `addVideo` / `addScene`, global overlays), the `TileHandle`, the detection
131
+ helpers, the `data-inline3d-overlay` contract, and the side-by-side buffer contract — as the
132
+ supported surface for 1.x. Exports `.` and `./three`.
package/README.md CHANGED
@@ -1,16 +1,108 @@
1
- # @displayxr/inline3d — 0.0.1 is a bootstrap placeholder
1
+ # displayxr-web
2
2
 
3
- **Do not install this version.** It contains no code.
3
+ Inline-3D **web samples** and a small JS SDK for the
4
+ [DisplayXR Browser](https://github.com/DisplayXR/displayxr-browser) — the DisplayXR analog of
5
+ [`immersive-web/webxr-samples`](https://github.com/immersive-web/webxr-samples). This is the canonical
6
+ repo web developers clone to build glasses-free 3D pages, and the site the browser navigates to for the
7
+ live demos.
4
8
 
5
- npm attaches a [Trusted Publisher](https://docs.npmjs.com/trusted-publishers)
6
- to an *existing* package, so this empty `0.0.1` exists only to reserve the name
7
- long enough for that link to be configured. Every real release is published
8
- from CI via OIDC, with provenance, and no npm token exists anywhere.
9
+ **▶ See it live:** <https://displayxr.github.io/displayxr-web/> — open in the
10
+ [DisplayXR Browser](https://github.com/DisplayXR/displayxr-browser/releases) on DisplayXR hardware for
11
+ glasses-free 3D; in any other browser the pages render as a normal 2D fallback, so they're safe to view
12
+ anywhere.
9
13
 
10
- Install the real SDK instead:
14
+ ## Install
15
+
16
+ The SDK is published as **[`@displayxr/inline3d`](https://www.npmjs.com/package/@displayxr/inline3d)**
17
+ (dependency-free ESM, ships its own TypeScript types):
11
18
 
12
19
  ```sh
13
20
  npm install @displayxr/inline3d
14
21
  ```
15
22
 
16
- Source and docs: https://github.com/DisplayXR/displayxr-web
23
+ ```js
24
+ import { createInline3D } from '@displayxr/inline3d';
25
+ import { EyeCamera, EdgeFeather } from '@displayxr/inline3d/three'; // optional three.js glue
26
+ import { addSplat } from '@displayxr/inline3d/splat'; // experimental: 3DGS in a tile
27
+ import { SceneViewer } from '@displayxr/inline3d/viewer'; // experimental: framing + orbit
28
+ ```
29
+
30
+ No build step or bundler required — it's plain ES modules. You can also import a pinned version by
31
+ URL from a CDN (jsDelivr / unpkg) without npm. The samples in this repo import the SDK by relative
32
+ path (`./js/inline3d.js`) so they run straight off GitHub Pages; in your own app prefer the package.
33
+
34
+ `three` and `@sparkjsdev/spark` are **optional peer dependencies** — the core is dependency-free
35
+ and only the `/three`, `/viewer` and `/splat` subpaths need them. The two viewer subpaths are
36
+ **experimental**: they turn "one object in a tile, look around it, drag to spin" into a single
37
+ call (auto-framing on the zero-disparity plane, orbit, idle turntable, mono fallback), but their
38
+ API is not yet covered by the semver promise below.
39
+
40
+ Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
41
+ that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
42
+
43
+ ## Quick start
44
+
45
+ One SDK call turns a `<canvas>` into a glasses-free-3D window. Everything degrades to plain 2D on a
46
+ non-DisplayXR browser, so a page is safe to ship anywhere.
47
+
48
+ ```js
49
+ import { createInline3D } from '@displayxr/inline3d';
50
+
51
+ const wall = await createInline3D(); // opens an inline-3d session (detects support)
52
+ if (!wall.supported) {
53
+ // Not the DisplayXR Browser (or no 3D display) — your page's normal 2D content shows. Done.
54
+ } else {
55
+ // Woven, glasses-free 3D. Add content — one call per element:
56
+ wall.addImage(canvas, 'photo-sbs.png'); // a still side-by-side 3D photo
57
+ wall.addVideo(canvas, videoEl); // an SBS 3D video
58
+ wall.addScene(canvas, (views, layer) => { /* render */ });// a live three.js / WebGL stereo scene
59
+ }
60
+ ```
61
+
62
+ The browser weaves each element's stereo pair at its on-screen rect; the surrounding DOM stays flat.
63
+ The runtime batches every visible window into one weave per frame, so it scales to a wall of elements.
64
+
65
+ > **Detection:** call `createInline3D()` and check `wall.supported` — do **not** gate on
66
+ > `navigator.xr.isSessionSupported('inline-3d')`. That async probe resolves `false` if it runs before the
67
+ > OS weave service has bound (typically at page load), a false-negative that silently drops you to 2D.
68
+ > `createInline3D()` detects by actually acquiring a session, which is authoritative.
69
+
70
+ Full API + authoring guidance: [`docs/authoring-inline-3d.md`](docs/authoring-inline-3d.md).
71
+ Three.js glue (an off-axis `EyeCamera`) in [`js/inline3d-three.js`](js/inline3d-three.js).
72
+
73
+ ## What's here
74
+
75
+ ```
76
+ index.html landing (Pages entry point)
77
+ samples/
78
+ windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
79
+ each woven with one SDK call, all on one session
80
+ splat/ a 3D Gaussian splat in a tile, auto-framed, with a 2D price plate over it
81
+ js/
82
+ inline3d.js the SDK: createInline3D() → { addImage, addVideo, addScene }, feature-detect,
83
+ SBS buffer management, and a lazy create/close lifecycle for many windows
84
+ inline3d-three.js optional three.js helper (EyeCamera: off-axis projection from the session's eyes)
85
+ inline3d-viewer.js experimental: SceneViewer — framing, orbit, idle turntable, mono fallback
86
+ inline3d-splat.js experimental: addSplat() — a Gaussian splat window via Spark
87
+ docs/
88
+ authoring-inline-3d.md the authoring guide
89
+ ```
90
+
91
+ ## The inline-3D model (under the SDK)
92
+
93
+ If you want the raw WebXR surface the SDK wraps, an inline-3d element:
94
+
95
+ 1. `const session = await navigator.xr.requestSession('inline-3d')` — a sensorless inline session
96
+ (feature-detect by whether this resolves; falls back to plain 2D).
97
+ 2. `const layer = new XRDisplayLayer(session, canvas)` — binds the weave to that element.
98
+ 3. Each XR frame: render the scene as a **side-by-side stereo pair** into the canvas, re-projected
99
+ **off-axis** (asymmetric-frustum / Kooima) from the eye positions the session reports that frame —
100
+ so moving your head looks *around* the 3D content.
101
+
102
+ See the [WebXR inline-3D explainer](https://github.com/DisplayXR/displayxr-runtime/blob/main/docs/roadmap/webxr-displayxr-explainer.md).
103
+
104
+ ## Local preview
105
+
106
+ Any static server, e.g. `python -m http.server 8080`, then open `http://localhost:8080/`.
107
+ (Loading over `file://` is fine for pure-2D, but WebXR requires a **secure context** — use
108
+ `http://localhost` or `https://`.)
package/index.d.ts ADDED
@@ -0,0 +1,221 @@
1
+ // Type definitions for @displayxr/inline3d — the DisplayXR inline-3D SDK.
2
+ // Public 1.0 surface. See docs/sdk-stability.md for the semver contract.
3
+
4
+ /** Options shared by every add*() call. */
5
+ export interface TileOptions {
6
+ /** Per-eye buffer resolution in px (defaults to the CSS box × devicePixelRatio, dpr capped at 2). */
7
+ width?: number;
8
+ /** Per-eye buffer height in px (see `width`). */
9
+ height?: number;
10
+ /** Round each eye's corners, in BUFFER px (CSS radii can't cross the packed side-by-side pair). */
11
+ cornerRadius?: number;
12
+ /** Fade each eye's outer edges to transparent over this many buffer px. */
13
+ feather?: number;
14
+ }
15
+
16
+ /** Extra options for {@link Inline3D.addScene}. */
17
+ export interface SceneOptions extends TileOptions {
18
+ /**
19
+ * Metre height of the virtual display this scene is authored for (default 0.24). The runtime
20
+ * scales the eye poses it reports so the z=0 plane spans a display this tall — author in metres
21
+ * and render the reported views as-is. Halving it doubles how much of the window an object fills.
22
+ */
23
+ virtualDisplayHeight?: number;
24
+ /** Element whose visibility drives the lazy create/close lifecycle (defaults to the canvas). */
25
+ observe?: Element;
26
+ }
27
+
28
+ /** The per-frame render callback passed to {@link Inline3D.addScene}. */
29
+ export type SceneFrameCallback = (
30
+ views: readonly XRView[],
31
+ layer: XRDisplayLayer,
32
+ frame: XRFrame,
33
+ ) => void;
34
+
35
+ /** The handle returned by every add*() call. */
36
+ export interface TileHandle {
37
+ /** Remove this window: close its weave layer and stop driving it. */
38
+ remove(): void;
39
+ /**
40
+ * Mark a 2D element painted OVER this window so the weave leaves it crisp 2D instead of
41
+ * garbling it (browser#18). No-op on browsers without overlay exclusion.
42
+ *
43
+ * @deprecated Legacy-browser mechanism. A browser with draw-order occlusion
44
+ * ({@link inline3dOcclusionByDrawOrder}) composites 2D over woven 3D per-pixel with nothing
45
+ * declared, so the call is accepted and ignored there — harmless everywhere, and still
46
+ * needed on older DisplayXR Browsers. Keep it unless you ship to Phase-2 browsers only.
47
+ */
48
+ exclude(el: Element): void;
49
+ /**
50
+ * Stop excluding `el` from this window's weave.
51
+ *
52
+ * @deprecated See {@link TileHandle.exclude} — no-op on browsers with draw-order occlusion.
53
+ */
54
+ unexclude(el: Element): void;
55
+ }
56
+
57
+ /** An open inline-3D session you add weaved windows to. Returned by {@link createInline3D}. */
58
+ export interface Inline3D {
59
+ readonly supported: true;
60
+ /** The underlying WebXR session. */
61
+ readonly session: XRSession;
62
+ /** The reference space the eye poses are reported in (may be null if none could be acquired). */
63
+ readonly refSpace: XRReferenceSpace | null;
64
+ /** Number of currently-active (weaving) windows. */
65
+ readonly liveCount: number;
66
+
67
+ /** Weave a still side-by-side 3D photo from a URL or decoded image source. */
68
+ addImage(
69
+ canvas: HTMLCanvasElement,
70
+ source: string | HTMLImageElement | ImageBitmap | HTMLCanvasElement,
71
+ opts?: TileOptions,
72
+ ): TileHandle;
73
+
74
+ /** Weave a side-by-side 3D video element (re-drawn each decoded frame). */
75
+ addVideo(
76
+ canvas: HTMLCanvasElement,
77
+ video: HTMLVideoElement,
78
+ opts?: TileOptions,
79
+ ): TileHandle;
80
+
81
+ /**
82
+ * Weave a live-rendered stereo scene. Your callback receives the two eye views + the layer;
83
+ * render each `layer.getViewport(view)` into the canvas's SBS backing (three.js: see the
84
+ * `@displayxr/inline3d/three` helpers).
85
+ */
86
+ addScene(
87
+ canvas: HTMLCanvasElement,
88
+ onFrame: SceneFrameCallback,
89
+ opts?: SceneOptions,
90
+ ): TileHandle;
91
+
92
+ /**
93
+ * Register a PAGE-GLOBAL 2D overlay (a fixed/sticky header, a floating toolbar) excluded from
94
+ * EVERY window's weave and re-applied when a window lazily re-activates. Register once instead
95
+ * of calling {@link TileHandle.exclude} per tile. No-op without overlay exclusion (browser#18).
96
+ *
97
+ * @deprecated Legacy-browser mechanism. Where {@link inline3dOcclusionByDrawOrder} is true,
98
+ * page chrome occludes every tile by itself: the element is stored and nothing is done to it
99
+ * (no `will-change` promotion). Harmless everywhere; still required on older browsers.
100
+ */
101
+ addGlobalOverlay(el: Element): void;
102
+ /**
103
+ * Stop treating `el` as a page-global overlay and drop it from every live window.
104
+ *
105
+ * @deprecated See {@link Inline3D.addGlobalOverlay} — no-op with draw-order occlusion.
106
+ */
107
+ removeGlobalOverlay(el: Element): void;
108
+
109
+ /** Close the session and remove every window. */
110
+ close(): void;
111
+ }
112
+
113
+ /** The shape {@link createInline3D} resolves to when inline-3D is unavailable. */
114
+ export interface Inline3DUnsupported {
115
+ supported: false;
116
+ error?: unknown;
117
+ }
118
+
119
+ /** Options for {@link createInline3D}. */
120
+ export interface CreateInline3DOptions {
121
+ /** WebXR reference space for the eye poses (default `"viewer"`). */
122
+ referenceSpace?: string;
123
+ /**
124
+ * Create each window's weave layer only while it is (near-)visible and close it when it scrolls
125
+ * away, so a long wall only pays for what's on screen (default `true`). Set `false` for a single
126
+ * always-on element.
127
+ */
128
+ lazy?: boolean;
129
+ /** IntersectionObserver margin for lazy mode (default `"50% 0px"`). */
130
+ rootMargin?: string;
131
+ /**
132
+ * Auto-exclude page chrome (default `true`): sticky/fixed elements near the top of
133
+ * the DOM (headers, toolbars) are registered as page-global overlays automatically —
134
+ * the bar plus its text/replaced descendants — so woven windows scroll UNDER the
135
+ * chrome with no per-app wiring. Opt an element (and its subtree) out with
136
+ * `data-inline3d-no-overlay`; set `false` to manage chrome exclusively via
137
+ * `addGlobalOverlay()` / `data-inline3d-overlay`.
138
+ *
139
+ * Ignored where {@link inline3dOcclusionByDrawOrder} is true: nothing is scanned and the
140
+ * SDK never touches your DOM's `will-change`, because the chrome already occludes the tiles.
141
+ */
142
+ autoChrome?: boolean;
143
+ }
144
+
145
+ /** The return of {@link startInline3D}. */
146
+ export interface StartInline3DResult {
147
+ supported: boolean;
148
+ /** The manager (present when supported). */
149
+ wall?: Inline3D;
150
+ /** The underlying WebXR session (present when supported). */
151
+ session?: XRSession;
152
+ /** Close the session (present when supported). */
153
+ close?: () => void;
154
+ error?: unknown;
155
+ }
156
+
157
+ /**
158
+ * Cheap, synchronous "can this browser even attempt inline-3D?" gate — true only in the DisplayXR
159
+ * Browser with the feature enabled. Use it to decide page UI up front.
160
+ */
161
+ export function inline3DAvailable(): boolean;
162
+
163
+ /**
164
+ * True when a 2D element painted ON a woven tile composites as crisp 2D over the woven 3D
165
+ * instead of being woven — by declaration (browser#18 overlay exclusion) or automatically
166
+ * ({@link inline3dOcclusionByDrawOrder}). Same answer on both generations, so it stays true on
167
+ * a draw-order-occlusion browser. Implies {@link inline3DAvailable}. Sync + cheap.
168
+ */
169
+ export function inline3dOverlaySupported(): boolean;
170
+
171
+ /**
172
+ * True when the browser occludes woven tiles with 2D content AUTOMATICALLY — anything that
173
+ * paints over a tile (header, badge, dropdown, translucent scrim) composites per-pixel by draw
174
+ * order, with nothing declared. When true this SDK's exclusion machinery is off: `autoChrome`
175
+ * does not scan, `data-inline3d-overlay` is not watched, and {@link TileHandle.exclude} /
176
+ * {@link Inline3D.addGlobalOverlay} are accepted but do nothing (no `will-change` promotion).
177
+ *
178
+ * You do not have to branch on it — the legacy calls are harmless where it is true and still
179
+ * required where it is false. Branch only to skip work of your own. Reads a readonly capability
180
+ * flag on `XRDisplayLayer`, never a version or UA string, and is `false` on any browser that
181
+ * does not expose the flag (the safe answer: the legacy path runs).
182
+ */
183
+ export function inline3dOcclusionByDrawOrder(): boolean;
184
+
185
+ /** Open the page's inline-3D session and return a manager you add windows to. */
186
+ export function createInline3D(
187
+ opts?: CreateInline3DOptions,
188
+ ): Promise<Inline3D | Inline3DUnsupported>;
189
+
190
+ /**
191
+ * Back-compatible single-scene helper: open a session, weave one canvas, drive a render callback
192
+ * each frame. Equivalent to `createInline3D({lazy:false})` then `addScene(canvas, onFrame)`.
193
+ */
194
+ export function startInline3D(
195
+ canvas: HTMLCanvasElement,
196
+ opts?: {
197
+ onFrame?: SceneFrameCallback;
198
+ referenceSpace?: string;
199
+ virtualDisplayHeight?: number;
200
+ },
201
+ ): Promise<StartInline3DResult>;
202
+
203
+ // XRDisplayLayer is a DisplayXR-Browser extension to WebXR; declare the minimum the SDK exposes.
204
+ export interface XRDisplayLayer {
205
+ getViewport(view: XRView): { x: number; y: number; width: number; height: number } | null;
206
+ /**
207
+ * @deprecated Legacy-browser overlay exclusion (browser#18). Present-but-no-op on a browser
208
+ * with draw-order occlusion, which is exactly why its presence cannot be used to detect the
209
+ * generation — use {@link inline3dOcclusionByDrawOrder} (i.e. `occlusionByDrawOrder`).
210
+ */
211
+ excludeElement?(el: Element): void;
212
+ /** @deprecated See {@link XRDisplayLayer.excludeElement}. */
213
+ unexcludeElement?(el: Element): void;
214
+ /**
215
+ * Readonly capability flag: `true` when this browser composites 2D over woven 3D per-pixel by
216
+ * draw order, making overlay exclusion unnecessary. Optional because it is absent on every
217
+ * browser shipped so far — the SDK treats absent as `false` and runs the legacy path.
218
+ */
219
+ readonly occlusionByDrawOrder?: boolean;
220
+ close(): void;
221
+ }
@@ -0,0 +1,169 @@
1
+ // inline3d-model.js — a glTF/GLB model as an inline-3D window, in one call.
2
+ //
3
+ // EXPERIMENTAL. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
4
+ //
5
+ // import { createInline3D } from '@displayxr/inline3d';
6
+ // import { addModel } from '@displayxr/inline3d/model';
7
+ //
8
+ // const wall = await createInline3D();
9
+ // const lamp = addModel(wall, canvas, 'lamp.glb', { virtualDisplayHeight: 0.3 });
10
+ // lamp.exclude(document.getElementById('buy'));
11
+ //
12
+ // Deliberately the same options, the same handle and the same framing behaviour as ./splat, so a
13
+ // catalogue can switch a product between a captured splat and a vendor mesh by changing one word.
14
+ // That symmetry is the point: retailers already hold glTF for a slice of their catalogue, and
15
+ // rendering those unchanged is a far stronger claim than "re-capture everything".
16
+ //
17
+ // Requires `three` as a peer, and resolves GLTFLoader from `three/addons/`. That mapping is
18
+ // already mandatory for anyone using ./splat (Spark reaches into three/addons internally), so
19
+ // this adds no new requirement — but on a bare importmap it must be declared:
20
+ //
21
+ // "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
22
+ //
23
+ // You can also hand the class in directly (`opts.GLTFLoader`) and skip the specifier entirely.
24
+
25
+ import * as THREE from 'three';
26
+ import { EyeCamera, EdgeFeather } from './inline3d-three.js';
27
+ import { SceneViewer } from './inline3d-viewer.js';
28
+
29
+ /** Cached across calls so a grid of models resolves the loader module once. */
30
+ let _GLTFLoader = null;
31
+
32
+ async function resolveLoader(injected) {
33
+ if (injected) return injected;
34
+ if (!_GLTFLoader) {
35
+ const mod = await import('three/addons/loaders/GLTFLoader.js');
36
+ _GLTFLoader = mod.GLTFLoader;
37
+ }
38
+ return _GLTFLoader;
39
+ }
40
+
41
+ /**
42
+ * Load a glTF/GLB into an inline-3D window.
43
+ *
44
+ * @param {object} wall the manager from createInline3D(), supported or not.
45
+ * @param {HTMLCanvasElement} canvas
46
+ * @param {string} src URL of a .glb / .gltf.
47
+ * @param {object} [opts] every option ./splat takes, plus:
48
+ * @param {'studio'|'none'} [opts.environment='studio'] built-in three-point lighting. Meshes
49
+ * arrive unlit otherwise — unlike splats, which carry their own baked appearance.
50
+ * @param {object} [opts.envMap] a PMREM-processed environment texture, if you have one. Better
51
+ * than `environment` for metal and glass; overrides it.
52
+ * @param {unknown} [opts.GLTFLoader] hand in the class instead of resolving `three/addons/`.
53
+ * @returns {object} the same handle shape as addSplat: a TileHandle plus `viewer`, `model`,
54
+ * `setPose`, `resetPose`, `frame`, and `ready`.
55
+ */
56
+ export function addModel(wall, canvas, src, opts = {}) {
57
+ const {
58
+ virtualDisplayHeight = 0.24,
59
+ frame = null,
60
+ idleSpin = 8,
61
+ orbit = true,
62
+ fit = 'contain',
63
+ margin = 0.8,
64
+ depthLimit = 4.0,
65
+ fitSweep = true,
66
+ renderScale = 1,
67
+ feather = 0,
68
+ environment = 'studio',
69
+ envMap = null,
70
+ GLTFLoader: injectedLoader = null,
71
+ observe,
72
+ } = opts;
73
+
74
+ const viewer = new SceneViewer(THREE, canvas, {
75
+ virtualDisplayHeight,
76
+ fit,
77
+ margin,
78
+ depthLimit,
79
+ fitSweep,
80
+ orbit,
81
+ idleSpin,
82
+ renderScale,
83
+ feather,
84
+ }).useEyeCamera(EyeCamera, EdgeFeather);
85
+
86
+ if (envMap) viewer.scene.environment = envMap;
87
+ else if (environment === 'studio') addStudioLights(viewer.scene);
88
+
89
+ const out = {
90
+ viewer,
91
+ model: null,
92
+ frame: null,
93
+ setPose: (p) => viewer.setPose(p),
94
+ resetPose: () => viewer.resetPose(),
95
+ remove() {
96
+ handle?.remove();
97
+ viewer.dispose();
98
+ if (out.model) disposeTree(out.model);
99
+ },
100
+ exclude: (el) => handle?.exclude(el),
101
+ unexclude: (el) => handle?.unexclude(el),
102
+ };
103
+
104
+ // Window first, content when it lands — same reasoning as ./splat: a grid should not appear
105
+ // one tile at a time in download order.
106
+ let handle = null;
107
+ if (wall && wall.supported) {
108
+ handle = wall.addScene(canvas, viewer.onFrame, {
109
+ virtualDisplayHeight,
110
+ ...(observe ? { observe } : {}),
111
+ });
112
+ } else {
113
+ viewer.startMono();
114
+ }
115
+
116
+ out.ready = (async () => {
117
+ const Loader = await resolveLoader(injectedLoader);
118
+ const gltf = await new Loader().loadAsync(src);
119
+ out.model = gltf.scene;
120
+ viewer.content.add(gltf.scene);
121
+
122
+ // Meshes have exact bounds, so unlike a splat there is nothing to be robust ABOUT: no
123
+ // percentile trim, no flood-fill, no sidecar needed. Box3 is the whole story.
124
+ const bounds = frame || boundsOf(gltf.scene);
125
+ if (bounds) {
126
+ out.frame = bounds;
127
+ viewer.fitTo(bounds.center, bounds.extent);
128
+ }
129
+ return out;
130
+ })().catch((err) => {
131
+ console.warn('[inline3d/model] failed to load', src, err);
132
+ throw err;
133
+ });
134
+
135
+ return out;
136
+ }
137
+
138
+ /** Exact model-space bounds of an object tree. */
139
+ function boundsOf(object3d) {
140
+ const box = new THREE.Box3().setFromObject(object3d);
141
+ if (!isFinite(box.min.x) || box.isEmpty()) return null;
142
+ const c = box.getCenter(new THREE.Vector3());
143
+ const e = box.getSize(new THREE.Vector3());
144
+ return { center: [c.x, c.y, c.z], extent: [Math.max(e.x, 1e-6), Math.max(e.y, 1e-6), Math.max(e.z, 1e-6)] };
145
+ }
146
+
147
+ /**
148
+ * A neutral three-point rig. Not a substitute for a real environment map on metal or glass, but
149
+ * it has no external dependency and no download, which matters for a tile that may be one of
150
+ * several on a page.
151
+ */
152
+ function addStudioLights(scene) {
153
+ const key = new THREE.DirectionalLight(0xffffff, 2.2);
154
+ key.position.set(1, 1.4, 1.6);
155
+ const fill = new THREE.DirectionalLight(0xffffff, 0.7);
156
+ fill.position.set(-1.4, 0.4, 0.8);
157
+ const rim = new THREE.DirectionalLight(0xffffff, 1.0);
158
+ rim.position.set(-0.4, 0.8, -1.6);
159
+ scene.add(key, fill, rim, new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
160
+ }
161
+
162
+ function disposeTree(root) {
163
+ root.traverse((o) => {
164
+ o.geometry?.dispose?.();
165
+ const m = o.material;
166
+ if (Array.isArray(m)) m.forEach((x) => x?.dispose?.());
167
+ else m?.dispose?.();
168
+ });
169
+ }