@displayxr/inline3d 1.30.0 → 1.32.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.
@@ -51,8 +51,10 @@ import {
51
51
  } from './inline3d-splat-perf.js';
52
52
  import { boundsFromPositions, boundsFromPositionsAsync } from './inline3d-viewer.js';
53
53
  import { cameraRigFromPose, displayRig } from './inline3d-three.js';
54
+ import { displayMetricsFrom } from './inline3d-display-metrics.js';
54
55
  import {
55
56
  SplatEffects,
57
+ ENVELOPE_MAX_RECTS,
56
58
  EFFECTS,
57
59
  EASINGS,
58
60
  resolveRevealOption,
@@ -67,6 +69,7 @@ import {
67
69
  sequenceSpans,
68
70
  } from './inline3d-splat-effects.js';
69
71
  import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
72
+ import { viewerEaseFor, frameTrackingState } from './inline3d-viewer-ease.js';
70
73
  import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNIFORM, makeSbsMaterial } from './inline3d-splat-video.js';
71
74
  import { LayerRigCameras, validateLayerRig, validateLayerRigOptions, DEFAULT_VIEWER_DISTANCE_M } from './inline3d-splat-layer-rig.js';
72
75
  import { RigTracker, remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
@@ -817,8 +820,13 @@ export class PlayCanvasSplatViewer {
817
820
  sky = false,
818
821
  pageCamera = false,
819
822
  toneMapping = 'none',
823
+ viewerEase,
820
824
  } = opts;
821
825
  this.canvas = canvas;
826
+ // The tracking-acquisition ease (./inline3d-viewer-ease.js): undefined = the session's
827
+ // createInline3D({ viewerEase }) default. Built on the first 3D frame, which knows the session.
828
+ this._viewerEaseOpt = viewerEase;
829
+ this.viewerEase = null;
822
830
  // controls:'page': the PAGE owns the camera (setPageCamera). No orbit, no idle, no fit; the
823
831
  // rig node carries the page's matrix instead of the inverse pivot.
824
832
  this.pageCamera = pageCamera === true;
@@ -1194,7 +1202,7 @@ export class PlayCanvasSplatViewer {
1194
1202
  }
1195
1203
 
1196
1204
  /** wall.addScene's frame callback. Validate BEFORE drawing; replay the last good frame else. */
1197
- onFrame(views, layer) {
1205
+ onFrame(views, layer, frame) {
1198
1206
  if (this._disposed) return;
1199
1207
  if (this._mode !== '3d') this.stopMono();
1200
1208
  // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
@@ -1224,6 +1232,9 @@ export class PlayCanvasSplatViewer {
1224
1232
  vps.push(vp);
1225
1233
  }
1226
1234
  this._cacheGood(views, vps);
1235
+ // Ease a tracking acquisition/loss on the copies (never the XRViews), before anything —
1236
+ // the rig map, the layer rig, the live outgoing photo — reads them.
1237
+ (this.viewerEase ||= viewerEaseFor(frame, this._viewerEaseOpt)).apply(this._lastGood.entries, frameTrackingState(frame));
1227
1238
  this._drawEntries(this._lastGood.entries, this._lastGood);
1228
1239
  }
1229
1240
 
@@ -2139,6 +2150,7 @@ export class PlayCanvasSplatViewer {
2139
2150
  if (this._disposed) return;
2140
2151
  const box = this.canvas.getBoundingClientRect();
2141
2152
  if (box.width < 1 || box.height < 1) return;
2153
+ this.viewerEase?.reset(); // a new window size moves every projection: not a viewer jump
2142
2154
  const dpr = Math.min((typeof window !== 'undefined' && window.devicePixelRatio) || 1, 2) * this.renderScale;
2143
2155
  // Clamp to the device's GL limits BEFORE sizing (./inline3d-buffer-limit.js). Measured on an
2144
2156
  // Adreno 740 tablet: MAX_TEXTURE_SIZE 4096, a 5120-wide request silently became a 4096-wide
@@ -2302,6 +2314,26 @@ export class PlayCanvasSplatViewer {
2302
2314
  };
2303
2315
  }
2304
2316
 
2317
+ /**
2318
+ * The MONO camera's frame in WORLD (content) space — the rig the page declared, not the tracked
2319
+ * eyes: `origin`, unit `axis`/`right`/`up`, and `scale` = world units per rig unit (the rig
2320
+ * node's uniform scale). The depth envelope keys on it, so it holds still under head motion.
2321
+ */
2322
+ monoFrame() {
2323
+ const M = mat4Mul(this.rigMatrix(), this.mono.pose);
2324
+ const len = (x, y, z) => Math.hypot(x, y, z) || 1;
2325
+ const sx = len(M[0], M[1], M[2]);
2326
+ const sy = len(M[4], M[5], M[6]);
2327
+ const sz = len(M[8], M[9], M[10]);
2328
+ return {
2329
+ origin: [M[12], M[13], M[14]],
2330
+ axis: [-M[8] / sz, -M[9] / sz, -M[10] / sz],
2331
+ right: [M[0] / sx, M[1] / sx, M[2] / sx],
2332
+ up: [M[4] / sy, M[5] / sy, M[6] / sy],
2333
+ scale: Math.cbrt(sx * sy * sz),
2334
+ };
2335
+ }
2336
+
2305
2337
  _easeFocus() {
2306
2338
  if (this._focusSettled) return;
2307
2339
  const f = this._focus;
@@ -2801,6 +2833,95 @@ const PAGE_SETRIG_ERROR =
2801
2833
  "@displayxr/inline3d/splat: setRig() is not available with controls:'page' — the page owns the " +
2802
2834
  'camera (its camera IS the rig). Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
2803
2835
 
2836
+ /** handle.setDepthEnvelope's defaults, metres on the panel (Ride Spatial's depth rules v2). */
2837
+ export const DEPTH_ENVELOPE_DEFAULTS = Object.freeze({ maxFrontM: 0.02, edgeM: 0.001, bandM: 0.016 });
2838
+
2839
+ /**
2840
+ * handle.setDepthEnvelope's argument, validated and merged onto the current one (throws at the
2841
+ * call). A key given replaces; `null` (the whole argument) turns the envelope off. Rects are kept
2842
+ * as given (canvas fractions, y DOWN) plus `f` — [l, r, b, t] canvas-normalised, y UP — for the
2843
+ * shader; more than ENVELOPE_MAX_RECTS keeps the heaviest (then largest) ones.
2844
+ */
2845
+ export function validateDepthEnvelope(o, prev) {
2846
+ if (o === null) return null;
2847
+ const E = '@displayxr/inline3d/splat: setDepthEnvelope';
2848
+ if (typeof o !== 'object' || Array.isArray(o)) throw new TypeError(`${E} takes an object or null.`);
2849
+ const keys = ['maxFrontM', 'edgeM', 'bandM', 'stage', 'rects', 'viewerM', 'canvasSizeM'];
2850
+ const unknown = Object.keys(o).filter((k) => !keys.includes(k));
2851
+ if (unknown.length) throw new Error(`${E} — unknown option(s) ${unknown.join(', ')}.`);
2852
+ const next = { ...DEPTH_ENVELOPE_DEFAULTS, stage: { x: 0, y: 0, w: 1, h: 1 }, rects: [], viewerM: null, canvasSizeM: null, ...(prev || {}) };
2853
+ const num = (k, v, min, max = Infinity) => {
2854
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < min || v > max) throw new RangeError(`${E} — ${k} must be a finite number in [${min}, ${max}] (got ${v}).`);
2855
+ return v;
2856
+ };
2857
+ const rect = (r, what) => {
2858
+ if (!r || typeof r !== 'object') throw new TypeError(`${E} — ${what} must be { x, y, w, h }.`);
2859
+ const x = num(`${what}.x`, r.x, -10, 10);
2860
+ const y = num(`${what}.y`, r.y, -10, 10);
2861
+ const w = num(`${what}.w`, r.w, 0, 20);
2862
+ const h = num(`${what}.h`, r.h, 0, 20);
2863
+ return { x, y, w, h, f: [2 * x - 1, 2 * (x + w) - 1, 1 - 2 * (y + h), 1 - 2 * y] };
2864
+ };
2865
+ for (const k of ['maxFrontM', 'edgeM', 'bandM']) if (k in o && o[k] !== undefined) next[k] = num(k, o[k], k === 'bandM' ? 1e-5 : 0, 1);
2866
+ if ('viewerM' in o && o.viewerM !== undefined) next.viewerM = o.viewerM === null ? null : num('viewerM', o.viewerM, 0.05, 10);
2867
+ if ('canvasSizeM' in o && o.canvasSizeM !== undefined) {
2868
+ const c = o.canvasSizeM;
2869
+ if (c === null) next.canvasSizeM = null;
2870
+ else {
2871
+ if (!Array.isArray(c) || c.length !== 2) throw new TypeError(`${E} — canvasSizeM must be [width, height] in metres, or null.`);
2872
+ next.canvasSizeM = [num('canvasSizeM[0]', c[0], 1e-4, 10), num('canvasSizeM[1]', c[1], 1e-4, 10)];
2873
+ }
2874
+ }
2875
+ if ('stage' in o && o.stage !== undefined) next.stage = o.stage === null ? { x: 0, y: 0, w: 1, h: 1 } : rect(o.stage, 'stage');
2876
+ next.stage = next.stage.f ? next.stage : rect(next.stage, 'stage');
2877
+ if ('rects' in o && o.rects !== undefined) {
2878
+ if (o.rects !== null && !Array.isArray(o.rects)) throw new TypeError(`${E} — rects must be an array.`);
2879
+ next.rects = (o.rects || [])
2880
+ .map((r, i) => ({ ...rect(r, `rects[${i}]`), weight: r.weight === undefined ? 1 : num(`rects[${i}].weight`, r.weight, 0, 1) }))
2881
+ .filter((r) => r.weight > 0 && r.w > 0 && r.h > 0)
2882
+ .sort((a, b) => b.weight - a.weight || b.w * b.h - a.w * a.h)
2883
+ .slice(0, ENVELOPE_MAX_RECTS);
2884
+ }
2885
+ if (next.edgeM > next.maxFrontM) next.edgeM = next.maxFrontM;
2886
+ return next;
2887
+ }
2888
+
2889
+ /**
2890
+ * handle.setStereo's argument, validated and merged onto the current override (throws at the
2891
+ * call). Returns the new override, or null when nothing is overridden.
2892
+ */
2893
+ export function validateSetStereo(o, prev) {
2894
+ if (o === null) return null;
2895
+ if (typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setStereo takes an object or null.');
2896
+ // Absolute (…Factor) or RELATIVE to the asset's own (…Scale); per scalar, the last one given wins.
2897
+ const pairs = [
2898
+ ['ipdFactor', 'ipdScale'],
2899
+ ['parallaxFactor', 'parallaxScale'],
2900
+ ];
2901
+ const keys = pairs.flat();
2902
+ const unknown = Object.keys(o).filter((k) => !keys.includes(k));
2903
+ if (unknown.length) throw new Error(`@displayxr/inline3d/splat: setStereo — unknown option(s) ${unknown.join(', ')}.`);
2904
+ for (const [a, b] of pairs) {
2905
+ if (o[a] != null && o[b] != null) throw new Error(`@displayxr/inline3d/splat: setStereo — ${a} and ${b} are exclusive.`);
2906
+ }
2907
+ const next = { ...(prev || {}) };
2908
+ for (const [a, b] of pairs) {
2909
+ for (const k of [a, b]) {
2910
+ if (!(k in o) || o[k] === undefined) continue;
2911
+ if (o[k] === null) {
2912
+ delete next[k];
2913
+ continue;
2914
+ }
2915
+ if (typeof o[k] !== 'number' || !Number.isFinite(o[k]) || o[k] < 0) {
2916
+ throw new RangeError(`@displayxr/inline3d/splat: setStereo — ${k} must be a finite number >= 0 (got ${o[k]}).`);
2917
+ }
2918
+ next[k] = o[k];
2919
+ delete next[k === a ? b : a];
2920
+ }
2921
+ }
2922
+ return Object.keys(next).length ? next : null;
2923
+ }
2924
+
2804
2925
  /**
2805
2926
  * handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
2806
2927
  * @returns {{type:'display'|'camera'|'auto', o?:object}}
@@ -3072,6 +3193,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3072
3193
  nearClip: opts.nearClip,
3073
3194
  farClip: opts.farClip,
3074
3195
  sky: opts.sky,
3196
+ viewerEase: opts.viewerEase,
3075
3197
  });
3076
3198
 
3077
3199
  let handle = null;
@@ -3207,6 +3329,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3207
3329
  setSource,
3208
3330
  prepareSource,
3209
3331
  setRig,
3332
+ setStereo,
3333
+ setDepthEnvelope,
3334
+ /**
3335
+ * This tile in physical units (core TileHandle.displayMetrics, from getDisplayInfo()). Never
3336
+ * rejects: defaults, flagged in `source`, where the display cannot say.
3337
+ */
3338
+ async displayMetrics() {
3339
+ let info = null;
3340
+ try {
3341
+ info = (await handle?.getDisplayInfo?.()) ?? null;
3342
+ } catch {
3343
+ /* no display API / no live layer: the defaults */
3344
+ }
3345
+ return displayMetricsFrom(info, canvas.getBoundingClientRect(), globalThis.devicePixelRatio || 1);
3346
+ },
3210
3347
  setVideo,
3211
3348
  /**
3212
3349
  * Draw a layer of this tile's engine through the DISPLAY rig (round, physical-depth stage
@@ -3394,9 +3531,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3394
3531
  let initialViewRig = null; // the rig addScene is created with (controls:'page'), else the shorthand
3395
3532
  if (wall && wall.supported) {
3396
3533
  const onFrame = diag
3397
- ? (views, layer) => {
3534
+ ? (views, layer, frame) => {
3398
3535
  const t = performance.now();
3399
- viewer.onFrame(views, layer);
3536
+ viewer.onFrame(views, layer, frame);
3400
3537
  diag.frame(views, t); // after the draw: the overlay state is what this frame showed
3401
3538
  }
3402
3539
  : viewer.onFrame;
@@ -3939,6 +4076,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3939
4076
  canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
3940
4077
  });
3941
4078
  if (type !== 'auto') resolved.typeSource = 'setRig';
4079
+ // What the asset (or addSplat's options) asked for, kept so setStereo(null) can go back to it.
4080
+ resolved.stereoDefault = { ipdFactor: resolved.ipdFactor, parallaxFactor: resolved.parallaxFactor };
4081
+ applyStereoTo(resolved);
3942
4082
  resolved.focusDefault = resolved.focus.slice();
3943
4083
  resolved.focusDefaultSource = resolved.focusSource;
3944
4084
  return resolved;
@@ -3955,7 +4095,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3955
4095
  }
3956
4096
 
3957
4097
  /** Declare a DISPLAY rig to the runtime, unless that exact one is already declared. */
3958
- function declareDisplay(f) {
4098
+ function declareDisplay(base, { raw = false } = {}) {
4099
+ // raw: setVideo's own display rig — not the page's rig, so neither setStereo's factors nor
4100
+ // the base setStereo re-declares from.
4101
+ if (!raw) lastDisplayBase = base;
4102
+ const f =
4103
+ stereoOverride && !raw
4104
+ ? { ...base, ipdFactor: stereoScalar(base.ipdFactor ?? 1, 'ipd'), parallaxFactor: stereoScalar(base.parallaxFactor ?? 1, 'parallax') }
4105
+ : base;
3959
4106
  const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
3960
4107
  if (declaredDisplay === key) return;
3961
4108
  // Back at the boot rig from the shorthand it was built with: nothing to say.
@@ -4022,6 +4169,132 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4022
4169
  declareDisplay(disp || { vH: bootFraming.vH });
4023
4170
  }
4024
4171
 
4172
+ // ── handle.setStereo: live stereo strength on whatever rig is declared ──
4173
+ /** { ipdFactor?, parallaxFactor? } set by handle.setStereo — sticky across setSource; null = none. */
4174
+ let stereoOverride = null;
4175
+ /** The display rig declareDisplay was last asked for, BEFORE setStereo's factors (to re-declare). */
4176
+ let lastDisplayBase = null;
4177
+ /** setStereo's factors onto a resolved rig, over the asset's own (stereoDefault). */
4178
+ function applyStereoTo(r) {
4179
+ const d = r.stereoDefault;
4180
+ r.ipdFactor = stereoScalar(d.ipdFactor, 'ipd');
4181
+ r.parallaxFactor = stereoScalar(d.parallaxFactor, 'parallax');
4182
+ }
4183
+ /** One scalar under setStereo: its absolute value, else `base` × its scale, else `base`. */
4184
+ function stereoScalar(base, k) {
4185
+ const o = stereoOverride;
4186
+ if (o?.[`${k}Factor`] != null) return o[`${k}Factor`];
4187
+ if (o?.[`${k}Scale`] != null) return base * o[`${k}Scale`];
4188
+ return base;
4189
+ }
4190
+
4191
+ /**
4192
+ * handle.setStereo({ ipdFactor, parallaxFactor }) — re-declare the rig in force with new stereo
4193
+ * scalars, next frame, with no cut: the pose, focus and framing stay. A key given replaces, a key
4194
+ * set to null goes back to the asset's own value, `null` clears both. Sticky across setSource and
4195
+ * setRig. On a camera rig the values are ABSOLUTE (like addSplat's options); on a display rig
4196
+ * they replace the declared display rig's factors.
4197
+ */
4198
+ function setStereo(o) {
4199
+ const next = validateSetStereo(o, stereoOverride);
4200
+ stereoOverride = next;
4201
+ if (out.rig?.stereoDefault) applyStereoTo(out.rig);
4202
+ redeclareStereo();
4203
+ return out;
4204
+ }
4205
+ /** Re-declare the rig in force with setStereo's factors (deduplicated on a display rig). */
4206
+ function redeclareStereo() {
4207
+ if (pageMode || vid?.on || !out.rig) return; // pageTick reads out.rig; video exit calls this
4208
+ if (out.rig.type === 'camera') pushViewRig(true);
4209
+ else if (lastDisplayBase) declareDisplay(lastDisplayBase);
4210
+ }
4211
+
4212
+ // ── handle.setDepthEnvelope: the frame envelope (./inline3d-splat-effects.js EFFECTS.envelope) ──
4213
+ /** validateDepthEnvelope's result, or null (off). */
4214
+ let envelope = null;
4215
+ /** displayMetricsFrom(getDisplayInfo()) — metres per CSS px and the viewer; defaults until it lands. */
4216
+ let envMetrics = null;
4217
+ let envMetricsSeq = 0;
4218
+ function refreshEnvelopeMetrics() {
4219
+ const seq = ++envMetricsSeq;
4220
+ const done = (info) => {
4221
+ if (seq === envMetricsSeq) envMetrics = displayMetricsFrom(info ?? null, null, globalThis.devicePixelRatio || 1);
4222
+ };
4223
+ let p = null;
4224
+ try {
4225
+ p = handle?.getDisplayInfo?.();
4226
+ } catch {
4227
+ /* no display API: the defaults */
4228
+ }
4229
+ if (p && typeof p.then === 'function') p.then(done, () => done(null));
4230
+ else done(null);
4231
+ }
4232
+ /**
4233
+ * The envelope's inputs for this frame, all from the DECLARED rig (never the tracked eyes): null
4234
+ * = off — no envelope, not a camera rig (a display rig is a portal; its depth is the page's), a
4235
+ * stereo video holding the rig, no disparity to cap (ipdFactor 0), or no canvas box.
4236
+ */
4237
+ function envelopeState() {
4238
+ const E = envelope;
4239
+ const r = out.viewRig;
4240
+ if (!E || !r || r.type !== 'camera' || vid?.on) return null;
4241
+ const k = (r.ipdFactor ?? 1) * (r.metersToVirtual ?? 1);
4242
+ if (!(k > 0) || !(r.verticalFov > 0)) return null;
4243
+ // clientWidth/Height: no layout flush on a clean frame (a real canvas always has them)
4244
+ const box = canvas.clientWidth === undefined ? canvas.getBoundingClientRect() : null;
4245
+ const cw = box ? box.width : canvas.clientWidth;
4246
+ const ch = box ? box.height : canvas.clientHeight;
4247
+ if (!(cw > 0 && ch > 0)) return null;
4248
+ const m = envMetrics || displayMetricsFrom(null, null, 1);
4249
+ const W = E.canvasSizeM ? E.canvasSizeM[0] : cw * m.metersPerCssPx;
4250
+ const H = E.canvasSizeM ? E.canvasSizeM[1] : ch * m.metersPerCssPx;
4251
+ const tanY = Math.tan(r.verticalFov / 2);
4252
+ const fr = viewer.monoFrame();
4253
+ return {
4254
+ origin: fr.origin,
4255
+ axis: fr.axis,
4256
+ right: fr.right,
4257
+ up: fr.up,
4258
+ tanX: tanY * (cw / ch),
4259
+ tanY,
4260
+ halfW: W / 2,
4261
+ halfH: H / 2,
4262
+ // world units = rig units × fr.scale: the convergence and the eye baseline both scale with it
4263
+ invD: (r.convergenceDiopters || 0) / fr.scale,
4264
+ kfp: fr.scale * k * (H / (2 * tanY)),
4265
+ viewer: E.viewerM ?? m.nominalViewerM,
4266
+ maxFront: E.maxFrontM,
4267
+ stage: E.stage.f,
4268
+ band: E.bandM,
4269
+ edge: E.edgeM,
4270
+ rects: E.rects,
4271
+ };
4272
+ }
4273
+ /** Install / update / remove the tile-scope envelope effect (no-op until the effects exist). */
4274
+ function syncEnvelope() {
4275
+ if (!fx) return;
4276
+ fx.setInternal('tile', 'envelope', envelope ? { state: envelopeState } : null);
4277
+ const inst = fx.scopes.get('tile')?.get('envelope');
4278
+ if (inst) inst.hidden = true; // the SDK's, not the page's: never in effects(), never stopEffect()ed
4279
+ }
4280
+ /**
4281
+ * handle.setDepthEnvelope(opts | null) — cap how far the splat may come OUT of the glass by where
4282
+ * it lands on the canvas (docs/splat-effects.md §Depth envelope). Tile-wide and LIVE: every asset
4283
+ * shown — an incoming one included, from its first frame, and both sides of a crossfade — is
4284
+ * judged through the rig declared right now. A key given replaces; null turns it off.
4285
+ */
4286
+ function setDepthEnvelope(o) {
4287
+ const next = validateDepthEnvelope(o, envelope);
4288
+ if (next && !envelope) {
4289
+ refreshEnvelopeMetrics();
4290
+ // a lazy layer may not answer getDisplayInfo yet: ask again once the tile is woven
4291
+ Promise.resolve(out.firstWoven).then(() => envelope && refreshEnvelopeMetrics());
4292
+ }
4293
+ envelope = next;
4294
+ syncEnvelope();
4295
+ return out;
4296
+ }
4297
+
4025
4298
  // ── handle.setRig: a live, reversible rig switch (docs/playcanvas-adapter.md §setRig) ──
4026
4299
  /** null = 'auto' (the per-asset waterfall); else { type: 'camera' } | { type: 'display', o }. */
4027
4300
  let rigOverride = null;
@@ -4230,6 +4503,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4230
4503
  const kept = applyLoaded(loaded);
4231
4504
  current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
4232
4505
  fx = makeEffects(pcModule);
4506
+ syncEnvelope(); // a setDepthEnvelope before the first asset
4233
4507
  // `reveal`: installed at its START state before the asset's first frame, played once the
4234
4508
  // tile is woven (handle.firstWoven — which is immediate in 2D) and the first frames are built.
4235
4509
  if (revealSpec) {
@@ -4363,7 +4637,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4363
4637
  v._placeMonoForFit();
4364
4638
  v._updateMonoProjection();
4365
4639
  }
4366
- declareDisplay({ vH: state.vH });
4640
+ declareDisplay({ vH: state.vH }, { raw: true });
4641
+ state.stereoAtEnter = stereoOverride;
4367
4642
  state.on = true;
4368
4643
  }
4369
4644
 
@@ -4397,6 +4672,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4397
4672
  } else out.viewRig = s.viewRig;
4398
4673
  lastConvergence = s.lastConvergence;
4399
4674
  state.on = false;
4675
+ if (stereoOverride !== state.stereoAtEnter) redeclareStereo(); // a setStereo made while the video held the rig
4676
+
4400
4677
  }
4401
4678
 
4402
4679
  /** Tear the video down now (setVideo(null) / remove()). */
@@ -4455,6 +4732,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4455
4732
  // Another video on screen: keep ITS snapshot (the pre-video state), swap the source.
4456
4733
  state.saved = live.saved;
4457
4734
  state.on = true;
4735
+ state.stereoAtEnter = live.stereoAtEnter;
4458
4736
  // S2: the plane outlives this swap; the replaced video's rect subscriptions go with it.
4459
4737
  for (const off of live.rectOffs) off();
4460
4738
  live.rectOffs.clear();
@@ -4463,7 +4741,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4463
4741
  viewer.vH = state.vH;
4464
4742
  viewer._placeMonoForFit();
4465
4743
  viewer._updateMonoProjection();
4466
- declareDisplay({ vH: state.vH });
4744
+ declareDisplay({ vH: state.vH }, { raw: true });
4467
4745
  }
