@displayxr/inline3d 1.15.0 → 1.16.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,41 @@ 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.16.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive. A page that never
11
+ calls `setRig` renders the same pixels, except for the `setSource` fix below.
12
+
13
+ ### Added
14
+
15
+ - **`handle.setRig(type, options?)`** (#36): a live, reversible rig switch on the PlayCanvas splat
16
+ handle. It applies on the next frame with no remount, reload or new session, and returns a
17
+ `Promise<handle>`.
18
+ - `setRig('display', { virtualDisplayHeight, fit, margin, frame, … })` frames the enabled meshes
19
+ under `handle.engine.root`, plus the splat when it is shown. The defaults are addModel's (0.24,
20
+ `contain`, 0.8, depthLimit 4, idleSpin 8), and it declares a display rig.
21
+ - While the splat is hidden, the page's meshes get addModel's tone mapping (`'neutral'`) and, if
22
+ the scene has no `envAtlas` of its own, addModel's neutral-studio IBL. Both are removed when the
23
+ rig switches away.
24
+ - `setRig('camera')` goes back to the asset's capture rig, re-resolved from the waterfall's own
25
+ inputs. `setRig('auto')` is the load-time result.
26
+ - The pose resets to the new rig's rest. `handle.rig.typeSource` reads `'setRig'`. The choice
27
+ sticks across `setSource` until `'auto'`, and `controls:'page'` throws.
28
+ - Gates, on a real GPU: a hidden splat plus `setRig('display')` plus DamagedHelmet or Fox renders
29
+ **MAE 0.0000** against `addModel(url, { engine: 'playcanvas' })`, in mono and fake stereo, with
30
+ `antialias: true`. With the tile's default MSAA off the residual is 0.11–0.44, silhouette edges
31
+ only. The round trip `setRig('display')` → `setRig('camera')` returns to the exact pre-switch
32
+ render (**MAE 0.0000**) and the same camera-rig declaration.
33
+ - See [`docs/playcanvas-adapter.md` § setRig](docs/playcanvas-adapter.md#setrig--switching-between-the-display-rig-and-the-camera-rig-36).
34
+ - **`addSplat(…, { engine: 'playcanvas', antialias: true })`** creates the tile's WebGL context with
35
+ MSAA. The default stays off. Use it when a page draws meshes under `handle.engine.root`.
36
+
37
+ ### Fixed
38
+
39
+ - **`setSource` from a camera-rig asset to a display-rig asset** now declares the display rig to the
40
+ runtime. The camera rig used to stay declared. The new asset also orbits its frame centre again
41
+ (the camera rig's `recentre: false` was left behind).
42
+
8
43
  ## 1.15.0 — 2026-09-24
9
44
 
10
45
  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).
@@ -346,7 +346,7 @@ export function neutralStudioRGBE(width = 256, height = 128, env = NEUTRAL_STUDI
346
346
  export const ENV_YAW_DEG = 0;
347
347
 
348
348
  /** Build the default environment on this app and set it as the scene's envAtlas. */
349
- function useNeutralStudio(pc, app, yawDeg) {
349
+ export function useNeutralStudio(pc, app, yawDeg) {
350
350
  const W = 256;
351
351
  const H = 128;
352
352
  const tex = new pc.Texture(app.graphicsDevice, {
@@ -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,
@@ -912,6 +912,21 @@ export class PlayCanvasSplatViewer {
912
912
  this._updateMonoProjection();
913
913
  }
914
914
 
915
+ /**
916
+ * Switch the eye camera(s) to another tone mapping, by name (TONE_MAPPINGS). Live: the engine
917
+ * picks the new shader variant on the next draw. handle.setRig's display rig uses it to give a
918
+ * page's meshes addModel's 'neutral' while the splat is hidden.
919
+ */
920
+ setToneMapping(name) {
921
+ const key = TONE_MAPPINGS[name] ? name : 'none';
922
+ this.toneMapping = key;
923
+ const pc = this.pc;
924
+ if (!pc) return;
925
+ const v = pc[TONE_MAPPINGS[key]] ?? pc.TONEMAP_NONE;
926
+ if (this.eye?.camera) this.eye.camera.toneMapping = v;
927
+ if (this._viewPath === 'cameras') for (const c of this._views) if (c?.camera) c.camera.toneMapping = v;
928
+ }
929
+
915
930
  /** Forget the auto-fit (scale 1, no subject box) — the camera rig's framing. */
916
931
  resetFit() {
917
932
  this._fitScale = 1;
@@ -2151,6 +2166,100 @@ export function describeResource(res) {
2151
2166
  * one-pass fade, with the coverage-linear alpha remap on both assets.
2152
2167
  */
2153
2168
 
2169
+ /**
2170
+ * handle.setRig's DISPLAY-rig defaults: addModel's, so a mesh framed through setRig('display')
2171
+ * sits exactly where `addModel(url, { engine: 'playcanvas' })` puts it (docs/playcanvas-adapter.md
2172
+ * §setRig). `toneMapping` applies to the page's meshes while the splat is hidden (the splat keeps
2173
+ * 'none' whenever it is shown); `environment: 'room'` installs addModel's neutral-studio IBL when
2174
+ * the page has no `scene.envAtlas` of its own, and removes it again when the rig changes back.
2175
+ */
2176
+ export const SET_RIG_DISPLAY_DEFAULTS = Object.freeze({
2177
+ virtualDisplayHeight: 0.24,
2178
+ fit: 'contain',
2179
+ margin: 0.8,
2180
+ depthLimit: DEFAULT_DEPTH_LIMIT,
2181
+ fitSweep: true,
2182
+ idleSpin: 8,
2183
+ ipdFactor: 1,
2184
+ parallaxFactor: 1,
2185
+ perspectiveFactor: 1,
2186
+ toneMapping: 'neutral',
2187
+ environment: 'room',
2188
+ });
2189
+
2190
+ export const SET_RIG_TYPES = Object.freeze(['display', 'camera', 'auto']);
2191
+ const SET_RIG_FITS = ['contain', 'cover', 'height', 'none'];
2192
+ let warnedSetRigKeys = false;
2193
+
2194
+ const PAGE_SETRIG_ERROR =
2195
+ "@displayxr/inline3d/splat: setRig() is not available with controls:'page' — the page owns the " +
2196
+ 'camera (its camera IS the rig). Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
2197
+
2198
+ /**
2199
+ * handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
2200
+ * @returns {{type:'display'|'camera'|'auto', o?:object}}
2201
+ */
2202
+ export function validateSetRig(type, o = {}, pageMode = false) {
2203
+ if (pageMode) throw new Error(PAGE_SETRIG_ERROR);
2204
+ if (!SET_RIG_TYPES.includes(type)) {
2205
+ throw new Error(`@displayxr/inline3d/splat: setRig("${type}") — expected 'display', 'camera' or 'auto'.`);
2206
+ }
2207
+ if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setRig options must be an object.');
2208
+ if (type !== 'display') return { type };
2209
+ const D = SET_RIG_DISPLAY_DEFAULTS;
2210
+ const known = new Set([...Object.keys(D), 'frame']);
2211
+ const unknown = Object.keys(o).filter((k) => !known.has(k));
2212
+ if (unknown.length && !warnedSetRigKeys) {
2213
+ warnedSetRigKeys = true;
2214
+ console.warn(
2215
+ `[inline3d/splat] setRig('display') ignores ${unknown.join(', ')}` +
2216
+ (unknown.includes('transitionMs') ? ' — a rig switch is a clean cut (no eased transition in this version)' : '') +
2217
+ '.',
2218
+ );
2219
+ }
2220
+ const num = (k, ok) => {
2221
+ const v = o[k] === undefined ? D[k] : o[k];
2222
+ if (!Number.isFinite(v) || !ok(v)) throw new Error(`@displayxr/inline3d/splat: setRig('display') — bad ${k}: ${o[k]}.`);
2223
+ return v;
2224
+ };
2225
+ const fit = o.fit === undefined ? D.fit : o.fit;
2226
+ if (!SET_RIG_FITS.includes(fit)) throw new Error(`@displayxr/inline3d/splat: setRig('display') — fit "${fit}", expected ${SET_RIG_FITS.join(' | ')}.`);
2227
+ const toneMapping = o.toneMapping === undefined ? D.toneMapping : o.toneMapping;
2228
+ if (!TONE_MAPPINGS[toneMapping]) {
2229
+ throw new Error(`@displayxr/inline3d/splat: setRig('display') — toneMapping "${toneMapping}", expected ${Object.keys(TONE_MAPPINGS).join(' | ')}.`);
2230
+ }
2231
+ const environment = o.environment === undefined ? D.environment : o.environment;
2232
+ if (!['room', 'neutral', 'none'].includes(environment)) {
2233
+ throw new Error(`@displayxr/inline3d/splat: setRig('display') — environment "${environment}", expected 'room' | 'neutral' | 'none'.`);
2234
+ }
2235
+ let frame = null;
2236
+ if (o.frame != null) {
2237
+ const c = o.frame.center;
2238
+ const e = o.frame.extent;
2239
+ 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));
2240
+ if (!v3(c) || !v3(e)) throw new Error("@displayxr/inline3d/splat: setRig('display') — frame must be { center: [x,y,z], extent: [x,y,z] }.");
2241
+ const a3 = (v) => (Array.isArray(v) || ArrayBuffer.isView(v) ? [v[0], v[1], v[2]] : [v.x, v.y, v.z]);
2242
+ frame = { center: a3(c), extent: a3(e) };
2243
+ }
2244
+ return {
2245
+ type,
2246
+ o: {
2247
+ vH: num('virtualDisplayHeight', (v) => v > 0),
2248
+ fit,
2249
+ margin: num('margin', (v) => v > 0),
2250
+ depthLimit: num('depthLimit', (v) => v > 0),
2251
+ fitSweep: o.fitSweep === undefined ? D.fitSweep : o.fitSweep !== false,
2252
+ idleSpin: num('idleSpin', () => true),
2253
+ ipdFactor: num('ipdFactor', (v) => v >= 0),
2254
+ parallaxFactor: num('parallaxFactor', (v) => v >= 0),
2255
+ perspectiveFactor: num('perspectiveFactor', (v) => v > 0),
2256
+ toneMapping,
2257
+ environment,
2258
+ frame,
2259
+ },
2260
+ };
2261
+ }
2262
+
2154
2263
  /** setSource's transitions and their defaults. */
2155
2264
  export const SOURCE_TRANSITIONS = Object.freeze({
2156
2265
  cut: {},
@@ -2248,6 +2357,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2248
2357
  observe,
2249
2358
  firstWovenHoldMs,
2250
2359
  preserveDrawingBuffer = false,
2360
+ antialias = false,
2251
2361
  } = opts;
2252
2362
  // `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
2253
2363
  // ROTATES (one directional sort serves every view), not on a timer — docs/playcanvas-adapter.md.
@@ -2361,6 +2471,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2361
2471
  },
2362
2472
  setSource,
2363
2473
  prepareSource,
2474
+ setRig,
2364
2475
  /**
2365
2476
  * Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
2366
2477
  * docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
@@ -2588,6 +2699,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2588
2699
 
2589
2700
  // ── focus → view rig ──
2590
2701
  let lastConvergence = Number.NaN;
2702
+ // The camera rig's descriptor object (rewritten in place per focus change) and the DISPLAY rig
2703
+ // declared through setViewRig, if any. `declaredDisplay` null + no camera rig pushed = the
2704
+ // `virtualDisplayHeight` shorthand addScene was built with, which is where every non-page tile
2705
+ // starts. A display rig after a camera rig has to be SENT (the shorthand cannot be restored),
2706
+ // as the explicit descriptor it is shorthand for (displayRig: identity pose, factors 1).
2707
+ let camRigObj = null;
2708
+ let declaredDisplay = pageMode ? 'page' : 'shorthand';
2591
2709
  function pushViewRig(force) {
2592
2710
  if (pageMode) return; // pageTick declares the rig every frame
2593
2711
  if (!out.rig || out.rig.type !== 'camera') return;
@@ -2600,7 +2718,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2600
2718
  lastConvergence = d;
2601
2719
  out.rig.convergence = d;
2602
2720
  const q = quatFromMatrix(pose);
2603
- out.viewRig = cameraRigFromPose(
2721
+ camRigObj ||= {};
2722
+ declaredDisplay = null;
2723
+ out.viewRig = camRigObj = cameraRigFromPose(
2604
2724
  {
2605
2725
  position: { x: pose[12], y: pose[13], z: pose[14] },
2606
2726
  orientation: { x: q[0], y: q[1], z: q[2], w: q[3] },
@@ -2610,7 +2730,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2610
2730
  convergence: d > 0 ? d : 0,
2611
2731
  ipdFactor: out.rig.ipdFactor,
2612
2732
  parallaxFactor: out.rig.parallaxFactor,
2613
- out: out.viewRig || {},
2733
+ out: camRigObj,
2614
2734
  },
2615
2735
  );
2616
2736
  handle?.setViewRig?.(out.viewRig);
@@ -2821,16 +2941,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2821
2941
  ? lift(frame)
2822
2942
  : null;
2823
2943
 
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;
2944
+ // What the waterfall was fed, kept with the asset: handle.setRig re-resolves from exactly this
2945
+ // (never from a stale copy of the result), so setRig('camera') / ('auto') land where a load did.
2946
+ const rigIn = { camera: loaded.camera, sample: loaded.pre?.rest || null, bounds };
2947
+ // A setRig choice is STICKY across setSource: the new asset is resolved on the chosen rig.
2948
+ const resolved = resolveFor(rigIn, rigOverride ? rigOverride.type : 'auto');
2834
2949
  perfSpan('applyLoaded:rig', tB);
2835
2950
  out.camera = loaded.camera;
2836
2951
  out.rig = resolved;
@@ -2862,8 +2977,66 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2862
2977
  }),
2863
2978
  );
2864
2979
  }
2865
- } else if (resolved.type === 'camera') {
2980
+ } else {
2981
+ applyRig(resolved, bounds, false);
2982
+ }
2983
+ return { pickCentres, alpha8: cloud?.alpha8 || null, rigIn };
2984
+ }
2985
+
2986
+ /** resolveRig on an asset's kept inputs: 'auto' = the load-time waterfall, else forced. */
2987
+ function resolveFor(rigIn, type) {
2988
+ const box = canvas.getBoundingClientRect();
2989
+ const resolved = resolveRig({
2990
+ camera: rigIn.camera,
2991
+ opts: type === 'auto' ? rigOpts : { ...rigOpts, rig: type },
2992
+ cloud: rigIn.sample,
2993
+ canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
2994
+ });
2995
+ if (type !== 'auto') resolved.typeSource = 'setRig';
2996
+ resolved.focusDefault = resolved.focus.slice();
2997
+ resolved.focusDefaultSource = resolved.focusSource;
2998
+ return resolved;
2999
+ }
3000
+
3001
+ /** The framing knobs the viewer's fit reads, as addSplat was booted with. */
3002
+ const bootFraming = { vH: virtualDisplayHeight, fit, margin, depthLimit, fitSweep };
3003
+ function setFraming(f) {
3004
+ viewer.vH = f.vH;
3005
+ viewer.fit = f.fit;
3006
+ viewer.margin = f.margin;
3007
+ viewer.depthLimit = f.depthLimit;
3008
+ viewer.fitSweep = f.fitSweep;
3009
+ }
3010
+
3011
+ /** Declare a DISPLAY rig to the runtime, unless that exact one is already declared. */
3012
+ function declareDisplay(f) {
3013
+ const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
3014
+ if (declaredDisplay === key) return;
3015
+ // Back at the boot rig from the shorthand it was built with: nothing to say.
3016
+ if (declaredDisplay === 'shorthand' && key === `${bootFraming.vH}|1|1|1`) return;
3017
+ declaredDisplay = key;
3018
+ lastConvergence = Number.NaN;
3019
+ out.viewRig = displayRig({
3020
+ virtualDisplayHeight: f.vH,
3021
+ ipdFactor: f.ipdFactor ?? 1,
3022
+ parallaxFactor: f.parallaxFactor ?? 1,
3023
+ perspectiveFactor: f.perspectiveFactor ?? 1,
3024
+ });
3025
+ handle?.setViewRig?.(out.viewRig);
3026
+ }
3027
+
3028
+ /**
3029
+ * Put the viewer on a resolved rig: lens, framing, focus, and the rig declared to the runtime.
3030
+ * Shared by every load / setSource (reset=false: the pose is kept) and handle.setRig
3031
+ * (reset=true: the pose goes back to the rig's rest, yaw = pitch = 0, zoom 1, depth 0).
3032
+ */
3033
+ function applyRig(resolved, bounds, reset) {
3034
+ const disp = rigOverride?.type === 'display' ? rigOverride.o : null;
3035
+ if (reset) viewer.resetPose();
3036
+ if (resolved.type === 'camera') {
3037
+ setFraming(bootFraming);
2866
3038
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
3039
+ else if (reset) viewer.idleSpin = idleSpin;
2867
3040
  // The camera rig never auto-fits (the capture IS the framing): a scale left by a previous
2868
3041
  // display-rig asset (setSource) must not shrink this one.
2869
3042
  viewer.resetFit();
@@ -2871,15 +3044,170 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2871
3044
  viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: false });
2872
3045
  lastConvergence = Number.NaN;
2873
3046
  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 });
3047
+ return;
3048
+ }
3049
+ if (viewer.mono.capture) viewer.useDisplayCamera();
3050
+ // The display rig orbits the frame centre (SceneViewer's recentre), whatever a camera rig
3051
+ // before it left in the viewer.
3052
+ viewer._focusRecentres = true;
3053
+ const target = disp ? displayTarget(disp) : null;
3054
+ if (target) {
3055
+ // setRig('display') framing the page's content: addModel's fit, on addModel's defaults.
3056
+ setFraming(disp);
3057
+ viewer.idleSpin = disp.idleSpin;
3058
+ viewer.fitTo(target.center, target.extent);
3059
+ resolved.focus = contentToModel(target.center);
3060
+ resolved.focusSource = 'frame';
3061
+ resolved.focusDefault = resolved.focus.slice();
3062
+ resolved.focusDefaultSource = 'frame';
3063
+ resolved.frame = target;
3064
+ declareDisplay(disp);
3065
+ return;
3066
+ }
3067
+ // The splat itself, as a display-rig addSplat frames it (the load-time path).
3068
+ setFraming(disp || bootFraming);
3069
+ if (reset) viewer.idleSpin = disp ? disp.idleSpin : idleSpin;
3070
+ if (bounds) viewer.fitTo(bounds.center, bounds.extent);
3071
+ else console.warn('[inline3d/splat] no usable bounds — subject is UNFRAMED (model scale)', src);
3072
+ if (resolved.focusSource === 'caller' || resolved.focusSource === 'block') {
3073
+ viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: true });
3074
+ }
3075
+ if (disp) resolved.frame = bounds ? { center: bounds.center.slice(), extent: bounds.extent.slice(), source: 'splat' } : null;
3076
+ declareDisplay(disp || { vH: bootFraming.vH });
3077
+ }
3078
+
3079
+ // ── handle.setRig: a live, reversible rig switch (docs/playcanvas-adapter.md §setRig) ──
3080
+ /** null = 'auto' (the per-asset waterfall); else { type: 'camera' } | { type: 'display', o }. */
3081
+ let rigOverride = null;
3082
+ let rigSeq = 0;
3083
+ /** The neutral-studio IBL setRig('display') installed, and what it replaced — or null. */
3084
+ let rigEnv = null;
3085
+ const baseToneMapping = viewer.toneMapping;
3086
+
3087
+ /** Is the splat on screen: its entity (and every ancestor) enabled. */
3088
+ function splatShown() {
3089
+ let e = current?.entity || null;
3090
+ if (!e) return false;
3091
+ for (; e; e = e.parent) if (e.enabled === false) return false;
3092
+ return true;
3093
+ }
3094
+
3095
+ /**
3096
+ * World AABB of the page's meshes under handle.engine.root — enabled render components only,
3097
+ * union of their mesh instances' `aabb` (the engine's world box, skinning included). Content
3098
+ * space. The same measurement addModel's boundsOfEntity takes of its glTF.
3099
+ */
3100
+ function rootMeshBounds() {
3101
+ const root = viewer.content;
3102
+ const renders = root?.findComponents ? root.findComponents('render') : [];
3103
+ let min = null;
3104
+ let max = null;
3105
+ for (const r of renders) {
3106
+ if (r.enabled === false || r.entity?.enabled === false) continue;
3107
+ for (const mi of r.meshInstances || []) {
3108
+ const b = mi.aabb;
3109
+ if (!b) continue;
3110
+ const c = b.center;
3111
+ const h = b.halfExtents;
3112
+ const lo = [c.x - h.x, c.y - h.y, c.z - h.z];
3113
+ const hi = [c.x + h.x, c.y + h.y, c.z + h.z];
3114
+ if (!lo.every(Number.isFinite) || !hi.every(Number.isFinite)) continue;
3115
+ if (!min) {
3116
+ min = lo;
3117
+ max = hi;
3118
+ } else {
3119
+ for (let i = 0; i < 3; i++) {
3120
+ if (lo[i] < min[i]) min[i] = lo[i];
3121
+ if (hi[i] > max[i]) max[i] = hi[i];
3122
+ }
3123
+ }
2880
3124
  }
2881
3125
  }
2882
- return { pickCentres, alpha8: cloud?.alpha8 || null };
3126
+ return min ? { min, max } : null;
3127
+ }
3128
+
3129
+ /**
3130
+ * What setRig('display') frames: the caller's `frame`, else the meshes under root (plus the
3131
+ * splat's measured box if it is shown), else null — the splat, as a display-rig addSplat would.
3132
+ */
3133
+ function displayTarget(disp) {
3134
+ if (disp.frame) {
3135
+ return { center: toArray3(disp.frame.center), extent: toArray3(disp.frame.extent).map((v) => Math.max(Math.abs(v), 1e-6)), source: 'caller' };
3136
+ }
3137
+ const m = rootMeshBounds();
3138
+ if (!m) return null;
3139
+ let { min, max } = m;
3140
+ let source = 'root';
3141
+ const sb = splatShown() ? out.frame : null;
3142
+ if (sb) {
3143
+ source = 'root+splat';
3144
+ min = min.map((v, i) => Math.min(v, sb.center[i] - sb.extent[i] / 2));
3145
+ max = max.map((v, i) => Math.max(v, sb.center[i] + sb.extent[i] / 2));
3146
+ }
3147
+ return {
3148
+ center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
3149
+ 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)],
3150
+ source,
3151
+ };
3152
+ }
3153
+
3154
+ /** Per tick: addModel's tone mapping for the page's meshes while the splat is hidden. */
3155
+ viewer.onTick = pageMode
3156
+ ? viewer.onTick
3157
+ : () => {
3158
+ const want = rigOverride?.type === 'display' && !splatShown() ? rigOverride.o.toneMapping : baseToneMapping;
3159
+ if (want !== viewer.toneMapping) viewer.setToneMapping(want);
3160
+ };
3161
+
3162
+ /** Install addModel's default IBL for the display rig, if the page has none of its own. */
3163
+ async function ensureRigEnvironment(disp, seq) {
3164
+ if (disp.environment === 'none' || viewer.sky || rigEnv) return;
3165
+ const app = viewer.app;
3166
+ const scene = app?.scene;
3167
+ if (!scene || scene.envAtlas) return; // the page lights its own meshes: leave it alone
3168
+ const m = await import('./inline3d-model-playcanvas.js');
3169
+ if (removed || seq !== rigSeq || rigOverride?.type !== 'display' || scene.envAtlas || rigEnv) return;
3170
+ const prev = { skyboxIntensity: scene.skyboxIntensity, exposure: scene.exposure, skyboxRotation: scene.skyboxRotation };
3171
+ const atlas = m.useNeutralStudio(pcModule, app, m.ENV_YAW_DEG);
3172
+ rigEnv = { atlas, prev };
3173
+ }
3174
+
3175
+ /** Undo ensureRigEnvironment (only what it installed, only if the page has not replaced it). */
3176
+ function dropRigEnvironment() {
3177
+ if (!rigEnv) return;
3178
+ const scene = viewer.app?.scene;
3179
+ const { atlas, prev } = rigEnv;
3180
+ rigEnv = null;
3181
+ if (!scene || scene.envAtlas !== atlas) return;
3182
+ scene.envAtlas = null;
3183
+ scene.skyboxIntensity = prev.skyboxIntensity;
3184
+ scene.exposure = prev.exposure;
3185
+ if (prev.skyboxRotation !== undefined) scene.skyboxRotation = prev.skyboxRotation;
3186
+ atlas.destroy?.();
3187
+ }
3188
+
3189
+ /**
3190
+ * handle.setRig(type, options) — switch the rig the window is seen through, live: no remount,
3191
+ * no reload, no new session. 'display' frames the page's meshes under handle.engine.root (plus
3192
+ * the splat when shown) on addModel's defaults; 'camera' is the asset's capture rig re-resolved
3193
+ * from the waterfall's own inputs; 'auto' is exactly what the load resolved. The pose resets to
3194
+ * the new rig's rest. Sticky across setSource until setRig('auto'). Resolves to the handle once
3195
+ * applied (the display rig's default environment included).
3196
+ */
3197
+ function setRig(type, o = {}) {
3198
+ const req = validateSetRig(type, o, pageMode);
3199
+ const seq = ++rigSeq;
3200
+ return first.then(async () => {
3201
+ if (removed || seq !== rigSeq || !current) return out;
3202
+ rigOverride = req.type === 'auto' ? null : req;
3203
+ if (req.type !== 'display') dropRigEnvironment();
3204
+ const resolved = resolveFor(current.rigIn, req.type);
3205
+ out.rig = resolved;
3206
+ applyRig(resolved, current.rigIn.bounds, true);
3207
+ viewer.onTick?.(); // tone mapping now, not a frame late
3208
+ if (req.type === 'display') await ensureRigEnvironment(req.o, seq);
3209
+ return out;
3210
+ });
2883
3211
  }
