@displayxr/inline3d 1.1.1 → 1.2.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 CHANGED
@@ -5,6 +5,109 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.2.1 — 2026-08-25
9
+
10
+ ### Fixed
11
+
12
+ - **`./viewer` wheel zoom is proportional and eased.** It scaled by the delta's SIGN only — a flat
13
+ 8% step per wheel event — which is about right for one mouse notch and badly wrong for a
14
+ trackpad, where a single two-finger flick emits dozens of small events. Measured: a 20-event
15
+ flick reached **5.3x** and a longer swipe hit the 6x clamp, on gestures the user reads as gentle.
16
+
17
+ The handler now scales by magnitude, normalises `deltaMode` (Chrome reports pixels; Firefox
18
+ reports LINES for a mouse wheel, so identical hardware was ~2x more sensitive in one browser),
19
+ clamps per event against OS pointer-acceleration spikes, and applies the zoom as `exp()` so equal
20
+ deltas give equal ratios in both directions — `1 + d` and `1 - d` are not inverses, and the
21
+ asymmetry was felt as zooming out being weaker than zooming in. Zoom then eases toward its target
22
+ on the same damping curve yaw and pitch already used, so a notch glides instead of stepping.
23
+
24
+ Same gestures after: trackpad flick **1.13x**, mouse 3 notches **1.35x**, and Chrome and Firefox
25
+ now agree per notch. *(preview tier — no API change)*
26
+
27
+ ## 1.2.0 — 2026-08-20
28
+
29
+ ### Added
30
+
31
+ - **`./model` lights meshes with an image-based environment by default (`environment: 'room'`).**
32
+ The previous default, `studio`, is three punctual lights and nothing else. A punctual light
33
+ contributes a specular highlight but does not fill a metallic BRDF, so a `metalness: 1` surface
34
+ sampled an empty environment and resolved to **black** — chrome rendering as a dark disc, glass
35
+ lenses as opaque holes. The failure reads as a corrupt asset rather than a lighting choice, and
36
+ it cost real debugging time: two perfectly good models were discarded as broken before the cause
37
+ was found.
38
+
39
+ `environment` now takes `'room' | 'studio' | 'none'` and defaults to `'room'`, which bakes a
40
+ PMREM from three's procedural `RoomEnvironment` — generated in memory, so this buys IBL with no
41
+ HDRI to fetch and keeps an offline or kiosk build free of a CDN in its critical path. `'studio'`
42
+ remains for wholly dielectric matte content, and an explicit `envMap` still overrides both. Only
43
+ meshes are affected: splats carry their own baked radiance and never enter this path.
44
+
45
+ **This moves your pixels.** Metal and glass gain reflections they should always have had, and
46
+ dielectric surfaces pick up a softer ambient. Pass `environment: 'studio'` to keep 1.1.1's look.
47
+ *(preview tier — changed default)*
48
+
49
+ - **`./model` loads compressed glTF — Draco, meshopt and KTX2/Basis.** `addModel()` resolved
50
+ `GLTFLoader` and called a bare `new Loader().loadAsync(src)`, so any asset carrying a compression
51
+ extension threw outright: `"No DRACOLoader instance provided."` for
52
+ `KHR_draco_mesh_compression`, `"setMeshoptDecoder must be called before loading compressed
53
+ files"` for `EXT_meshopt_compression`, and a failed texture for `KHR_texture_basisu`. These are
54
+ hard failures, not degraded loads, and Draco is close to universal in catalogue GLBs — so the
55
+ module's own premise, that a retailer's existing glTF renders unchanged, was false for most real
56
+ files.
57
+
58
+ `addModel` now reads the asset's `extensionsUsed` / `extensionsRequired` **before** parsing and
59
+ attaches exactly the decoders it declares. The inspection is a single `fetch` whose bytes go
60
+ straight to `loader.parse()`, so it replaces the loader's own request rather than adding one, and
61
+ an asset that declares no compression imports nothing, constructs nothing and costs exactly what
62
+ it did in 1.1.1 (measured: zero decoder requests, one request for the asset). Decoders are shared
63
+ across tiles and ref-counted by configuration, so a grid of twelve products stands up one Draco
64
+ worker pool and one tile's `remove()` cannot tear down the pool the other eleven are decoding on.
65
+ *(preview tier — additive)*
66
+
67
+ - **`decoderPath`, and the reason it is not a CDN.** three ships the Draco decoder and the Basis
68
+ transcoder as *runtime* files that `DRACOLoader` / `KTX2Loader` fetch at decode time, so
69
+ something has to say where they are. The default is **your own origin** —
70
+ `{draco:'/draco/', basis:'/basis/'}`, the layout `cp -r node_modules/three/examples/jsm/libs/…`
71
+ produces — and there is deliberately **no CDN fallback**: pages built on this SDK include offline
72
+ kiosk builds, and a default that reached for `cdn.jsdelivr.net` the first time someone opened a
73
+ compressed product would make the page's offline story depend on one asset's compression setting.
74
+ A string is a parent directory holding both; an object overrides either key.
75
+ `EXT_meshopt_compression` needs nothing served — its decoder is pure JS. *(preview tier —
76
+ additive)*
77
+
78
+ - **`DRACOLoader` / `KTX2Loader` / `meshoptDecoder` injection**, mirroring the existing
79
+ `GLTFLoader` escape. A **class** is constructed for you and pointed at `decoderPath`; an
80
+ **instance** is used exactly as you configured it and its decoder path is left alone — rewriting
81
+ it would defeat the one thing people inject for. `KTX2Loader.detectSupport()` is called for you
82
+ with the tile's own `WebGLRenderer` (`SceneViewer` owns it, and it exists synchronously by the
83
+ time the asset lands), and re-called per tile on a shared instance. *(preview tier — additive)*
84
+
85
+ - **A failure message aimed at the page author.** A missing or mis-served decoder rejects
86
+ `handle.ready` with an error naming the **glTF extension**, the **option that fixes it** and the
87
+ **path it actually looked in** — plus `err.gltfExtension` / `err.decoder` for programmatic
88
+ handling. three's own message names a class the page never mentions and says nothing about the
89
+ two things that resolve it. The 404 case reads: *needs the "KHR_draco_mesh_compression" decoder
90
+ (Draco mesh compression) and it could not be used … Currently looking in "/draco/" — check that
91
+ it is actually served … Underlying error: fetch for ".../draco_wasm_wrapper.js" responded with
92
+ 404*. *(preview tier — additive)*
93
+
94
+ - **KTX2's silent failure is now loud.** `GLTFLoader` swallows a texture-load rejection, so a
95
+ mis-served Basis transcoder resolved a model with **zero textures and no error** — seven
96
+ compressed textures became none and `ready` resolved (measured on three r180). `addModel` now
97
+ loads the transcoder eagerly via `KTX2Loader.init()` once an asset declares
98
+ `KHR_texture_basisu`, which converts that into the named rejection above. *(preview tier)*
99
+
100
+ - **`samples/model/` gains a Draco tile**, with three's Draco decoder served out of this repo at
101
+ `vendor/draco/` and the sample passing `decoderPath` — because the site is hosted under a path
102
+ prefix, which is exactly the case where the absolute default 404s. The tile is the same
103
+ `addModel` call as the others; only the asset differs. Duck (CC0, Khronos glTF-Sample-Assets).
104
+
105
+ ### Fixed
106
+
107
+ - **`remove()` releases compressed textures.** `disposeTree` disposed geometries and materials but
108
+ not the materials' texture maps, which is real GPU memory as soon as KTX2 is in play and a
109
+ catalogue churns through it. *(preview tier)*
110
+
8
111
  ## 1.1.1 — 2026-08-20
