@displayxr/inline3d 1.15.0 → 1.17.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,78 @@ 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.17.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./model` and `./splat`, `engine: 'playcanvas'` only). The default look
11
+ does not move except on glTFs that use `KHR_materials_transmission`.
12
+
13
+ ### Changed
14
+
15
+ - **`environment: 'room'` is now three's room on the PlayCanvas backend** (#36). `addModel(…, {
16
+ environment: 'room' })` and `setRig('display', { environment: 'room' })` light with three.js's
17
+ procedural RoomEnvironment, regenerated in memory (ray cast, no asset, ~16 ms once per page), and
18
+ default to tone mapping `'none'`, three's. Through 1.16 `'room'` was an alias of `'neutral'` on
19
+ this engine, so a page that passed it to keep its three look got the Sample-Viewer studio with PBR
20
+ Neutral. **The default is now spelled `'neutral'` and renders exactly as before** (Khronos gate
21
+ re-run: 16.7 / 6.4 / 2.7 / 9.4, unchanged). On a four-item product catalogue, object MAE against three r180
22
+ `room` goes from 20.5 / 54.4 / 29.5 / 23.3 (the default) to **10.2 / 11.0 / 7.1 / 6.3** (storm
23
+ lantern / handbag / boot / compass). Tone mapping and the environment explain the gap, and what is
24
+ left is where three departs from the Sample Viewer (PlayCanvas `room` is closer to the Sample
25
+ Viewer lit by the same room than three is: handbag 4.8 vs 13.4). `setRig('display')` with
26
+ `'room'` renders pixel-identical to `addModel` with it. See
27
+ [`docs/playcanvas-model-backend.md` § Environments](docs/playcanvas-model-backend.md#environments--neutral-default-and-room-three).
28
+ - `setRig('display')`'s `toneMapping` now defaults to the environment's (`'none'` for `'room'`,
29
+ `'neutral'` otherwise). A second `setRig('display')` with another environment swaps the IBL it
30
+ installed.
31
+
32
+ ### Fixed
33
+
34
+ - **`KHR_materials_transmission` / `_volume` on PlayCanvas** (#36), for `addModel` and for meshes
35
+ under root on `setRig('display')`:
36
+ - The scene-colour grab pass the material samples is now requested. Without it the storm lantern's
37
+ burner rendered as a magenta blob inside the globe.
38
+ - Transmissive draws sort ahead of blended ones, the pass order of the Sample Viewer and three.
39
+ The lantern's opaque-alpha globe no longer shows the burner through it.
40
+ - The grab UV is taken per view. The engine mapped each view's NDC over the whole target, so in
41
+ stereo each eye sampled across both. Mono renders are identical.
42
+ - Lantern object MAE vs the Sample Viewer (default look): 14.4 → 12.4.
43
+ - See [§ Transmission](docs/playcanvas-model-backend.md#transmission-khr_materials_transmission--_volume).
44
+
45
+ ## 1.16.0 — 2026-09-24
46
+
47
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive. A page that never
48
+ calls `setRig` renders the same pixels, except for the `setSource` fix below.
49
+
50
+ ### Added
51
+
52
+ - **`handle.setRig(type, options?)`** (#36): a live, reversible rig switch on the PlayCanvas splat
53
+ handle. It applies on the next frame with no remount, reload or new session, and returns a
54
+ `Promise<handle>`.
55
+ - `setRig('display', { virtualDisplayHeight, fit, margin, frame, … })` frames the enabled meshes
56
+ under `handle.engine.root`, plus the splat when it is shown. The defaults are addModel's (0.24,
57
+ `contain`, 0.8, depthLimit 4, idleSpin 8), and it declares a display rig.
58
+ - While the splat is hidden, the page's meshes get addModel's tone mapping (`'neutral'`) and, if
59
+ the scene has no `envAtlas` of its own, addModel's neutral-studio IBL. Both are removed when the
60
+ rig switches away.
61
+ - `setRig('camera')` goes back to the asset's capture rig, re-resolved from the waterfall's own
62
+ inputs. `setRig('auto')` is the load-time result.
63
+ - The pose resets to the new rig's rest. `handle.rig.typeSource` reads `'setRig'`. The choice
64
+ sticks across `setSource` until `'auto'`, and `controls:'page'` throws.
65
+ - Gates, on a real GPU: a hidden splat plus `setRig('display')` plus DamagedHelmet or Fox renders
66
+ **MAE 0.0000** against `addModel(url, { engine: 'playcanvas' })`, in mono and fake stereo, with
67
+ `antialias: true`. With the tile's default MSAA off the residual is 0.11–0.44, silhouette edges
68
+ only. The round trip `setRig('display')` → `setRig('camera')` returns to the exact pre-switch
69
+ render (**MAE 0.0000**) and the same camera-rig declaration.
70
+ - See [`docs/playcanvas-adapter.md` § setRig](docs/playcanvas-adapter.md#setrig--switching-between-the-display-rig-and-the-camera-rig-36).
71
+ - **`addSplat(…, { engine: 'playcanvas', antialias: true })`** creates the tile's WebGL context with
72
+ MSAA. The default stays off. Use it when a page draws meshes under `handle.engine.root`.
73
+
74
+ ### Fixed
75
+
76
+ - **`setSource` from a camera-rig asset to a display-rig asset** now declares the display rig to the
77
+ runtime. The camera rig used to stay declared. The new asset also orbits its frame centre again
78
+ (the camera rig's `recentre: false` was left behind).
79
+
8
80
  ## 1.15.0 — 2026-09-24
9
81
 
10
82
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; the existing transitions
package/README.md CHANGED
@@ -45,7 +45,10 @@ API is not yet covered by the semver promise below.
45
45
  renders the same splat window with the [PlayCanvas](https://playcanvas.com/) engine instead of
46
46
  Spark — same handle, same rig/focus/camera-block behaviour, same `perf` presets (mapped onto the
47
47
  engine's knobs) — and adds `setSource(src, { fadeMs })` (crossfading asset swaps), `feather`, a
48
- tilt-and-relax orbit and `handle.engine` (the engine objects, for advanced pages). It needs the
48
+ tilt-and-relax orbit, `handle.engine` (the engine objects, for advanced pages) and
49
+ `handle.setRig('display' | 'camera' | 'auto')`: a live, reversible rig switch, so one persistent tile
50
+ can show a glTF under `handle.engine.root` exactly as `addModel` would and then go back to the photo's
51
+ capture rig ([§setRig](docs/playcanvas-adapter.md#setrig--switching-between-the-display-rig-and-the-camera-rig-36)). It needs the
49
52
  optional peer `playcanvas` (`>=2.22.3 <3`) and reads `.sog`, `.ply` and a Streamed-SOG
50
53
  `lod-meta.json`. **Spark stays the default**; a page that never passes `engine` never loads
51
54
  `playcanvas`. What differs, and why: [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md).
@@ -66,7 +69,8 @@ Splat and model rendering on the PlayCanvas backend uses the PlayCanvas engine (
66
69
  **Models render with PlayCanvas by default (1.12).** `addModel(wall, canvas, 'chair.glb')` loads
67
70
  the glTF with the PlayCanvas engine (optional peer `playcanvas`), lit to match the Khronos glTF
68
71
  Sample Viewer: an in-memory neutral studio environment, Khronos PBR Neutral tone mapping,
69
- exposure 1. `engine: 'three'` is the 1.11 three.js renderer, unchanged (also importable as
72
+ exposure 1. `environment: 'room'` gives three's RoomEnvironment look instead (a page tuned on the
73
+ three path). `engine: 'three'` is the 1.11 three.js renderer, unchanged (also importable as
70
74
  `@displayxr/inline3d/model/three`). Each engine is imported only when a tile asks for it. Draco
71
75
  and KTX2 use the same served decoder folders on both engines. Design, the Sample-Viewer MAE table
72
76
  and what throws: [`docs/playcanvas-model-backend.md`](docs/playcanvas-model-backend.md).
@@ -6,7 +6,7 @@ it are adapted from, or run on, the MIT-licensed projects below. Their notices f
6
6
  | Project | Version | How it is used |
7
7
  |---|---|---|
8
8
  | [PlayCanvas engine](https://github.com/playcanvas/engine) | 2.22.3 | Optional peer (`playcanvas`): the default splat backend and the `./model` backend. Shader code in this package is **adapted** from it: the non-square-pixel footprint rewrite of `src/scene/shader-lib/glsl/chunks/gsplat/vert/gsplatCorner.js` (`js/inline3d-splat-playcanvas.js`), the quad-extent cap in `gsplatCommon.js` (`js/inline3d-splat-perf.js`), and the dissolve effect and its hash/noise/fbm helpers from `scripts/esm/gsplat/shader-effect-dissolve.mjs` (`js/inline3d-splat-effects.js`). Each adapted site carries a provenance comment. |
9
- | [three.js](https://github.com/mrdoob/three.js) | ≥ 0.150 | Optional peer (`three`): the `./three` helpers, the Spark splat path, `./model`'s `engine: 'three'`. |
9
+ | [three.js](https://github.com/mrdoob/three.js) | ≥ 0.150 | Optional peer (`three`): the `./three` helpers, the Spark splat path, `./model`'s `engine: 'three'`. The PlayCanvas backend's `environment: 'room'` is **adapted** from `examples/jsm/environments/RoomEnvironment.js` (its scene numbers, re-rendered by ray casting in `js/inline3d-model-playcanvas.js`, `ROOM_ENVIRONMENT`). |
10
10
  | [Spark](https://github.com/sparkjsdev/spark) | ≥ 2.0 | Optional peer (`@sparkjsdev/spark`): the Spark splat backend. |
11
11
 
12
12
  ## PlayCanvas engine — MIT
@@ -24,6 +24,8 @@
24
24
  // and was fitted to the Sample Viewer's "Studio Neutral". three's path keeps RoomEnvironment
25
25
  // and no tone mapping (unchanged since 1.11); the two backends therefore do not look
26
26
  // identical, by design — docs/playcanvas-model-backend.md §Lighting has the MAE table.
27
+ // `environment: 'room'` is three's room on this engine (§ the room environment), untonemapped,
28
+ // for a page tuned on the three path (§Environments).
27
29
  // · Decoders are the ENGINE's: Draco and Basis through the engine's own worker readers (fed the
28
30
  // SAME files three's `libs/draco/` and `libs/basis/` folders hold), meshopt through the
29
31
  // container's bufferView hook with meshoptimizer's `MeshoptDecoder` (the engine has no
@@ -346,11 +348,14 @@ export function neutralStudioRGBE(width = 256, height = 128, env = NEUTRAL_STUDI
346
348
  export const ENV_YAW_DEG = 0;
347
349
 
348
350
  /** 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;
351
+ export function useNeutralStudio(pc, app, yawDeg) {
352
+ return useRgbeEquirect(pc, app, 'inline3d-neutral-studio', neutralStudioRGBE(256, 128), 256, 128, yawDeg);
353
+ }
354
+
355
+ /** A grey RGBE equirect (row 0 = up) → prefiltered envAtlas on the scene. */
356
+ function useRgbeEquirect(pc, app, name, bytes, W, H, yawDeg) {
352
357
  const tex = new pc.Texture(app.graphicsDevice, {
353
- name: 'inline3d-neutral-studio',
358
+ name,
354
359
  width: W,
355
360
  height: H,
356
361
  format: pc.PIXELFORMAT_RGBA8,
@@ -359,7 +364,7 @@ function useNeutralStudio(pc, app, yawDeg) {
359
364
  mipmaps: false,
360
365
  addressU: pc.ADDRESS_REPEAT,
361
366
  addressV: pc.ADDRESS_CLAMP_TO_EDGE,
362
- levels: [neutralStudioRGBE(W, H)],
367
+ levels: [bytes],
363
368
  });
364
369
  return useLightingSource(pc, app, tex, true, yawDeg);
365
370
  }
@@ -373,7 +378,9 @@ function useLightingSource(pc, app, source, own, yawDeg = 0) {
373
378
  app.scene.envAtlas = atlas;
374
379
  app.scene.skyboxIntensity = 1;
375
380
  app.scene.exposure = 1;
376
- if (pc.Quat && yawDeg) app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(0, yawDeg, 0);
381
+ // Always written (identity at 0): a later environment on the same scene must not inherit the
382
+ // previous one's yaw (setRig switches room ↔ neutral on one app).
383
+ if (pc.Quat) app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(0, yawDeg || 0, 0);
377
384
  return atlas;
378
385
  }
379
386
 
@@ -388,6 +395,244 @@ function loadTexture(pc, app, url) {
388
395
  });
389
396
  }
390
397
 
398
+ // ── the room environment (environment: 'room') ─────────────────────────────────────────────
399
+
400
+ /**
401
+ * `environment: 'room'` — three.js's procedural RoomEnvironment, regenerated here without three:
402
+ * the same scene (a white room lit by one point light, six grey boxes, six emissive panels), shaded
403
+ * the way three shades it when `PMREMGenerator.fromScene(new RoomEnvironment(), 0.04)` bakes it —
404
+ * which is exactly what `addModel(…, { engine: 'three', environment: 'room' })` lights with.
405
+ *
406
+ * It exists so a page that chose `'room'` on the three path gets that look on this backend. It is
407
+ * NOT the default: the default (`'neutral'`) is matched to the Khronos Sample Viewer, and three's
408
+ * room is a different, brighter room (mean radiance ~0.97 vs ~0.89, most of it from a 100-nit
409
+ * ceiling panel and two 50-nit wall panels).
410
+ *
411
+ * The scene's numbers (positions, rotations, scales, intensities) are three.js's
412
+ * examples/jsm/environments/RoomEnvironment.js (MIT, © three.js authors), itself after
413
+ * model-viewer's EnvironmentScene. No image is shipped: `roomRadiance` ray-casts the boxes per
414
+ * texel. Checked against three r180's own bake (its PMREM, read back through `textureCubeUV`):
415
+ * texel-to-texel ratio median 1.000 (p10 0.989, p90 1.006), solid-angle-weighted MAE 0.03 unblurred
416
+ * and 0.04 with the 0.04-rad blur. Solids are [cx, cy, cz, angleY, sx, sy, sz] (boxes) and
417
+ * [cx, cy, cz, sx, sy, sz, emissive] (panels), BoxGeometry(1) scaled — i.e. full extents.
418
+ */
419
+ export const ROOM_ENVIRONMENT = Object.freeze({
420
+ light: [0.418, 16.199, 0.3, 900, 28, 2], // point light: position, intensity (cd), cutoff distance, decay
421
+ room: [-0.757, 13.219, 0.717, 31.713, 28.305, 28.591], // seen from inside
422
+ boxes: [
423
+ [-10.906, 2.009, 1.846, -0.195, 2.328, 7.905, 4.651],
424
+ [-5.607, -0.754, -0.758, 0.994, 1.97, 1.534, 3.955],
425
+ [6.167, 0.857, 7.803, 0.561, 3.927, 6.285, 3.687],
426
+ [-2.017, 0.018, 6.124, 0.333, 2.002, 4.566, 2.064],
427
+ [2.291, -0.756, -2.621, -0.286, 1.546, 1.552, 1.496],
428
+ [-2.193, -0.369, -5.547, 0.516, 3.875, 3.487, 2.986],
429
+ ],
430
+ panels: [
431
+ [-16.116, 14.37, 8.208, 0.1, 2.428, 2.739, 50],
432
+ [-16.109, 18.021, -8.207, 0.1, 2.425, 2.751, 50],
433
+ [14.904, 12.198, -1.832, 0.15, 4.265, 6.331, 17],
434
+ [-0.462, 8.89, 14.52, 4.38, 5.441, 0.088, 43],
435
+ [3.235, 11.486, -12.541, 2.5, 2.0, 0.1, 20],
436
+ [0.0, 20.0, 0.0, 1.0, 0.1, 1.0, 100],
437
+ ],
438
+ blur: 0.04, // radians — the sigma addModel's three path passes to fromScene
439
+ });
440
+
441
+ /** Every solid as [cx, cy, cz, cosY, sinY, hx, hy, hz, kind (0 room, 1 lit box, 2 panel), emissive]. */
442
+ const _roomSolids = new WeakMap();
443
+ function roomSolids(env) {
444
+ let out = _roomSolids.get(env);
445
+ if (out) return out;
446
+ const [rx, ry, rz, sx, sy, sz] = env.room;
447
+ out = [[rx, ry, rz, 1, 0, sx / 2, sy / 2, sz / 2, 0, 0]];
448
+ for (const [px, py, pz, a, bx, by, bz] of env.boxes) out.push([px, py, pz, Math.cos(a), Math.sin(a), bx / 2, by / 2, bz / 2, 1, 0]);
449
+ for (const [px, py, pz, bx, by, bz, e] of env.panels) out.push([px, py, pz, 1, 0, bx / 2, by / 2, bz / 2, 2, e]);
450
+ _roomSolids.set(env, out);
451
+ return out;
452
+ }
453
+
454
+ /**
455
+ * Radiance of the room seen from the origin toward unit direction (x, y, z): the nearest solid
456
+ * along the ray. A panel is its emissive value; a wall or box (white, roughness 1, non-metal) is
457
+ * three's direct physical shading from the one point light, unshadowed as in three (the scene casts
458
+ * no shadows) — Lambert plus GGX at alpha 1 (D = 1/π, V = 0.5/(NL+NV), Schlick F0 0.04), with
459
+ * three's inverse-square falloff and its (1 − (d/cutoff)⁴)² window.
460
+ */
461
+ export function roomRadiance(x, y, z, env = ROOM_ENVIRONMENT) {
462
+ let best = Infinity;
463
+ let nx = 0;
464
+ let ny = 0;
465
+ let nz = 0;
466
+ let emit = -1;
467
+ let found = false;
468
+ for (const b of roomSolids(env)) {
469
+ const c = b[3];
470
+ const s = b[4];
471
+ // The ray in the solid's frame (a rotation about Y): local = Rᵀ(p − centre).
472
+ const ox = -b[0];
473
+ const oy = -b[1];
474
+ const oz = -b[2];
475
+ const o = [c * ox - s * oz, oy, s * ox + c * oz];
476
+ const d = [c * x - s * z, y, s * x + c * z];
477
+ let t0 = -Infinity;
478
+ let t1 = Infinity;
479
+ let a0 = 0;
480
+ let a1 = 0;
481
+ let miss = false;
482
+ for (let i = 0; i < 3; i++) {
483
+ const h = b[5 + i];
484
+ if (Math.abs(d[i]) < 1e-12) {
485
+ if (o[i] < -h || o[i] > h) {
486
+ miss = true;
487
+ break;
488
+ }
489
+ continue;
490
+ }
491
+ let n = (-h - o[i]) / d[i];
492
+ let f = (h - o[i]) / d[i];
493
+ let sn = -1; // the entry face's outward normal is −axis when the ray runs +axis
494
+ if (n > f) {
495
+ const t = n;
496
+ n = f;
497
+ f = t;
498
+ sn = 1;
499
+ }
500
+ if (n > t0) {
501
+ t0 = n;
502
+ a0 = (i + 1) * sn;
503
+ }
504
+ if (f < t1) {
505
+ t1 = f;
506
+ a1 = -(i + 1) * sn;
507
+ }
508
+ }
509
+ if (miss || t0 > t1 || t1 < 0) continue;
510
+ const inside = b[8] === 0;
511
+ const t = inside ? t1 : t0;
512
+ if (!(t > 0) || t >= best) continue;
513
+ best = t;
514
+ found = true;
515
+ if (b[8] === 2) {
516
+ emit = b[9];
517
+ continue;
518
+ }
519
+ emit = -1;
520
+ const a = inside ? a1 : a0;
521
+ const sg = (inside ? -1 : 1) * Math.sign(a); // the room is seen from inside: its normals point in
522
+ const ax = Math.abs(a) - 1;
523
+ const l0 = ax === 0 ? sg : 0;
524
+ const l2 = ax === 2 ? sg : 0;
525
+ nx = c * l0 + s * l2;
526
+ ny = ax === 1 ? sg : 0;
527
+ nz = -s * l0 + c * l2;
528
+ }
529
+ if (!found) return 0;
530
+ if (emit >= 0) return emit;
531
+ const [lx, ly, lz, I, cutoff, decay] = env.light;
532
+ let tx = lx - x * best;
533
+ let ty = ly - y * best;
534
+ let tz = lz - z * best;
535
+ const dist = Math.hypot(tx, ty, tz);
536
+ tx /= dist;
537
+ ty /= dist;
538
+ tz /= dist;
539
+ const nl = nx * tx + ny * ty + nz * tz;
540
+ if (nl <= 0) return 0;
541
+ const win = Math.max(0, Math.min(1, 1 - Math.pow(dist / cutoff, 4)));
542
+ const E = (I / Math.max(Math.pow(dist, decay), 0.01)) * win * win * nl;
543
+ const nv = Math.max(1e-4, -(nx * x + ny * y + nz * z));
544
+ const hx = tx - x;
545
+ const hy = ty - y;
546
+ const hz = tz - z;
547
+ const vh = Math.max(0, (tx * hx + ty * hy + tz * hz) / Math.hypot(hx, hy, hz));
548
+ const F = 0.04 + 0.96 * Math.pow(1 - vh, 5);
549
+ return (E / Math.PI) * (1 + F * (0.5 / (nl + nv)));
550
+ }
551
+
552
+ /**
553
+ * The room as a float equirect (row 0 = up, the same convention as neutralStudioRGBE), blurred by a
554
+ * Gaussian of `sigma` radians — separable on the sphere: σ in rows, σ/sinθ in columns (wrapping).
555
+ */
556
+ export function roomEquirect(W = 256, H = 128, sigma = ROOM_ENVIRONMENT.blur, env = ROOM_ENVIRONMENT) {
557
+ const out = new Float32Array(W * H);
558
+ for (let r = 0; r < H; r++) {
559
+ const th = ((r + 0.5) / H) * Math.PI;
560
+ const st = Math.sin(th);
561
+ const y = Math.cos(th);
562
+ for (let c = 0; c < W; c++) {
563
+ const ph = ((c + 0.5) / W) * 2 * Math.PI;
564
+ out[r * W + c] = roomRadiance(st * Math.cos(ph), y, st * Math.sin(ph), env);
565
+ }
566
+ }
567
+ if (!(sigma > 0)) return out;
568
+ const perRow = Math.PI / H; // radians per row, and per column at the equator
569
+ const tmp = new Float32Array(W * H);
570
+ for (let r = 0; r < H; r++) {
571
+ const st = Math.max(Math.sin(((r + 0.5) / H) * Math.PI), 1e-3);
572
+ const sg = Math.min(sigma / perRow / st, W / 4);
573
+ const R = Math.ceil(3 * sg);
574
+ const k = [];
575
+ let ks = 0;
576
+ for (let i = -R; i <= R; i++) {
577
+ const v = Math.exp(-(i * i) / (2 * sg * sg));
578
+ k.push(v);
579
+ ks += v;
580
+ }
581
+ for (let c = 0; c < W; c++) {
582
+ let a = 0;
583
+ for (let i = -R; i <= R; i++) a += k[i + R] * out[r * W + (((c + i) % W) + W) % W];
584
+ tmp[r * W + c] = a / ks;
585
+ }
586
+ }
587
+ const sg = sigma / perRow;
588
+ const R = Math.ceil(3 * sg);
589
+ for (let r = 0; r < H; r++) {
590
+ for (let c = 0; c < W; c++) {
591
+ let a = 0;
592
+ let ks = 0;
593
+ for (let i = -R; i <= R; i++) {
594
+ const rr = r + i;
595
+ if (rr < 0 || rr >= H) continue;
596
+ const v = Math.exp(-(i * i) / (2 * sg * sg));
597
+ a += v * tmp[rr * W + c];
598
+ ks += v;
599
+ }
600
+ out[r * W + c] = a / ks;
601
+ }
602
+ }
603
+ return out;
604
+ }
605
+
606
+ /** The room as RGBE bytes (cached per size: every tile on a page gets the same image). */
607
+ const _roomCache = new Map();
608
+ export function roomRGBE(W = 256, H = 128) {
609
+ const key = `${W}x${H}`;
610
+ let bytes = _roomCache.get(key);
611
+ if (!bytes) {
612
+ const f = roomEquirect(W, H);
613
+ bytes = new Uint8Array(W * H * 4);
614
+ for (let i = 0; i < f.length; i++) rgbe(f[i], bytes, i * 4);
615
+ _roomCache.set(key, bytes);
616
+ }
617
+ return bytes.slice();
618
+ }
619
+
620
+ /**
621
+ * Yaw that lines the generated room up with three's world (its +X panel on +X, the ceiling panel
622
+ * overhead). Calibrated headless against three r180's own `environment: 'room'` render on two
623
+ * catalogue meshes: a sweep over 0/90/180/270 × mirrored has a clean minimum at 90, unmirrored, on
624
+ * both. (The neutral studio's 0 is the Sample Viewer's orientation, a different frame.)
625
+ */
626
+ export const ROOM_YAW_DEG = 90;
627
+
628
+ /** Build the room environment on this app and set it as the scene's envAtlas. */
629
+ export function useRoomEnvironment(pc, app, yawDeg = ROOM_YAW_DEG) {
630
+ return useRgbeEquirect(pc, app, 'inline3d-room', roomRGBE(256, 128), 256, 128, yawDeg);
631
+ }
632
+
633
+ /** Each environment's tone mapping: the look it was matched to. */
634
+ export const ENVIRONMENT_TONE_MAPPING = Object.freeze({ neutral: 'neutral', room: 'none', studio: 'neutral', none: 'neutral' });
635
+
391
636
  /**
392
637
  * `environment: 'studio'` — three's three-point rig, same directions and ratios. Engine lights
393
638
  * shine along their local −Y, so each is aimed by rotation (never lookAt). three's punctual
@@ -430,6 +675,100 @@ function addStudioLights(pc, app, root) {
430
675
  app.scene.ambientLight = new pc.Color(amb, amb, amb);
431
676
  }
432
677
 
678
+ // ── KHR_materials_transmission / volume on this engine ─────────────────────────────────────
679
+
680
+ /**
681
+ * The engine reads KHR_materials_transmission (and _volume) into `useDynamicRefraction` +
682
+ * `BLEND_NORMAL`, and a dynamic-refraction material samples the camera's SCENE COLOUR MAP. Three
683
+ * things the engine leaves to the app, all needed for a glass or transmissive glTF to look like
684
+ * it does in the Khronos Sample Viewer (and three):
685
+ *
686
+ * 1. THE GRAB PASS. Nothing renders the scene colour map unless a camera asks for it; without it
687
+ * the material samples an unbound texture — the storm lantern's burner rendered as a MAGENTA
688
+ * blob inside its globe (measured). `viewer.useSceneColor()` asks, on every eye camera.
689
+ * 2. PASS ORDER. Both references draw opaque → transmissive → blended. The engine puts a
690
+ * transmissive material in the ONE back-to-front transparent list with the blended ones, so a
691
+ * blended globe (depthWrite off) can be drawn before the transmissive body behind it, which
692
+ * then paints over it — the burner showed THROUGH the lantern's opaque-alpha globe. A sort
693
+ * distance that puts transmissive draws first (back-to-front among themselves) restores the
694
+ * order: object MAE 10.7 → 10.2 vs three, 11.2 → 10.8 vs the Sample Viewer.
695
+ * 3. STEREO. The engine turns the refracted point's clip position into a grab-texture UV by
696
+ * mapping the VIEW's NDC over the WHOLE target (`getGrabScreenPos`). A stereo tile draws two
697
+ * views side by side into one target, so each eye would sample across both. The patched chunk
698
+ * measures the offset from THIS fragment instead (NDC delta ÷ NDC-per-pixel, from the
699
+ * derivatives), which is exact for any viewport and identical in mono.
700
+ */
701
+ const GRAB_UV_FN = `
702
+ vec2 inline3dGrabUV(vec4 clipPos) {
703
+ vec4 p0 = matrix_viewProjection * vec4(vPositionW, 1.0);
704
+ vec2 n0 = p0.xy / p0.w;
705
+ vec2 dn = clipPos.xy / clipPos.w - n0;
706
+ vec2 perPx = vec2(dFdx(n0.x), dFdy(n0.y));
707
+ vec2 off = vec2(abs(perPx.x) > 1e-9 ? dn.x / perPx.x : 0.0, abs(perPx.y) > 1e-9 ? dn.y / perPx.y : 0.0);
708
+ return (gl_FragCoord.xy + off) * uScreenSize.zw;
709
+ }
710
+ `;
711
+ const _grabChunk = new WeakMap();
712
+ let _warnedGrabChunk = false;
713
+
714
+ /** The engine's refractionDynamicPS with the per-view grab UV (null if its shape is not the expected one). */
715
+ function stereoRefractionChunk(pc, device) {
716
+ if (_grabChunk.has(device)) return _grabChunk.get(device);
717
+ let out = null;
718
+ try {
719
+ const src = pc.ShaderChunks?.get?.(device, pc.SHADERLANGUAGE_GLSL)?.get?.('refractionDynamicPS');
720
+ const call = /getGrabScreenPos\(\s*projectionPoint\s*\)/;
721
+ const def = /vec3\s+evalRefractionColor\s*\(/;
722
+ if (typeof src === 'string' && call.test(src) && def.test(src)) {
723
+ out = src.replace(def, (m) => `${GRAB_UV_FN}\n${m}`).replace(call, 'inline3dGrabUV(projectionPoint)');
724
+ }
725
+ } catch {
726
+ out = null;
727
+ }
728
+ if (!out && !_warnedGrabChunk) {
729
+ _warnedGrabChunk = true;
730
+ console.warn(`${TAG} this engine's refraction shader has an unexpected shape; transmissive materials keep the engine's grab UV (correct in mono, not per eye in stereo).`);
731
+ }
732
+ _grabChunk.set(device, out);
733
+ return out;
734
+ }
735
+
736
+ /** Transmissive draws first, back-to-front among themselves (the engine's own key + an offset). */
737
+ function transmissiveFirst(mi, camPos, camFwd) {
738
+ const c = mi.aabb.center;
739
+ return 1e6 + (c.x - camPos.x) * camFwd.x + (c.y - camPos.y) * camFwd.y + (c.z - camPos.z) * camFwd.z;
740
+ }
741
+
742
+ /**
743
+ * Apply 1–3 above to every dynamic-refraction mesh under `entity` not already handled (`seen`).
744
+ * Returns how many transmissive mesh instances there are under it (handled now or before) —
745
+ * the caller turns the grab pass on when that is > 0. Cheap to call again: handled ones are skipped.
746
+ */
747
+ export function prepareTransmission(pc, entity, device, seen = new WeakSet()) {
748
+ const renders = entity?.findComponents ? entity.findComponents('render') : [];
749
+ let n = 0;
750
+ for (const r of renders) {
751
+ for (const mi of r.meshInstances || []) {
752
+ const m = mi.material;
753
+ if (!m?.useDynamicRefraction) continue;
754
+ n++;
755
+ if (seen.has(mi)) continue;
756
+ seen.add(mi);
757
+ mi.calculateSortDistance = transmissiveFirst;
758
+ if (!seen.has(m)) {
759
+ seen.add(m);
760
+ const chunk = device ? stereoRefractionChunk(pc, device) : null;
761
+ if (chunk && typeof m.getShaderChunks === 'function') {
762
+ m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('refractionDynamicPS', chunk);
763
+ m.shaderChunksVersion = pc.CHUNKAPI_2_8 ?? '2.8';
764
+ m.update?.();
765
+ }
766
+ }
767
+ }
768
+ }
769
+ return n;
770
+ }
771
+
433
772
  // ── framing ─────────────────────────────────────────────────────────────────────────────────
434
773
 
435
774
  /** Union of the render mesh instances' world AABBs (content space, the file's own units). */
@@ -535,7 +874,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
535
874
  fitSweep = true,
536
875
  renderScale = 1,
537
876
  feather = 0,
538
- environment = 'room',
877
+ environment = 'neutral',
539
878
  envMap = null,
540
879
  decoderPath = null,
541
880
  meshoptDecoder = null,
@@ -568,7 +907,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
568
907
  nearClip: opts.nearClip,
569
908
  farClip: opts.farClip,
570
909
  sky: false,
571
- toneMapping: 'neutral',
910
+ // Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
911
+ toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
572
912
  });
573
913
 
574
914
  let handle = null;
@@ -701,7 +1041,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
701
1041
  const yaw = Number.isFinite(opts.environmentRotation) ? opts.environmentRotation : 0;
702
1042
  if (typeof envMap === 'string') useLightingSource(pc, app, await loadTexture(pc, app, envMap), true, yaw);
703
1043
  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);
1044
+ else if (environment === 'room') useRoomEnvironment(pc, app, ROOM_YAW_DEG + yaw);
1045
+ else if (environment === 'neutral') useNeutralStudio(pc, app, ENV_YAW_DEG + yaw);
705
1046
  else if (environment === 'studio') addStudioLights(pc, app, viewer.content);
706
1047
  if (removed) return out;
707
1048
 
@@ -736,6 +1077,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
736
1077
  if (removed) return out;
737
1078
 
738
1079
  const entity = asset.resource.instantiateRenderEntity();
1080
+ // KHR_materials_transmission / volume: grab pass, pass order, per-eye grab UV (§ prepareTransmission).
1081
+ if (prepareTransmission(pc, entity, app.graphicsDevice) > 0) viewer.useSceneColor(true);
739
1082
  viewer.content.addChild(entity);
740
1083
  out.model = entity;
741
1084
  out.container = asset.resource; // animations, materials, textures (engine types)
@@ -53,6 +53,7 @@ export {
53
53
  SEMANTIC_TEXCOORD0,
54
54
  ShaderChunks,
55
55
  SHADERLANGUAGE_GLSL,
56
+ CHUNKAPI_2_8,
56
57
  // setSource's frame snapshot
57
58
  RenderTarget,
58
59
  FILTER_NEAREST,
@@ -50,7 +50,7 @@ import {
50
50
  budgetPerManager,
51
51
  } from './inline3d-splat-perf.js';
52
52
  import { boundsFromPositions, boundsFromPositionsAsync } from './inline3d-viewer.js';
53
- import { cameraRigFromPose } from './inline3d-three.js';
53
+ import { cameraRigFromPose, displayRig } from './inline3d-three.js';
54
54
  import {
55
55
  SplatEffects,
56
56
  EFFECTS,
@@ -652,6 +652,7 @@ export class PlayCanvasSplatViewer {
652
652
  // loaded). 'none' for splats — their colours are already display-referred; ./model's mesh
653
653
  // tiles pass 'neutral' (Khronos PBR Neutral), the glTF Sample Viewer's default.
654
654
  this.toneMapping = toneMapping;
655
+ this.sceneColor = false;
655
656
  // Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
656
657
  // other; splats only test against them). The projections' own near/far stay the adapter's —
657
658
  // these only raise the near (floor) and lower the far (cap). Unset: untouched.
@@ -912,6 +913,33 @@ export class PlayCanvasSplatViewer {
912
913
  this._updateMonoProjection();
913
914
  }
914
915
 
916
+ /**
917
+ * Switch the eye camera(s) to another tone mapping, by name (TONE_MAPPINGS). Live: the engine
918
+ * picks the new shader variant on the next draw. handle.setRig's display rig uses it to give a
919
+ * page's meshes addModel's 'neutral' while the splat is hidden.
920
+ */
921
+ setToneMapping(name) {
922
+ const key = TONE_MAPPINGS[name] ? name : 'none';
923
+ this.toneMapping = key;
924
+ const pc = this.pc;
925
+ if (!pc) return;
926
+ const v = pc[TONE_MAPPINGS[key]] ?? pc.TONEMAP_NONE;
927
+ if (this.eye?.camera) this.eye.camera.toneMapping = v;
928
+ if (this._viewPath === 'cameras') for (const c of this._views) if (c?.camera) c.camera.toneMapping = v;
929
+ }
930
+
931
+ /**
932
+ * Turn the scene-colour grab pass on (true) or off on every eye camera — what a
933
+ * KHR_materials_transmission material samples (./model's prepareTransmission). Idempotent: the
934
+ * engine's `renderSceneColorMap` setter counts one request per camera. Cameras made later (a
935
+ * view-path switch) inherit it via `_makeCamera`.
936
+ */
937
+ useSceneColor(on = true) {
938
+ this.sceneColor = !!on;
939
+ const cams = [this.eye, ...(this._viewPath === 'cameras' ? this._views : [])];
940
+ for (const c of cams) if (c?.camera && c.camera.renderSceneColorMap !== undefined) c.camera.renderSceneColorMap = this.sceneColor;
941
+ }
942
+
915
943
  /** Forget the auto-fit (scale 1, no subject box) — the camera rig's framing. */
916
944
  resetFit() {
917
945
  this._fitScale = 1;
@@ -1475,6 +1503,7 @@ export class PlayCanvasSplatViewer {
1475
1503
  // decodeGamma → toneMap → gammaCorrectOutput. Spark writes the stored colour straight out;
1476
1504
  // NONE is the same thing here (GAMMA_SRGB alone leaves a gamma-space colour untouched).
1477
1505
  e.camera.toneMapping = TONE_MAPPINGS[this.toneMapping] ? pc[TONE_MAPPINGS[this.toneMapping]] ?? pc.TONEMAP_NONE : pc.TONEMAP_NONE;
1506
+ if (this.sceneColor) e.camera.renderSceneColorMap = true;
1478
1507
  this.rigNode.addChild(e);
1479
1508
  return e;
1480
1509
  }
@@ -2151,6 +2180,105 @@ export function describeResource(res) {
2151
2180
  * one-pass fade, with the coverage-linear alpha remap on both assets.
2152
2181
  */
2153
2182
 
2183
+ /**
2184
+ * handle.setRig's DISPLAY-rig defaults: addModel's, so a mesh framed through setRig('display')
2185
+ * sits exactly where `addModel(url, { engine: 'playcanvas' })` puts it (docs/playcanvas-adapter.md
2186
+ * §setRig). `toneMapping` applies to the page's meshes while the splat is hidden (the splat keeps
2187
+ * 'none' whenever it is shown); `environment` installs addModel's IBL of the same name when the
2188
+ * page has no `scene.envAtlas` of its own, and removes it again when the rig changes back —
2189
+ * `'neutral'` (default, the Sample Viewer's studio) or `'room'` (three's RoomEnvironment; its
2190
+ * tone mapping defaults to `'none'`, three's, unless `toneMapping` is passed).
2191
+ */
2192
+ export const SET_RIG_DISPLAY_DEFAULTS = Object.freeze({
2193
+ virtualDisplayHeight: 0.24,
2194
+ fit: 'contain',
2195
+ margin: 0.8,
2196
+ depthLimit: DEFAULT_DEPTH_LIMIT,
2197
+ fitSweep: true,
2198
+ idleSpin: 8,
2199
+ ipdFactor: 1,
2200
+ parallaxFactor: 1,
2201
+ perspectiveFactor: 1,
2202
+ toneMapping: 'neutral',
2203
+ environment: 'neutral',
2204
+ });
2205
+
2206
+ /** The tone mapping each setRig environment defaults to (addModel's ENVIRONMENT_TONE_MAPPING). */
2207
+ const SET_RIG_ENV_TONE_MAPPING = Object.freeze({ neutral: 'neutral', room: 'none', none: 'neutral' });
2208
+
2209
+ export const SET_RIG_TYPES = Object.freeze(['display', 'camera', 'auto']);
2210
+ const SET_RIG_FITS = ['contain', 'cover', 'height', 'none'];
2211
+ let warnedSetRigKeys = false;
2212
+
2213
+ const PAGE_SETRIG_ERROR =
2214
+ "@displayxr/inline3d/splat: setRig() is not available with controls:'page' — the page owns the " +
2215
+ 'camera (its camera IS the rig). Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
2216
+
2217
+ /**
2218
+ * handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
2219
+ * @returns {{type:'display'|'camera'|'auto', o?:object}}
2220
+ */
2221
+ export function validateSetRig(type, o = {}, pageMode = false) {
2222
+ if (pageMode) throw new Error(PAGE_SETRIG_ERROR);
2223
+ if (!SET_RIG_TYPES.includes(type)) {
2224
+ throw new Error(`@displayxr/inline3d/splat: setRig("${type}") — expected 'display', 'camera' or 'auto'.`);
2225
+ }
2226
+ if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setRig options must be an object.');
2227
+ if (type !== 'display') return { type };
2228
+ const D = SET_RIG_DISPLAY_DEFAULTS;
2229
+ const known = new Set([...Object.keys(D), 'frame']);
2230
+ const unknown = Object.keys(o).filter((k) => !known.has(k));
2231
+ if (unknown.length && !warnedSetRigKeys) {
2232
+ warnedSetRigKeys = true;
2233
+ console.warn(
2234
+ `[inline3d/splat] setRig('display') ignores ${unknown.join(', ')}` +
2235
+ (unknown.includes('transitionMs') ? ' — a rig switch is a clean cut (no eased transition in this version)' : '') +
2236
+ '.',
2237
+ );
2238
+ }
2239
+ const num = (k, ok) => {
2240
+ const v = o[k] === undefined ? D[k] : o[k];
2241
+ if (!Number.isFinite(v) || !ok(v)) throw new Error(`@displayxr/inline3d/splat: setRig('display') — bad ${k}: ${o[k]}.`);
2242
+ return v;
2243
+ };
2244
+ const fit = o.fit === undefined ? D.fit : o.fit;
2245
+ if (!SET_RIG_FITS.includes(fit)) throw new Error(`@displayxr/inline3d/splat: setRig('display') — fit "${fit}", expected ${SET_RIG_FITS.join(' | ')}.`);
2246
+ const environment = o.environment === undefined ? D.environment : o.environment;
2247
+ if (!['room', 'neutral', 'none'].includes(environment)) {
2248
+ throw new Error(`@displayxr/inline3d/splat: setRig('display') — environment "${environment}", expected 'room' | 'neutral' | 'none'.`);
2249
+ }
2250
+ const toneMapping = o.toneMapping === undefined ? SET_RIG_ENV_TONE_MAPPING[environment] : o.toneMapping;
2251
+ if (!TONE_MAPPINGS[toneMapping]) {
2252
+ throw new Error(`@displayxr/inline3d/splat: setRig('display') — toneMapping "${toneMapping}", expected ${Object.keys(TONE_MAPPINGS).join(' | ')}.`);
2253
+ }
2254
+ let frame = null;
2255
+ if (o.frame != null) {
2256
+ const c = o.frame.center;
2257
+ const e = o.frame.extent;
2258
+ const v3 = (v) => (Array.isArray(v) || ArrayBuffer.isView(v) ? v.length >= 3 && [0, 1, 2].every((i) => Number.isFinite(v[i])) : v && [v.x, v.y, v.z].every(Number.isFinite));
2259
+ if (!v3(c) || !v3(e)) throw new Error("@displayxr/inline3d/splat: setRig('display') — frame must be { center: [x,y,z], extent: [x,y,z] }.");
2260
+ const a3 = (v) => (Array.isArray(v) || ArrayBuffer.isView(v) ? [v[0], v[1], v[2]] : [v.x, v.y, v.z]);
2261
+ frame = { center: a3(c), extent: a3(e) };
2262
+ }
2263
+ return {
2264
+ type,
2265
+ o: {
2266
+ vH: num('virtualDisplayHeight', (v) => v > 0),
2267
+ fit,
2268
+ margin: num('margin', (v) => v > 0),
2269
+ depthLimit: num('depthLimit', (v) => v > 0),
2270
+ fitSweep: o.fitSweep === undefined ? D.fitSweep : o.fitSweep !== false,
2271
+ idleSpin: num('idleSpin', () => true),
2272
+ ipdFactor: num('ipdFactor', (v) => v >= 0),
2273
+ parallaxFactor: num('parallaxFactor', (v) => v >= 0),
2274
+ perspectiveFactor: num('perspectiveFactor', (v) => v > 0),
2275
+ toneMapping,
2276
+ environment,
2277
+ frame,
2278
+ },
2279
+ };
2280
+ }
2281
+
2154
2282
  /** setSource's transitions and their defaults. */
2155
2283
  export const SOURCE_TRANSITIONS = Object.freeze({
2156
2284
  cut: {},
@@ -2248,6 +2376,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2248
2376
  observe,
2249
2377
  firstWovenHoldMs,
2250
2378
  preserveDrawingBuffer = false,
2379
+ antialias = false,
2251
2380
  } = opts;
2252
2381
  // `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
