@displayxr/inline3d 1.31.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.
package/index.d.ts CHANGED
@@ -123,6 +123,35 @@ export interface XRDisplayInfo {
123
123
  nominalViewerPosition?: DOMPointReadOnly;
124
124
  }
125
125
 
126
+ /** {@link TileHandle.displayMetrics}: a window in physical units. Metres throughout. */
127
+ export interface DisplayMetrics {
128
+ /** The canvas's CSS box on the panel, [width, height]. */
129
+ canvasSizeM: [number, number];
130
+ /** One CSS px on the panel (physical pixel pitch × devicePixelRatio). */
131
+ metersPerCssPx: number;
132
+ displaySizeM: [number, number];
133
+ /** `nominalViewerPosition.z`, else the default. */
134
+ nominalViewerM: number;
135
+ /** Always the default today: no browser or runtime surface reports one. */
136
+ eyeSeparationM: number;
137
+ source: { size: 'display' | 'default'; viewer: 'display' | 'default'; eyeSeparation: 'default' };
138
+ }
139
+
140
+ /** What {@link TileHandle.displayMetrics} fills in when the display cannot say. */
141
+ export const DISPLAY_METRICS_DEFAULTS: Readonly<{
142
+ displaySizeM: readonly [number, number];
143
+ displayPixels: readonly [number, number];
144
+ nominalViewerM: number;
145
+ eyeSeparationM: number;
146
+ }>;
147
+
148
+ /** {@link TileHandle.displayMetrics} as a pure function of an XRDisplayInfo, a client rect and devicePixelRatio. */
149
+ export function displayMetricsFrom(
150
+ info: XRDisplayInfo | null,
151
+ rect: { width: number; height: number } | null,
152
+ dpr?: number,
153
+ ): DisplayMetrics;
154
+
126
155
  /**
127
156
  * One rendering mode the DISPLAY can be put in, as reported by the runtime.
128
157
  *
@@ -310,6 +339,13 @@ export interface TileHandle {
310
339
  * tile off screen).
311
340
  */
312
341
  getDisplayInfo(): Promise<XRDisplayInfo | null>;
