@displayxr/inline3d 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.27.0 — 2026-09-26
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only) and adds one optional field
11
+ to the core `XRDisplayInfo` type. A page on a browser that does not report the new field sees no
12
+ change.
13
+
14
+ - **`setLayerRig`'s default `viewerDistance` now comes from the browser.** Where
15
+ `XRDisplayInfo.nominalViewerPosition` is reported (DisplayXR Browser patch 0221, Windows and
16
+ Android), the layer rig's n is its **z** — the runtime's nominal viewer distance for the panel,
17
+ the value its camera rig is calibrated to — instead of the fixed 0.6 m. The tile asks the frame's
18
+ `XRDisplayLayer.getDisplayInfo()` once, asynchronously; frames drawn before it answers use 0.6 m.
19
+ A page's `viewerDistance` still wins, and clearing it (`null`) falls back to the browser's value,
20
+ then 0.6 m. **Pixels move** on a panel whose nominal distance is not 0.6 m: display-rig objects'
21
+ pop-out scales by 0.6/n (that was the bug); the contact plane and the 2D picture do not move.
22
+ - **`layerRigState().viewerDistanceSource`** — `'page'` | `'browser'` | `'default'` — says where
23
+ `viewerDistance` came from, and the WARN line prints it: `viewerDistance=0.45m(browser)`.
24
+ - **Types:** `XRDisplayInfo.nominalViewerPosition?: DOMPointReadOnly` (metres, display space, +z
25
+ out of the glass; absent when unreported), `SplatLayerRigState.viewerDistanceSource`.
26
+
8
27
  ## 1.26.0 — 2026-09-25
9
28
 
10
29
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; nothing changes
package/index.d.ts CHANGED
@@ -103,6 +103,16 @@ export interface XRDisplayInfo {
103
103
  displayPixelHeight: number;
104
104
  recommendedViewScaleX: number;
105
105
  recommendedViewScaleY: number;
106
+ /**
107
+ * The runtime's NOMINAL viewer position, metres, in display space: origin at the panel
108
+ * centre, +x right, +y up, +z out of the glass toward the viewer (w = 1). A constant of the
109
+ * panel — where the runtime seats the viewer when nobody is tracked, and the distance its
110
+ * camera rig is calibrated to (`z` is that rig's n) — NOT the live eye position.
111
+ *
112
+ * Absent on browsers that predate it (DisplayXR Browser patch 0221) and when the runtime
113
+ * reports nothing usable, so read it as `info.nominalViewerPosition?.z ?? fallback`.
114
+ */
115
+ nominalViewerPosition?: DOMPointReadOnly;
106
116
  }
107
117
 
108
118
  /**
@@ -16,8 +16,10 @@
16
16
  // view_round_i = view_photo_i · M_i, proj_round_i = proj_photo_i
17
17
  //
18
18
  // k = D / (m·n): D the declared convergence (world units), m the declared metersToVirtual, n the
19
- // nominal viewer distance in metres — the one quantity the browser does not expose (see the
20
- // design note §5). Everything here is PLAIN ARITHMETIC on arrays, unit-testable without a GPU.
19
+ // nominal viewer distance in metres. n comes from the page's `viewerDistance` if it passed one,
20
+ // else from the browser (XRDisplayInfo.nominalViewerPosition.z, DisplayXR Browser 0221+), else
21
+ // 0.6 m (design note §5, (c)1). Everything here is PLAIN ARITHMETIC on arrays, unit-testable
22
+ // without a GPU.
21
23
 
22
24
  import { invertAffine } from './inline3d-splat-rig-map.js';
23
25
 
@@ -27,6 +29,32 @@ export const LAYER_RIGS = Object.freeze(['display', 'camera']);
27
29
  /** Nominal viewer distance (m) when neither the page nor the platform says: the browser's own. */
28
30
  export const DEFAULT_VIEWER_DISTANCE_M = 0.6;
29
31
 