4468
4746
  } else enterVideo(state);
4469
4747
  vid = state;
@@ -246,6 +246,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
246
246
  renderScale,
247
247
  feather,
248
248
  logTag: '[inline3d/splat]',
249
+ viewerEase: opts.viewerEase,
249
250
  }).useEyeCamera(EyeCamera, EdgeFeather);
250
251
 
251
252
  // Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
@@ -400,6 +401,20 @@ export function addSplat(wall, canvas, src, opts = {}) {
400
401
  "(addSplat(…, { engine:'playcanvas' })).",
401
402
  );
402
403
  },
404
+ /** Not on this backend: the depth envelope is a PlayCanvas-backend feature. */
405
+ setDepthEnvelope() {
406
+ throw new Error(
407
+ "@displayxr/inline3d/splat: setDepthEnvelope() is implemented on the PlayCanvas backend only " +
408
+ "(addSplat(…, { engine:'playcanvas' })).",
409
+ );
410
+ },
411
+ /** Not on this backend: live stereo strength is a PlayCanvas-backend feature. */
412
+ setStereo() {
413
+ throw new Error(
414
+ "@displayxr/inline3d/splat: setStereo() is implemented on the PlayCanvas backend only " +
415
+ "(addSplat(…, { engine:'playcanvas' })).",
416
+ );
417
+ },
403
418
  /** Not on this backend (splat effects are PlayCanvas-only in this version). */
404
419
  playEffect() {
405
420
  throw effectsNotOnSpark('playEffect()');