@displayxr/inline3d 1.0.0 → 1.1.1

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,198 @@
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.1 — 2026-08-20
9
+
10
+ ### Fixed
11
+
12
+ - **`./viewer` validates a frame before it clears the canvas — the dark blink under GPU load
13
+ (web#12).** `SceneViewer.onFrame` cleared unconditionally and then rendered whatever it could.
14
+ Under load the session hands the callback a **short view list** — one view, or none, a per-frame
15
+ mono fallback — and the old loop turned that into a cleared buffer with a single origin-camera
16
+ view drawn into it whose content is entirely near-plane-clipped: a fully transparent
17
+ side-by-side buffer, i.e. **one dark woven tile**. The blink was the viewer's, not the weave's;
18
+ it was reported as a compositor fault (glTF and splat tiles blinking on a busy box) with the
19
+ whole submit/match path provably healthy.
20
+
21
+ Now every disqualifying condition — a short view list, a `null` or degenerate
22
+ `layer.getViewport(view)`, a disposed viewer — is checked **while the canvas still holds the
23
+ last good image**, and only a frame that will draw is allowed to clear. A frame that cannot
24
+ draw **replays the last good one** from per-eye `Float32Array(16)` copies of
25
+ `projectionMatrix` / `transform.matrix` plus the viewport rects (copies, because an `XRView` is
26
+ valid only inside its own frame callback), rather than skipping the commit — the SDK's
27
+ every-frame-repaint invariant is real, and an un-redrawn canvas can drop out of the aggregated
28
+ frame and leave the weave reading a stale sub-rect. A one-frame-stale eye pose is
29
+ imperceptible; a black frame and a smear are not. Before the first good frame there is nothing
30
+ to replay, and the frame simply returns without clearing.
31
+
32
+ **This changes pixels only on frames that were previously black.** A frame that passed
33
+ validation renders byte-for-byte as it did in 1.1.0 — same clear, same viewports, same
34
+ matrices, same order. *(preview tier)*
35
+
36
+ - **A no-op resize no longer blanks the tile (web#12).** `renderer.setSize()` writes
37
+ `canvas.width`/`canvas.height` unconditionally, and writing either — *including the same value*
38
+ — reallocates and clears the drawing buffer. `ResizeObserver` fires on things that leave the
39
+ buffer's dimensions exactly where they were (a sub-pixel reflow, a scrollbar coming and going, a
40
+ sibling settling), and its callback runs after rAF and before paint, so each one committed a
41
+ black frame with nothing on the way to repaint it. `_resize` now compares against
42
+ `renderer.domElement.width/height` and returns early when nothing moved; a real change resizes
43
+ and then **immediately** re-renders from the replay cache (rects scaled to the new buffer), so
44
+ the cleared store never reaches the compositor. Observer bursts coalesce to one animation frame,
45
+ matching what the core already does for its own windows. *(preview tier)*
46
+
47
+ - **`SceneViewer` without `useEyeCamera()` says so instead of rendering nothing.** With no
48
+ `./three` glue the 3D path had no eye camera, so it cleared and drew nothing every frame,
49
+ forever, in silence — and this module's own header example omitted the call, making the failure
50
+ reachable by copy-paste. It now warns once and renders the **mono camera** into both eye
51
+ viewports (flat, but visible), and the example passes `EyeCamera`. `./splat` and `./model` were
52
+ never affected — they supply the glue for you. *(preview tier)*
53
+
54
+ ### Added
55
+
56
+ - **`EyeCamera.setFromMatrices(projectionMatrix, transformMatrix)`** — the same two matrices an
57
+ `XRView` carries, handed over separately, for re-drawing a frame you have already drawn.
58
+ `setFromView` is now a one-line forward to it, so a replay path can never drift from the live
59
+ one. *(core tier — additive)*
60
+ - **`handle.stats()` → `{ frames, monoFrames }`** on the handle every `add*()` returns. For scene
61
+ windows, `monoFrames` counts the deliveries that carried fewer than two views — the
62
+ load-induced fallback that used to be invisible from the page, since nothing throws and nothing
63
+ logs. A rising ratio is the machine telling you the session is degrading before it becomes a bug
64
+ report about "blinking"; one throttled `console.debug` (the first, then 1-in-300) names the
65
+ rate. The core's own contract is unchanged: the view list is passed to `onFrame` exactly as
66
+ reported, filtered by nothing and synthesised from nothing. *(core tier — additive)*
67
+ - **Unit tests.** `test/*.test.mjs` under `node --test`, with the DOM and three.js stubbed by
68
+ hand (`test/stubs.mjs`) so the test run needs no dependency either. They pin the rules above:
69
+ zero `clear()` calls for an empty view list, a one-eye list, a null viewport and a missing
70
+ layer; a replay that renders the cached matrices and survives the UA recycling the views it
71
+ cached from; no `setSize` on a no-op resize; an immediate repaint after a real one. 13 of the
72
+ 15 fail against 1.1.0. Wired into CI as a second job.
73
+
74
+ ## 1.1.0 — 2026-08-19
75
+
76
+ ### Added
77
+
78
+ - **`inline3dOcclusionByDrawOrder()` — and the whole overlay-exclusion machinery turns itself off
79
+ where it's true.** The browser's Phase-2 compositor path composites ANY 2D content over woven
80
+ tiles per-pixel by draw order: a header, a badge, a dropdown, a translucent scrim, even a
81
+ full-tile plate occludes a tile with nothing declared. On such a browser this SDK stops working
82
+ around it — no auto-chrome DOM scan (a `querySelectorAll` + `getComputedStyle` sweep at every
83
+ layer activation), no `MutationObserver` per live tile for `data-inline3d-overlay`, and no
84
+ `will-change: transform` promotions written onto the page's own elements. `exclude()`,
85
+ `addGlobalOverlay()` and their `remove`/`unexclude` pairs still accept and store their argument
86
+ and simply do nothing, so ONE page runs unchanged on both browser generations; one
87
+ `console.info` says so the first time a page calls one.
88
+
89
+ The probe is a **capability, not a version**: the browser change is compositor-side and leaves
90
+ the JS API untouched, so `excludeElement` is present on both generations and only its effect
91
+ differs — its presence cannot tell them apart, and neither can `inline3dOverlaySupported()`,
92
+ whose question ("does 2D on a tile composite as crisp 2D?") is true on both. The gate is a
93
+ readonly capability flag the browser exposes on `XRDisplayLayer` —
94
+ `typeof XRDisplayLayer.occlusionByDrawOrder === 'boolean' ? XRDisplayLayer.occlusionByDrawOrder : …`,
95
+ falling back to the same-named per-layer attribute read off the first live layer if that is the
96
+ shape it lands in. **DisplayXR Browser 0.1.11 is the first build to expose it**, so on 0.1.11 and
97
+ newer this release stands the machinery down; on 0.1.10 and earlier the legacy path runs, byte for
98
+ byte as in 1.0 — verified by replaying one page against both SDK builds and diffing every
99
+ exclusion call, promotion, warning and registration. A user-agent or version gate was rejected: a
100
+ page pins an SDK for years, and a version string cannot describe a compositor behaviour that is
101
+ switch-gated — which is also why the flag reads `false` on a 0.1.11 launched with
102
+ `--disable-inline-3d-occlusion`, and the SDK correctly resumes the legacy path there.
103
+
104
+ Note what the *obvious* probe would have done.
105
+ `!!XRDisplayLayer.prototype.occlusionByDrawOrder` **throws** — a Blink IDL attribute getter
106
+ raises `TypeError: Illegal invocation` when its receiver is the prototype rather than an
107
+ instance — so the natural one-liner would have failed on precisely the browser it was looking
108
+ for. Presence is therefore probed with `in` (which calls no getter) and every value read has a
109
+ legal receiver: the interface object, or a real layer.
110
+
111
+ Effects on an element that overlaps a tile remain the exception on both generations: a
112
+ `backdrop-filter` (a function of what is behind it, and what is behind it is the woven buffer),
113
+ and — new small print for the Phase-2 path — a pixel-moving `filter`, a non-normal blend mode or
114
+ a 3D sorting context, none of which draw as the plain quad the split can lift. Plain chrome is
115
+ unaffected. *(core tier — additive: one new helper, no behaviour change on current browsers. The
116
+ exclusion APIs are deprecated-but-covered; see the stability policy.)*
117
+
118
+ - **`./viewer`, `./splat` and `./model` are now published exports.** 1.0.0 shipped `exports` for
119
+ `.` and `./three` only, so `import { addSplat } from '@displayxr/inline3d/splat'` failed on an
120
+ npm install even though the modules existed in the repo — vendoring the files was the only way to
121
+ use a splat or a mesh tile. Additive, so nothing in 1.0.0 changes.
122
+ *(preview tier — see the stability policy before depending on their option shapes)*
123
+ - `boundsFromPositions` takes `expand` (default 2.5), the width of the outlier-rejection window in
124
+ core extents. `expand: 0` restores the 1.0-era percentile-only box. *(preview)*
125
+ - `addSplat` checks `THREE.REVISION` and throws a named error when three is older than 0.180,
126
+ Spark's floor. npm cannot express a peer range per export, so the manifest states the
127
+ package-wide `>=0.150` and an install on 0.16x succeeds; the failure used to surface from inside
128
+ a Spark worker as something unrelated to versions. *(preview)*
129
+ - **A live window now tracks its own box and `devicePixelRatio`.** `addImage`/`addVideo` windows
130
+ get a `ResizeObserver` while active, plus a `(resolution: Ndppx)` media query for the changes a
131
+ `ResizeObserver` cannot see (browser zoom, a drag to a different-scale monitor); the
132
+ side-by-side buffer is re-derived and repainted on the next animation frame. `addScene`
133
+ canvases and windows given an explicit `{ width, height }` are box-independent and untouched.
134
+ *(core tier — additive; no API change)*
135
+ - **Creating a second manager while one is live warns.** The browser's element-rect channel is a
136
+ whole-widget setter, so two live sessions in one document overwrite each other's rect list
137
+ every frame and neither one's tiles hold still. One `console.warn` says so; nothing is refused,
138
+ because a route change that closes one manager and opens the next is the normal case.
139
+ *(core tier)*
140
+ - **A full-tile overlay is refused with an explanation instead of destroying the tile.** The
141
+ browser matches an excluded element to a composited quad by ≥70% area overlap, so a plate
142
+ congruent with its own canvas matches the **canvas** — which then leaves the weave input and
143
+ presents its raw side-by-side buffer. Both the imperative `exclude()` and the
144
+ `data-inline3d-overlay` scan now measure mutual overlap and skip such an element. The test is
145
+ mutual, so page-global chrome that fully covers a small tile is unaffected. Make the overlay a
146
+ partial region of the tile, or page chrome via `addGlobalOverlay()`. *(core tier)*
147
+
148
+ ### Changed — this moves existing pixels
149
+
150
+ - **A splat's framing changes: subjects that were 10–15% too large now render smaller.**
151
+ `boundsFromPositions` returned a percentile-trimmed box as the subject's extent. Trimming is
152
+ essential on captured content — one floater a hundred metres out and the subject is a speck — but
153
+ the tail it drops on a DENSE subject is that subject's own outer shell, so the box came back
154
+ small and the fit faithfully turned that into a subject overflowing its tile. A uniform cube of
155
+ 20k points measured 0.899 of its real size with no outliers present at all.
156
+
157
+ Percentiles now bound a rejection window and the returned extent is the true min/max inside it;
158
+ the same cube measures 1.000 and the floater is still rejected. Measured across seven scanned
159
+ products, rendered silhouettes went from 0.849–0.980 of the tile to 0.739–0.880, against 0.856
160
+ for the `./model` path whose `Box3` bounds were always exact.
161
+
162
+ If a page compensated for the old behaviour with a reduced `margin`, remove that compensation.
163
+ *(preview tier — `./viewer`, and `./splat` through it. `./model` is unaffected: its bounds were
164
+ never percentile-based.)*
165
+
166
+ ### Fixed
167
+
168
+ - **Back-navigation left ghost 3D windows woven over the next page.** A window's rect reaches the
169
+ compositor from the session's own animation frames, and the only way to clear a rect is to push
170
+ a list without it — so a page frozen into the bfcache mid-loop leaves its last list standing and
171
+ its tiles keep weaving over whatever is on screen now (context:
172
+ [displayxr-browser#87](https://github.com/DisplayXR/displayxr-browser/issues/87)). Every live
173
+ window is now released on `pagehide` (and `freeze`, for a tab frozen without one) while frames
174
+ still run, so the outgoing frames report an empty list, and re-armed on `pageshow`/`resume`
175
+ through the existing lazy logic — re-observing re-delivers the current intersection state, so a
176
+ tile scrolled away before leaving stays dark. Page chrome is rescanned on restore. *(core tier)*
177
+ - **A restored page could come back alive but never paint.** A bfcache restore can hand back a
178
+ session whose pending animation frame never arrives, leaving the manager nominally running with
179
+ a dead loop. A persisted `pageshow` now gives it a second to prove otherwise and then starts a
180
+ fresh loop; loops carry an id and only the current one re-arms, so a stalled predecessor cannot
181
+ double the loop if it later fires. *(core tier)*
182
+ - `addSplat` threw a `ReferenceError` on the **URL path** — every ordinary page — because the
183
+ loader assigned `out.mesh` before `const out` was initialised. An async body runs synchronously
184
+ to its first `await`, and the URL path has none. The throw escaped into `ready` *after* the mesh
185
+ had joined the scene, so the splat rendered at raw model scale and never got framed: the symptom
186
+ was "the fit is wrong" when the fit had never run. The Blob path awaited `arrayBuffer()` and so
187
+ was unaffected, which is how it survived a commit about the bytes path. *(preview)*
188
+ - A rejected splat load now detaches its mesh, so a failed tile is empty as documented rather than
189
+ an unframed subject spilling out of the window under the caller's error state. *(preview)*
190
+ - `addSplat` warns when no usable bounds could be measured, instead of silently drawing at model
191
+ scale. *(preview)*
192
+
193
+ ## 1.0.0 — 2026-07-20
194
+
195
+ First published release. Freezes the imperative authoring API — `createInline3D`, the `Inline3D`
196
+ manager (`addImage` / `addVideo` / `addScene`, global overlays), the `TileHandle`, the detection
197
+ helpers, the `data-inline3d-overlay` contract, and the side-by-side buffer contract — as the
198
+ supported surface for 1.x. Exports `.` and `./three`.
package/README.md CHANGED
@@ -23,13 +23,19 @@ npm install @displayxr/inline3d
23
23
  ```js
24
24
  import { createInline3D } from '@displayxr/inline3d';
25
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
26
28
  ```
27
29
 
28
30
  No build step or bundler required — it's plain ES modules. You can also import a pinned version by
29
31
  URL from a CDN (jsDelivr / unpkg) without npm. The samples in this repo import the SDK by relative
30
32
  path (`./js/inline3d.js`) so they run straight off GitHub Pages; in your own app prefer the package.
31
33
 
32
- `three` is an **optional peer dependency** — only the `@displayxr/inline3d/three` helpers need it.
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.
33
39
 
34
40
  Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
35
41
  that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
@@ -71,10 +77,13 @@ index.html landing (Pages entry point)
71
77
  samples/
72
78
  windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
73
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
74
81
  js/
75
82
  inline3d.js the SDK: createInline3D() → { addImage, addVideo, addScene }, feature-detect,
76
83
  SBS buffer management, and a lazy create/close lifecycle for many windows
77
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
78
87
  docs/
79
88
  authoring-inline-3d.md the authoring guide
80
89
  ```
package/index.d.ts CHANGED
@@ -39,10 +39,31 @@ export interface TileHandle {
39
39
  /**
40
40
  * Mark a 2D element painted OVER this window so the weave leaves it crisp 2D instead of
41
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.
42
47
  */
43
48
  exclude(el: Element): void;
44
- /** Stop excluding `el` from this window's weave. */
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
+ */
45
54
  unexclude(el: Element): void;
55
+ /**
56
+ * Per-window frame counters, for diagnosing the load-induced mono fallback.
57
+ *
58
+ * `frames` counts `onFrame` deliveries; `monoFrames` counts the ones that carried fewer than
59
+ * two views — a session under GPU pressure reporting a single view where it normally reports
60
+ * two. `./viewer` replays its last good stereo frame for those rather than clearing (web#12);
61
+ * a rising ratio is the machine telling you the session is falling back, and is worth
62
+ * surfacing before it turns into a bug report about "blinking".
63
+ *
64
+ * Scene windows only — image/video windows always report `{ frames: 0, monoFrames: 0 }`.
65
+ */
66
+ stats(): { frames: number; monoFrames: number };
46
67
  }
47
68
 
48
69
  /** An open inline-3D session you add weaved windows to. Returned by {@link createInline3D}. */
@@ -84,9 +105,17 @@ export interface Inline3D {
84
105
  * Register a PAGE-GLOBAL 2D overlay (a fixed/sticky header, a floating toolbar) excluded from
85
106
  * EVERY window's weave and re-applied when a window lazily re-activates. Register once instead
86
107
  * of calling {@link TileHandle.exclude} per tile. No-op without overlay exclusion (browser#18).
108
+ *
109
+ * @deprecated Legacy-browser mechanism. Where {@link inline3dOcclusionByDrawOrder} is true,
110
+ * page chrome occludes every tile by itself: the element is stored and nothing is done to it
111
+ * (no `will-change` promotion). Harmless everywhere; still required on older browsers.
87
112
  */
88
113
  addGlobalOverlay(el: Element): void;
89
- /** Stop treating `el` as a page-global overlay and drop it from every live window. */
114
+ /**
115
+ * Stop treating `el` as a page-global overlay and drop it from every live window.
116
+ *
117
+ * @deprecated See {@link Inline3D.addGlobalOverlay} — no-op with draw-order occlusion.
118
+ */
90
119
  removeGlobalOverlay(el: Element): void;
91
120
 
92
121
  /** Close the session and remove every window. */
@@ -111,6 +140,18 @@ export interface CreateInline3DOptions {
111
140
  lazy?: boolean;
112
141
  /** IntersectionObserver margin for lazy mode (default `"50% 0px"`). */
113
142
  rootMargin?: string;
143
+ /**
144
+ * Auto-exclude page chrome (default `true`): sticky/fixed elements near the top of
145
+ * the DOM (headers, toolbars) are registered as page-global overlays automatically —
146
+ * the bar plus its text/replaced descendants — so woven windows scroll UNDER the
147
+ * chrome with no per-app wiring. Opt an element (and its subtree) out with
148
+ * `data-inline3d-no-overlay`; set `false` to manage chrome exclusively via
149
+ * `addGlobalOverlay()` / `data-inline3d-overlay`.
150
+ *
151
+ * Ignored where {@link inline3dOcclusionByDrawOrder} is true: nothing is scanned and the
152
+ * SDK never touches your DOM's `will-change`, because the chrome already occludes the tiles.
153
+ */
154
+ autoChrome?: boolean;
114
155
  }
115
156
 
116
157
  /** The return of {@link startInline3D}. */
@@ -132,12 +173,27 @@ export interface StartInline3DResult {
132
173
  export function inline3DAvailable(): boolean;
133
174
 
134
175
  /**
135
- * True when this browser supports 2D-overlay exclusion (browser#18) putting a 2D element ON a
136
- * woven tile so it composites as crisp 2D over the woven 3D. Implies {@link inline3DAvailable}.
137
- * Sync + cheap.
176
+ * True when a 2D element painted ON a woven tile composites as crisp 2D over the woven 3D
177
+ * instead of being woven by declaration (browser#18 overlay exclusion) or automatically
178
+ * ({@link inline3dOcclusionByDrawOrder}). Same answer on both generations, so it stays true on
179
+ * a draw-order-occlusion browser. Implies {@link inline3DAvailable}. Sync + cheap.
138
180
  */
139
181
  export function inline3dOverlaySupported(): boolean;
140
182
 
183
+ /**
184
+ * True when the browser occludes woven tiles with 2D content AUTOMATICALLY — anything that
185
+ * paints over a tile (header, badge, dropdown, translucent scrim) composites per-pixel by draw
186
+ * order, with nothing declared. When true this SDK's exclusion machinery is off: `autoChrome`
187
+ * does not scan, `data-inline3d-overlay` is not watched, and {@link TileHandle.exclude} /
188
+ * {@link Inline3D.addGlobalOverlay} are accepted but do nothing (no `will-change` promotion).
189
+ *
190
+ * You do not have to branch on it — the legacy calls are harmless where it is true and still
191
+ * required where it is false. Branch only to skip work of your own. Reads a readonly capability
192
+ * flag on `XRDisplayLayer`, never a version or UA string, and is `false` on any browser that
193
+ * does not expose the flag (the safe answer: the legacy path runs).
194
+ */
195
+ export function inline3dOcclusionByDrawOrder(): boolean;
196
+
141
197
  /** Open the page's inline-3D session and return a manager you add windows to. */
142
198
  export function createInline3D(
143
199
  opts?: CreateInline3DOptions,
@@ -159,6 +215,19 @@ export function startInline3D(
159
215
  // XRDisplayLayer is a DisplayXR-Browser extension to WebXR; declare the minimum the SDK exposes.
160
216
  export interface XRDisplayLayer {
161
217
  getViewport(view: XRView): { x: number; y: number; width: number; height: number } | null;
218
+ /**
219
+ * @deprecated Legacy-browser overlay exclusion (browser#18). Present-but-no-op on a browser
220
+ * with draw-order occlusion, which is exactly why its presence cannot be used to detect the
221
+ * generation — use {@link inline3dOcclusionByDrawOrder} (i.e. `occlusionByDrawOrder`).
222
+ */
162
223
  excludeElement?(el: Element): void;
224
+ /** @deprecated See {@link XRDisplayLayer.excludeElement}. */
225
+ unexcludeElement?(el: Element): void;
226
+ /**
227
+ * Readonly capability flag: `true` when this browser composites 2D over woven 3D per-pixel by
228
+ * draw order, making overlay exclusion unnecessary. Optional because it is absent on every
229
+ * browser shipped so far — the SDK treats absent as `false` and runs the legacy path.
230
+ */
231
+ readonly occlusionByDrawOrder?: boolean;
163
232
  close(): void;
164
233
  }
@@ -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
+ }