32
+ /** How many times a tile asks the layer for its display info before giving up (rejections only). */
33
+ const MAX_DISPLAY_INFO_ASKS = 3;
34
+
35
+ /**
36
+ * The runtime's nominal viewer DISTANCE n (m) from an XRDisplayInfo, or null when the browser does
37
+ * not report one. It is the position's **z** — the distance out of the glass along the display
38
+ * normal — not its length: the runtime's camera rig reads only `nominal_viewer->z`
39
+ * (displayxr-common dxr_view_math.c, `eye_local.z = m2v·(eye.z − nominal_z)`), and z is what
40
+ * D/n undoes. A browser without the field, a null info, or a z that is not finite and > 0 → null.
41
+ */
42
+ export function nominalViewerDistanceFromInfo(info) {
43
+ const z = info && info.nominalViewerPosition ? info.nominalViewerPosition.z : undefined;
44
+ return typeof z === 'number' && Number.isFinite(z) && z > 0 ? z : null;
45
+ }
46
+
47
+ /**
48
+ * The n the layer rig uses, and where it came from: the page's `viewerDistance` wins ('page'),
49
+ * then the browser's nominal viewer ('browser'), then DEFAULT_VIEWER_DISTANCE_M ('default').
50
+ * @returns {{ value: number, source: 'page'|'browser'|'default' }}
51
+ */
52
+ export function resolveViewerDistance(pageViewerDistance, browserViewerDistance) {
53
+ if (pageViewerDistance !== null && pageViewerDistance !== undefined) return { value: pageViewerDistance, source: 'page' };
54
+ if (browserViewerDistance !== null && browserViewerDistance !== undefined) return { value: browserViewerDistance, source: 'browser' };
55
+ return { value: DEFAULT_VIEWER_DISTANCE_M, source: 'default' };
56
+ }
57
+
30
58
  /** A round eye may not reach the window plane: h(E') is floored at this fraction of D. */
31
59
  const MIN_EYE_HEIGHT_FRAC = 1e-3;
32
60
 