342
+ /**
343
+ * This window in physical units — canvas size in metres, metres per CSS px, the panel, the
344
+ * nominal viewer distance, the eye separation — from {@link getDisplayInfo}, with anything not
345
+ * measurable filled from {@link DISPLAY_METRICS_DEFAULTS} and flagged in `source`. Never rejects.
346
+ * The canvas size is read at the call: call again after a resize.
347
+ */
348
+ displayMetrics(): Promise<DisplayMetrics>;
313
349
  /** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
314
350
  getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
315
351
  /**
@@ -0,0 +1,51 @@
1
+ // inline3d-display-metrics.js — a tile in PHYSICAL units, from XRDisplayInfo.
2
+ //
3
+ // What TileHandle.displayMetrics() resolves, as a pure function, so ./splat's depth envelope (and
4
+ // a page) can share it without pulling in the core. Dependency-free; re-exported by ./inline3d.js.
5
+
6
+ /**
7
+ * The defaults displayMetrics() fills in what the display cannot report: a 15.6" 16:9 panel at
8
+ * 1920x1080 CSS px, the viewer at 650 mm, eyes 63 mm apart. EYE SEPARATION IS ALWAYS A DEFAULT —
9
+ * no browser or runtime surface reports one (the runtime tracks eye POSITIONS; a page cannot read
10
+ * them) — so `source.eyeSeparation` is always 'default' today.
11
+ */
12
+ export const DISPLAY_METRICS_DEFAULTS = Object.freeze({
13
+ displaySizeM: Object.freeze([0.3456, 0.1944]),
14
+ displayPixels: Object.freeze([1920, 1080]),
15
+ nominalViewerM: 0.65,
16
+ eyeSeparationM: 0.063,
17
+ });
18
+
19
+ /**
20
+ * displayMetrics() as a pure function: `info` an XRDisplayInfo (or null), `rect` the canvas's
21
+ * client rect (CSS px), `dpr` window.devicePixelRatio. Physical px pitch = displayWidthMeters /
22
+ * displayPixelWidth (the same per axis), and one CSS px is `dpr` physical px — exact when the
23
+ * browser window is on the panel at its native resolution, which is the only place a woven tile
24
+ * draws in 3D.
25
+ */
26
+ export function displayMetricsFrom(info, rect, dpr = 1) {
27
+ const D = DISPLAY_METRICS_DEFAULTS;
28
+ const ok =
29
+ !!info &&
30
+ info.displayWidthMeters > 0 &&
31
+ info.displayHeightMeters > 0 &&
32
+ info.displayPixelWidth > 0 &&
33
+ info.displayPixelHeight > 0;
34
+ const displaySizeM = ok ? [info.displayWidthMeters, info.displayHeightMeters] : [...D.displaySizeM];
35
+ const px = ok ? [info.displayPixelWidth, info.displayPixelHeight] : [...D.displayPixels];
36
+ // Per axis, then their mean: a panel's pixels are square to well under a percent, and one
37
+ // number per CSS px is what a page converting a DOM rect wants.
38
+ const pitch = ((displaySizeM[0] / px[0]) + (displaySizeM[1] / px[1])) / 2;
39
+ const d = dpr > 0 ? dpr : 1;
40
+ const metersPerCssPx = ok ? pitch * d : pitch * (px[1] / Math.max(1, globalThis.innerHeight || px[1]));
41
+ const z = info?.nominalViewerPosition?.z;
42
+ const viewerOk = Number.isFinite(z) && z > 0.1;
43
+ return {
44
+ canvasSizeM: [(rect?.width || 0) * metersPerCssPx, (rect?.height || 0) * metersPerCssPx],
45
+ metersPerCssPx,
46
+ displaySizeM,
47
+ nominalViewerM: viewerOk ? z : D.nominalViewerM,
48
+ eyeSeparationM: D.eyeSeparationM,
49
+ source: { size: ok ? 'display' : 'default', viewer: viewerOk ? 'display' : 'default', eyeSeparation: 'default' },
50
+ };
51
+ }
@@ -91,6 +91,18 @@ export function addSplatDeferred(wall, canvas, src, opts) {
91
91
  validateSetRigArgs(type, o);
92
92
  return out.ready.then(() => out.setRig(type, o));
93
93
  },
94
+ // Stereo strength / the depth envelope before the adapter has loaded: queued, replayed in
95
+ // order once it has (both are sticky handle state, applied to the first asset from its first
96
+ // frame). Validated by the adapter at replay.
97
+ setStereo: (...args) => {
98
+ pending.push(['setStereo', args]);
99
+ return out;
100
+ },
101
+ setDepthEnvelope: (...args) => {
102
+ pending.push(['setDepthEnvelope', args]);
103
+ return out;
104
+ },
105
+ displayMetrics: () => out.ready.then(() => out.displayMetrics()),
94
106
  // A video before the first asset has landed: controls:'page' throws at the call's own line,
95
107
  // the rest is validated by the adapter's setVideo, which runs once the asset is on screen.
96
108
  setVideo: page ? pageOnly('setVideo') : (src, o) => out.ready.then(() => out.setVideo(src, o)),
@@ -23,7 +23,7 @@
23
23
  //
24
24
  // The SDK owns the chunk: every active effect of a scope is ONE function body with its own
25
25
  // uniform prefix, and the chunk calls them in a FIXED order, STAGE_ORDER: grade → clip → reveal →
26
- // pulse → custom. Removing the last effect deletes the chunk (tile) or the modifier (entity),
26
+ // pulse → custom → envelope (→ cull). Removing the last effect deletes the chunk (tile) or the modifier (entity),
27
27
  // which restores the engine's own default — the exact baseline, not a no-op look-alike.