9
112
 
10
113
  ### Fixed
package/README.md CHANGED
@@ -24,6 +24,9 @@ npm install @displayxr/inline3d
24
24
  import { createInline3D } from '@displayxr/inline3d';
25
25
  import { EyeCamera, EdgeFeather } from '@displayxr/inline3d/three'; // optional three.js glue
26
26
  import { addSplat } from '@displayxr/inline3d/splat'; // experimental: 3DGS in a tile
27
+ import { addModel } from '@displayxr/inline3d/model'; // experimental: glTF/GLB in a tile
28
+ // (Draco / meshopt / KTX2 too —
29
+ // you serve the decoder files)
27
30
  import { SceneViewer } from '@displayxr/inline3d/viewer'; // experimental: framing + orbit
28
31
  ```
29
32
 
@@ -78,12 +81,18 @@ samples/
78
81
  windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
79
82
  each woven with one SDK call, all on one session
80
83
  splat/ a 3D Gaussian splat in a tile, auto-framed, with a 2D price plate over it
84
+ model/ a glTF mesh, a mesh+splat scene, and a Draco-COMPRESSED glTF in three tiles
85
+ composition/ the 14-case 2D/3D overlap matrix — demo AND standing hardware regression
86
+ surface; red cases ship red (see samples/README.md)
87
+ vendor/draco/ three's Draco decoder, served for samples/model (compressed glTF needs it)
81
88
  js/
82
89
  inline3d.js the SDK: createInline3D() → { addImage, addVideo, addScene }, feature-detect,
83
90
  SBS buffer management, and a lazy create/close lifecycle for many windows
84
91
  inline3d-three.js optional three.js helper (EyeCamera: off-axis projection from the session's eyes)
85
92
  inline3d-viewer.js experimental: SceneViewer — framing, orbit, idle turntable, mono fallback
86
93
  inline3d-splat.js experimental: addSplat() — a Gaussian splat window via Spark
94
+ inline3d-model.js experimental: addModel() — a glTF/GLB window; wires Draco / meshopt / KTX2
95
+ from what the asset declares (you serve the decoder files — see the guide)
87
96
  docs/
88
97
  authoring-inline-3d.md the authoring guide
89
98
  ```
@@ -21,10 +21,29 @@
21
21
  // "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
22
22
  //
23
23
  // You can also hand the class in directly (`opts.GLTFLoader`) and skip the specifier entirely.
24
+ //
25
+ // ── COMPRESSED ASSETS ───────────────────────────────────────────────────────────────────────
26
+ // "Render the catalogue unchanged" is only true if the catalogue's actual files load, and a real
27
+ // e-commerce GLB is nearly always Draco-compressed. A bare GLTFLoader cannot decode any of the
28
+ // three compression extensions — it throws, it does not degrade — so this module inspects the
29
+ // asset's `extensionsUsed` before parsing and attaches exactly the decoders it declares:
30
+ //
31
+ // KHR_draco_mesh_compression → DRACOLoader (needs decoder files SERVED by your page)
32
+ // KHR_texture_basisu → KTX2Loader (needs transcoder files SERVED by your page)
33
+ // EXT_meshopt_compression → MeshoptDecoder (pure JS, nothing to serve)
34
+ //
35
+ // Nothing is imported or instantiated for an asset that declares none of them — an uncompressed
36
+ // GLB costs exactly what it did before. The decoder BINARIES are deliberately not fetched from a
37
+ // CDN: pages that ship this SDK include offline kiosk builds, so the default looks for them on
38
+ // your own origin (`/draco/`, `/basis/`) and `opts.decoderPath` moves that. See
39
+ // docs/authoring-inline-3d.md#compressed-gltf.
24
40
 
