@displayxr/inline3d 1.10.1 → 1.12.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 CHANGED
@@ -5,6 +5,96 @@ 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.12.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./model`, and `./splat`'s `engine: 'playcanvas'` import path). A
11
+ page that calls `addModel` without `engine` now renders with PlayCanvas and **looks different**:
12
+ it is lit to match the Khronos glTF Sample Viewer instead of three's RoomEnvironment. Pass
13
+ `engine: 'three'` to keep 1.11 exactly. Epic [#36](https://github.com/DisplayXR/displayxr-web/issues/36);
14
+ design and measurements: [`docs/playcanvas-model-backend.md`](docs/playcanvas-model-backend.md).
15
+
16
+ ### Changed
17
+
18
+ - **`addModel` defaults to PlayCanvas** (optional peer `playcanvas >=2.22.3 <3`). The handle is
19
+ the same: `ready`, `firstWoven`, `setPose`, `resetPose`, `exclude`, `unexclude`, `remove`,
20
+ `frame`, `model`, `viewer`. On PlayCanvas `model` is the render `pc.Entity`, and the handle also
21
+ carries `backend`, `engine` (`{ app, root, camera }`) and `container`.
22
+ - **Both engines are imported dynamically** by `./model` (now `js/inline3d-model-entry.js`): a
23
+ default page loads no three, an `engine: 'three'` page loads no PlayCanvas.
24
+ - **`handle.viewer` is null until the backend module has loaded**, on both engines (1.11 set it
25
+ synchronously). Read it after `ready`, or import **`@displayxr/inline3d/model/three`**: the 1.11
26
+ module, byte for byte, synchronous `viewer` included.
27
+ - **No `engine` and no `playcanvas` installed:** the tile renders on three with one console
28
+ warning. Neither installed: `ready` rejects naming both. With no `engine`, a three.js object in
29
+ `GLTFLoader` / `DRACOLoader` / `KTX2Loader` / `envMap` selects three (a 1.11 page that injects
30
+ loaders keeps working unchanged).
31
+ - **The PlayCanvas adapters import `playcanvas` by name** (`js/inline3d-playcanvas-engine.js`), not
32
+ as a namespace, so bundlers tree-shake the engine: 2,434,536 → 1,499,851 bytes minified,
33
+ **626 → 397 KB gzip** (esbuild 0.28.2), for `addModel` and for `addSplat(…, {engine:'playcanvas'})`.
34
+
35
+ ### Added (PlayCanvas backend)
36
+
37
+ - **Lighting matched to the Khronos glTF Sample Viewer** (not to three.js): an in-memory neutral
38
+ studio fitted to the Sample Viewer's "Studio Neutral" (no asset, no licence), Khronos PBR Neutral
39
+ tone mapping, exposure 1, sRGB output, MSAA on. On-object MAE vs the Sample Viewer (/255):
40
+ DamagedHelmet 16.7 (three 36.2), Fox 6.4 (19.5), BrainStem 2.7 (26.7), MetalRoughSpheres 9.4 (26.4).
41
+ - `environment: 'neutral'` (alias of the default `'room'`), `environmentRotation` (degrees),
42
+ `envMap` as a URL (equirect .hdr/.png/.jpg) or a `pc.Texture`; `'studio'` and `'none'` as on three.
43
+ - Compressed glTF with the **same served files as three**: Draco and KTX2/Basis through the
44
+ engine's own workers, fed three's `libs/draco/` and `libs/basis/`; `decoderPath` unchanged; a
45
+ mis-served folder rejects `ready` naming the extension, the file and the option.
46
+ `EXT_meshopt_compression` via meshoptimizer's `MeshoptDecoder` (new optional peer
47
+ `meshoptimizer >=1`, loaded only for an asset that declares it; `meshoptDecoder` injects it).
48
+ - `controls: 'page'` + `setCameraPose` / `getCameraPose` / `onBeforeFrame` / `comfortDepth` on
49
+ models, as on splats (the rig converges on the model's bounds centre unless the pose says).
50
+ - `antialias`, `preserveDrawingBuffer`, `nearClip`, `farClip`, `orbitMaxDeg`, `orbitEase`, `playcanvas`.
51
+
52
+ ### Throws at call time
53
+
54
+ - An unknown `engine` or `environment`; a PlayCanvas-only option with `engine: 'three'`; a three.js
55
+ loader or texture with `engine: 'playcanvas'`; a `meshoptDecoder` without `decodeGltfBuffer`;
56
+ `setCameraPose` without `controls: 'page'`; `setPose` / `resetPose` with it.
57
+
58
+ ### Samples
59
+
60
+ - `samples/model/` (tiles A and C) and `samples/shopify/` render on the new default;
61
+ `?engine=three` switches back. Tile B stays on three (it composes a Spark splat into a three scene).
62
+
63
+ ## 1.11.0 — 2026-09-24
64
+
65
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), additively. Nothing changes
66
+ for a page that does not pass `controls: 'page'`.
67
+
68
+ ### Added
69
+
70
+ - **`controls: 'page'`: the page owns the camera.** This is for a game, or any page with its own
71
+ camera (F1000's phase 1). With `addSplat(…, { engine: 'playcanvas', controls: 'page' })` the
72
+ adapter runs no orbit, idle spin, auto-fit or focus gestures. It keeps the eye math: the
73
+ `RenderView` list, the footprint fix, near/far and the mono fallback.
74
+ - **`handle.setCameraPose(matrixWorld, { verticalFovDeg, near?, far?, convergence? })`**, called
75
+ every frame. `matrixWorld` is the page camera in the splat's own space (three.js convention).
76
+ A uniform scale is allowed: F1000 passes `inv(splatWorld) · camera.matrixWorld`, which carries
77
+ 0.4. The adapter applies the OpenCV → engine flip itself. The last call wins, and a page that
78
+ stops calling keeps its last pose. **`handle.getCameraPose()`** returns a copy of it.
79
+ - **`onBeforeFrame(frame)`** (`{ time, views, dt }`) runs once per adapter frame, before
80
+ anything renders. A pose set inside it is what that frame draws, with zero lag. A throw is
81
+ warned once and frames keep rendering.
82
+ - **Mono** renders exactly that camera: fov × canvas aspect, principal point centred.
83
+ - **3D** is the attach pattern. Each eye = `matrixWorld × view.transform`, with the runtime's
84
+ projection untouched: the page's near/far rewrite only the depth mapping. The rig is declared
85
+ every frame and is the auto-3D shim's, field for field: `type: 'camera'`, identity pose,
86
+ `verticalFov`, `convergenceDiopters = 1/d`, `metersToVirtual = comfortDepth · d / 0.5`.
87
+ **`comfortDepth`** is a new option, default 0.3, in (0, 1].
88
+ - `d` is the page's `convergence`, else the focus waterfall's. It stays fixed while the camera
89
+ moves and is re-estimated only on `setSource`. `setFocus(point)` sets `d` to the point's
90
+ distance along the view axis. `getFocus` / `onFocusChange` report the convergence point.
91
+ - `setPose` / `resetPose` throw, and `rig: 'display'` throws. `fit`, `virtualDisplayHeight`,
92
+ `orbit`, `idleSpin`, `focusInput` and the other framing/orbit knobs are ignored, named once in
93
+ a `console.info`.
94
+ - **Spark:** `controls: 'page'` throws, naming `engine: 'playcanvas'`. Spark would need
95
+ SceneViewer (shared with `./viewer` and `./model`) to take an external camera on both its
96
+ paths.
97
+
8
98
  ## 1.10.1 — 2026-09-23
9
99
 
10
100
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), and `boundsFromPositions` in
package/README.md CHANGED
@@ -34,8 +34,9 @@ No build step or bundler required — it's plain ES modules. You can also import
34
34
  URL from a CDN (jsDelivr / unpkg) without npm. The samples in this repo import the SDK by relative
35
35
  path (`./js/inline3d.js`) so they run straight off GitHub Pages; in your own app prefer the package.
36
36
 
37
- `three` and `@sparkjsdev/spark` are **optional peer dependencies** — the core is dependency-free
38
- and only the `/three`, `/viewer` and `/splat` subpaths need them. The two viewer subpaths are
37
+ `three`, `@sparkjsdev/spark`, `playcanvas` and `meshoptimizer` are **optional peer dependencies**
38
+ — the core is dependency-free and only the scene subpaths need them (`/model` needs `playcanvas`,
39
+ or `three` with `engine: 'three'`). The two viewer subpaths are
39
40
  **experimental**: they turn "one object in a tile, look around it, drag to spin" into a single
40
41
  call (auto-framing on the zero-disparity plane, orbit, idle turntable, mono fallback), but their
41
42
  API is not yet covered by the semver promise below.
@@ -48,9 +49,18 @@ tilt-and-relax orbit and `handle.engine` (the engine objects, for advanced pages
48
49
  optional peer `playcanvas` (`>=2.22.3 <3`) and reads `.sog`, `.ply` and a Streamed-SOG
49
50
  `lod-meta.json`. **Spark stays the default**; a page that never passes `engine` never loads
50
51
  `playcanvas`. What differs, and why: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md).
51
- Bundlers: the engine is a literal `import('playcanvas')`; its sort workers are Blob URLs (CSP
52
+ Bundlers: the engine is reached through a named-import module (`js/inline3d-playcanvas-engine.js`,
53
+ so the unused ~40% of the engine tree-shakes away); its sort workers are Blob URLs (CSP
52
54
  `worker-src blob:`), and esbuild needs `node:worker_threads` marked external.
53
55
 
56
+ **Models render with PlayCanvas by default (1.12).** `addModel(wall, canvas, 'chair.glb')` loads
57
+ the glTF with the PlayCanvas engine (optional peer `playcanvas`), lit to match the Khronos glTF
58
+ Sample Viewer: an in-memory neutral studio environment, Khronos PBR Neutral tone mapping,
59
+ exposure 1. `engine: 'three'` is the 1.11 three.js renderer, unchanged (also importable as
60
+ `@displayxr/inline3d/model/three`). Each engine is imported only when a tile asks for it. Draco
61
+ and KTX2 use the same served decoder folders on both engines. Design, the Sample-Viewer MAE table
62
+ and what throws: [`docs/playcanvas-model-backend.md`](docs/playcanvas-model-backend.md).
63
+
54
64
  **Large scenes stream (preview).** On `engine: 'playcanvas'`, a Streamed SOG streams: pass the URL
55
65
  of its `lod-meta.json` (or of its directory), not its bytes. The engine fetches only the chunks
56
66
  the view needs and draws at most `perf.splatBudget` of them. That budget is per tile and shared
@@ -60,6 +70,15 @@ one dense sheet that is fully on screen, so it gains nothing from streaming. How
60
70
  and budget one, with measurements:
61
71
  [`docs/playcanvas-adapter.md` §Streamed SOG](docs/playcanvas-adapter.md#streamed-sog-p2).
62
72
 
73
+ **A game owns its camera (preview).** `addSplat(wall, canvas, src, { engine: 'playcanvas',
74
+ controls: 'page', onBeforeFrame })` hands the camera to the page. Call
75
+ `handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far, convergence? })` inside
76
+ `onBeforeFrame` and that frame renders it. The SDK runs no orbit, idle spin, fit or focus
77
+ gestures. It keeps the eye math: the attach-pattern camera rig (the auto-3D shim's, with
78
+ `comfortDepth` 0.3 by default), the runtime's projections, and the mono fallback. Spark refuses
79
+ this mode. Contract and numbers:
80
+ [`docs/playcanvas-adapter.md` §controls:'page'](docs/playcanvas-adapter.md#controlspage--the-page-owns-the-camera).
81
+
63
82
  Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
64
83
  that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
65
84
 
@@ -125,7 +144,7 @@ samples/
125
144
  windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
126
145
  each woven with one SDK call, all on one session
127
146
  splat/ a 3D Gaussian splat in a tile, auto-framed, with a 2D price plate over it
128
- model/ a glTF mesh, a mesh+splat scene, and a Draco-COMPRESSED glTF in three tiles
147
+ model/ a glTF mesh, a mesh+splat scene, and a Draco-COMPRESSED glTF (PlayCanvas; ?engine=three)
129
148
  composition/ the 14-case 2D/3D overlap matrix — demo AND standing hardware regression
130
149
  surface; red cases ship red (see samples/README.md)
131
150
  vendor/draco/ three's Draco decoder, served for samples/model (compressed glTF needs it)
@@ -139,7 +158,10 @@ js/
139
158
  (`perf` cuts overdraw; a `.sog`'s `camera` block picks the view rig)
140
159
  inline3d-splat-playcanvas.js
141
160
  preview: the `engine: 'playcanvas'` backend of addSplat(), loaded on demand
142
- inline3d-model.js experimental: addModel() — a glTF/GLB window; wires Draco / meshopt / KTX2
161
+ inline3d-model-entry.js experimental: addModel() — picks the engine (PlayCanvas default)
162
+ inline3d-model-playcanvas.js the PlayCanvas model backend (Sample-Viewer lighting, engine decoders)
163
+ inline3d-model.js the three.js model backend (engine:'three'; ./model/three)
164
+ inline3d-playcanvas-engine.js named playcanvas imports for both PlayCanvas adapters (tree-shaking)
143
165
  from what the asset declares (you serve the decoder files — see the guide)
144
166
  docs/
145
167
  authoring-inline-3d.md the authoring guide
@@ -0,0 +1,242 @@
1
+ // inline3d-model-entry.js — `@displayxr/inline3d/model`: a glTF/GLB model as an inline-3D window.
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
+ // WHICH ENGINE (1.12). PlayCanvas by default; `engine: 'three'` is the 1.11 renderer, unchanged
13
+ // (./inline3d-model.js, byte for byte — also importable directly as `@displayxr/inline3d/model/three`).
14
+ // BOTH are imported DYNAMICALLY from here, so a default page never resolves `three` and a
15
+ // three page never resolves `playcanvas`. The handle is returned synchronously on either engine;
16
+ // calls made before the backend has loaded (exclude() on the next line, above all) are queued
17
+ // and replayed, and `viewer` appears one module-load later — the same contract as
18
+ // `addSplat(…, { engine: 'playcanvas' })`.
19
+ //
20
+ // FALLBACK. With no `engine` given, a page that has three but not playcanvas keeps working: the
21
+ // engine import fails, one warning names what to install, and the tile renders on three. With
22
+ // neither, `ready` rejects naming both. An explicit `engine` never falls back.
23
+
24
+ /** The engines `addModel` renders with; the first is the default. */
25
+ export const MODEL_ENGINES = Object.freeze(['playcanvas', 'three']);
26
+
27
+ /**
28
+ * Options whose VALUE is a three.js object. With no explicit `engine` they select three (the page
29
+ * plainly has it, and handing a three loader to PlayCanvas can only be a mistake); with
30
+ * `engine: 'playcanvas'` they throw.
31
+ */
32
+ const THREE_ONLY = ['GLTFLoader', 'DRACOLoader', 'KTX2Loader'];
33
+ const isThreeTexture = (t) => !!t && typeof t === 'object' && (t.isTexture === true || t.isRenderTargetTexture === true);
34
+
35
+ /**
36
+ * Which backend a call gets. Pure, synchronous.
37
+ *
38
+ * @returns {{engine:'playcanvas'|'three', explicit:boolean, reason:string}}
39
+ */
40
+ export function resolveModelEngine(opts = {}) {
41
+ const e = opts.engine;
42
+ if (e !== undefined && e !== null) {
43
+ if (!MODEL_ENGINES.includes(e)) {
44
+ throw new Error(
45
+ `@displayxr/inline3d/model: unknown engine "${e}" — expected ${MODEL_ENGINES.map((n) => `'${n}'`).join(' or ')} ` +
46
+ `(default 'playcanvas').`,
47
+ );
48
+ }
49
+ return { engine: e, explicit: true, reason: 'option' };
50
+ }
51
+ const threeOpt = THREE_ONLY.find((k) => opts[k]) || (isThreeTexture(opts.envMap) ? 'envMap' : null);
52
+ if (threeOpt) return { engine: 'three', explicit: false, reason: `three.js object in \`${threeOpt}\`` };
53
+ return { engine: 'playcanvas', explicit: false, reason: 'default' };
54
+ }
55
+
56
+ /** Options that exist only on the PlayCanvas backend: an error on an explicit engine:'three'. */
57
+ const PLAYCANVAS_ONLY = ['playcanvas', 'environmentRotation', 'controls', 'comfortDepth', 'onBeforeFrame', 'antialias', 'preserveDrawingBuffer', 'nearClip', 'farClip', 'orbitMaxDeg', 'orbitEase'];
58
+
59
+ /**
60
+ * Everything that can be checked before a module loads. Throws at CALL time — a page bug is true
61
+ * of every call and must not surface as an "asset failed" rejection.
62
+ */
63
+ export function validateModelCall(canvas, src, opts, route) {
64
+ if (!canvas || typeof canvas.getBoundingClientRect !== 'function') {
65
+ throw new Error('@displayxr/inline3d/model: addModel(wall, canvas, src) — `canvas` must be an HTMLCanvasElement.');
66
+ }
67
+ if (typeof src !== 'string' || !src) {
68
+ throw new Error('@displayxr/inline3d/model: addModel(wall, canvas, src) — `src` must be the URL of a .glb or .gltf.');
69
+ }
70
+ if (route.engine === 'three' && route.explicit) {
71
+ const bad = PLAYCANVAS_ONLY.filter((k) => opts[k] !== undefined && !(k === 'controls' && opts[k] === 'viewer'));
72
+ if (bad.length) {
73
+ throw new Error(
74
+ `@displayxr/inline3d/model: ${bad.map((k) => `\`${k}\``).join(', ')} ${bad.length === 1 ? 'is' : 'are'} PlayCanvas-backend ` +
75
+ "options and do nothing on engine:'three'. Drop them, or drop engine:'three'.",
76
+ );
77
+ }
78
+ }
79
+ if (route.engine === 'playcanvas' && route.explicit) {
80
+ const bad = THREE_ONLY.find((k) => opts[k]) || (isThreeTexture(opts.envMap) ? 'envMap' : null);
81
+ if (bad) {
82
+ throw new Error(
83
+ `@displayxr/inline3d/model: \`${bad}\` is a three.js object and engine:'playcanvas' cannot use it. ` +
84
+ "Drop it (the engine decodes with its own readers; serve the files at `decoderPath`), or pass engine:'three'.",
85
+ );
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Load a glTF/GLB into an inline-3D window. See model.d.ts for every option.
92
+ *
93
+ * @returns {object} the handle, synchronously: `ready`, `firstWoven`, `setPose`, `resetPose`,
94
+ * `exclude`, `unexclude`, `remove` work at once; `viewer`, `model`, `frame`, `engine`
95
+ * fill in as the backend loads.
96
+ */
97
+ export function addModel(wall, canvas, src, opts = {}) {
98
+ const route = resolveModelEngine(opts);
99
+ validateModelCall(canvas, src, opts, route);
100
+ if (route.engine === 'playcanvas') {
101
+ // The backend's own call-time checks (environment, envMap, controls, three loaders). Imported
102
+ // lazily below, so this is the cheap pure copy that lives there — see validatePlayCanvasModelOptions.
103
+ preValidatePlayCanvas(opts);
104
+ } else if (route.reason !== 'option' && route.reason !== 'default') {
105
+ console.info(`[inline3d/model] rendering ${src} with three.js: ${route.reason} (pass engine:'three' to say so explicitly).`);
106
+ }
107
+
108
+ const pending = [];
109
+ const queue = (name) => (...args) => {
110
+ pending.push([name, args]);
111
+ };
112
+ const page = opts.controls === 'page';
113
+ let pendingPose = null;
114
+ const out = {
115
+ backend: route.engine,
116
+ engine: null,
117
+ viewer: null,
118
+ model: null,
119
+ frame: null,
120
+ setPose: queue('setPose'),
121
+ resetPose: queue('resetPose'),
122
+ setCameraPose(matrixWorld, o) {
123
+ if (!page) {
124
+ throw new Error("@displayxr/inline3d/model: setCameraPose() needs addModel(…, { controls:'page' }) — with the default controls the SDK owns the camera (use setPose).");
125
+ }
126
+ if (pendingPose) pendingPose[1] = [matrixWorld, o];
127
+ else pending.push((pendingPose = ['setCameraPose', [matrixWorld, o]]));
128
+ return out;
129
+ },
130
+ getCameraPose: () => null,
131
+ remove: queue('remove'),
132
+ exclude: queue('exclude'),
133
+ unexclude: queue('unexclude'),
134
+ };
135
+ out.firstWoven = new Promise((resolve) => {
136
+ out._resolveFirstWoven = resolve;
137
+ });
138
+ const settleFirstWoven = (reason) => {
139
+ if (typeof out._resolveFirstWoven === 'function') {
140
+ out._resolveFirstWoven(Object.freeze({ woven: false, confirmed: false, reason, ms: 0 }));
141
+ delete out._resolveFirstWoven;
142
+ }
143
+ };
144
+
145
+ out.ready = (async () => {
146
+ if (route.engine === 'three') return attachThree(out, wall, canvas, src, opts, pending);
147
+ const mod = await import('./inline3d-model-playcanvas.js');
148
+ let pc = opts.playcanvas || null;
149
+ if (!pc) {
150
+ try {
151
+ pc = await import('./inline3d-playcanvas-engine.js');
152
+ } catch (err) {
153
+ if (route.explicit) throw playcanvasMissing(err, false);
154
+ let three;
155
+ try {
156
+ three = await import('./inline3d-model.js');
157
+ } catch (err3) {
158
+ throw playcanvasMissing(err, err3);
159
+ }
160
+ console.warn(
161
+ `[inline3d/model] \`playcanvas\` could not be loaded (${err?.message || err}); rendering ${src} with three.js instead. ` +
162
+ "npm i playcanvas for the default renderer, or pass engine:'three' to silence this.",
163
+ );
164
+ out.backend = 'three';
165
+ return attachThree(out, wall, canvas, src, opts, pending, three);
166
+ }
167
+ }
168
+ return mod.attachPlayCanvasModel(out, wall, canvas, src, opts, pending, pc);
169
+ })().catch((err) => {
170
+ settleFirstWoven('layer-failed');
171
+ throw err;
172
+ });
173
+ return out;
174
+ }
175
+
176
+ function playcanvasMissing(err, err3) {
177
+ const e = new Error(
178
+ '@displayxr/inline3d/model: addModel renders with PlayCanvas by default and `playcanvas` could not be loaded' +
179
+ (err3 ? ' — and neither could `three` for the fallback' : '') +
180
+ ". Install one: `npm i playcanvas` (default), or `npm i three` and pass engine:'three'. On a bare importmap, map " +
181
+ '"playcanvas" (or "three" + "three/addons/"). ' +
182
+ `Underlying error: ${err?.message || err}` +
183
+ (err3 ? ` / ${err3?.message || err3}` : ''),
184
+ );
185
+ e.cause = err;
186
+ return e;
187
+ }
188
+
189
+ /** Call-time checks for the PlayCanvas backend that need no module (mirrors the backend's own). */
190
+ function preValidatePlayCanvas(opts) {
191
+ const env = opts.environment;
192
+ if (env !== undefined && !['room', 'neutral', 'studio', 'none'].includes(env)) {
193
+ throw new Error(`@displayxr/inline3d/model: environment "${env}" — expected 'room', 'neutral', 'studio' or 'none'.`);
194
+ }
195
+ if (opts.envMap !== undefined && opts.envMap !== null && typeof opts.envMap !== 'string' && typeof opts.envMap !== 'object') {
196
+ throw new Error('@displayxr/inline3d/model: `envMap` must be a URL string or a pc.Texture.');
197
+ }
198
+ const c = opts.controls;
199
+ if (c !== undefined && c !== 'viewer' && c !== 'page') {
200
+ throw new Error(`@displayxr/inline3d/model: controls "${c}" — expected 'viewer' or 'page' (default 'viewer').`);
201
+ }
202
+ if (opts.onBeforeFrame !== undefined && (typeof opts.onBeforeFrame !== 'function' || c !== 'page')) {
203
+ throw new Error("@displayxr/inline3d/model: onBeforeFrame must be a function, and needs controls:'page'.");
204
+ }
205
+ if (opts.meshoptDecoder && typeof opts.meshoptDecoder.decodeGltfBuffer !== 'function') {
206
+ throw new Error("@displayxr/inline3d/model: `meshoptDecoder` must be meshoptimizer's MeshoptDecoder (it has decodeGltfBuffer()).");
207
+ }
208
+ }
209
+
210
+ /**
211
+ * engine:'three' — today's addModel (./inline3d-model.js), unchanged, behind this handle: its own
212
+ * handle's fields are forwarded (getters, since it fills them as it loads) and the queued calls
213
+ * are replayed into it.
214
+ */
215
+ async function attachThree(out, wall, canvas, src, opts, pending, mod) {
216
+ const m = mod || (await import('./inline3d-model.js'));
217
+ const inner = m.addModel(wall, canvas, src, opts);
218
+ for (const k of ['viewer', 'model', 'frame']) {
219
+ Object.defineProperty(out, k, { get: () => inner[k], set: (v) => (inner[k] = v), enumerable: true, configurable: true });
220
+ }
221
+ out.backend = 'three';
222
+ out.engine = null;
223
+ out.setPose = inner.setPose;
224
+ out.resetPose = inner.resetPose;
225
+ out.remove = inner.remove;
226
+ out.exclude = inner.exclude;
227
+ out.unexclude = inner.unexclude;
228
+ out.inner = inner;
229
+ if (typeof out._resolveFirstWoven === 'function') {
230
+ out._resolveFirstWoven(inner.firstWoven);
231
+ delete out._resolveFirstWoven;
232
+ }
233
+ for (const [name, args] of pending) {
234
+ if (name === 'remove') {
235
+ inner.remove();
236
+ break;
237
+ }
238
+ inner[name]?.(...args);
239
+ }
240
+ await inner.ready;
241
+ return out;
242
+ }