2253
2382
  // ROTATES (one directional sort serves every view), not on a timer — docs/playcanvas-adapter.md.
@@ -2361,6 +2490,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2361
2490
  },
2362
2491
  setSource,
2363
2492
  prepareSource,
2493
+ setRig,
2364
2494
  /**
2365
2495
  * Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
2366
2496
  * docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
@@ -2588,6 +2718,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2588
2718
 
2589
2719
  // ── focus → view rig ──
2590
2720
  let lastConvergence = Number.NaN;
2721
+ // The camera rig's descriptor object (rewritten in place per focus change) and the DISPLAY rig
2722
+ // declared through setViewRig, if any. `declaredDisplay` null + no camera rig pushed = the
2723
+ // `virtualDisplayHeight` shorthand addScene was built with, which is where every non-page tile
2724
+ // starts. A display rig after a camera rig has to be SENT (the shorthand cannot be restored),
2725
+ // as the explicit descriptor it is shorthand for (displayRig: identity pose, factors 1).
2726
+ let camRigObj = null;
2727
+ let declaredDisplay = pageMode ? 'page' : 'shorthand';
2591
2728
  function pushViewRig(force) {
2592
2729
  if (pageMode) return; // pageTick declares the rig every frame
2593
2730
  if (!out.rig || out.rig.type !== 'camera') return;
@@ -2600,7 +2737,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2600
2737
  lastConvergence = d;
2601
2738
  out.rig.convergence = d;
2602
2739
  const q = quatFromMatrix(pose);
2603
- out.viewRig = cameraRigFromPose(
2740
+ camRigObj ||= {};
2741
+ declaredDisplay = null;
2742
+ out.viewRig = camRigObj = cameraRigFromPose(
2604
2743
  {
2605
2744
  position: { x: pose[12], y: pose[13], z: pose[14] },
2606
2745
  orientation: { x: q[0], y: q[1], z: q[2], w: q[3] },
@@ -2610,7 +2749,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2610
2749
  convergence: d > 0 ? d : 0,
2611
2750
  ipdFactor: out.rig.ipdFactor,
2612
2751
  parallaxFactor: out.rig.parallaxFactor,
2613
- out: out.viewRig || {},
2752
+ out: camRigObj,
2614
2753
  },
2615
2754
  );
2616
2755
  handle?.setViewRig?.(out.viewRig);
@@ -2821,16 +2960,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2821
2960
  ? lift(frame)
2822
2961
  : null;
2823
2962
 
2824
- const sample = loaded.pre?.rest || null;
2825
- const box = canvas.getBoundingClientRect();
2826
- const resolved = resolveRig({
2827
- camera: loaded.camera,
2828
- opts: rigOpts,
2829
- cloud: sample,
2830
- canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
2831
- });
2832
- resolved.focusDefault = resolved.focus.slice();
2833
- resolved.focusDefaultSource = resolved.focusSource;
2963
+ // What the waterfall was fed, kept with the asset: handle.setRig re-resolves from exactly this
2964
+ // (never from a stale copy of the result), so setRig('camera') / ('auto') land where a load did.
2965
+ const rigIn = { camera: loaded.camera, sample: loaded.pre?.rest || null, bounds };
2966
+ // A setRig choice is STICKY across setSource: the new asset is resolved on the chosen rig.
2967
+ const resolved = resolveFor(rigIn, rigOverride ? rigOverride.type : 'auto');
2834
2968
  perfSpan('applyLoaded:rig', tB);
2835
2969
  out.camera = loaded.camera;
2836
2970
  out.rig = resolved;
@@ -2862,8 +2996,66 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2862
2996
  }),
2863
2997
  );
2864
2998
  }
2865
- } else if (resolved.type === 'camera') {
2999
+ } else {
3000
+ applyRig(resolved, bounds, false);
3001
+ }
3002
+ return { pickCentres, alpha8: cloud?.alpha8 || null, rigIn };
3003
+ }
3004
+
3005
+ /** resolveRig on an asset's kept inputs: 'auto' = the load-time waterfall, else forced. */
3006
+ function resolveFor(rigIn, type) {
3007
+ const box = canvas.getBoundingClientRect();
3008
+ const resolved = resolveRig({
3009
+ camera: rigIn.camera,
3010
+ opts: type === 'auto' ? rigOpts : { ...rigOpts, rig: type },
3011
+ cloud: rigIn.sample,
3012
+ canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
3013
+ });
3014
+ if (type !== 'auto') resolved.typeSource = 'setRig';
3015
+ resolved.focusDefault = resolved.focus.slice();
3016
+ resolved.focusDefaultSource = resolved.focusSource;
3017
+ return resolved;
3018
+ }
3019
+
3020
+ /** The framing knobs the viewer's fit reads, as addSplat was booted with. */
3021
+ const bootFraming = { vH: virtualDisplayHeight, fit, margin, depthLimit, fitSweep };
3022
+ function setFraming(f) {
3023
+ viewer.vH = f.vH;
3024
+ viewer.fit = f.fit;
3025
+ viewer.margin = f.margin;
3026
+ viewer.depthLimit = f.depthLimit;
3027
+ viewer.fitSweep = f.fitSweep;
3028
+ }
3029
+
3030
+ /** Declare a DISPLAY rig to the runtime, unless that exact one is already declared. */
3031
+ function declareDisplay(f) {
3032
+ const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
3033
+ if (declaredDisplay === key) return;
3034
+ // Back at the boot rig from the shorthand it was built with: nothing to say.
3035
+ if (declaredDisplay === 'shorthand' && key === `${bootFraming.vH}|1|1|1`) return;
3036
+ declaredDisplay = key;
3037
+ lastConvergence = Number.NaN;
3038
+ out.viewRig = displayRig({
3039
+ virtualDisplayHeight: f.vH,
3040
+ ipdFactor: f.ipdFactor ?? 1,
3041
+ parallaxFactor: f.parallaxFactor ?? 1,
3042
+ perspectiveFactor: f.perspectiveFactor ?? 1,
3043
+ });
3044
+ handle?.setViewRig?.(out.viewRig);
3045
+ }
3046
+
3047
+ /**
3048
+ * Put the viewer on a resolved rig: lens, framing, focus, and the rig declared to the runtime.
3049
+ * Shared by every load / setSource (reset=false: the pose is kept) and handle.setRig
3050
+ * (reset=true: the pose goes back to the rig's rest, yaw = pitch = 0, zoom 1, depth 0).
3051
+ */
3052
+ function applyRig(resolved, bounds, reset) {
3053
+ const disp = rigOverride?.type === 'display' ? rigOverride.o : null;
3054
+ if (reset) viewer.resetPose();
3055
+ if (resolved.type === 'camera') {
3056
+ setFraming(bootFraming);
2866
3057
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
3058
+ else if (reset) viewer.idleSpin = idleSpin;
2867
3059
  // The camera rig never auto-fits (the capture IS the framing): a scale left by a previous
2868
3060
  // display-rig asset (setSource) must not shrink this one.
2869
3061
  viewer.resetFit();
@@ -2871,15 +3063,200 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2871
3063
  viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: false });