25
41
  import * as THREE from 'three';
26
42
  import { EyeCamera, EdgeFeather } from './inline3d-three.js';
27
43
  import { SceneViewer } from './inline3d-viewer.js';
44
+ // Procedural — built in memory, no asset to serve. Imported eagerly rather than lazily because it
45
+ // is the default lighting path, so deferring it would only add a frame of unlit content.
46
+ import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
28
47
 
29
48
  /** Cached across calls so a grid of models resolves the loader module once. */
30
49
  let _GLTFLoader = null;
@@ -38,6 +57,246 @@ async function resolveLoader(injected) {
38
57
  return _GLTFLoader;
39
58
  }
40
59
 
60
+ // ── the three compression extensions, and everything needed to talk about them ───────────────
61
+
62
+ /**
63
+ * One entry per decoder: the glTF extension that demands it, where its class lives, where its
64
+ * runtime files live, and which option overrides each. The error messages are generated from
65
+ * this table, so a message can never name an option that does not exist.
66
+ */
67
+ const DECODERS = {
68
+ draco: {
69
+ ext: 'KHR_draco_mesh_compression',
70
+ label: 'Draco mesh compression',
71
+ module: 'three/addons/loaders/DRACOLoader.js',
72
+ exportName: 'DRACOLoader',
73
+ option: 'DRACOLoader',
74
+ pathKey: 'draco',
75
+ files: 'three/examples/jsm/libs/draco/',
76
+ attach: (loader, d) => loader.setDRACOLoader(d),
77
+ },
78
+ ktx2: {
79
+ ext: 'KHR_texture_basisu',
80
+ label: 'KTX2 / Basis Universal textures',
81
+ module: 'three/addons/loaders/KTX2Loader.js',
82
+ exportName: 'KTX2Loader',
83
+ option: 'KTX2Loader',
84
+ pathKey: 'basis',
85
+ files: 'three/examples/jsm/libs/basis/',
86
+ attach: (loader, d) => loader.setKTX2Loader(d),
87
+ },
88
+ meshopt: {
89
+ ext: 'EXT_meshopt_compression',
90
+ label: 'meshopt compression',
91
+ module: 'three/addons/libs/meshopt_decoder.module.js',
92
+ exportName: 'MeshoptDecoder',
93
+ option: 'meshoptDecoder',
94
+ pathKey: null, // pure JS + inlined wasm; nothing for the page to serve
95
+ files: null,
96
+ attach: (loader, d) => loader.setMeshoptDecoder(d),
97
+ },
98
+ };
99
+
100
+ const DECODER_KINDS = /** @type {const} */ (['draco', 'ktx2', 'meshopt']);
101
+
102
+ /**
103
+ * Where the decoder binaries are expected on YOUR origin. Not a CDN, on purpose — see the header.
104
+ * `/draco/` and `/basis/` are the paths three's own examples use, and the ones every "copy these
105
+ * two folders into public/" recipe on the web produces.
106
+ */
107
+ const DEFAULT_DECODER_PATH = { draco: '/draco/', basis: '/basis/' };
108
+
109
+ /** `'/vendor/'` → `{draco:'/vendor/draco/', basis:'/vendor/basis/'}`; an object overrides per-key. */
110
+ function normalizeDecoderPath(v) {
111
+ if (!v) return { ...DEFAULT_DECODER_PATH };
112
+ if (typeof v === 'string') {
113
+ const base = v.endsWith('/') ? v : `${v}/`;
114
+ return { draco: `${base}draco/`, basis: `${base}basis/` };
115
+ }
116
+ return {
117
+ draco: v.draco || DEFAULT_DECODER_PATH.draco,
118
+ basis: v.basis || v.ktx2 || DEFAULT_DECODER_PATH.basis,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Decoders are SHARED across tiles, ref-counted by configuration key. A DRACOLoader owns a worker
124
+ * pool; a catalogue grid of twelve products must not stand up twelve of them, and one tile's
125
+ * `remove()` must not tear down the pool the other eleven are decoding on. Injected decoders never
126
+ * enter this map — they belong to the caller, who disposes them.
127
+ */
128
+ const _shared = new Map(); // key -> { refs, p: Promise<decoder> }
129
+
130
+ function acquireShared(key, make) {
131
+ let e = _shared.get(key);
132
+ if (!e) {
133
+ e = { refs: 0, p: make() };
134
+ // A failed build must not poison every later attempt with a rejected promise.
135
+ e.p.catch(() => _shared.delete(key));
136
+ _shared.set(key, e);
137
+ }
138
+ e.refs++;
139
+ return e.p;
140
+ }
141
+
142
+ function releaseShared(key) {
143
+ const e = _shared.get(key);
144
+ if (!e || --e.refs > 0) return;
145
+ _shared.delete(key);
146
+ e.p.then((d) => d?.dispose?.()).catch(() => {});
147
+ }
148
+
149
+ /**
150
+ * Import (or accept) a decoder and configure it. `injected` may be a class OR a ready instance —
151
+ * MeshoptDecoder is a namespace object rather than a class, and a caller who already holds a
152
+ * configured DRACOLoader should be able to hand THAT in rather than a constructor.
153
+ *
154
+ * `decoderPath` is applied only to instances WE construct. An instance you hand in is used exactly
155
+ * as you configured it: silently rewriting its decoder path would make injection useless for the
156
+ * one thing people inject for, which is pointing it somewhere unusual.
157
+ */
158
+ async function buildDecoder(kind, paths, injected) {
159
+ const spec = DECODERS[kind];
160
+ let thing = injected;
161
+ if (!thing) {
162
+ const mod = await import(spec.module);
163
+ thing = mod[spec.exportName];
164
+ if (!thing) throw new Error(`${spec.module} has no export "${spec.exportName}"`);
165
+ }
166
+ if (typeof thing !== 'function') return thing; // already an instance (or the meshopt namespace)
167
+ const d = new thing();
168
+ if (kind === 'draco') d.setDecoderPath?.(paths.draco);
169
+ if (kind === 'ktx2') d.setTranscoderPath?.(paths.basis);
170
+ return d;
171
+ }
172
+
173
+ /** @returns {Promise<{decoder:object, key:string|null}>} `key` is set when the tile took a share. */
174
+ async function getDecoder(kind, paths, injected, renderer) {
175
+ let decoder;
176
+ let key = null;
177
+ if (injected) {
178
+ decoder = await buildDecoder(kind, paths, injected);
179
+ } else {
180
+ const p = DECODERS[kind].pathKey;
181
+ key = `${kind}|${p ? paths[p] : ''}`;
182
+ decoder = await acquireShared(key, () => buildDecoder(kind, paths, null));
183
+ }
184
+ if (kind === 'ktx2') {
185
+ // MUST run with the renderer that will sample the texture: detectSupport reads the context's
186
+ // compressed-texture extensions to pick a transcode target. Re-run per tile because the shared
187
+ // instance may have been built against a sibling's context; it is a cheap flag assignment.
188
+ decoder.detectSupport?.(renderer);
189
+ // And then force the transcoder to load NOW, because KTX2 is the one decoder that fails
190
+ // SILENTLY: GLTFLoader swallows a texture-load rejection, so a mis-served transcoder resolves
191
+ // a model with ZERO textures and no error at all (measured on three r180 — 7 textures became
192
+ // 0, `ready` resolved). init() turns that into a rejection naming the URL it could not fetch.
193
+ await decoder.init?.();
194
+ }
195
+ return { decoder, key };
196
+ }
197
+
198
+ // ── asset inspection ─────────────────────────────────────────────────────────────────────────
199
+
200
+ const GLB_MAGIC = 0x46546c67; // 'glTF', little-endian
201
+ const GLB_CHUNK_JSON = 0x4e4f534a; // 'JSON'
202
+
203
+ /**
204
+ * Fetch the asset ONCE and read its glTF JSON header. The bytes are handed to `loader.parse()`
205
+ * afterwards, so inspecting costs no extra request — this replaces the loader's own fetch rather
206
+ * than adding to it.
207
+ */
208
+ async function fetchAndInspect(src) {
209
+ const res = await fetch(src);
210
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${src}`);
211
+ const buffer = await res.arrayBuffer();
212
+ return { buffer, json: gltfJsonOf(buffer) };
213
+ }
214
+
215
+ function gltfJsonOf(buffer) {
216
+ const view = new DataView(buffer);
217
+ const text = new TextDecoder();
218
+ if (buffer.byteLength >= 20 && view.getUint32(0, true) === GLB_MAGIC) {
219
+ const len = view.getUint32(12, true);
220
+ if (view.getUint32(16, true) !== GLB_CHUNK_JSON) return null;
221
+ return JSON.parse(text.decode(new Uint8Array(buffer, 20, len)));
222
+ }
223
+ return JSON.parse(text.decode(new Uint8Array(buffer))); // a .gltf is plain JSON
224
+ }
225
+
226
+ /** `extensionsUsed` ∪ `extensionsRequired`. Draco appears in both; meshopt sometimes only in one. */
227
+ function declaredExtensions(json) {
228
+ const out = new Set();
229
+ for (const key of ['extensionsUsed', 'extensionsRequired']) {
230
+ const list = json?.[key];
231
+ if (Array.isArray(list)) for (const name of list) out.add(name);
232
+ }
233
+ return out;
234
+ }
235
+
236
+ function urlBaseOf(src) {
237
+ const extract = THREE.LoaderUtils?.extractUrlBase;
238
+ if (extract) return extract(src);
239
+ const i = src.lastIndexOf('/');
240
+ return i < 0 ? './' : src.slice(0, i + 1);
241
+ }
242
+
243
+ // ── errors that tell a page author what to do ────────────────────────────────────────────────
244
+
245
+ /**
246
+ * three's own messages for these failures ("No DRACOLoader instance provided.") name a class the
247
+ * page never mentions and say nothing about the two things that actually fix it: which option to
248
+ * pass, and which files to serve. This builds the message that does.
249
+ *
250
+ * @param {string} kind a key of DECODERS
251
+ * @param {string} src
252
+ * @param {{draco:string,basis:string}} paths
253
+ * @param {unknown} cause
254
+ * @param {boolean} inspected whether we managed to read the asset's extension list
255
+ */
256
+ function decoderError(kind, src, paths, cause, inspected) {
257
+ const spec = DECODERS[kind];
258
+ const lines = [
259
+ `[inline3d/model] ${src} needs the "${spec.ext}" decoder (${spec.label}) and it could not be used.`,
260
+ ];
261
+ if (spec.files) {
262
+ const dir = spec.pathKey;
263
+ const where = paths[dir];
264
+ lines.push(
265
+ `Serve three's decoder files from your own origin and point addModel at them:`,
266
+ ` cp -r node_modules/${spec.files} <web-root>${where.startsWith('/') ? where : `/${dir}/`}`,
267
+ ` addModel(wall, canvas, src, { decoderPath: { ${dir}: '${where}' } })`,
268
+ `Currently looking in "${where}" — check that it is actually served (a 404 there fails exactly like this).`,
269
+ );
270
+ } else {
271
+ lines.push(
272
+ `Nothing to serve for this one — the meshopt decoder is pure JS, so this is a module-resolution failure.`,
273
+ );
274
+ }
275
+ lines.push(
276
+ `Or hand the decoder in: addModel(…, { ${spec.option}: X }) where X is ${spec.exportName} from '${spec.module}' (a class or a ready instance).`,
277
+ `On a bare importmap, '${spec.module}' additionally needs a "three/addons/" prefix mapping.`,
278
+ );
279
+ if (!inspected) {
280
+ lines.push(
281
+ `(The asset's extension list could not be read up front, so the decoder was not attached automatically.)`,
282
+ );
283
+ }
284
+ lines.push(`Underlying error: ${cause?.message || cause}`);
285
+ const err = new Error(lines.join('\n'));
286
+ err.cause = cause;
287
+ err.decoder = kind;
288
+ err.gltfExtension = spec.ext;
289
+ return err;
290
+ }
291
+
292
+ /** Which decoder is a raw three.js failure about? Used when inspection failed and three threw. */
293
+ function kindFromMessage(msg) {
294
+ if (/DRACOLoader|draco/i.test(msg)) return 'draco';
295
+ if (/KTX2Loader|basisu|basis/i.test(msg)) return 'ktx2';
296
+ if (/MeshoptDecoder|meshopt/i.test(msg)) return 'meshopt';
297
+ return null;
298
+ }
299
+
41
300
  /**
42
301
  * Load a glTF/GLB into an inline-3D window.
43
302
  *
@@ -45,11 +304,26 @@ async function resolveLoader(injected) {
45
304
  * @param {HTMLCanvasElement} canvas
46
305
  * @param {string} src URL of a .glb / .gltf.
47
306
  * @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.
307
+ * @param {'room'|'studio'|'none'} [opts.environment='room'] how the mesh is lit. Meshes arrive
308
+ * unlit otherwise — unlike splats, which carry their own baked appearance.
309
+ * - `room` (default) bakes an image-based environment from three's procedural
310
+ * RoomEnvironment. Metal and glass NEED this: a punctual light contributes a specular
311
+ * dot but does not fill a metallic BRDF, so under `studio` a `metalness: 1` surface
312
+ * samples an empty environment and resolves to black. Procedural, so it costs no HDRI
313
+ * fetch and an offline build stays offline.
314
+ * - `studio` is the older three-point punctual rig. Cheaper, and fine for wholly dielectric
315
+ * matte content, but it is what makes chrome render as a dark hole.
316
+ * @param {object} [opts.envMap] a PMREM-processed environment texture of your own. Overrides
317
+ * `environment` entirely — pass this when you want the product lit by a specific room.
52
318
  * @param {unknown} [opts.GLTFLoader] hand in the class instead of resolving `three/addons/`.
319
+ * @param {string|{draco?:string,basis?:string}} [opts.decoderPath] where YOUR PAGE serves three's
320
+ * Draco decoder and Basis transcoder (default `{draco:'/draco/', basis:'/basis/'}`). A
321
+ * string is treated as a parent directory holding `draco/` and `basis/`. Never a CDN by
322
+ * default: an offline build must not depend on one.
323
+ * @param {unknown} [opts.DRACOLoader] DRACOLoader class or instance, instead of `three/addons/`.
324
+ * @param {unknown} [opts.KTX2Loader] KTX2Loader class or instance. `detectSupport()` is called
325
+ * for you with this tile's renderer.
326
+ * @param {unknown} [opts.meshoptDecoder] MeshoptDecoder namespace, instead of `three/addons/`.
53
327
  * @returns {object} the same handle shape as addSplat: a TileHandle plus `viewer`, `model`,
54
328
  * `setPose`, `resetPose`, `frame`, and `ready`.
55
329
  */