2884
3212
 
2885
3213
  let pcModule = null;
@@ -2892,6 +3220,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2892
3220
  preserveDrawingBuffer,
2893
3221
  perf: perfResolved,
2894
3222
  viewPath: opts.playcanvasViewPath,
3223
+ antialias: antialias === true,
2895
3224
  });
2896
3225
  if (!app || removed) return null;
2897
3226
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.15.0",
3
+ "version": "1.16.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,46 @@ 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 `'neutral'` — addModel's (Khronos PBR Neutral).
533
+ */
534
+ toneMapping?: 'none' | 'linear' | 'neutral' | 'aces' | 'aces2' | 'filmic' | 'hejl';
535
+ /**
536
+ * `'room'` (default; `'neutral'` is the same): install addModel's generated neutral-studio IBL
537
+ * when the scene has no `envAtlas` of its own, removed again when the rig switches away. A page
538
+ * that lights its own meshes is never touched. `'none'`: never install it.
539
+ */
540
+ environment?: 'room' | 'neutral' | 'none';
541
+ /**
542
+ * Frame THIS box (in `handle.engine.root`'s space) instead of measuring. Default: the enabled
543
+ * meshes under root (+ the splat's box when it is shown); with no meshes, the splat itself.
544
+ */
545
+ frame?: { center: number[] | { x: number; y: number; z: number }; extent: number[] | { x: number; y: number; z: number } };
546
+ }
547
+
493
548
  /** `setSource`'s options. */