2872
3064
  lastConvergence = Number.NaN;
2873
3065
  pushViewRig(true);
2874
- } else {
2875
- if (viewer.mono.capture) viewer.useDisplayCamera();
2876
- if (bounds) viewer.fitTo(bounds.center, bounds.extent);
2877
- else console.warn('[inline3d/splat] no usable bounds — subject is UNFRAMED (model scale)', src);
2878
- if (resolved.focusSource === 'caller' || resolved.focusSource === 'block') {
2879
- viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: true });
3066
+ return;
3067
+ }
3068
+ if (viewer.mono.capture) viewer.useDisplayCamera();
3069
+ // The display rig orbits the frame centre (SceneViewer's recentre), whatever a camera rig
3070
+ // before it left in the viewer.
3071
+ viewer._focusRecentres = true;
3072
+ const target = disp ? displayTarget(disp) : null;
3073
+ if (target) {
3074
+ // setRig('display') framing the page's content: addModel's fit, on addModel's defaults.
3075
+ setFraming(disp);
3076
+ viewer.idleSpin = disp.idleSpin;
3077
+ viewer.fitTo(target.center, target.extent);
3078
+ resolved.focus = contentToModel(target.center);
3079
+ resolved.focusSource = 'frame';
3080
+ resolved.focusDefault = resolved.focus.slice();
3081
+ resolved.focusDefaultSource = 'frame';
3082
+ resolved.frame = target;
3083
+ declareDisplay(disp);
3084
+ return;
3085
+ }
3086
+ // The splat itself, as a display-rig addSplat frames it (the load-time path).
3087
+ setFraming(disp || bootFraming);
3088
+ if (reset) viewer.idleSpin = disp ? disp.idleSpin : idleSpin;
3089
+ if (bounds) viewer.fitTo(bounds.center, bounds.extent);
3090
+ else console.warn('[inline3d/splat] no usable bounds — subject is UNFRAMED (model scale)', src);
3091
+ if (resolved.focusSource === 'caller' || resolved.focusSource === 'block') {
3092
+ viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: true });
3093
+ }
3094
+ if (disp) resolved.frame = bounds ? { center: bounds.center.slice(), extent: bounds.extent.slice(), source: 'splat' } : null;
3095
+ declareDisplay(disp || { vH: bootFraming.vH });
3096
+ }
3097
+
3098
+ // ── handle.setRig: a live, reversible rig switch (docs/playcanvas-adapter.md §setRig) ──
3099
+ /** null = 'auto' (the per-asset waterfall); else { type: 'camera' } | { type: 'display', o }. */
3100
+ let rigOverride = null;
3101
+ let rigSeq = 0;
3102
+ /** The IBL setRig('display') installed ({ kind, atlas, prev }), and what it replaced — or null. */
3103
+ let rigEnv = null;
3104
+ /** ./inline3d-model-playcanvas.js once a display rig has loaded it (transmission, environments). */
3105
+ let rigModelModule = null;
3106
+ /** Mesh instances / materials prepareTransmission has handled, and whether it turned the grab on. */
3107
+ const rigTransmissionSeen = new WeakSet();
3108
+ let rigGrab = false;
3109
+ let rigTick = 0;
3110
+ const baseToneMapping = viewer.toneMapping;
3111
+
3112
+ /** Is the splat on screen: its entity (and every ancestor) enabled. */
3113
+ function splatShown() {
3114
+ let e = current?.entity || null;
3115
+ if (!e) return false;
3116
+ for (; e; e = e.parent) if (e.enabled === false) return false;
3117
+ return true;
3118
+ }
3119
+
3120
+ /**
3121
+ * World AABB of the page's meshes under handle.engine.root — enabled render components only,
3122
+ * union of their mesh instances' `aabb` (the engine's world box, skinning included). Content
3123
+ * space. The same measurement addModel's boundsOfEntity takes of its glTF.
3124
+ */
3125
+ function rootMeshBounds() {
3126
+ const root = viewer.content;
3127
+ const renders = root?.findComponents ? root.findComponents('render') : [];
3128
+ let min = null;
3129
+ let max = null;
3130
+ for (const r of renders) {
3131
+ if (r.enabled === false || r.entity?.enabled === false) continue;
3132
+ for (const mi of r.meshInstances || []) {
3133
+ const b = mi.aabb;
3134
+ if (!b) continue;
3135
+ const c = b.center;
3136
+ const h = b.halfExtents;
3137
+ const lo = [c.x - h.x, c.y - h.y, c.z - h.z];
3138
+ const hi = [c.x + h.x, c.y + h.y, c.z + h.z];
3139
+ if (!lo.every(Number.isFinite) || !hi.every(Number.isFinite)) continue;
3140
+ if (!min) {
3141
+ min = lo;
3142
+ max = hi;
3143
+ } else {
3144
+ for (let i = 0; i < 3; i++) {
3145
+ if (lo[i] < min[i]) min[i] = lo[i];
3146
+ if (hi[i] > max[i]) max[i] = hi[i];
3147
+ }
3148
+ }
2880
3149
  }
2881
3150
  }
2882
- return { pickCentres, alpha8: cloud?.alpha8 || null };
3151
+ return min ? { min, max } : null;
3152
+ }
3153
+
3154
+ /**
3155
+ * What setRig('display') frames: the caller's `frame`, else the meshes under root (plus the
3156
+ * splat's measured box if it is shown), else null — the splat, as a display-rig addSplat would.
3157
+ */
3158
+ function displayTarget(disp) {
3159
+ if (disp.frame) {
3160
+ return { center: toArray3(disp.frame.center), extent: toArray3(disp.frame.extent).map((v) => Math.max(Math.abs(v), 1e-6)), source: 'caller' };
3161
+ }
3162
+ const m = rootMeshBounds();
3163
+ if (!m) return null;
3164
+ let { min, max } = m;
3165
+ let source = 'root';
3166
+ const sb = splatShown() ? out.frame : null;
3167
+ if (sb) {
3168
+ source = 'root+splat';
3169
+ min = min.map((v, i) => Math.min(v, sb.center[i] - sb.extent[i] / 2));
3170
+ max = max.map((v, i) => Math.max(v, sb.center[i] + sb.extent[i] / 2));
3171
+ }
3172
+ return {
3173
+ center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
3174
+ 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)],
3175
+ source,
3176
+ };
3177
+ }
3178
+
3179
+ /** Per tick: addModel's tone mapping for the page's meshes while the splat is hidden. */
3180
+ viewer.onTick = pageMode
3181
+ ? viewer.onTick
3182
+ : () => {
3183
+ const display = rigOverride?.type === 'display';
3184
+ const want = display && !splatShown() ? rigOverride.o.toneMapping : baseToneMapping;
3185
+ if (want !== viewer.toneMapping) viewer.setToneMapping(want);
3186
+ // A transmissive glTF the page hangs under root on the display rig (a glass
3187
+ // lantern): addModel's grab pass + pass order + per-eye grab UV. Re-checked every 30
3188
+ // frames, since a page adds its meshes whenever they load — before or after setRig.
3189
+ if (display && rigModelModule && pcModule && (rigTick++ % 30 === 0)) syncRigTransmission();
3190
+ };
3191
+
3192
+ /** prepareTransmission over root; the grab pass on while a transmissive mesh is there. */
3193
+ function syncRigTransmission() {
3194
+ const n = rigModelModule.prepareTransmission(pcModule, viewer.content, viewer.app?.graphicsDevice, rigTransmissionSeen);
3195
+ if ((n > 0) !== rigGrab) {
3196
+ rigGrab = n > 0;
3197
+ viewer.useSceneColor(rigGrab);
3198
+ }
3199
+ }
3200
+
3201
+ /** Install addModel's IBL (`disp.environment`) for the display rig, if the page has none of its own. */
3202
+ async function ensureRigEnvironment(disp, seq) {
3203
+ const m = (rigModelModule ||= await import('./inline3d-model-playcanvas.js'));
3204
+ if (removed || seq !== rigSeq || rigOverride?.type !== 'display') return;
3205
+ rigTick = 0; // transmission check on the next tick
3206
+ const kind = disp.environment;
3207
+ if (rigEnv && rigEnv.kind === kind) return;
3208
+ dropRigEnvironment(); // a different environment of ours (setRig('display') called again)
3209
+ if (kind === 'none' || viewer.sky) return;
3210
+ const app = viewer.app;
3211
+ const scene = app?.scene;
3212
+ if (!scene || scene.envAtlas) return; // the page lights its own meshes: leave it alone
3213
+ const prev = { skyboxIntensity: scene.skyboxIntensity, exposure: scene.exposure, skyboxRotation: scene.skyboxRotation };
3214
+ const atlas = kind === 'room' ? m.useRoomEnvironment(pcModule, app, m.ROOM_YAW_DEG) : m.useNeutralStudio(pcModule, app, m.ENV_YAW_DEG);
3215
+ rigEnv = { kind, atlas, prev };
3216
+ }
3217
+
3218
+ /** Undo ensureRigEnvironment (only what it installed, only if the page has not replaced it). */
3219
+ function dropRigEnvironment() {
3220
+ if (!rigEnv) return;
3221
+ const scene = viewer.app?.scene;
3222
+ const { atlas, prev } = rigEnv;
3223
+ rigEnv = null;
3224
+ if (!scene || scene.envAtlas !== atlas) return;
3225
+ scene.envAtlas = null;
3226
+ scene.skyboxIntensity = prev.skyboxIntensity;
3227
+ scene.exposure = prev.exposure;
3228
+ if (prev.skyboxRotation !== undefined) scene.skyboxRotation = prev.skyboxRotation;
3229
+ atlas.destroy?.();
3230
+ }
3231
+
3232
+ /**
3233
+ * handle.setRig(type, options) — switch the rig the window is seen through, live: no remount,
3234
+ * no reload, no new session. 'display' frames the page's meshes under handle.engine.root (plus
3235
+ * the splat when shown) on addModel's defaults; 'camera' is the asset's capture rig re-resolved
3236
+ * from the waterfall's own inputs; 'auto' is exactly what the load resolved. The pose resets to
3237
+ * the new rig's rest. Sticky across setSource until setRig('auto'). Resolves to the handle once
3238
+ * applied (the display rig's default environment included).
3239
+ */
3240
+ function setRig(type, o = {}) {
3241
+ const req = validateSetRig(type, o, pageMode);
3242
+ const seq = ++rigSeq;
3243
+ return first.then(async () => {
3244
+ if (removed || seq !== rigSeq || !current) return out;
3245
+ rigOverride = req.type === 'auto' ? null : req;
3246
+ if (req.type !== 'display') {
3247
+ dropRigEnvironment();
3248
+ if (rigGrab) {
3249
+ rigGrab = false;
3250
+ viewer.useSceneColor(false);
3251
+ }
3252
+ }
3253
+ const resolved = resolveFor(current.rigIn, req.type);
3254
+ out.rig = resolved;
3255
+ applyRig(resolved, current.rigIn.bounds, true);
3256
+ viewer.onTick?.(); // tone mapping now, not a frame late
3257
+ if (req.type === 'display') await ensureRigEnvironment(req.o, seq);
3258
+ return out;
3259
+ });
2883
3260
  }