28
28
  //
29
29
  // Adding an effect = one GLSL body + one registry entry in EFFECTS below: `glsl(P, opts)` defines
@@ -47,7 +47,16 @@
47
47
  import { coverageExponent, FADE_TRANSMITTANCE_FLOOR } from './inline3d-splat-shared.js';
48
48
 
49
49
  /** The fixed composition order of a generated chunk. */
50
- export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom', 'cull']);
50
+ export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom', 'envelope', 'cull']);
51
+
52
+ /** handle.setDepthEnvelope's flat rects the shader takes (the largest-weight first beyond it). */
53
+ export const ENVELOPE_MAX_RECTS = 8;
54
+ const ENVELOPE_NO_RECT = Object.freeze([1, -1, 1, -1]);
55
+ const ENVELOPE_OFF = (() => {
56
+ const o = { on: 0, O: [0, 0, 0], A: [0, 0, -1], R: [1, 0, 0], U: [0, 1, 0], T: [1, 1, 1, 1], K: [0, 1, 1, 0], S: [-1, 1, -1, 1], E: [1, 0, 0, 0], W0: [0, 0, 0, 0], W1: [0, 0, 0, 0] };
57
+ for (let i = 0; i < ENVELOPE_MAX_RECTS; i++) o['F' + i] = ENVELOPE_NO_RECT;
58
+ return Object.freeze(o);
59
+ })();
51
60
 
52
61
  /** The most eye views setSource's wavefront culls for (more: every gaussian is drawn). */
53
62
  export const WIPE_CULL_MAX_VIEWS = 4;
@@ -603,6 +612,87 @@ void ${P}color(vec3 c, inout vec4 col) { if (${P}cut) col.a = 0.0; }
603
612
  },
604
613
  },
605
614
 
