@displayxr/inline3d 1.11.0 → 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,61 @@ 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
+
8
63
  ## 1.11.0 — 2026-09-24
9
64
 
10
65
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), additively. Nothing changes
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
@@ -134,7 +144,7 @@ samples/
134
144
  windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
135
145
  each woven with one SDK call, all on one session
136
146
  splat/ a 3D Gaussian splat in a tile, auto-framed, with a 2D price plate over it
137
- 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)
138
148
  composition/ the 14-case 2D/3D overlap matrix — demo AND standing hardware regression
139
149
  surface; red cases ship red (see samples/README.md)
140
150
  vendor/draco/ three's Draco decoder, served for samples/model (compressed glTF needs it)
@@ -148,7 +158,10 @@ js/
148
158
  (`perf` cuts overdraw; a `.sog`'s `camera` block picks the view rig)
149
159
  inline3d-splat-playcanvas.js
150
160
  preview: the `engine: 'playcanvas'` backend of addSplat(), loaded on demand
151
- 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)
152
165
  from what the asset declares (you serve the decoder files — see the guide)
153
166
  docs/
154
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
+ }