@displayxr/inline3d 1.11.0 → 1.12.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.
@@ -0,0 +1,754 @@
1
+ // inline3d-model-playcanvas.js — `addModel`'s default backend (1.12): a glTF/GLB rendered by the
2
+ // PlayCanvas engine instead of three.js.
3
+ //
4
+ // EXPERIMENTAL. Internal to `./model`, which imports this module DYNAMICALLY — so a page that
5
+ // passes `engine: 'three'` never resolves `playcanvas`, and a page on this default never resolves
6
+ // `three`. Not covered by the SDK's 1.x semver promise; see docs/sdk-stability.md and
7
+ // docs/playcanvas-model-backend.md (what differs from the three path, and why).
8
+ //
9
+ // WHAT IS THE SAME AS THE THREE PATH. The handle (`ready`, `remove`, `exclude/unexclude`,
10
+ // `setPose`, `resetPose`, `frame`, `model`, `viewer`, `firstWoven`), the framing (exact mesh
11
+ // bounds → the shared fit arithmetic), the pose model and its constants, the buffer-shape rule
12
+ // (double-width in 3D, 1:1 in mono), validate-before-clear + last-good replay, `feather`,
13
+ // `renderScale`, and the decoder contract: the asset's `extensionsUsed` decides which decoders
14
+ // are wired, the page SERVES the Draco and Basis files (never a CDN), and a decoder that is needed
15
+ // and missing rejects `ready` with an Error naming the extension, the option and the path.
16
+ //
17
+ // WHAT IS NOT.
18
+ // · The tile host is the PlayCanvas splat adapter's (`PlayCanvasSplatViewer`, reused as is):
19
+ // one AppBase per tile, one camera with N `RenderView`s, the EYES move and the model stays
20
+ // put (the rig node carries the inverse pivot).
21
+ // · LIGHTING. The reference is the Khronos glTF Sample Viewer, not three.js: an image-based
22
+ // "neutral studio" environment, Khronos PBR Neutral tone mapping, exposure 1, sRGB output.
23
+ // The environment is generated in memory (§ neutralStudioRGBE) — no HDRI to fetch or license —
24
+ // and was fitted to the Sample Viewer's "Studio Neutral". three's path keeps RoomEnvironment
25
+ // and no tone mapping (unchanged since 1.11); the two backends therefore do not look
26
+ // identical, by design — docs/playcanvas-model-backend.md §Lighting has the MAE table.
27
+ // · Decoders are the ENGINE's: Draco and Basis through the engine's own worker readers (fed the
28
+ // SAME files three's `libs/draco/` and `libs/basis/` folders hold), meshopt through the
29
+ // container's bufferView hook with meshoptimizer's `MeshoptDecoder` (the engine has no
30
+ // meshopt reader in 2.22.3).
31
+ //
32
+ // ENGINE SHAPE:
33
+ // AppBase (no XrManager, no input) on our own WebGL2 device, MSAA on
34
+ // ├── content ← the glTF's render entity (instantiateRenderEntity), never moved by the SDK
35
+ // └── rig ← inverse(pivot), written by the viewer's tick and nothing else
36
+ // └── eye camera, N RenderViews, TONEMAP_NEUTRAL, no sky layer
37
+
38
+ import { PlayCanvasSplatViewer, pageRigTRS, pageViewAxis, pageViewRig } from './inline3d-splat-playcanvas.js';
39
+ import { resolveControls, normalizeCameraPose, now, MAX_DT_S } from './inline3d-splat-shared.js';
40
+
41
+ const TAG = '[inline3d/model]';
42
+
43
+ // ── the decoders ────────────────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * One entry per compression extension. The FILES are the same ones three's addModel asks a page
47
+ * to serve (three/examples/jsm/libs/{draco,basis}/): the engine's Draco worker takes Google's
48
+ * wasm wrapper as its glue, and its Basis worker takes the Binomial transcoder three ships. So one
49
+ * served folder works for either engine, and switching `engine` never moves a file.
50
+ */
51
+ export const PC_DECODERS = Object.freeze({
52
+ draco: Object.freeze({
53
+ ext: 'KHR_draco_mesh_compression',
54
+ label: 'Draco mesh compression',
55
+ pathKey: 'draco',
56
+ files: ['draco_wasm_wrapper.js', 'draco_decoder.wasm'],
57
+ from: 'three/examples/jsm/libs/draco/',
58
+ }),
59
+ ktx2: Object.freeze({
60
+ ext: 'KHR_texture_basisu',
61
+ label: 'KTX2 / Basis Universal textures',
62
+ pathKey: 'basis',
63
+ files: ['basis_transcoder.js', 'basis_transcoder.wasm'],
64
+ from: 'three/examples/jsm/libs/basis/',
65
+ }),
66
+ meshopt: Object.freeze({
67
+ ext: 'EXT_meshopt_compression',
68
+ label: 'meshopt compression',
69
+ pathKey: null, // pure JS with inlined wasm: nothing to serve
70
+ files: null,
71
+ module: 'meshoptimizer/decoder',
72
+ }),
73
+ });
74
+
75
+ export const PC_DECODER_KINDS = Object.freeze(['draco', 'ktx2', 'meshopt']);
76
+
77
+ const DEFAULT_DECODER_PATH = Object.freeze({ draco: '/draco/', basis: '/basis/' });
78
+
79
+ /** Same rule as the three path: a string is a parent of draco/ and basis/; an object per key. */
80
+ export function normalizeDecoderPath(v) {
81
+ if (!v) return { ...DEFAULT_DECODER_PATH };
82
+ if (typeof v === 'string') {
83
+ const base = v.endsWith('/') ? v : `${v}/`;
84
+ return { draco: `${base}draco/`, basis: `${base}basis/` };
85
+ }
86
+ const slash = (p) => (p.endsWith('/') ? p : `${p}/`);
87
+ return {
88
+ draco: slash(v.draco || DEFAULT_DECODER_PATH.draco),
89
+ basis: slash(v.basis || v.ktx2 || DEFAULT_DECODER_PATH.basis),
90
+ };
91
+ }
92
+
93
+ /** The glTF extensions an asset declares (used ∪ required). */
94
+ export function declaredExtensions(json) {
95
+ const out = new Set();
96
+ for (const key of ['extensionsUsed', 'extensionsRequired']) {
97
+ const list = json?.[key];
98
+ if (Array.isArray(list)) for (const name of list) out.add(name);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ const GLB_MAGIC = 0x46546c67;
104
+ const GLB_CHUNK_JSON = 0x4e4f534a;
105
+
106
+ /** The glTF JSON of a .glb or .gltf buffer, or null. */
107
+ export function gltfJsonOf(buffer) {
108
+ const view = new DataView(buffer);
109
+ const text = new TextDecoder();
110
+ if (buffer.byteLength >= 20 && view.getUint32(0, true) === GLB_MAGIC) {
111
+ const len = view.getUint32(12, true);
112
+ if (view.getUint32(16, true) !== GLB_CHUNK_JSON) return null;
113
+ return JSON.parse(text.decode(new Uint8Array(buffer, 20, len)));
114
+ }
115
+ return JSON.parse(text.decode(new Uint8Array(buffer)));
116
+ }
117
+
118
+ /**
119
+ * The error a page author can act on: which extension, which files to serve and where, which
120
+ * option moves them — and, for the three-only loader options, the fact that they do not apply.
121
+ */
122
+ export function decoderError(kind, src, paths, cause) {
123
+ const spec = PC_DECODERS[kind];
124
+ const lines = [`${TAG} ${src} needs the "${spec.ext}" decoder (${spec.label}) and it could not be used.`];
125
+ if (spec.files) {
126
+ const where = paths[spec.pathKey];
127
+ lines.push(
128
+ `Serve the decoder files from your own origin (the same files three.js ships) and point addModel at them:`,
129
+ ` cp -r node_modules/${spec.from} <web-root>${where}`,
130
+ ` addModel(wall, canvas, src, { decoderPath: { ${spec.pathKey}: '${where}' } })`,
131
+ `Expected ${spec.files.map((f) => where + f).join(' and ')} — check they are actually served (a 404 there fails exactly like this).`,
132
+ );
133
+ } else {
134
+ lines.push(
135
+ `Nothing to serve for this one: the decoder is meshoptimizer's MeshoptDecoder (pure JS). Install it`,
136
+ ` npm i meshoptimizer (on a bare importmap, map "meshoptimizer/decoder")`,
137
+ `or hand it in: addModel(…, { meshoptDecoder: MeshoptDecoder }).`,
138
+ );
139
+ }
140
+ lines.push(`Underlying error: ${cause?.message || cause}`);
141
+ const err = new Error(lines.join('\n'));
142
+ err.cause = cause;
143
+ err.decoder = kind;
144
+ err.gltfExtension = spec.ext;
145
+ return err;
146
+ }
147
+
148
+ /** Fetch each file once (cached per URL): a mis-served folder must reject `ready`, not decode nothing. */
149
+ const _preflight = new Map();
150
+ function preflight(urls) {
151
+ return Promise.all(
152
+ urls.map((u) => {
153
+ let p = _preflight.get(u);
154
+ if (!p) {
155
+ p = fetch(u, { method: 'GET' }).then((r) => {
156
+ if (!r.ok) throw new Error(`HTTP ${r.status} for ${u}`);
157
+ return true;
158
+ });
159
+ p.catch(() => _preflight.delete(u));
160
+ _preflight.set(u, p);
161
+ }
162
+ return p;
163
+ }),
164
+ );
165
+ }
166
+
167
+ /**
168
+ * The engine's Draco and Basis readers are configured ONCE per page (a module-level worker pool
169
+ * in the engine). The first tile that needs one sets its path; a later tile asking for a
170
+ * DIFFERENT path is warned (the engine cannot run two pools) and decodes with the first.
171
+ */
172
+ const _engineDecoderPath = { draco: null, ktx2: null };
173
+
174
+ async function wireEngineDecoder(pc, kind, paths) {
175
+ const spec = PC_DECODERS[kind];
176
+ const dir = paths[spec.pathKey];
177
+ const urls = spec.files.map((f) => dir + f);
178
+ await preflight(urls);
179
+ if (_engineDecoderPath[kind]) {
180
+ if (_engineDecoderPath[kind] !== dir) {
181
+ console.warn(
182
+ `${TAG} decoderPath.${spec.pathKey} "${dir}" ignored: the engine's ${spec.label} reader is ` +
183
+ `already configured from "${_engineDecoderPath[kind]}" on this page (one worker pool per page).`,
184
+ );
185
+ }
186
+ return;
187
+ }
188
+ _engineDecoderPath[kind] = dir;
189
+ if (kind === 'draco') {
190
+ if (typeof pc.dracoInitialize !== 'function') throw new Error('this playcanvas build has no dracoInitialize()');
191
+ pc.dracoInitialize({ jsUrl: urls[0], wasmUrl: urls[1], lazyInit: true });
192
+ } else {
193
+ if (typeof pc.basisInitialize !== 'function') throw new Error('this playcanvas build has no basisInitialize()');
194
+ pc.basisInitialize({ glueUrl: urls[0], wasmUrl: urls[1], lazyInit: true });
195
+ }
196
+ }
197
+
198
+ let _meshopt = null;
199
+ async function resolveMeshopt(injected) {
200
+ const d = injected || (_meshopt ||= import('meshoptimizer/decoder').then((m) => m.MeshoptDecoder));
201
+ const dec = await d;
202
+ if (!dec || typeof dec.decodeGltfBuffer !== 'function') {
203
+ throw new Error('the meshopt decoder has no decodeGltfBuffer() — expected meshoptimizer\'s MeshoptDecoder');
204
+ }
205
+ if (dec.ready) await dec.ready;
206
+ return dec;
207
+ }
208
+
209
+ /**
210
+ * The container's bufferView hook: decode EXT_meshopt_compression views with MeshoptDecoder;
211
+ * every other view falls through to the engine (a null result). The compressed bytes live in
212
+ * `ext.buffer`; the view's own buffer is the extension's `fallback` placeholder and is never read.
213
+ */
214
+ export function meshoptBufferViewHook(decoder) {
215
+ return {
216
+ processAsync(bufferView, buffers, callback) {
217
+ const ext = bufferView?.extensions?.EXT_meshopt_compression;
218
+ if (!ext) {
219
+ callback(null, null);
220
+ return;
221
+ }
222
+ Promise.resolve(buffers[ext.buffer])
223
+ .then((buf) => {
224
+ const source = new Uint8Array(buf.buffer, buf.byteOffset + (ext.byteOffset || 0), ext.byteLength);
225
+ const target = new Uint8Array(ext.count * ext.byteStride);
226
+ decoder.decodeGltfBuffer(target, ext.count, ext.byteStride, source, ext.mode, ext.filter || 'NONE');
227
+ callback(null, target);
228
+ })
229
+ .catch((err) => callback(err));
230
+ },
231
+ };
232
+ }
233
+
234
+ // ── lighting ────────────────────────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * THE DEFAULT ENVIRONMENT: a neutral photo studio, generated in memory as an equirect radiance
238
+ * map — a floor-to-ceiling brightness profile plus a handful of soft area lights (spherical
239
+ * Gaussians). The parameters were FITTED (least squares, solid-angle weighted) to the glTF Sample
240
+ * Viewer's "Studio Neutral" environment, so that this backend's default lighting matches the
241
+ * Khronos reference; the image itself is ours (a dozen numbers, not a copy of the HDRI), so the
242
+ * SDK ships no asset and carries no attribution requirement. Grey by construction (r = g = b).
243
+ *
244
+ * `profile` is the radiance at evenly spaced polar angles from straight up (0) to straight down
245
+ * (π); `lobes` are [amplitude, sharpness, polar, azimuth] with the direction
246
+ * (sinθ·cosφ, cosθ, sinθ·sinφ) — Y up, the engine's equirect convention. A lobe's amplitude may
247
+ * be negative (a dark backdrop); the result is clamped at 0.
248
+ */
249
+ export const NEUTRAL_STUDIO = Object.freeze({
250
+ profile: [5.003, 5.049, 3.917, 1.968, 1.378, 0.893, 0.8074, 0.7525, 0.6427, -0.1814],
251
+ lobes: [
252
+ [11.31, 4.869, 0.9712, 6.105],
253
+ [1.759, 3.839, 1.404, 3.558],
254
+ [0.9139, 1.621, 1.719, 6.239],
255
+ [0.572, 7.846, 1.497, 6.066],
256
+ [0.6379, 7.687, 1.42, 6.385],
257
+ [-0.7782, 6.701, 1.13, 6.133],
258
+ [-1.263, 8.179, 1.671, 6.188],
259
+ [-1.456, 7.734, 1.256, 6.568],
260
+ [-6.7, 0.8014, 0.449, 6.171],
261
+ [0.4015, 3.915, 1.621, 5.688],
262
+ [-0.5387, 6.494, 2.828, 5.614],
263
+ [6.623, 1.864, 0.7199, 6.135],
264
+ [0.2623, 6.922, 1.755, 5.903],
265
+ [0.2597, 6.906, 1.566, 5.934],
266
+ [0.1584, 6.954, 1.514, 6.179],
267
+ [0.2272, 6.945, 1.422, 5.891],
268
+ [0.03496, 6.974, 1.414, 6.205],
269
+ [0.3804, 6.979, 1.393, 6.08],
270
+ ],
271
+ });
272
+
273
+ /** Lobes as [amplitude, sharpness, mx, my, mz] — the trig done once per env, not per texel. */
274
+ const _lobeCache = new WeakMap();
275
+ function lobesOf(env) {
276
+ let l = _lobeCache.get(env);
277
+ if (!l) {
278
+ l = env.lobes.map(([a, s, th, ph]) => [a, Math.exp(s), Math.sin(th) * Math.cos(ph), Math.cos(th), Math.sin(th) * Math.sin(ph)]);
279
+ _lobeCache.set(env, l);
280
+ }
281
+ return l;
282
+ }
283
+
284
+ /** Radiance of NEUTRAL_STUDIO (or `env`) toward unit direction (x, y, z). */
285
+ export function neutralStudioRadiance(x, y, z, env = NEUTRAL_STUDIO) {
286
+ const prof = env.profile;
287
+ const n = prof.length - 1;
288
+ const theta = Math.acos(Math.max(-1, Math.min(1, y)));
289
+ const t = (theta / Math.PI) * n;
290
+ const i = Math.min(n - 1, Math.floor(t));
291
+ const f = t - i;
292
+ let v = prof[i] * (1 - f) + prof[i + 1] * f;
293
+ for (const [a, k, mx, my, mz] of lobesOf(env)) v += a * Math.exp(k * (x * mx + y * my + z * mz - 1));
294
+ return v > 0 ? v : 0;
295
+ }
296
+
297
+ /** RGBE-encode a grey radiance. */
298
+ function rgbe(v, out, o) {
299
+ if (!(v > 1e-32)) {
300
+ out[o] = out[o + 1] = out[o + 2] = out[o + 3] = 0;
301
+ return;
302
+ }
303
+ let e = Math.ceil(Math.log2(v));
304
+ let m = v / Math.pow(2, e);
305
+ if (m >= 1) {
306
+ m /= 2;
307
+ e += 1;
308
+ }
309
+ const b = Math.min(255, Math.floor(m * 256));
310
+ out[o] = out[o + 1] = out[o + 2] = b;
311
+ out[o + 3] = e + 128;
312
+ }
313
+
314
+ /**
315
+ * The equirect as RGBE bytes, row 0 = straight up. `u` runs with the azimuth φ = 2π·u, which is
316
+ * the convention the fit used; the engine's own equirect orientation is applied through
317
+ * `scene.skyboxRotation` (§ ENV_YAW_DEG), not baked in here.
318
+ */
319
+ const _rgbeCache = new Map();
320
+ export function neutralStudioRGBE(width = 256, height = 128, env = NEUTRAL_STUDIO) {
321
+ // Every tile on a page gets the same image: build it once (a copy per call, since the engine
322
+ // may keep the array it is handed).
323
+ const key = env === NEUTRAL_STUDIO ? `${width}x${height}` : null;
324
+ if (key && _rgbeCache.has(key)) return _rgbeCache.get(key).slice();
325
+ const out = new Uint8Array(width * height * 4);
326
+ for (let r = 0; r < height; r++) {
327
+ const theta = ((r + 0.5) / height) * Math.PI;
328
+ const st = Math.sin(theta);
329
+ const y = Math.cos(theta);
330
+ for (let c = 0; c < width; c++) {
331
+ const phi = ((c + 0.5) / width) * 2 * Math.PI;
332
+ rgbe(neutralStudioRadiance(st * Math.cos(phi), y, st * Math.sin(phi), env), out, (r * width + c) * 4);
333
+ }
334
+ }
335
+ if (key) _rgbeCache.set(key, out.slice());
336
+ return out;
337
+ }
338
+
339
+ /**
340
+ * Yaw (degrees about +Y) applied to the generated studio, as `scene.skyboxRotation`. Calibrated
341
+ * headless against the Sample Viewer at its default `environmentRotation` (90°): a sweep over
342
+ * 0/90/180/270 × mirrored, then ±15/±30, has a clean minimum at 0 on all four reference models
343
+ * (the engine's equirect convention already lines up with the layout the fit used). Kept as a
344
+ * named constant so a future engine that flips its convention is a one-line fix.
345
+ */
346
+ export const ENV_YAW_DEG = 0;
347
+
348
+ /** Build the default environment on this app and set it as the scene's envAtlas. */
349
+ function useNeutralStudio(pc, app, yawDeg) {
350
+ const W = 256;
351
+ const H = 128;
352
+ const tex = new pc.Texture(app.graphicsDevice, {
353
+ name: 'inline3d-neutral-studio',
354
+ width: W,
355
+ height: H,
356
+ format: pc.PIXELFORMAT_RGBA8,
357
+ type: pc.TEXTURETYPE_RGBE,
358
+ projection: pc.TEXTUREPROJECTION_EQUIRECT,
359
+ mipmaps: false,
360
+ addressU: pc.ADDRESS_REPEAT,
361
+ addressV: pc.ADDRESS_CLAMP_TO_EDGE,
362
+ levels: [neutralStudioRGBE(W, H)],
363
+ });
364
+ return useLightingSource(pc, app, tex, true, yawDeg);
365
+ }
366
+
367
+ /** Any equirect/cube source → prefiltered envAtlas on the scene. */
368
+ function useLightingSource(pc, app, source, own, yawDeg = 0) {
369
+ const lighting = pc.EnvLighting.generateLightingSource(source);
370
+ const atlas = pc.EnvLighting.generateAtlas(lighting);
371
+ lighting.destroy?.();
372
+ if (own) source.destroy?.();
373
+ app.scene.envAtlas = atlas;
374
+ app.scene.skyboxIntensity = 1;
375
+ app.scene.exposure = 1;
376
+ if (pc.Quat && yawDeg) app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(0, yawDeg, 0);
377
+ return atlas;
378
+ }
379
+
380
+ /** A texture asset from a URL (an equirect .hdr/.png/.jpg for `envMap`). */
381
+ function loadTexture(pc, app, url) {
382
+ return new Promise((resolve, reject) => {
383
+ const a = new pc.Asset(`inline3d-env:${url}`, 'texture', { url });
384
+ a.once('load', () => resolve(a.resource));
385
+ a.once('error', (e) => reject(new Error(`${TAG} envMap ${url}: ${e}`)));
386
+ app.assets.add(a);
387
+ app.assets.load(a);
388
+ });
389
+ }
390
+
391
+ /**
392
+ * `environment: 'studio'` — three's three-point rig, same directions and ratios. Engine lights
393
+ * shine along their local −Y, so each is aimed by rotation (never lookAt). three's punctual
394
+ * intensity is irradiance with a Lambert 1/π in the BRDF; the engine's has none — hence the /π.
395
+ * three's hemisphere light becomes the scene ambient (its average over the upper hemisphere).
396
+ */
397
+ export const STUDIO_LIGHTS = Object.freeze([
398
+ { name: 'key', intensity: 2.2, position: [1, 1.4, 1.6] },
399
+ { name: 'fill', intensity: 0.7, position: [-1.4, 0.4, 0.8] },
400
+ { name: 'rim', intensity: 1.0, position: [-0.4, 0.8, -1.6] },
401
+ ]);
402
+
403
+ /** Quaternion [x,y,z,w] rotating (0,−1,0) onto the unit vector `d`. */
404
+ export function aimDownAt(d) {
405
+ // from a = (0,-1,0) to b = d: q = (a × b, 1 + a·b), normalised
406
+ const [bx, by, bz] = d;
407
+ const dot = -by;
408
+ if (dot < -0.999999) return [1, 0, 0, 0]; // opposite: half turn about X
409
+ const cx = -1 * bz - 0; // a × b = (ay*bz - az*by, az*bx - ax*bz, ax*by - ay*bx) with a=(0,-1,0)
410
+ const cy = 0;
411
+ const cz = 0 - -1 * bx;
412
+ const w = 1 + dot;
413
+ const l = Math.hypot(cx, cy, cz, w);
414
+ return [cx / l, cy / l, cz / l, w / l];
415
+ }
416
+
417
+ function addStudioLights(pc, app, root) {
418
+ for (const L of STUDIO_LIGHTS) {
419
+ const e = new pc.Entity(`inline3d-${L.name}`, app);
420
+ e.addComponent('light', { type: 'directional', color: new pc.Color(1, 1, 1), intensity: L.intensity / Math.PI, castShadows: false });
421
+ const [x, y, z] = L.position;
422
+ const l = Math.hypot(x, y, z);
423
+ const q = aimDownAt([-x / l, -y / l, -z / l]);
424
+ e.setLocalRotation(q[0], q[1], q[2], q[3]);
425
+ root.addChild(e);
426
+ }
427
+ // three: HemisphereLight(white, 0x444444, 0.6) — mean of sky and (linear) ground over the
428
+ // upper hemisphere, through the Lambert 1/π.
429
+ const amb = (0.6 * (1 + 0.058)) / 2 / Math.PI;
430
+ app.scene.ambientLight = new pc.Color(amb, amb, amb);
431
+ }
432
+
433
+ // ── framing ─────────────────────────────────────────────────────────────────────────────────
434
+
435
+ /** Union of the render mesh instances' world AABBs (content space, the file's own units). */
436
+ export function boundsOfEntity(pc, entity) {
437
+ const renders = entity.findComponents ? entity.findComponents('render') : [];
438
+ let min = null;
439
+ let max = null;
440
+ for (const r of renders) {
441
+ for (const mi of r.meshInstances || []) {
442
+ const b = mi.aabb;
443
+ if (!b) continue;
444
+ const c = b.center;
445
+ const h = b.halfExtents;
446
+ const lo = [c.x - h.x, c.y - h.y, c.z - h.z];
447
+ const hi = [c.x + h.x, c.y + h.y, c.z + h.z];
448
+ if (!lo.every(Number.isFinite) || !hi.every(Number.isFinite)) continue;
449
+ if (!min) {
450
+ min = lo;
451
+ max = hi;
452
+ } else {
453
+ for (let i = 0; i < 3; i++) {
454
+ if (lo[i] < min[i]) min[i] = lo[i];
455
+ if (hi[i] > max[i]) max[i] = hi[i];
456
+ }
457
+ }
458
+ }
459
+ }
460
+ if (!min) return null;
461
+ return {
462
+ center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
463
+ extent: [Math.max(max[0] - min[0], 1e-6), Math.max(max[1] - min[1], 1e-6), Math.max(max[2] - min[2], 1e-6)],
464
+ };
465
+ }
466
+
467
+ // ── option validation (pure; ./model calls it synchronously, before anything loads) ──────────
468
+
469
+ /** Options that only mean something on three's path: their values ARE three.js objects. */
470
+ export const THREE_ONLY_OPTIONS = Object.freeze(['GLTFLoader', 'DRACOLoader', 'KTX2Loader']);
471
+
472
+ export const ENVIRONMENTS = Object.freeze(['room', 'neutral', 'studio', 'none']);
473
+
474
+ /**
475
+ * Throws, at call time, for an option this backend cannot honour — a three.js loader, a three
476
+ * texture as `envMap`, an unknown `environment`, a bad `controls`. Returns the resolved controls.
477
+ */
478
+ export function validatePlayCanvasModelOptions(opts = {}) {
479
+ for (const k of THREE_ONLY_OPTIONS) {
480
+ if (opts[k]) {
481
+ throw new Error(
482
+ `@displayxr/inline3d/model: \`${k}\` is a three.js loader and does nothing on the PlayCanvas ` +
483
+ `backend (the default since 1.12), which decodes with the engine's own readers. Drop it and serve ` +
484
+ `the decoder files at \`decoderPath\`, or pass engine:'three' to keep the 1.11 renderer.`,
485
+ );
486
+ }
487
+ }
488
+ const env = opts.environment;
489
+ if (env !== undefined && !ENVIRONMENTS.includes(env)) {
490
+ throw new Error(
491
+ `@displayxr/inline3d/model: environment "${env}" — expected ${ENVIRONMENTS.map((e) => `'${e}'`).join(', ')}.`,
492
+ );
493
+ }
494
+ const em = opts.envMap;
495
+ if (em && typeof em === 'object' && (em.isTexture === true || em.isRenderTargetTexture === true)) {
496
+ throw new Error(
497
+ '@displayxr/inline3d/model: `envMap` is a three.js texture, and the PlayCanvas backend (the default ' +
498
+ "since 1.12) cannot sample it. Pass a URL of an equirect image (.hdr/.png/.jpg) or a pc.Texture, " +
499
+ "or pass engine:'three' to keep the PMREM texture.",
500
+ );
501
+ }
502
+ if (em !== undefined && em !== null && typeof em !== 'string' && typeof em !== 'object') {
503
+ throw new Error('@displayxr/inline3d/model: `envMap` must be a URL string or a pc.Texture.');
504
+ }
505
+ const md = opts.meshoptDecoder;
506
+ if (md && typeof md.decodeGltfBuffer !== 'function' && typeof md.then !== 'function') {
507
+ throw new Error("@displayxr/inline3d/model: `meshoptDecoder` must be meshoptimizer's MeshoptDecoder (it has decodeGltfBuffer()).");
508
+ }
509
+ let ctl;
510
+ try {
511
+ ctl = resolveControls(opts);
512
+ } catch (err) {
513
+ throw new Error(err.message.replace('@displayxr/inline3d/splat', '@displayxr/inline3d/model'));
514
+ }
515
+ return ctl;
516
+ }
517
+
518
+ // ── the backend ─────────────────────────────────────────────────────────────────────────────
519
+
520
+ /**
521
+ * Fill `out` (the handle ./model already returned) with the PlayCanvas implementation, replay the
522
+ * calls queued before this module arrived, and return the load promise (./model owns `ready`).
523
+ *
524
+ * @param {object} pc the `playcanvas` module (or the SDK's named-import subset of it).
525
+ */
526
+ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = [], pc) {
527
+ const {
528
+ virtualDisplayHeight = 0.24,
529
+ frame = null,
530
+ idleSpin = 8,
531
+ orbit = true,
532
+ fit = 'contain',
533
+ margin = 0.8,
534
+ depthLimit = 4.0,
535
+ fitSweep = true,
536
+ renderScale = 1,
537
+ feather = 0,
538
+ environment = 'room',
539
+ envMap = null,
540
+ decoderPath = null,
541
+ meshoptDecoder = null,
542
+ observe,
543
+ firstWovenHoldMs,
544
+ preserveDrawingBuffer = false,
545
+ antialias = true,
546
+ } = opts;
547
+ const ctl = validatePlayCanvasModelOptions(opts);
548
+ const pageMode = ctl.page;
549
+ if (pageMode && ctl.ignored.length) {
550
+ console.info(`${TAG} controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ${ctl.ignored.length === 1 ? 'is' : 'are'} ignored.`);
551
+ }
552
+ const paths = normalizeDecoderPath(decoderPath);
553
+
554
+ const viewer = new PlayCanvasSplatViewer(canvas, {
555
+ pageCamera: pageMode,
556
+ virtualDisplayHeight,
557
+ fit,
558
+ margin,
559
+ depthLimit,
560
+ fitSweep,
561
+ orbit,
562
+ idleSpin,
563
+ renderScale,
564
+ flipY: false, // glTF is already Y-up
565
+ orbitMaxDeg: opts.orbitMaxDeg,
566
+ orbitEase: opts.orbitEase,
567
+ feather,
568
+ nearClip: opts.nearClip,
569
+ farClip: opts.farClip,
570
+ sky: false,
571
+ toneMapping: 'neutral',
572
+ });
573
+
574
+ let handle = null;
575
+ let removed = false;
576
+ let lastPagePose = null;
577
+
578
+ Object.assign(out, {
579
+ backend: 'playcanvas',
580
+ engine: null,
581
+ viewer,
582
+ model: null,
583
+ frame: null,
584
+ setPose: (p) => viewer.setPose(p),
585
+ resetPose: () => viewer.resetPose(),
586
+ setCameraPose(matrixWorld, o) {
587
+ if (!pageMode) {
588
+ throw new Error("@displayxr/inline3d/model: setCameraPose() needs addModel(…, { controls:'page' }) — with the default controls the SDK owns the camera (use setPose).");
589
+ }
590
+ let pose;
591
+ try {
592
+ pose = normalizeCameraPose(matrixWorld, o);
593
+ } catch (err) {
594
+ throw new Error(err.message.replace('@displayxr/inline3d/splat', '@displayxr/inline3d/model'));
595
+ }
596
+ lastPagePose = pose;
597
+ viewer.setPageCamera(pose);
598
+ return out;
599
+ },
600
+ getCameraPose() {
601
+ if (!lastPagePose) return null;
602
+ return {
603
+ matrixWorld: Float32Array.from(lastPagePose.matrixWorld),
604
+ verticalFovDeg: lastPagePose.verticalFovDeg,
605
+ near: lastPagePose.near,
606
+ far: lastPagePose.far,
607
+ convergence: lastPagePose.convergence,
608
+ };
609
+ },
610
+ remove() {
611
+ removed = true;
612
+ handle?.remove();
613
+ viewer.dispose(); // app.destroy(): the model, its textures and any page entities go with it
614
+ },
615
+ exclude: (el) => handle?.exclude(el),
616
+ unexclude: (el) => handle?.unexclude(el),
617
+ });
618
+
619
+ if (wall && wall.supported) {
620
+ handle = wall.addScene(canvas, viewer.onFrame, {
621
+ ...(pageMode
622
+ ? { viewRig: pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth }) }
623
+ : { virtualDisplayHeight }),
624
+ onLayerLost: viewer.onLayerLost,
625
+ ...(observe ? { observe } : {}),
626
+ ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
627
+ });
628
+ } else {
629
+ viewer.startMono();
630
+ }
631
+ if (typeof out._resolveFirstWoven === 'function') {
632
+ out._resolveFirstWoven(
633
+ handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 })),
634
+ );
635
+ delete out._resolveFirstWoven;
636
+ }
637
+
638
+ for (const [name, args] of pending) {
639
+ if (name === 'remove') {
640
+ out.remove();
641
+ break;
642
+ }
643
+ out[name]?.(...args);
644
+ }
645
+
646
+ // controls:'page' — declare the attach rig every frame: convergence = the page's, else the
647
+ // distance along the view axis to the model's bounds centre (else 2 page units until it loads).
648
+ if (pageMode) {
649
+ viewer.onTick = () => {
650
+ let d = lastPagePose?.convergence ?? null;
651
+ if (d === null && out.frame) {
652
+ const ax = pageViewAxis(viewer.page.matrix);
653
+ const c = out.frame.center;
654
+ const dm = (c[0] - ax.origin[0]) * ax.forward[0] + (c[1] - ax.origin[1]) * ax.forward[1] + (c[2] - ax.origin[2]) * ax.forward[2];
655
+ if (dm > 0) d = dm / (ax.scale || 1);
656
+ }
657
+ out.viewRig = pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: d ?? 2, comfortDepth: ctl.comfortDepth }, out.viewRig || {});
658
+ handle?.setViewRig?.(out.viewRig);
659
+ };
660
+ if (typeof opts.onBeforeFrame === 'function') {
661
+ const cb = opts.onBeforeFrame;
662
+ let lastT = 0;
663
+ let warned = false;
664
+ viewer._beforeFrame = (views) => {
665
+ const t = now();
666
+ const dt = lastT ? Math.min((t - lastT) / 1000, MAX_DT_S) : 0;
667
+ lastT = t;
668
+ try {
669
+ cb({ time: t, views, dt });
670
+ } catch (err) {
671
+ if (!warned) {
672
+ warned = true;
673
+ console.warn(`${TAG} onBeforeFrame threw (warned once; frames keep rendering)`, err);
674
+ }
675
+ }
676
+ };
677
+ }
678
+ // (pageRigTRS is the viewer's; referenced so a reader can find where the page matrix lands.)
679
+ void pageRigTRS;
680
+ }
681
+
682
+ const load = (async () => {
683
+ // Fetch + inspect first: which decoders does the asset declare?
684
+ const res = await fetch(src);
685
+ if (!res.ok) throw new Error(`${TAG} HTTP ${res.status} ${res.statusText} for ${src}`);
686
+ const buffer = await res.arrayBuffer();
687
+ if (removed) return out;
688
+ let declared = null;
689
+ try {
690
+ declared = declaredExtensions(gltfJsonOf(buffer));
691
+ } catch {
692
+ declared = null; // not JSON / not a glb: let the engine say what it is
693
+ }
694
+ const wanted = PC_DECODER_KINDS.filter((k) => (k === 'meshopt' && meshoptDecoder) || (declared ? declared.has(PC_DECODERS[k].ext) : false));
695
+
696
+ const app = await viewer.attachEngine(pc, { preserveDrawingBuffer, antialias, patchSplats: false });
697
+ if (!app || removed) return out;
698
+ out.engine = Object.freeze({ app, root: viewer.content, camera: viewer.eye || null });
699
+
700
+ // Lighting before the model, so the first frame the model is in is lit.
701
+ const yaw = Number.isFinite(opts.environmentRotation) ? opts.environmentRotation : 0;
702
+ if (typeof envMap === 'string') useLightingSource(pc, app, await loadTexture(pc, app, envMap), true, yaw);
703
+ else if (envMap && typeof envMap === 'object') useLightingSource(pc, app, envMap, false, yaw);
704
+ else if (environment === 'room' || environment === 'neutral') useNeutralStudio(pc, app, ENV_YAW_DEG + yaw);
705
+ else if (environment === 'studio') addStudioLights(pc, app, viewer.content);
706
+ if (removed) return out;
707
+
708
+ let containerOptions;
709
+ for (const kind of wanted) {
710
+ try {
711
+ if (kind === 'meshopt') {
712
+ const dec = await resolveMeshopt(meshoptDecoder);
713
+ containerOptions = { bufferView: meshoptBufferViewHook(dec) };
714
+ } else {
715
+ await wireEngineDecoder(pc, kind, paths);
716
+ }
717
+ } catch (err) {
718
+ throw decoderError(kind, src, paths, err);
719
+ }
720
+ }
721
+ if (removed) return out;
722
+
723
+ const asset = await new Promise((resolve, reject) => {
724
+ const name = String(src).split('/').pop() || 'model.glb';
725
+ const a = new pc.Asset(name, 'container', { url: src, filename: name, contents: buffer }, null, containerOptions);
726
+ a.once('load', () => resolve(a));
727
+ a.once('error', (e) => {
728
+ const err = e instanceof Error ? e : new Error(String(e));
729
+ const msg = String(err.message);
730
+ const kind = /draco/i.test(msg) ? 'draco' : /basis|ktx2/i.test(msg) ? 'ktx2' : /meshopt/i.test(msg) ? 'meshopt' : wanted.length === 1 ? wanted[0] : null;
731
+ reject(kind ? decoderError(kind, src, paths, err) : err);
732
+ });
733
+ app.assets.add(a);
734
+ app.assets.load(a);
735
+ });
736
+ if (removed) return out;
737
+
738
+ const entity = asset.resource.instantiateRenderEntity();
739
+ viewer.content.addChild(entity);
740
+ out.model = entity;
741
+ out.container = asset.resource; // animations, materials, textures (engine types)
742
+ const bounds = frame || boundsOfEntity(pc, entity);
743
+ if (bounds) {
744
+ out.frame = bounds;
745
+ viewer.fitTo(bounds.center, bounds.extent);
746
+ }
747
+ return out;
748
+ })();
749
+
750
+ return load.catch((err) => {
751
+ console.warn(`${TAG} failed to load (engine:playcanvas)`, src, err);
752
+ throw err;
753
+ });
754
+ }