@displayxr/inline3d 1.0.0 → 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
@@ -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,9 +39,18 @@ 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;
46
55
  }
47
56
 
@@ -84,9 +93,17 @@ export interface Inline3D {
84
93
  * Register a PAGE-GLOBAL 2D overlay (a fixed/sticky header, a floating toolbar) excluded from
85
94
  * EVERY window's weave and re-applied when a window lazily re-activates. Register once instead
86
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.
87
100
  */
88
101
  addGlobalOverlay(el: Element): void;
89
- /** Stop treating `el` as a page-global overlay and drop it from every live window. */
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
+ */
90
107
  removeGlobalOverlay(el: Element): void;
91
108
 
92
109
  /** Close the session and remove every window. */
@@ -111,6 +128,18 @@ export interface CreateInline3DOptions {
111
128
  lazy?: boolean;
112
129
  /** IntersectionObserver margin for lazy mode (default `"50% 0px"`). */
113
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;
114
143
  }
115
144
 
116
145
  /** The return of {@link startInline3D}. */
@@ -132,12 +161,27 @@ export interface StartInline3DResult {
132
161
  export function inline3DAvailable(): boolean;
133
162
 
134
163
  /**
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.
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.
138
168
  */
139
169
  export function inline3dOverlaySupported(): boolean;
140
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
+
141
185
  /** Open the page's inline-3D session and return a manager you add windows to. */
142
186
  export function createInline3D(
143
187
  opts?: CreateInline3DOptions,
@@ -159,6 +203,19 @@ export function startInline3D(
159
203
  // XRDisplayLayer is a DisplayXR-Browser extension to WebXR; declare the minimum the SDK exposes.
160
204
  export interface XRDisplayLayer {
161
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
+ */
162
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;
163
220
  close(): void;
164
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
+ }