@displayxr/inline3d 1.14.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.
@@ -50,13 +50,17 @@ 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,
57
57
  EASINGS,
58
58
  resolveRevealOption,
59
+ resolveEffectOptions,
59
60
  validateEffectCall,
61
+ PARTICLE_TRANSITIONS,
62
+ PARTICLE_TRANSITION_OPTIONS,
63
+ particleSpan,
60
64
  } from './inline3d-splat-effects.js';
61
65
  import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
62
66
  import {
@@ -908,6 +912,21 @@ export class PlayCanvasSplatViewer {
908
912
  this._updateMonoProjection();
909
913
  }
910
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
+
911
930
  /** Forget the auto-fit (scale 1, no subject box) — the camera rig's framing. */
912
931
  resetFit() {
913
932
  this._fitScale = 1;
@@ -1269,8 +1288,9 @@ export class PlayCanvasSplatViewer {
1269
1288
  * so a region only one of the two photos covers fades against the page, not against black.
1270
1289
  */
1271
1290
  _ensureSnapshotOverlay() {
1272
- const s = this._snap;
1291
+ const s = (this._snap ||= { w: 0, h: 0, tex: null, rt: null, parts: null }); // prewarm: no capture yet
1273
1292
  if (s.parts) {
1293
+ if (!s.tex) return;
1274
1294
  for (const p of s.parts) {
1275
1295
  p.mat.setParameter('dxrSnap', s.tex);
1276
1296
  p.mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
@@ -1295,6 +1315,7 @@ export class PlayCanvasSplatViewer {
1295
1315
  uniform vec2 dxrSnapInvSize;
1296
1316
  uniform float dxrSnapAlpha;
1297
1317
  uniform vec3 dxrSnapWipe; // t, band, views across (t < -1: no wipe)
1318
+ uniform float dxrSnapOver; // 0 = lerp, 1 = A OVER the scene (the particle transitions)
1298
1319
  float dxrSnapWeight() {
1299
1320
  if (dxrSnapWipe.x < -1.0) return dxrSnapAlpha;
1300
1321
  float vw = 1.0 / (dxrSnapInvSize.x * dxrSnapWipe.z);
@@ -1309,10 +1330,13 @@ export class PlayCanvasSplatViewer {
1309
1330
  mat.depthTest = false;
1310
1331
  mat.depthWrite = false;
1311
1332
  mat.cull = pc.CULLFACE_NONE;
1312
- mat.setParameter('dxrSnap', s.tex);
1313
- mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
1333
+ if (s.tex) {
1334
+ mat.setParameter('dxrSnap', s.tex);
1335
+ mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
1336
+ }
1314
1337
  mat.setParameter('dxrSnapAlpha', 0);
1315
1338
  mat.setParameter('dxrSnapWipe', [-2, 0.1, 1]);
1339
+ mat.setParameter('dxrSnapOver', 0);
1316
1340
  mat.update();
1317
1341
  const mi = new pc.MeshInstance(mesh, mat, new pc.GraphNode(name));
1318
1342
  mi.cull = false;
@@ -1321,7 +1345,14 @@ export class PlayCanvasSplatViewer {
1321
1345
  return { mat, mi };
1322
1346
  };
1323
1347
  s.parts = [
1324
- part('inline3dSnapshotScale', 'gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0 - dxrSnapWeight());', pc.BLENDMODE_ZERO, pc.BLENDMODE_SRC_ALPHA, -2),
1348
+ // lerp: dst·(1 − w); over: dst·(1 − w·A.a) — premultiplied A over the scene
1349
+ part(
1350
+ 'inline3dSnapshotScale',
1351
+ 'float w = dxrSnapWeight(); float a = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize).a; gl_FragColor = vec4(0.0, 0.0, 0.0, mix(1.0 - w, 1.0 - w * a, dxrSnapOver));',
1352
+ pc.BLENDMODE_ZERO,
1353
+ pc.BLENDMODE_SRC_ALPHA,
1354
+ -2,
1355
+ ),
1325
1356
  part('inline3dSnapshotAdd', 'gl_FragColor = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize) * dxrSnapWeight();', pc.BLENDMODE_ONE, pc.BLENDMODE_ONE, -1),
1326
1357
  ];
1327
1358
  this.app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances(s.parts.map((p) => p.mi));
@@ -1331,7 +1362,7 @@ export class PlayCanvasSplatViewer {
1331
1362
  * Lerp the snapshot over the scene at `alpha` (0 hides it). Returns false when the canvas buffer
1332
1363
  * no longer matches the capture (resize, 2D/3D switch): the caller ends its fade.
1333
1364
  */
1334
- setSnapshotAlpha(alpha, wipe = null) {
1365
+ setSnapshotAlpha(alpha, wipe = null, mix = null) {
1335
1366
  const s = this._snap;
1336
1367
  if (!s?.parts) return false;
1337
1368
  const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
@@ -1344,6 +1375,8 @@ export class PlayCanvasSplatViewer {
1344
1375
  // SAME viewport-relative x in every eye — a front at the zero-disparity plane, so both
1345
1376
  // eyes agree. `views` = eye viewports side by side across the buffer.
1346
1377
  p.mat.setParameter('dxrSnapWipe', wipe ? [wipe.t, wipe.band, wipe.views] : [-2, 0.1, 1]);
1378
+ // setSource's particle transitions: the live outgoing image OVER the incoming one
1379
+ p.mat.setParameter('dxrSnapOver', mix?.over ? 1 : 0);
1347
1380
  p.mat.update();
1348
1381
  }
1349
1382
  }
@@ -2133,14 +2166,140 @@ export function describeResource(res) {
2133
2166
  * one-pass fade, with the coverage-linear alpha remap on both assets.
2134
2167
  */
2135
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
+
2136
2263
  /** setSource's transitions and their defaults. */
2137
2264
  export const SOURCE_TRANSITIONS = Object.freeze({
2138
2265
  cut: {},
2139
2266
  crossfade: {},
2140
2267
  flip: { durationMs: 2200, easing: 'easeInOutSine' },
2141
2268
  wavefront: { durationMs: 2000, easing: 'easeInOutSine', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
2269
+ // the particle transitions (./inline3d-splat-effects.js PARTICLE_TRANSITIONS)
2270
+ ...Object.fromEntries(Object.entries(PARTICLE_TRANSITIONS).map(([k, v]) => [k, { durationMs: v.durationMs, easing: v.easing, particle: true }])),
2142
2271
  });
2143
2272
 
2273
+ /**
2274
+ * A particle transition's two sides, resolved: { out: { effect, opts }, in: { effect, opts },
2275
+ * overlap } — the table's defaults, then the page's shared particle options, then its
2276
+ * per-side `outgoingFx` / `incomingFx` overrides. Every side is validated as its effect (throws).
2277
+ */
2278
+ export function resolveParticleTransition(name, o = {}) {
2279
+ const spec = PARTICLE_TRANSITIONS[name];
2280
+ const shared = {};
2281
+ for (const k of PARTICLE_TRANSITION_OPTIONS) if (o[k] !== undefined) shared[k] = o[k];
2282
+ for (const k of ['outgoingFx', 'incomingFx']) {
2283
+ if (o[k] !== undefined && (o[k] === null || typeof o[k] !== 'object')) throw new TypeError(`@displayxr/inline3d/splat: setSource ${k} must be an object of effect options.`);
2284
+ }
2285
+ const overlap = o.overlap ?? spec.overlap ?? 0;
2286
+ if (!(typeof overlap === 'number' && overlap >= 0 && overlap <= 1)) throw new RangeError(`@displayxr/inline3d/splat: setSource overlap must be a number in [0, 1], got ${o.overlap}.`);
2287
+ const side = (which, dflt, extra) => {
2288
+ const effect = dflt.effect;
2289
+ const opts = { ...dflt.opts, ...shared, ...(extra || {}) };
2290
+ resolveEffectOptions(effect, { ...opts, scope: 'entity' }, 'set', { internal: true }); // throws on a bad one
2291
+ return { effect, opts };
2292
+ };
2293
+ const out = side('out', spec.out, o.outgoingFx);
2294
+ const inc = side('in', spec.in, o.incomingFx);
2295
+ // Both photos run ONE render-time body, compiled for one `order`; `layers` reads splat.index,
2296
+ // which is a file index only in a work-buffer modifier (not at render time).
2297
+ const order = (x) => x.opts.order ?? EFFECTS[x.effect].defaults.order;
2298
+ if (order(out) !== order(inc)) throw new Error(`@displayxr/inline3d/splat: setSource ${name}: both photos take the same order (got '${order(out)}' and '${order(inc)}').`);
2299
+ if (order(inc) === 'layers') throw new Error(`@displayxr/inline3d/splat: setSource ${name}: order 'layers' is a reveal-only order (it needs the file index, which a transition's render-time body does not have).`);
2300
+ return { out, in: inc, overlap };
2301
+ }
2302
+
2144
2303
  /** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
2145
2304
  export function resolveSwap(o = {}) {
2146
2305
  if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setSource options must be an object.');
@@ -2155,9 +2314,10 @@ export function resolveSwap(o = {}) {
2155
2314
  const easing = o.easing ?? d.easing ?? 'linear';
2156
2315
  if (typeof easing !== 'function' && !EASINGS[easing]) throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
2157
2316
  const reveal = resolveRevealOption(o.reveal);
2158
- if (reveal && (transition === 'flip' || transition === 'wavefront')) {
2317
+ if (reveal && (transition === 'flip' || transition === 'wavefront' || d.particle)) {
2159
2318
  throw new Error(`@displayxr/inline3d/splat: setSource reveal plays with transition 'cut' or 'crossfade'; '${transition}' is its own reveal.`);
2160
2319
  }
2320
+ const particles = d.particle ? resolveParticleTransition(transition, o) : null;
2161
2321
  const band = o.band ?? d.band ?? 0.18;
2162
2322
  const ridge = o.ridge ?? d.ridge ?? 0.03;
2163
2323
  const ridgeMaxDisparity = o.ridgeMaxDisparity ?? d.ridgeMaxDisparity ?? 0.004;
@@ -2165,7 +2325,7 @@ export function resolveSwap(o = {}) {
2165
2325
  EFFECTS.wavefront.validate({ band, ridge, ridgeMaxDisparity });
2166
2326
  }
2167
2327
  const outgoing = resolveOutgoingOption(o.outgoing);
2168
- return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity, outgoing };
2328
+ return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity, outgoing, particles };
2169
2329
  }
2170
2330
 
2171
2331
  /**
@@ -2197,6 +2357,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2197
2357
  observe,
2198
2358
  firstWovenHoldMs,
2199
2359
  preserveDrawingBuffer = false,
2360
+ antialias = false,
2200
2361
  } = opts;
2201
2362
  // `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
2202
2363
  // ROTATES (one directional sort serves every view), not on a timer — docs/playcanvas-adapter.md.
@@ -2310,6 +2471,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2310
2471
  },
2311
2472
  setSource,
2312
2473
  prepareSource,
2474
+ setRig,
2313
2475
  /**
2314
2476
  * Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
2315
2477
  * docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
@@ -2537,6 +2699,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2537
2699
 
2538
2700
  // ── focus → view rig ──
2539
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';
2540
2709
  function pushViewRig(force) {
2541
2710
  if (pageMode) return; // pageTick declares the rig every frame
2542
2711
  if (!out.rig || out.rig.type !== 'camera') return;
@@ -2549,7 +2718,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2549
2718
  lastConvergence = d;
2550
2719
  out.rig.convergence = d;
2551
2720
  const q = quatFromMatrix(pose);
2552
- out.viewRig = cameraRigFromPose(
2721
+ camRigObj ||= {};
2722
+ declaredDisplay = null;
2723
+ out.viewRig = camRigObj = cameraRigFromPose(
2553
2724
  {
2554
2725
  position: { x: pose[12], y: pose[13], z: pose[14] },
2555
2726
  orientation: { x: q[0], y: q[1], z: q[2], w: q[3] },
@@ -2559,7 +2730,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2559
2730
  convergence: d > 0 ? d : 0,
2560
2731
  ipdFactor: out.rig.ipdFactor,
2561
2732
  parallaxFactor: out.rig.parallaxFactor,
2562
- out: out.viewRig || {},
2733
+ out: camRigObj,
2563
2734
  },
2564
2735
  );
2565
2736
  handle?.setViewRig?.(out.viewRig);
@@ -2770,16 +2941,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2770
2941
  ? lift(frame)
2771
2942
  : null;
2772
2943
 
2773
- const sample = loaded.pre?.rest || null;
2774
- const box = canvas.getBoundingClientRect();
2775
- const resolved = resolveRig({
2776
- camera: loaded.camera,
2777
- opts: rigOpts,
2778
- cloud: sample,
2779
- canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
2780
- });
2781
- resolved.focusDefault = resolved.focus.slice();
2782
- 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');
2783
2949
  perfSpan('applyLoaded:rig', tB);
2784
2950
  out.camera = loaded.camera;
2785
2951
  out.rig = resolved;
@@ -2811,8 +2977,66 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2811
2977
  }),
2812
2978
  );
2813
2979
  }
2814
- } 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);
2815
3038
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
3039
+ else if (reset) viewer.idleSpin = idleSpin;
2816
3040
  // The camera rig never auto-fits (the capture IS the framing): a scale left by a previous
2817
3041
  // display-rig asset (setSource) must not shrink this one.
2818
3042
  viewer.resetFit();
@@ -2820,15 +3044,170 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2820
3044
  viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: false });
2821
3045
  lastConvergence = Number.NaN;
2822
3046
  pushViewRig(true);
2823
- } else {
2824
- if (viewer.mono.capture) viewer.useDisplayCamera();
2825
- if (bounds) viewer.fitTo(bounds.center, bounds.extent);
2826
- else console.warn('[inline3d/splat] no usable bounds — subject is UNFRAMED (model scale)', src);
2827
- if (resolved.focusSource === 'caller' || resolved.focusSource === 'block') {
2828
- 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
+ }
2829
3124
  }
2830
3125
  }
2831
- 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
+ });
2832
3211
  }
2833
3212
 
2834
3213
  let pcModule = null;
@@ -2841,6 +3220,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2841
3220
  preserveDrawingBuffer,
2842
3221
  perf: perfResolved,
2843
3222
  viewPath: opts.playcanvasViewPath,
3223
+ antialias: antialias === true,
2844
3224
  });
2845
3225
  if (!app || removed) return null;
2846
3226
  pcModule = pc;
@@ -2892,13 +3272,17 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2892
3272
  * Memory: the prepared asset is fully resident (a 1.18M-gaussian SOG: its GPU textures plus the
2893
3273
  * engine's centre array) alongside the current one until it is used or disposed.
2894
3274
  */
2895
- async function prepareSource(src) {
3275
+ async function prepareSource(src, po = {}) {
3276
+ if (po === null || typeof po !== 'object') throw new TypeError('@displayxr/inline3d/splat: prepareSource options must be an object.');
3277
+ const warm = po.transition !== undefined ? resolveSwap(po) : null; // the setSource options it will get; throws on a bad one
2896
3278
  const app = await booted;
2897
3279
  await first.catch(() => null);
2898
3280
  if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
2899
3281
  const t0 = performance.now();
2900
3282
  const loaded = await loadOne(pcModule, app, src, { background: true });
2901
3283
  perfSpan('prepareSource', t0);
3284
+ // The transition the page declared: compile its shader now, in the dwell, not on its first frame.
3285
+ if (warm?.particles) await prewarmTransition(warm);
2902
3286
  const entry = { loaded, state: 'ready', dispose: null };
2903
3287
  const prepared = {
2904
3288
  [PREPARED_TAG]: true,
@@ -2926,6 +3310,98 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2926
3310
  return prepared;
2927
3311
  }
2928
3312
 
3313
+ // ── shader pre-warm for the particle transitions ──
3314
+ const prewarmed = new Set();
3315
+ /**
3316
+ * Compile + link the render-time variant a particle transition will install, ahead of it. The
3317
+ * first frame of a transition otherwise blocks on the link (GetProgramiv: 35–45 ms on an M1,
3318
+ * measured with a trace) — on the very transition a user sees first. A throwaway material with
3319
+ * the eye renderer's own description, defines and chunks, plus the transition's chunk, asks the
3320
+ * engine's program library for the same variant the renderer will ask for (the library keys on
3321
+ * the generated source + processing options, not on the material), so the compile is issued
3322
+ * now and, with KHR_parallel_shader_compile, finishes off the main thread. Best effort: an
3323
+ * engine whose internals differ just compiles on the transition's first frame, as before.
3324
+ */
3325
+ async function prewarmTransition(plan) {
3326
+ const key = `${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`;
3327
+ if (!fx || prewarmed.has(key)) return;
3328
+ await yieldIdle();
3329
+ if (removed || !fx) return;
3330
+ try {
3331
+ const pc = pcModule;
3332
+ const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
3333
+ const code = fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order });
3334
+ const made = [];
3335
+ let issued = 0;
3336
+ for (const cam of cams) {
3337
+ const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam.camera?.camera);
3338
+ let mi = null;
3339
+ if (cd?.layersMap) for (const ld of cd.layersMap.values()) mi ||= ld?.gsplatManager?.renderer?.meshInstance ?? null;
3340
+ const src = mi?.material;
3341
+ const camera = cam.camera?.camera;
3342
+ if (!src?.shaderDesc || !camera?.shaderParams || typeof src.getShaderVariant !== 'function') continue;
3343
+ const m = new pc.ShaderMaterial(src.shaderDesc);
3344
+ src.defines.forEach((v, k) => m.setDefine(k, v));
3345
+ m.shaderChunks.copy(src.shaderChunks);
3346
+ m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('gsplatModifyVS', code);
3347
+ m.blendState = src.blendState;
3348
+ made.push(m.getShaderVariant({
3349
+ device: viewer.app.graphicsDevice,
3350
+ scene: viewer.app.scene,
3351
+ objDefs: mi._shaderDefs,
3352
+ cameraShaderParams: camera.shaderParams,
3353
+ pass: 0, // SHADER_FORWARD
3354
+ sortedLights: [],
3355
+ viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
3356
+ vertexFormat: mi.mesh?.vertexBuffer?.format,
3357
+ }));
3358
+ issued++;
3359
+ }
3360
+ // the overlay's two quads (created on the first capture otherwise, and compiled on its draw)
3361
+ const cam = viewer.eye?.camera?.camera;
3362
+ if (cam?.shaderParams) {
3363
+ viewer._ensureSnapshotOverlay();
3364
+ for (const p of viewer._snap?.parts || []) {
3365
+ made.push(p.mat.getShaderVariant?.({
3366
+ device: viewer.app.graphicsDevice,
3367
+ scene: viewer.app.scene,
3368
+ objDefs: p.mi._shaderDefs,
3369
+ cameraShaderParams: cam.shaderParams,
3370
+ pass: 0,
3371
+ sortedLights: [],
3372
+ viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
3373
+ vertexFormat: p.mi.mesh?.vertexBuffer?.format,
3374
+ }));
3375
+ }
3376
+ }
3377
+ if (issued) prewarmed.add(key);
3378
+ await finalizeWhenLinked(made.filter(Boolean));
3379
+ } catch (err) {
3380
+ console.info('[inline3d/splat] transition shader pre-warm skipped', err);
3381
+ }
3382
+ }
3383
+
3384
+ /**
3385
+ * Creating a program only ISSUES its compile + link; the browser resolves the link when the
3386
+ * program is first queried — the engine's first draw, blocking (the 35–50 ms GetProgramiv the
3387
+ * trace shows, even for a program created seconds earlier). So finish the engine's own
3388
+ * finalize here, in the dwell: wait (in idle periods, not blocking) for the link to complete
3389
+ * where the browser reports it (KHR_parallel_shader_compile), then finalize.
3390
+ */
3391
+ async function finalizeWhenLinked(shaders) {
3392
+ const dev = viewer.app?.graphicsDevice;
3393
+ if (!dev) return;
3394
+ const pending = () => shaders.filter((x) => x && !x.ready && !x.failed && x.impl?.finalize);
3395
+ for (let i = 0; i < 40 && pending().length; i++) {
3396
+ for (const x of pending()) if (x.impl.isLinked?.(dev)) x.impl.finalize(dev, x) || (x.failed = true);
3397
+ if (pending().length) await yieldIdle(50);
3398
+ if (removed) return;
3399
+ }
3400
+ // still linking (or no completion query): finalize now — a block in the dwell, not on the
3401
+ // transition's first frame
3402
+ for (const x of pending()) x.impl.finalize(dev, x) || (x.failed = true);
3403
+ }
3404
+
2929
3405
  // ── setSource: swap the asset — a cut, a crossfade, or a transition ──
2930
3406
  let sourceGen = 0;
2931
3407
  let pendingSwap = null;
@@ -2972,9 +3448,10 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2972
3448
  const prev = current;
2973
3449
  pendingSwap?.finish();
2974
3450
  const transition = prev ? plan.transition : 'cut';
3451
+ const particle = plan.particles && transition in PARTICLE_TRANSITIONS ? plan.particles : null;
2975
3452
  // FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
2976
3453
  // drawn. The overlay goes up in the same task, so no frame shows neither photo.
2977
- const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront';
3454
+ const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront' || !!particle;
2978
3455
  const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
2979
3456
  if (removed || gen !== sourceGen) {
2980
3457
  app.assets.remove(loaded.asset);
@@ -2985,7 +3462,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2985
3462
  // LIVE outgoing (default in 3D): the old asset stays resident and keeps rendering, through the
2986
3463
  // same eye views, into its own target; the frozen capture above bridges the first frames until
2987
3464
  // its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
2988
- const outgoingMode = plan.outgoing || defaultOutgoing(viewer.is3D);
3465
+ // A particle transition's outgoing photo MOVES, so it is live in 2D too (where the engine has
3466
+ // the RenderView path); 'frozen' there = its snapshot fades out while the new one plays in.
3467
+ const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
2989
3468
  const live =
2990
3469
  snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
2991
3470
  /** Each frame of the window: once the live camera is ready, the overlay samples it. */
@@ -3070,6 +3549,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3070
3549
  return out;
3071
3550
  }
3072
3551
 
3552
+ if (particle && snapped) {
3553
+ playParticleTransition({ particle, plan, prev, entity, live, adopt, release, finishers, finish, isFinished: () => finished, pumpLive, isLiveShown: () => liveShown });
3554
+ await done;
3555
+ return out;
3556
+ }
3557
+
3073
3558
  adopt(entity);
3074
3559
  if (transition === 'cut') {
3075
3560
  release(prev);
@@ -3173,6 +3658,108 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3173
3658
  return out;
3174
3659
  }
3175
3660
 
3661
+ /**
3662
+ * setSource's particle transitions (swarm, burst, shimmer-cross, dust), once the frame snapshot
3663
+ * is up. The OUTGOING asset plays its effect in reverse, on the live camera, keyed on its own
3664
+ * frame (taken here, before the rig switches); the INCOMING one plays forwards on the eye camera,
3665
+ * its frame re-taken when the clock starts. One clock drives both, on overlapping spans
3666
+ * (particleSpan). The overlay puts the live outgoing image OVER the incoming one, per eye. With
3667
+ * no live camera (outgoing:'frozen', or an engine without the RenderView path) the frozen frame
3668
+ * fades out over the outgoing span while the incoming asset plays its side. The end: effects
3669
+ * removed (modifiers deleted), overlay hidden, live camera off, old asset released — the frame
3670
+ * is a plain cut's.
3671
+ */
3672
+ function playParticleTransition({ particle, plan, prev, entity, live, adopt, release, finishers, finish, isFinished, pumpLive, isLiveShown }) {
3673
+ const ease = typeof plan.easing === 'function' ? plan.easing : EASINGS[plan.easing] || EASINGS.linear;
3674
+ const overlap = particle.overlap;
3675
+ // RENDER-TIME (the default): one tile-scope body, each photo's values on the mesh instance of
3676
+ // the gsplat manager that draws it — the eye camera's (incoming) and the live camera's
3677
+ // (outgoing). No work-buffer rewrite and no re-sort per frame. An engine that does not expose
3678
+ // the managers' mesh instances gets the entity-scope modifiers instead (a full work-buffer
3679
+ // rewrite + a re-sort per asset per frame: correct, but it hitches on a 1.18M photo).
3680
+ const managerMi = (cam, layer) => {
3681
+ const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
3682
+ if (!cd?.layersMap) return null;
3683
+ if (layer) return cd.layersMap.get(layer)?.gsplatManager?.renderer?.meshInstance ?? null;
3684
+ for (const ld of cd.layersMap.values()) if (ld?.gsplatManager?.renderer?.meshInstance) return ld.gsplatManager.renderer.meshInstance;
3685
+ return null;
3686
+ };
3687
+ const eyeMi = () => managerMi(viewer.eye, null);
3688
+ const liveMi = () => (live?.active ? managerMi(live.cam, live.layer) : null);
3689
+ const shared = particle.out.effect === particle.in.effect && eyeMi() ? fx.driveShared('transition', particle.in.effect, { order: particle.in.opts.order }) : null;
3690
+ // the outgoing side, BEFORE adopt(): its frame (eyes, focus, framing) is the old asset's
3691
+ let outFx = null;
3692
+ if (live) {
3693
+ outFx = shared ? shared.side(liveMi, particle.out.opts) : fx.drive(prev.entity, particle.out.effect, particle.out.opts);
3694
+ outFx.set(1, 0);
3695
+ } else {
3696
+ release(prev); // the frozen frame shows it from here on
3697
+ }
3698
+ adopt(entity);
3699
+ // the incoming side, hidden (amount 0) until the clock starts
3700
+ const inFx = shared ? shared.side(eyeMi, particle.in.opts) : fx.drive(entity, particle.in.effect, particle.in.opts);
3701
+ inFx.set(0, 0);
3702
+ viewer._transitionPath = shared ? 'render' : 'entity'; // diagnostics (docs §Gates)
3703
+ finishers.push(() => {
3704
+ viewer._transitionState = null;
3705
+ if (shared) shared.remove();
3706
+ else {
3707
+ outFx?.remove();
3708
+ inFx.remove();
3709
+ }
3710
+ viewer.setSnapshotAlpha(0);
3711
+ viewer.releaseSnapshot();
3712
+ if (live) {
3713
+ viewer.stopLiveOutgoing();
3714
+ release(prev);
3715
+ }
3716
+ });
3717
+ const dur = plan.durationMs;
3718
+ let t0 = null;
3719
+ let ticks = 0;
3720
+ viewer._hooks.push((t) => {
3721
+ if (removed) return (finish(), false);
3722
+ if (isFinished()) return false;
3723
+ if (live && !live.fits) {
3724
+ finish(); // a resize or a 2D/3D switch mid-window: end now
3725
+ return false;
3726
+ }
3727
+ pumpLive();
3728
+ // The clock starts on the SECOND tick (the incoming asset's first frame builds its work
3729
+ // buffer) and, live, once the live camera has drawn a sorted frame (until then the frozen
3730
+ // capture stands in for the outgoing photo, untouched).
3731
+ if (++ticks < 2 || (live && !isLiveShown())) {
3732
+ if (shared) {
3733
+ // the managers (and their mesh instances) may be new: keep both photos' values on them
3734
+ outFx?.set(1, 0);
3735
+ inFx.set(0, 0);
3736
+ }
3737
+ if (!viewer.setSnapshotAlpha(1)) {
3738
+ finish();
3739
+ return false;
3740
+ }
3741
+ return true;
3742
+ }
3743
+ if (t0 === null) {
3744
+ t0 = t;
3745
+ inFx.restart(); // the incoming asset's frame, now its rig and first frames are in
3746
+ }
3747
+ const raw = dur > 0 ? Math.min(1, Math.max(0, (t - t0) / dur)) : 1;
3748
+ const te = ease(raw);
3749
+ const timeS = (t - t0) / 1000;
3750
+ viewer._transitionState = { raw, live: !!live }; // diagnostics (docs §Gates)
3751
+ const outSpan = particleSpan(te, overlap, 'out');
3752
+ outFx?.set(1 - outSpan, timeS);
3753
+ inFx.set(particleSpan(te, overlap, 'in'), timeS);
3754
+ const ok = live ? viewer.setSnapshotAlpha(1, null, { over: true }) : viewer.setSnapshotAlpha(1 - outSpan);
3755
+ if (!ok || raw >= 1) {
3756
+ finish();
3757
+ return false;
3758
+ }
3759
+ return true;
3760
+ });
3761
+ }
3762
+
3176
3763
  // The load promise is RETURNED, never written to `out.ready`: ./splat's addSplat owns that
3177
3764
  // field (one promise, one owner).
3178
3765
  return first