2884
3261
 
2885
3262
  let pcModule = null;
@@ -2892,6 +3269,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2892
3269
  preserveDrawingBuffer,
2893
3270
  perf: perfResolved,
2894
3271
  viewPath: opts.playcanvasViewPath,
3272
+ antialias: antialias === true,
2895
3273
  });
2896
3274
  if (!app || removed) return null;
2897
3275
  pcModule = pc;
@@ -373,6 +373,13 @@ export function addSplat(wall, canvas, src, opts = {}) {
373
373
  prepareSource() {
374
374
  throw new Error("@displayxr/inline3d/splat: prepareSource() is implemented on the PlayCanvas backend only.");
375
375
  },
376
+ /** Not on this backend: the live rig switch is a PlayCanvas-backend feature. */
377
+ setRig() {
378
+ throw new Error(
379
+ "@displayxr/inline3d/splat: setRig() is implemented on the PlayCanvas backend only " +
380
+ "(addSplat(…, { engine:'playcanvas' })).",
381
+ );
382
+ },
376
383
  /** Not on this backend (splat effects are PlayCanvas-only in this version). */
377
384
  playEffect() {
378
385
  throw effectsNotOnSpark('playEffect()');
@@ -657,6 +664,16 @@ export function addSplat(wall, canvas, src, opts = {}) {
657
664
  * Fields (`viewer`, `mesh`, `rig`, …) are null until then; `viewer` in particular appears one
658
665
  * module-load later than on Spark.
659
666
  */
667
+ /** setRig's type/options shape, checked at the call before the adapter has loaded (it re-checks all of it). */
668
+ function validateSetRigArgs(type, o) {
669
+ if (!['display', 'camera', 'auto'].includes(type)) {
670
+ throw new Error(`@displayxr/inline3d/splat: setRig("${type}") — expected 'display', 'camera' or 'auto'.`);
671
+ }
672
+ if (o !== undefined && (o === null || typeof o !== 'object')) {
673
+ throw new TypeError('@displayxr/inline3d/splat: setRig options must be an object.');
674
+ }
675
+ }
676
+
660
677
  function addSplatDeferred(wall, canvas, src, opts) {
661
678
  const pending = [];
662
679
  const queue = (name) => (...args) => {
@@ -711,6 +728,14 @@ function addSplatDeferred(wall, canvas, src, opts) {
711
728
  // setSource replaces this stub on the same object by then).
712
729
  setSource: (...args) => out.ready.then(() => out.setSource(...args)),
713
730
  prepareSource: (...args) => out.ready.then(() => out.prepareSource(...args)),
731
+ // A rig switch before the first asset has landed: validated now (controls:'page' and a bad
732
+ // type throw at the call's own line), applied once it has.
733
+ setRig: page
734
+ ? pageOnly('setRig')
735
+ : (type, o) => {
736
+ validateSetRigArgs(type, o);
737
+ return out.ready.then(() => out.setRig(type, o));
738
+ },
714
739
  // Effects before the adapter has loaded: validated NOW (a bad call throws at its own line),
715
740
  // then run once the first asset is on screen.
716
741
  playEffect: (name, o) => {
package/model.d.ts CHANGED
@@ -40,18 +40,24 @@ export interface ModelOptions {
40
40
  /**
41
41
  * How the mesh is lit.
42
42
  *
43
- * PlayCanvas (default engine): `room` (default; `neutral` is an alias) is image-based lighting
44
- * from an in-memory neutral photo studio fitted to the Khronos glTF Sample Viewer's "Studio
45
- * Neutral", with Khronos PBR Neutral tone mapping and exposure 1 — the Sample Viewer's look.
46
- * three: `room` bakes three's procedural RoomEnvironment, untonemapped (unchanged since 1.11).
43
+ * PlayCanvas (default engine):
44
+ * - `neutral` (default): image-based lighting from an in-memory neutral photo studio fitted to
45
+ * the Khronos glTF Sample Viewer's "Studio Neutral", with Khronos PBR Neutral tone mapping and
46
+ * exposure 1 — the Sample Viewer's look.
47
+ * - `room`: three.js's procedural RoomEnvironment, regenerated in memory (no asset), untonemapped
48
+ * — the look `engine: 'three'` gives `room`. Pass it when a page was tuned on the three path.
49
+ * (Through 1.16 `room` was an alias of `neutral` here.)
50
+ *
51
+ * three: `room` (default) bakes three's procedural RoomEnvironment, untonemapped (unchanged since
52
+ * 1.11); `neutral` is not offered.
47
53
  *
48
54
  * Both: `studio` is the three-point punctual rig (metal renders dark under it — nothing to
49
55
  * reflect); `none` adds no light at all. Anything else throws on PlayCanvas.
50
56
  */
51
57
  environment?: 'room' | 'neutral' | 'studio' | 'none';
52
58
  /**
53
- * PlayCanvas only: yaw of the environment about +Y, degrees (default 0 = the Sample Viewer's
54
- * default orientation).
59
+ * PlayCanvas only: yaw of the environment about +Y, degrees, on top of its own calibrated
60
+ * orientation (default 0 = the Sample Viewer's orientation for `neutral`, three's for `room`).
55
61
  */
56
62
  environmentRotation?: number;
57
63
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
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",
package/splat.d.ts CHANGED
@@ -131,7 +131,8 @@ export interface SogCamera {
131
131
  */
132
132
  export interface ResolvedRig {
133
133
  type: 'camera' | 'display';
134
- typeSource: 'caller' | 'block' | 'block-present' | 'default';
134
+ /** `'setRig'` after `handle.setRig('display' | 'camera')`; the waterfall's step otherwise. */
135
+ typeSource: 'caller' | 'block' | 'block-present' | 'default' | 'setRig';
135
136
  rest: { position: number[]; rotation: number[] };
136
137
  intrinsics: SogIntrinsics;
137
138
  intrinsicsSource: 'block' | 'caller' | 'estimated' | 'fallback-28mm';
@@ -154,7 +155,14 @@ export interface ResolvedRig {
154
155
  | 'median-disparity'
155
156
  | 'default'
156
157
  | 'picked'
157
- | 'set';
158
+ | 'set'
159
+ | 'frame';
160
+ /**
161
+ * On a display rig set by `handle.setRig('display')`: what it framed, in `handle.engine.root`'s
162
+ * space, and where that came from (the page's meshes, meshes + the shown splat, the splat alone,
163
+ * or the caller's `frame`). Absent otherwise.
164
+ */
165
+ frame?: { center: number[]; extent: number[]; source: 'root' | 'root+splat' | 'splat' | 'caller' } | null;
158
166
  /** The block's own `focus.source` string (e.g. `'convergence'`, `'cloud-median'`), for diagnostics. */
159
167
  blockFocusSource: string | null;
160
168
  /** Fraction of the central crop's opacity-weighted mass the winning clump carried (`nearest-clump` only). */
@@ -187,6 +195,13 @@ export interface SplatOptions {
187
195
  * the knob for the weave's zero-copy read race on large canvases.
188
196
  */
189
197
  preserveDrawingBuffer?: boolean;
198
+ /**
199
+ * `engine: 'playcanvas'` only: create the tile's WebGL context with MSAA (default false — it buys
200
+ * nothing on alpha-blended splats). Turn it on when the page draws meshes under
201
+ * `handle.engine.root` and wants addModel's silhouettes (addModel's context has MSAA on);
202
+ * `setRig('display')` is then pixel-identical to an addModel tile. Fixed at context creation.
203
+ */
204
+ antialias?: boolean;
190
205
  /**
191
206
  * `engine: 'playcanvas'` only: the `playcanvas` module namespace to use instead of
192
207
  * `import('playcanvas')` — for a page that already bundles its own copy.
@@ -490,6 +505,51 @@ export interface SplatEffectState {
490
505
  progress: number;
491
506
  }
492
507
 
508
+ /**
509
+ * `handle.setRig('display', opts)`'s options. Every default is addModel's, so a mesh framed this
510
+ * way renders exactly as `addModel(url, { engine: 'playcanvas' })` renders it.
511
+ * docs/playcanvas-adapter.md §setRig.
512
+ */
513
+ export interface SplatSetRigDisplayOptions {
514
+ /** Default 0.24. */
515
+ virtualDisplayHeight?: number;
516
+ /** Default `'contain'`. */
517
+ fit?: 'contain' | 'cover' | 'height' | 'none';
518
+ /** Default 0.8. */
519
+ margin?: number;
520
+ /** Default 4. */
521
+ depthLimit?: number;
522
+ /** Default true. */
523
+ fitSweep?: boolean;
524
+ /** Idle turntable, °/s after 2.5 s without input. Default 8 (addModel's); 0 for a still product. */
525
+ idleSpin?: number;
526
+ /** On the declared display rig. Default 1 each. */
527
+ ipdFactor?: number;
528
+ parallaxFactor?: number;
529
+ perspectiveFactor?: number;
530
+ /**
531
+ * The eye camera's tone mapping WHILE THE SPLAT IS HIDDEN (the splat's display-referred colours
532
+ * keep `'none'` whenever it is shown). Default: the environment's own — `'neutral'` (Khronos PBR
533
+ * Neutral) for `environment: 'neutral'`, `'none'` (three's) for `environment: 'room'`.
534
+ */
535
+ toneMapping?: 'none' | 'linear' | 'neutral' | 'aces' | 'aces2' | 'filmic' | 'hejl';
536
+ /**
537
+ * addModel's IBL of the same name, installed when the scene has no `envAtlas` of its own and
538
+ * removed again when the rig switches away (a page that lights its own meshes is never touched):
539
+ * `'neutral'` (default) the Sample-Viewer-matched studio; `'room'` three's RoomEnvironment (the
540
+ * look `addModel(…, { engine: 'three', environment: 'room' })` gives), with tone mapping
541
+ * `'none'` unless `toneMapping` is passed; `'none'` installs nothing. (Through 1.16 `'room'` was
542
+ * the default and an alias of `'neutral'`.) A transmissive glTF under root (KHR_materials_
543
+ * transmission) also gets the scene-colour grab pass while the display rig is active.
544
+ */
545
+ environment?: 'room' | 'neutral' | 'none';
546
+ /**
547
+ * Frame THIS box (in `handle.engine.root`'s space) instead of measuring. Default: the enabled
548
+ * meshes under root (+ the splat's box when it is shown); with no meshes, the splat itself.
549
+ */
550
+ frame?: { center: number[] | { x: number; y: number; z: number }; extent: number[] | { x: number; y: number; z: number } };
551
+ }
552
+
493
553
  /** `setSource`'s options. */
494
554
  export interface SplatSourceOptions {
495
555
  /** > 0 = a crossfade of this length (the 1.10 option; same as `transition: 'crossfade'`). */
@@ -727,6 +787,19 @@ export interface SplatHandle {
727
787
  * SOG). `remove()` disposes any still unused.
728
788
  */
729
789
  prepareSource(src: string | Blob | ArrayBuffer | Uint8Array, opts?: SplatPrepareOptions): Promise<SplatPreparedSource>;
790
+ /**
791
+ * PlayCanvas backend only — throws on Spark, and with `controls:'page'` (the page's camera IS
792
+ * the rig). Switch the rig live, on the next frame: no remount, no reload, no new session.
793
+ *
794
+ * `'display'` frames the page's meshes under `handle.engine.root` (+ the splat when shown) on
795
+ * addModel's defaults and declares a display rig; `'camera'` returns to the asset's capture rig,
796
+ * re-resolved from the waterfall's own inputs (lens, captureFit, focus rung); `'auto'` is what
797
+ * the load resolved. The pose resets to the new rig's rest; the declared view rig switches with
798
+ * it; the choice is sticky across `setSource` until `'auto'`. A clean cut (no eased transition).
799
+ * `handle.rig.typeSource` reads `'setRig'`. Resolves once applied. docs/playcanvas-adapter.md §setRig.
800
+ */
801
+ setRig(type: 'display', opts?: SplatSetRigDisplayOptions): Promise<SplatHandle>;
802
+ setRig(type: 'camera' | 'auto'): Promise<SplatHandle>;
730
803
  /**
731
804
  * `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
732
805
  * validated at the call, run once the first asset is on screen. Resolves `{ finished }` —