@@ -45,7 +73,8 @@ export const LAYER_RIG_OPTION_KEYS = Object.freeze(['viewerDistance', 'gain', 'p
45
73
  /**
46
74
  * setLayerRig / setLayerRigOptions options, validated (throws at the call). Only the keys given
47
75
  * are returned: they MERGE into the tile's options. `null` clears a key (back to its default).
48
- * viewerDistance m, > 0 the nominal viewer distance n (default 0.6)
76
+ * viewerDistance m, > 0 the nominal viewer distance n (default: the browser's
77
+ * XRDisplayInfo.nominalViewerPosition.z, else 0.6)
49
78
  * gain > 0 an explicit rounding gain k instead of D/(m·n)
50
79
  * planeOffset m, finite moves the stage TOWARD the viewer by this much on the panel (display
51
80
  * space): the plane that lands on the glass moves back by offset·D/n
@@ -330,6 +359,11 @@ export class LayerRigCameras {
330
359
  this.layers = new Map();
331
360
  /** The tile's options: merged across setLayerRig / setLayerRigOptions calls. */
332
361
  this.opts = { viewerDistance: null, gain: null, planeOffset: null, planeDistance: null };
362
+ /** n as the browser reports it (XRDisplayInfo.nominalViewerPosition.z), once known; else null. */
363
+ this.browserViewerDistance = null;
364
+ this._infoAsks = 0;
365
+ this._infoPending = false;
366
+ this._infoSettled = false;
333
367
  this.disabled = false; // the kill switch (diag 'nolayerrig')
334
368
  this.cams = { display: [], post: [] };
335
369
  this.moved = { display: [], post: [] };
@@ -369,6 +403,41 @@ export class LayerRigCameras {
369
403
  for (const [k, v] of Object.entries(o)) this.opts[k] = v;
370
404
  }
371
405
 
406
+ /** The n this tile uses right now, and its source ('page' | 'browser' | 'default'). */
407
+ viewerDistance() {
408
+ return resolveViewerDistance(this.opts.viewerDistance, this.browserViewerDistance);
409
+ }
410
+
411
+ /**
412
+ * Called with the frame's XRDisplayLayer: ask it ONCE for the display info and keep the
413
+ * browser's nominal viewer distance. Asynchronous — frames drawn before it resolves use the
414
+ * page's value or 0.6 m, and the state's `viewerDistanceSource` says which. A browser without
415
+ * getDisplayInfo (or without the field) leaves the default in place; a REJECTED ask (e.g. no live
416
+ * layer yet) is retried on later frames, up to MAX_DISPLAY_INFO_ASKS times.
417
+ */
418
+ noteLayer(layer) {
419
+ if (this._infoSettled || this._infoPending || this._infoAsks >= MAX_DISPLAY_INFO_ASKS) return;
420
+ if (!layer || typeof layer.getDisplayInfo !== 'function') return;
421
+ this._infoAsks++;
422
+ this._infoPending = true;
423
+ let p;
424
+ try {
425
+ p = Promise.resolve(layer.getDisplayInfo());
426
+ } catch (e) {
427
+ p = Promise.reject(e);
428
+ }
429
+ return p.then(
430
+ (info) => {
431
+ this._infoPending = false;
432
+ this._infoSettled = true;
433
+ this.browserViewerDistance = nominalViewerDistanceFromInfo(info);
434
+ },
435
+ () => {
436
+ this._infoPending = false;
437
+ }
438
+ );
439
+ }
440
+
372
441
  /** The eye camera entities of the current view path. */
373
442
  _eyeCams() {
374
443
  const v = this.viewer;
@@ -494,8 +563,9 @@ export class LayerRigCameras {
494
563
  L.residual = residual;
495
564
  const hasRun = this.sync();
496
565
  const frame = cameraRigFrame(rig);
497
- const k = frame ? layerRigGain(frame, this.opts) : null;
498
- const plane = frame ? layerRigPlane(frame, this.opts) : null;
566
+ const opts = { ...this.opts, viewerDistance: this.viewerDistance().value };
567
+ const k = frame ? layerRigGain(frame, opts) : null;
568
+ const plane = frame ? layerRigPlane(frame, opts) : null;
499
569
  L.gain = k;
500
570
  L.planeM = plane;
501
571
  L.photoConvergenceM = frame ? frame.D : null;
@@ -606,6 +676,7 @@ export class LayerRigCameras {
606
676
  /** The public state (handle.layerRigState). */
607
677
  state() {
608
678
  const L = this.last;
679
+ const n = this.viewerDistance();
609
680
  return {
610
681
  display: [...this.requests.keys()],
611
682
  disabled: this.disabled,
@@ -613,7 +684,8 @@ export class LayerRigCameras {
613
684
  engaged: !!L.engaged,
614
685
  rounded: !!L.engaged,
615
686
  reason: this.disabled ? 'kill switch (?dxrdiag=nolayerrig)' : L.reason,
616
- viewerDistance: this.opts.viewerDistance ?? DEFAULT_VIEWER_DISTANCE_M,
687
+ viewerDistance: n.value,
688
+ viewerDistanceSource: n.source,
617
689
  gain: L.gain,
618
690
  planeM: L.planeM,
619
691
  photoConvergenceM: L.photoConvergenceM ?? null,
@@ -634,7 +706,7 @@ export function layerRigLine(st) {
634
706
  const n = (x, d = 3) => (x === null || x === undefined ? '-' : Number(x).toFixed(d));
635
707
  return (
636
708
  `path=${st.path ?? '-'} engaged=${st.engaged} layers=[${st.display.join(', ')}] ` +
637
- `viewerDistance=${n(st.viewerDistance, 2)}m gain=${n(st.gain)} planeM=${n(st.planeM)} ` +
709
+ `viewerDistance=${n(st.viewerDistance, 2)}m(${st.viewerDistanceSource ?? '-'}) gain=${n(st.gain)} planeM=${n(st.planeM)} ` +
638
710
  `photoConvergenceM=${n(st.photoConvergenceM)} planeOffset=${n(st.planeOffset)}m ` +
639
711
  `located=${st.located === null || st.located === undefined ? '-' : st.located} reason=${st.reason ?? '-'}`
640
712
  );
@@ -1184,7 +1184,11 @@ export class PlayCanvasSplatViewer {
1184
1184
  if (this._mode !== '3d') this.stopMono();
1185
1185
  // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
1186
1186
  // and the tick below may declare a new one (a focus ease) for the NEXT locate.
1187
- if (this.layerRigs?.active) this._rigSnap = snapshotRig(this.layerRigSource?.(), this._rigSnap);
1187
+ if (this.layerRigs?.active) {
1188
+ this._rigSnap = snapshotRig(this.layerRigSource?.(), this._rigSnap);
1189
+ // n for the layer rig: the browser's nominal viewer, asked once (async; no-op after).
1190
+ this.layerRigs.noteLayer(layer);
1191
+ }
1188
1192
  // Same moment, for the rig map: the rig these views were most likely located for (a tie-break).
1189
1193
  this._rigAtPull = this.rigTrack ? this.rigTrack.latest : null;
1190
1194
  // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
@@ -3186,7 +3190,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3186
3190
  layerRigState() {
3187
3191
  const lr = viewer.layerRigs;
3188
3192
  if (lr) return lr.state();
3189
- return { display: [], disabled: false, path: null, engaged: false, rounded: false, reason: 'no layer on the display rig', viewerDistance: DEFAULT_VIEWER_DISTANCE_M, gain: null, planeM: null, photoConvergenceM: null, planeOffset: 0, located: null };
3193
+ return { display: [], disabled: false, path: null, engaged: false, rounded: false, reason: 'no layer on the display rig', viewerDistance: DEFAULT_VIEWER_DISTANCE_M, viewerDistanceSource: 'default', gain: null, planeM: null, photoConvergenceM: null, planeOffset: 0, located: null };
3190
3194
  },
3191
3195
  /**
3192
3196
  * An unlit material showing the left half of `texture` to left-eye views and the right half to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
package/splat.d.ts CHANGED
@@ -185,7 +185,11 @@ export interface ResolvedRig {
185
185
  * viewer's. docs/proposals/layer-display-rig.md.
186
186
  */
187
187
  export interface SplatLayerRigOptions {
188
- /** Nominal viewer distance in metres (default 0.6, the browser's own nominal). */
188
+ /**
189
+ * Nominal viewer distance n in metres. Default: the browser's
190
+ * `XRDisplayInfo.nominalViewerPosition.z` where it reports one, else 0.6. Passing it pins n
191
+ * (see {@link SplatLayerRigState.viewerDistanceSource}).
192
+ */
189
193
  viewerDistance?: number | null;
190
194
  /** An explicit gain k instead (1 = exactly the photo rig; larger = rounder). */
191
195
  gain?: number | null;
@@ -216,7 +220,14 @@ export interface SplatLayerRigState {
216
220
  rounded: boolean;
217
221
  /** Why not engaged (or what is wrong with some layers while engaged); null when all is well. */
218
222
  reason: string | null;
223
+ /** The n in use (metres). */
219
224
  viewerDistance: number;
225
+ /**
226
+ * Where `viewerDistance` came from: the page's option, the browser's reported nominal viewer
227
+ * (`XRDisplayInfo.nominalViewerPosition.z`, asked once per tile, asynchronously — early frames
228
+ * may read 'default' before it lands), or the 0.6 m fallback.
229
+ */
230
+ viewerDistanceSource: 'page' | 'browser' | 'default';
220
231
  /** The gain the last frame used (null off a camera rig). */
221
232
  gain: number | null;
222
233
  /** The plane that lands on the glass, as a distance from the photo's camera (world units). */