615
+ // Internal: handle.setDepthEnvelope — THE FRAME ENVELOPE. Content may come OUT of the glass only
616
+ // well inside the frame: a gaussian whose panel depth would exceed the cap at its screen spot
617
+ // F(x, y) = edge + (maxFront − edge) · e, e = smoothstep(0, band, distance to the stage edge or
618
+ // to a flat rect (weighted)), in metres on the panel,
619
+ // slides back ALONG ITS OWN RAY from the declared camera rig's centre (centre and scale × λ), so
620
+ // the rig's mono picture is unchanged and the engine's sort order stays valid. On a camera rig a
621
+ // point at depth d has panel disparity k·e·f_p·(1/D − 1/d) (k = ipdFactor·metersToVirtual per
622
+ // world unit, f_p = canvas height / (2·tan(vfov/2)), D the convergence), and F metres out needs
623
+ // −e·F/(n − F) — so the deepest-out allowed depth is 1/d_env = 1/D + F / (k·f_p·(n − F)); the
624
+ // viewer's eye separation e cancels. Behind the glass is never touched. Keyed on world position
625
+ // and the RIG (not the tracked eyes), so both eyes and every head position agree. The adapter
626
+ // supplies everything per tick through opts.state() (null = off); world space throughout.
627
+ envelope: {
628
+ stage: 'envelope',
629
+ kind: 'persistent',
630
+ internal: true,
631
+ defaults: {},
632
+ glsl: (P) => `
633
+ uniform float ${P}on;
634
+ uniform vec3 ${P}O;
635
+ uniform vec3 ${P}A;
636
+ uniform vec3 ${P}R;
637
+ uniform vec3 ${P}U;
638
+ uniform vec4 ${P}T; // tan half-fov x, y; canvas half-size x, y (m)
639
+ uniform vec4 ${P}K; // 1/D (world), k·f_p (world·m), viewer n (m), maxFront (m)
640
+ uniform vec4 ${P}S; // stage l, r, b, t (canvas-normalised, y up)
641
+ uniform vec4 ${P}E; // band (m), edge (m), rect count, -
642
+ ${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => `uniform vec4 ${P}F${i};`).join('\n')}
643
+ uniform vec4 ${P}W0;
644
+ uniform vec4 ${P}W1;
645
+ float ${P}lambda;
646
+ float ${P}flat(vec2 f, vec4 C, float w) {
647
+ if (w <= 0.0 || C.x >= C.y || C.z >= C.w) return 1.0;
648
+ vec2 o = max(vec2(max(C.x - f.x, f.x - C.y) * ${P}T.z, max(C.z - f.y, f.y - C.w) * ${P}T.w), vec2(0.0));
649
+ return mix(1.0, smoothstep(0.0, ${P}E.x, length(o)), w);
650
+ }
651
+ void ${P}center(inout vec3 c) {
652
+ ${P}lambda = 1.0;
653
+ if (${P}on < 0.5) return;
654
+ vec3 v = c - ${P}O;
655
+ float d = dot(v, ${P}A);
656
+ if (d <= 1e-4) return;
657
+ vec2 f = vec2(dot(v, ${P}R) / (d * ${P}T.x), dot(v, ${P}U) / (d * ${P}T.y));
658
+ vec4 S = ${P}S;
659
+ float inside = min(min(f.x - S.x, S.y - f.x) * ${P}T.z, min(f.y - S.z, S.w - f.y) * ${P}T.w);
660
+ float e = smoothstep(0.0, ${P}E.x, inside);
661
+ ${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ` e = min(e, ${P}flat(f, ${P}F${i}, ${P}W${i >> 2}.${'xyzw'[i & 3]}));`).join('\n')}
662
+ float F = ${P}E.y + max(${P}K.w - ${P}E.y, 0.0) * e;
663
+ float invEnv = ${P}K.x + F / (${P}K.y * max(${P}K.z - F, 1e-3));
664
+ if (1.0 / d <= invEnv) return;
665
+ ${P}lambda = 1.0 / (invEnv * d);
666
+ c = ${P}O + v * ${P}lambda;
667
+ }
668
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) { sc *= ${P}lambda; }
669
+ void ${P}color(vec3 c, inout vec4 col) {}
670
+ `,
671
+ uniforms: (ctx, inst) => {
672
+ const st = typeof inst.opts.state === 'function' ? inst.opts.state() : null;
673
+ if (!st) return ENVELOPE_OFF;
674
+ const out = {
675
+ on: 1,
676
+ O: st.origin,
677
+ A: st.axis,
678
+ R: st.right,
679
+ U: st.up,
680
+ T: [st.tanX, st.tanY, st.halfW, st.halfH],
681
+ K: [st.invD, st.kfp, st.viewer, st.maxFront],
682
+ S: st.stage,
683
+ E: [st.band, st.edge, st.rects.length, 0],
684
+ W0: [0, 0, 0, 0],
685
+ W1: [0, 0, 0, 0],
686
+ };
687
+ for (let i = 0; i < ENVELOPE_MAX_RECTS; i++) {
688
+ const r = st.rects[i];
689
+ out['F' + i] = r ? r.f : ENVELOPE_NO_RECT;
690
+ if (r) out[i < 4 ? 'W0' : 'W1'][i & 3] = r.weight;
691
+ }
692
+ return out;
693
+ },
694
+ },
695
+
606
696
  // Internal: setSource's crossfade FALLBACK (no frame snapshot) — the coverage remap on one
607
697
  // entity, driven by the adapter.
608
698
  xfade: {
@@ -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,
@@ -2312,6 +2314,26 @@ export class PlayCanvasSplatViewer {
2312
2314
  };
2313
2315
  }