494
549
  export interface SplatSourceOptions {
495
550
  /** > 0 = a crossfade of this length (the 1.10 option; same as `transition: 'crossfade'`). */
@@ -727,6 +782,19 @@ export interface SplatHandle {
727
782
  * SOG). `remove()` disposes any still unused.
728
783
  */
729
784
  prepareSource(src: string | Blob | ArrayBuffer | Uint8Array, opts?: SplatPrepareOptions): Promise<SplatPreparedSource>;
785
+ /**
786
+ * PlayCanvas backend only — throws on Spark, and with `controls:'page'` (the page's camera IS
787
+ * the rig). Switch the rig live, on the next frame: no remount, no reload, no new session.
788
+ *
789
+ * `'display'` frames the page's meshes under `handle.engine.root` (+ the splat when shown) on
790
+ * addModel's defaults and declares a display rig; `'camera'` returns to the asset's capture rig,
791
+ * re-resolved from the waterfall's own inputs (lens, captureFit, focus rung); `'auto'` is what
792
+ * the load resolved. The pose resets to the new rig's rest; the declared view rig switches with
793
+ * it; the choice is sticky across `setSource` until `'auto'`. A clean cut (no eased transition).
794
+ * `handle.rig.typeSource` reads `'setRig'`. Resolves once applied. docs/playcanvas-adapter.md §setRig.
795
+ */
796
+ setRig(type: 'display', opts?: SplatSetRigDisplayOptions): Promise<SplatHandle>;
797
+ setRig(type: 'camera' | 'auto'): Promise<SplatHandle>;
730
798
  /**
731
799
  * `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
732
800
  * validated at the call, run once the first asset is on screen. Resolves `{ finished }` —