@@ -65,12 +339,19 @@ export function addModel(wall, canvas, src, opts = {}) {
65
339
  fitSweep = true,
66
340
  renderScale = 1,
67
341
  feather = 0,
68
- environment = 'studio',
342
+ environment = 'room',
69
343
  envMap = null,
70
344
  GLTFLoader: injectedLoader = null,
345
+ decoderPath = null,
346
+ DRACOLoader: injectedDraco = null,
347
+ KTX2Loader: injectedKtx2 = null,
348
+ meshoptDecoder: injectedMeshopt = null,
71
349
  observe,
72
350
  } = opts;
73
351
 
352
+ const paths = normalizeDecoderPath(decoderPath);
353
+ const injected = { draco: injectedDraco, ktx2: injectedKtx2, meshopt: injectedMeshopt };
354
+
74
355
  const viewer = new SceneViewer(THREE, canvas, {
75
356
  virtualDisplayHeight,
76
357
  fit,
@@ -84,8 +365,12 @@ export function addModel(wall, canvas, src, opts = {}) {
84
365
  }).useEyeCamera(EyeCamera, EdgeFeather);
85
366
 
86
367
  if (envMap) viewer.scene.environment = envMap;
368
+ else if (environment === 'room') addRoomEnvironment(viewer);
87
369
  else if (environment === 'studio') addStudioLights(viewer.scene);
88
370
 
371
+ /** Shared-decoder keys this tile holds a reference to, released in remove(). */
372
+ const held = [];
373
+
89
374
  const out = {
90
375
  viewer,
91
376
  model: null,
@@ -96,6 +381,7 @@ export function addModel(wall, canvas, src, opts = {}) {
96
381
  handle?.remove();
97
382
  viewer.dispose();
98
383
  if (out.model) disposeTree(out.model);
384
+ while (held.length) releaseShared(held.pop());
99
385
  },
100
386
  exclude: (el) => handle?.exclude(el),
101
387
  unexclude: (el) => handle?.unexclude(el),
@@ -115,7 +401,49 @@ export function addModel(wall, canvas, src, opts = {}) {
115
401
 
116
402
  out.ready = (async () => {
117
403
  const Loader = await resolveLoader(injectedLoader);
118
- const gltf = await new Loader().loadAsync(src);
404
+ const loader = new Loader();
405
+
406
+ // Read the header before parsing, so decoders are attached from what the asset DECLARES
407
+ // rather than from a guess or from a failure. If this can't be done (an exotic URL scheme,
408
+ // a CORS setup fetch dislikes) we fall back to the loader's own fetch and rely on the
409
+ // message-sniffing catch below — the guidance survives, the laziness does not.
410
+ let inspected = null;
411
+ try {
412
+ inspected = await fetchAndInspect(src);
413
+ } catch (err) {
414
+ console.debug('[inline3d/model] could not inspect', src, '— falling back to loadAsync', err);
415
+ }
416
+
417
+ const declared = inspected?.json ? declaredExtensions(inspected.json) : null;
418
+ const wanted = DECODER_KINDS.filter(
419
+ (k) => injected[k] || (declared ? declared.has(DECODERS[k].ext) : false),
420
+ );
421
+
422
+ for (const kind of wanted) {
423
+ let got;
424
+ try {
425
+ got = await getDecoder(kind, paths, injected[kind], viewer.renderer);
426
+ } catch (err) {
427
+ throw decoderError(kind, src, paths, err, !!declared);
428
+ }
429
+ if (got.key) held.push(got.key);
430
+ DECODERS[kind].attach(loader, got.decoder);
431
+ }
432
+
433
+ let gltf;
434
+ try {
435
+ gltf = inspected
436
+ ? await new Promise((res, rej) => loader.parse(inspected.buffer, urlBaseOf(src), res, rej))
437
+ : await loader.loadAsync(src);
438
+ } catch (err) {
439
+ // A decoder that was attached can still fail at decode time — almost always because its
440
+ // files 404 at `decoderPath`, and three's message then carries the URL it could not fetch,
441
+ // which is why the message is asked first. Attributing by elimination is only safe with a
442
+ // single candidate; with none or several, the original error is the honest answer.
443
+ const kind = kindFromMessage(String(err?.message || err)) || (wanted.length === 1 ? wanted[0] : null);
444
+ throw kind ? decoderError(kind, src, paths, err, !!declared) : err;
445
+ }
446
+
119
447
  out.model = gltf.scene;
120
448
  viewer.content.add(gltf.scene);
121
449
 
@@ -144,6 +472,43 @@ function boundsOf(object3d) {
144
472
  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
473
  }
146
474
 
475
+ /**
476
+ * Bake an image-based environment from three's procedural RoomEnvironment.
477
+ *
478
+ * This is the default because the alternative is silently wrong. `addStudioLights` is punctual
479
+ * only, and a punctual light contributes a specular highlight without filling a metallic BRDF —
480
+ * so a `metalness: 1` surface has nothing to reflect and resolves to BLACK. Chrome bells render as
481
+ * a dark disc, glass lenses as opaque holes, and the result reads as a corrupt asset rather than a
482
+ * lighting choice. It has cost real debugging time more than once.
483
+ *
484
+ * RoomEnvironment is generated in memory — a small box of emissive panels — so this buys IBL with
485
+ * no HDRI to fetch and no CDN in the critical path, which an offline or kiosk build depends on.
486
+ *
487
+ * Deliberately deferred behind the viewer: a PMREM is baked against one renderer's GL context and
488
+ * cannot be shared across renderers, so this cannot be hoisted into a module-level constant even
489
+ * though every tile bakes an identical one. The generator is disposed immediately; the resulting
490
+ * texture is owned by the scene and released with it.
491
+ *
492
+ * Splats never come through here — they carry baked radiance and no PBR material, and an
493
+ * environment would only wash them out.
494
+ */
495
+ function addRoomEnvironment(viewer) {
496
+ const renderer = viewer?.renderer;
497
+ // No renderer means no context to bake against (a headless or not-yet-realised viewer). Fall
498
+ // back rather than throw: unlit-but-visible beats a tile that fails to appear at all.
499
+ if (!renderer) return addStudioLights(viewer.scene);
500
+
501
+ const pmrem = new THREE.PMREMGenerator(renderer);
502
+ try {
503
+ // Low blur on purpose. These are product shots, so a tighter environment keeps the highlight a
504
+ // travelling band rather than a broad wash — and a highlight that travels as the viewer moves
505
+ // is most of what separates an object from a picture of one on a head-tracked display.
506
+ viewer.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
507
+ } finally {
508
+ pmrem.dispose();
509
+ }
510
+ }
511
+
147
512
  /**
148
513
  * A neutral three-point rig. Not a substitute for a real environment map on metal or glass, but
149
514
  * it has no external dependency and no download, which matters for a tile that may be one of
@@ -160,10 +525,19 @@ function addStudioLights(scene) {
160
525
  }
161
526
 
162
527
  function disposeTree(root) {
528
+ const seen = new Set();
529
+ const dropTextures = (mat) => {
530
+ if (!mat || seen.has(mat)) return;
531
+ seen.add(mat);
532
+ // Compressed (KTX2/Basis) textures are real GPU memory and a catalogue churns through them,
533
+ // so a tile's remove() has to give them back — the material's own dispose() does not.
534
+ for (const v of Object.values(mat)) if (v && v.isTexture) v.dispose?.();
535
+ mat.dispose?.();
536
+ };
163
537
  root.traverse((o) => {
164
538
  o.geometry?.dispose?.();
165
539
  const m = o.material;
166
- if (Array.isArray(m)) m.forEach((x) => x?.dispose?.());
167
- else m?.dispose?.();
540
+ if (Array.isArray(m)) m.forEach(dropTextures);
541
+ else dropTextures(m);
168
542
  });
169
543
  }
@@ -219,6 +219,7 @@ export class SceneViewer {
219
219
 
220
220
  this._fitScale = 1;
221
221
  this._zoom = 1;
222
+ this._targetZoom = 1;
222
223
  this._yaw = 0;
223
224
  this._pitch = 0;
224
225
  this._targetYaw = 0;
@@ -346,7 +347,7 @@ export class SceneViewer {
346
347
  if (pitch !== undefined) {
347
348
  this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
348
349
  }
349
- if (zoom !== undefined) this._zoom = clamp(zoom, 0.2, 6);
350
+ if (zoom !== undefined) this._targetZoom = this._zoom = clamp(zoom, ZOOM_MIN, ZOOM_MAX);
350
351
  this._applyTransform();
351
352
  }
352
353
 
@@ -682,6 +683,14 @@ export class SceneViewer {
682
683
  const k = dt > 0 ? 1 - Math.pow(0.001, dt) : 1;
683
684
  this._yaw += (this._targetYaw - this._yaw) * k;
684
685
  this._pitch += (this._targetPitch - this._pitch) * k;
686
+ // Zoom eases on the same curve. Multiplicatively, because zoom is a ratio: approaching 2x
687
+ // linearly spends most of its time near the start and then lurches, while a ratio approach
688
+ // covers equal PERCEPTUAL steps per frame.
689
+ if (Math.abs(this._targetZoom - this._zoom) > 1e-4) {
690
+ this._zoom *= Math.pow(this._targetZoom / this._zoom, k);
691
+ } else {
692
+ this._zoom = this._targetZoom;
693
+ }
685
694
  this._applyTransform();
686
695
  }
687
696
 
@@ -725,9 +734,29 @@ export class SceneViewer {
725
734
  };
726
735
  this._onWheel = (ev) => {
727
736
  ev.preventDefault();
728
- this._zoom = clamp(this._zoom * (ev.deltaY > 0 ? 0.92 : 1.087), 0.2, 6);
737
+ // Scale by the delta's MAGNITUDE, not just its sign. The previous version applied a fixed
738
+ // 8% step per event, which is roughly right for one mouse notch and badly wrong for a
739
+ // trackpad: a two-finger flick emits dozens of small events, so an 8% step compounded per
740
+ // event sent the subject to the clamp on a gesture the user read as gentle.
741
+ //
742
+ // deltaMode has to be normalised first or the same code means different things per browser:
743
+ // Chrome reports pixels, Firefox reports LINES for a mouse wheel (deltaY 3, not 100), and
744
+ // a page-mode device would otherwise be ~200x more sensitive than a trackpad.
745
+ let px = ev.deltaY;
746
+ if (ev.deltaMode === 1) px *= WHEEL_LINE_PX;
747
+ else if (ev.deltaMode === 2) px *= WHEEL_PAGE_PX;
748
+ // OS pointer acceleration can spike a single event past 500px. Clamping per event keeps one
749
+ // hard flick from teleporting the subject while leaving the gesture's total travel intact,
750
+ // since the events keep coming.
751
+ px = clamp(px, -WHEEL_MAX_PX, WHEEL_MAX_PX);
752
+
753
+ // exp() rather than a multiply-add: zoom is a ratio, so equal deltas should give equal
754
+ // ratios in both directions. `1 + d` and `1 - d` are not inverses, and the asymmetry is
755
+ // felt as zooming out being weaker than zooming in.
756
+ this._targetZoom = clamp(this._targetZoom * Math.exp(-px * ZOOM_PER_PX), ZOOM_MIN, ZOOM_MAX);
729
757
  this._lastInput = now();
730
- this._applyTransform();
758
+ // No _applyTransform() here: _tick() eases toward the target and applies it, which is what
759
+ // makes a wheel notch glide instead of step.
731
760
  };
732
761
 
733
762
  el.style.touchAction = 'none'; // or the browser eats the drag as a scroll
@@ -749,6 +778,24 @@ export class SceneViewer {
749
778
  }
750
779
  }
751
780
 
781
+ /**
782
+ * Wheel-zoom tuning.
783
+ *
784
+ * ZOOM_PER_PX is set so one ordinary mouse notch (~100 px in Chrome) is about a 10% step, which
785
+ * puts a trackpad's 1-10 px events at a fraction of a percent each — small enough that the easing
786
+ * reads as continuous rather than as a stack of jumps.
787
+ */
788
+ // A deltaMode-1 "line" is sized to match a wheel DETENT, not a line of text. Firefox reports a
789
+ // notch as deltaY 3 in lines where Chrome reports it as ~100 in pixels, so 33 makes one physical
790
+ // notch feel the same in both; 16 (a text line) would make Firefox roughly half as responsive as
791
+ // Chrome for identical hardware.
792
+ const WHEEL_LINE_PX = 33;
793
+ const WHEEL_PAGE_PX = 400; // a "page" in deltaMode 2; rare, but it must not be unbounded
794
+ const WHEEL_MAX_PX = 120; // per-event ceiling, against OS pointer acceleration spikes
795
+ const ZOOM_PER_PX = 0.001;
796
+ const ZOOM_MIN = 0.2;
797
+ const ZOOM_MAX = 6;
798
+
752
799
  function now() {
753
800
  return typeof performance !== 'undefined' ? performance.now() : Date.now();
754
801
  }
package/model.d.ts CHANGED
@@ -25,11 +25,51 @@ export interface ModelOptions {
25
25
  renderScale?: number;
26
26
  feather?: number;
27
27
  /** Built-in three-point lighting. Meshes arrive unlit; splats do not need this. */
28
- environment?: 'studio' | 'none';
28
+ /**
29
+ * How the mesh is lit. `room` (default) bakes IBL from three's procedural RoomEnvironment —
30
+ * metal and glass need it, because a punctual rig leaves a `metalness: 1` surface with nothing
31
+ * to reflect and it renders black. `studio` is the older three-point punctual rig.
32
+ */
33
+ environment?: 'room' | 'studio' | 'none';
29
34
  /** A PMREM-processed environment texture. Better than `environment` for metal; overrides it. */
30
35
  envMap?: object;
31
36
  /** Hand in the GLTFLoader class instead of resolving it from `three/addons/`. */
32
37
  GLTFLoader?: unknown;
38
+
39
+ /**
40
+ * Where **your page** serves three's Draco decoder and Basis (KTX2) transcoder.
41
+ *
42
+ * `addModel` reads the asset's `extensionsUsed` before parsing and attaches only the decoders it
43
+ * declares, so an uncompressed model never touches any of this. But a decoder that IS needed has
44
+ * to come from somewhere, and the default is deliberately **not a CDN** — a page shipping an
45
+ * offline build must not acquire a network dependency by loading a compressed file. Copy the
46
+ * files out of `three` and serve them yourself:
47
+ *
48
+ * ```sh
49
+ * cp -r node_modules/three/examples/jsm/libs/draco/ public/draco/
50
+ * cp -r node_modules/three/examples/jsm/libs/basis/ public/basis/
51
+ * ```
52
+ *
53
+ * A string is a parent directory holding `draco/` and `basis/`; an object overrides either key.
54
+ * `EXT_meshopt_compression` needs nothing served — its decoder is pure JS.
55
+ *
56
+ * @default {draco:'/draco/', basis:'/basis/'}
57
+ */
58
+ decoderPath?: string | { draco?: string; basis?: string };
59
+ /**
60
+ * DRACOLoader class **or** a ready instance, instead of resolving `three/addons/`. A class is
61
+ * constructed and pointed at `decoderPath`; an instance is used exactly as you configured it.
62
+ */
63
+ DRACOLoader?: unknown;
64
+ /**
65
+ * KTX2Loader class or instance. `detectSupport()` is called for you with this tile's renderer,
66
+ * and the transcoder is loaded eagerly so a mis-served path throws instead of silently
67
+ * resolving a model with no textures.
68
+ */
69
+ KTX2Loader?: unknown;
70
+ /** The `MeshoptDecoder` namespace, instead of resolving `three/addons/`. Nothing to serve. */
71
+ meshoptDecoder?: unknown;
72
+
33
73
  /** Element whose visibility gates the lazy create/close lifecycle. */
34
74
  observe?: Element;
35
75
  }
@@ -55,6 +95,13 @@ export interface ModelHandle {
55
95
  /**
56
96
  * Load a glTF/GLB into an inline-3D window. Safe to call with an unsupported wall — it renders a
57
97
  * flat, orbitable view instead, so pages need no branch.
98
+ *
99
+ * Compressed assets (Draco, meshopt, KTX2/Basis) load too: the asset's declared extensions decide
100
+ * which decoders are imported, and nothing is imported for an asset that declares none. Draco and
101
+ * KTX2 additionally need their runtime files served by your page — see {@link ModelOptions.decoderPath}.
102
+ * When a decoder is needed and unavailable, `ready` rejects with an Error naming the glTF
103
+ * extension, the option that fixes it and the path it looked in; the extension is also on the
104
+ * error as `gltfExtension`.
58
105
  */
59
106
  export function addModel(
60
107
  wall: object | null | undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",