2314
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
+
2315
2337
  _easeFocus() {
2316
2338
  if (this._focusSettled) return;
2317
2339
  const f = this._focus;
@@ -2811,6 +2833,95 @@ const PAGE_SETRIG_ERROR =
2811
2833
  "@displayxr/inline3d/splat: setRig() is not available with controls:'page' — the page owns the " +
2812
2834
  'camera (its camera IS the rig). Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
2813
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
+
2814
2925
  /**
2815
2926
  * handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
2816
2927
  * @returns {{type:'display'|'camera'|'auto', o?:object}}
@@ -3218,6 +3329,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3218
3329
  setSource,
3219
3330
  prepareSource,
3220
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
+ },
3221
3347
  setVideo,
3222
3348
  /**
3223
3349
  * Draw a layer of this tile's engine through the DISPLAY rig (round, physical-depth stage
@@ -3950,6 +4076,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3950
4076
  canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
3951
4077
  });
3952
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);
3953
4082
  resolved.focusDefault = resolved.focus.slice();
3954
4083
  resolved.focusDefaultSource = resolved.focusSource;
3955
4084
  return resolved;
@@ -3966,7 +4095,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3966
4095
  }
3967
4096
 
3968
4097
  /** Declare a DISPLAY rig to the runtime, unless that exact one is already declared. */
3969
- 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;
3970
4106
  const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
3971
4107
  if (declaredDisplay === key) return;
3972
4108
  // Back at the boot rig from the shorthand it was built with: nothing to say.
@@ -4033,6 +4169,132 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4033
4169
  declareDisplay(disp || { vH: bootFraming.vH });
4034
4170
  }
4035
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
+
4036
4298
  // ── handle.setRig: a live, reversible rig switch (docs/playcanvas-adapter.md §setRig) ──
4037
4299
  /** null = 'auto' (the per-asset waterfall); else { type: 'camera' } | { type: 'display', o }. */
4038
4300
  let rigOverride = null;
@@ -4241,6 +4503,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4241
4503
  const kept = applyLoaded(loaded);
4242
4504
  current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
4243
4505
  fx = makeEffects(pcModule);
4506
+ syncEnvelope(); // a setDepthEnvelope before the first asset
4244
4507
  // `reveal`: installed at its START state before the asset's first frame, played once the
4245
4508
  // tile is woven (handle.firstWoven — which is immediate in 2D) and the first frames are built.
4246
4509
  if (revealSpec) {
@@ -4374,7 +4637,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4374
4637
  v._placeMonoForFit();
4375
4638
  v._updateMonoProjection();
4376
4639
  }
4377
- declareDisplay({ vH: state.vH });
4640
+ declareDisplay({ vH: state.vH }, { raw: true });
4641
+ state.stereoAtEnter = stereoOverride;
4378
4642
  state.on = true;
4379
4643
  }
4380
4644
 
@@ -4408,6 +4672,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4408
4672
  } else out.viewRig = s.viewRig;
4409
4673
  lastConvergence = s.lastConvergence;
4410
4674
  state.on = false;
4675
+ if (stereoOverride !== state.stereoAtEnter) redeclareStereo(); // a setStereo made while the video held the rig
4676
+
4411
4677
  }
4412
4678
 
4413
4679
  /** Tear the video down now (setVideo(null) / remove()). */
@@ -4466,6 +4732,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4466
4732
  // Another video on screen: keep ITS snapshot (the pre-video state), swap the source.
4467
4733
  state.saved = live.saved;
4468
4734
  state.on = true;
4735
+ state.stereoAtEnter = live.stereoAtEnter;
4469
4736
  // S2: the plane outlives this swap; the replaced video's rect subscriptions go with it.
4470
4737
  for (const off of live.rectOffs) off();
4471
4738
  live.rectOffs.clear();
@@ -4474,7 +4741,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4474
4741
  viewer.vH = state.vH;
4475
4742
  viewer._placeMonoForFit();
4476
4743
  viewer._updateMonoProjection();
4477
- declareDisplay({ vH: state.vH });
4744
+ declareDisplay({ vH: state.vH }, { raw: true });
4478
4745
  }
4479
4746
  } else enterVideo(state);
4480
4747
  vid = state;
@@ -401,6 +401,20 @@ export function addSplat(wall, canvas, src, opts = {}) {
401
401
  "(addSplat(…, { engine:'playcanvas' })).",
402
402
  );
403
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
+ },
404
418
  /** Not on this backend (splat effects are PlayCanvas-only in this version). */
405
419
  playEffect() {
406
420
  throw effectsNotOnSpark('playEffect()');
package/js/inline3d.js CHANGED
@@ -34,6 +34,8 @@ export { undock, undockAvailable, undockUrl, tileScreenRect };
34
34
  import { setSessionViewerEase } from './inline3d-viewer-ease.js';
35
35
  // The tracking-acquisition ease, for pages that render their own addScene from the views.
36
36
  export { ViewerEase, resolveViewerEaseOption, frameTrackingState, VIEWER_EASE_DEFAULT_MS } from './inline3d-viewer-ease.js';
37
+ export { DISPLAY_METRICS_DEFAULTS, displayMetricsFrom } from './inline3d-display-metrics.js';
38
+ import { displayMetricsFrom } from './inline3d-display-metrics.js';
37
39
 
38
40
  // The eased 2D<->3D transition. A pure state machine (no DOM, no WebXR) ported from the native
39
41
  // `dxr::ModeSwitch`, so the browser eases the disparity around a mode switch the same way — and in
@@ -133,6 +135,7 @@ const hasViewRig = () => {
133
135
  // required — a browser with a partial set is a browser mid-implementation, and treating it as
134
136
  // supported would hand a page a `requestRenderingMode is not a function` at the worst moment.
135
137
  const DISPLAY_MODE_METHODS = ['getDisplayInfo', 'getRenderingModes', 'requestRenderingMode'];
138
+
136
139
  // The two display events, fired on the XRSession rather than the layer — so a page hears about a
137
140
  // mode or hardware-state change even while its tile's layer is closed (lazy mode), and one
138
141
  // subscription covers every window in the document.
@@ -1088,6 +1091,24 @@ class Inline3D {
1088
1091
  * @returns {Promise<object|null>}
1089
1092
  */
1090
1093
  getDisplayInfo: () => this._layerCall(win, 'getDisplayInfo', 'getDisplayInfo()'),
1094
+ /**
1095
+ * This window in PHYSICAL units: the canvas's size in metres, metres per CSS px, the panel,
1096
+ * the nominal viewer distance and the eye separation — from getDisplayInfo(), with every
1097
+ * value that could not be measured filled from a stated default (see `source`). Never
1098
+ * rejects: no display API, no live layer, no glasses-free display all resolve the defaults.
1099
+ * The canvas size is re-read at each call (call it again after a resize).
1100
+ *
1101
+ * @returns {Promise<object>}
1102
+ */
1103
+ displayMetrics: async () => {
1104
+ let info = null;
1105
+ try {
1106
+ info = await this._layerCall(win, 'getDisplayInfo', 'getDisplayInfo()');
1107
+ } catch {
1108
+ /* no display API / no live layer: the defaults */
1109
+ }
1110
+ return displayMetricsFrom(info, canvas.getBoundingClientRect(), globalThis.devicePixelRatio || 1);
1111
+ },
1091
1112
  /**
1092
1113
  * Every rendering mode the runtime can put this display in, as reported by the runtime:
1093
1114
  * `{modeIndex, modeName, viewCount, viewScaleX, viewScaleY, tileColumns, tileRows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -65,6 +65,7 @@
65
65
  "files": [
66
66
  "js/inline3d.js",
67
67
  "js/inline3d-undock.js",
68
+ "js/inline3d-display-metrics.js",
68
69
  "js/inline3d-mode-switch.js",
69
70
  "js/inline3d-buffer-limit.js",
70
71
  "js/inline3d-viewer-ease.js",