@displayxr/inline3d 1.25.1 → 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,39 @@ 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
+
27
+ ## 1.26.0 — 2026-09-25
28
+
29
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; nothing changes
30
+ for a page that does not call it.
31
+
32
+ - **`handle.setViewOffset({x, y})`** moves the 2D tier's mono EYE in its own plane, off-axis with
33
+ the focus plane pinned — a head-parallax analogue for a flat screen with no tracked eyes (a
34
+ phone's tilt). Normalised and clamped to the unit disc; |offset| = 1 swings the line of sight to
35
+ the focus by `orbitMaxDeg` (15°), the drag orbit's cone. It moves the eye, not the scene, so it
36
+ composes with the orbit instead of fighting its relax. A snap (ease it in the page); `null`
37
+ resets. Mono only: a no-op for the pixels in woven 3D, where the head tracker owns the eyes, and
38
+ ignored on `controls: 'page'`. **`handle.viewOffset`** reads it back. `pick()` and the effects'
39
+ eye frame follow the offset camera.
40
+
8
41
  ## 1.25.1 — 2026-09-25
9
42
 
10
43
  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
  );
@@ -452,6 +452,40 @@ export function pageViewAxis(M) {
452
452
  return { origin: [M[12], M[13], M[14]], forward: [-M[8] / fl, -M[9] / fl, -M[10] / fl], scale: s };
453
453
  }
454
454
 
455
+ /**
456
+ * handle.setViewOffset — the mono camera moved IN ITS OWN PLANE, off-axis, with the focus plane
457
+ * pinned. A head-parallax analogue for the 2D tier (a phone's tilt), where there are no tracked
458
+ * eyes: the eye slides sideways/up, the window through the focus plane stays where it was, so the
459
+ * focus stays put on screen while nearer content moves against the farther. Not a rotation — the
460
+ * orbit (setPose / the drag) turns the SCENE about the focus; this moves the EYE, and the two
461
+ * compose.
462
+ *
463
+ * e = (ox, oy) · c · tan(maxDeg) camera-plane displacement, display units
464
+ * pose' = pose · T(e.x, e.y, 0)
465
+ * P'[8] = P[8] − P[0]·e.x / c, P'[9] = P[9] − P[5]·e.y / c (the window shifted by −e·n/c)
466
+ *
467
+ * `c` is the focus's distance along the camera's view axis, so |offset| = 1 swings the line of
468
+ * sight to the focus by `maxDeg` — the same comfort cone as the drag orbit (orbitMaxDeg, 15°).
469
+ * Being an ANGLE it is scale-free: zoom scales the scene about the focus, and c does not move.
470
+ * Writes into outPose/outProj and returns true; false (outputs untouched) when the offset is zero
471
+ * or the focus is not in front of the camera — the caller then draws the unshifted camera.
472
+ */
473
+ export function offsetMonoView(pose, proj, ox, oy, c, maxDeg, outPose, outProj) {
474
+ if ((!ox && !oy) || !(c > 1e-6) || !Number.isFinite(c)) return false;
475
+ const k = c * Math.tan(maxDeg * DEG);
476
+ const ex = ox * k;
477
+ const ey = oy * k;
478
+ outPose.set(pose);
479
+ // pose · T(e): translate along the camera's own x/y axes (columns 0 and 1).
480
+ outPose[12] = pose[12] + pose[0] * ex + pose[4] * ey;
481
+ outPose[13] = pose[13] + pose[1] * ex + pose[5] * ey;
482
+ outPose[14] = pose[14] + pose[2] * ex + pose[6] * ey;
483
+ outProj.set(proj);
484
+ outProj[8] = proj[8] - (proj[0] * ex) / c;
485
+ outProj[9] = proj[9] - (proj[5] * ey) / c;
486
+ return true;
487
+ }
488
+
455
489
  /**
456
490
  * The attach-pattern camera rig for controls:'page' — the auto-3D shim's buildRig, field for
457
491
  * field: identity pose, the page's vertical FOV, convergence d, metersToVirtual = depth · d / 0.5
@@ -874,6 +908,10 @@ export class PlayCanvasSplatViewer {
874
908
  this._orbitCentre = { x: 0, y: 0, z: 0 };
875
909
  this._focusRecentres = true;
876
910
  this._focusSettled = true;
911
+ // handle.setViewOffset: normalised camera-plane eye offset, mono only (offsetMonoView).
912
+ this._viewOffset = [0, 0];
913
+ this._offPose = new Float64Array(16);
914
+ this._offProj = new Float64Array(16);
877
915
  this.onFocusChange = null;
878
916
  this.onTick = null;
879
917
  /** controls:'page' — `(views|null) => void`, run once per frame before anything else. */
@@ -1146,7 +1184,11 @@ export class PlayCanvasSplatViewer {
1146
1184
  if (this._mode !== '3d') this.stopMono();
1147
1185
  // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
1148
1186
  // and the tick below may declare a new one (a focus ease) for the NEXT locate.
1149
- 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
+ }
1150
1192
  // Same moment, for the rig map: the rig these views were most likely located for (a tie-break).
1151
1193
  this._rigAtPull = this.rigTrack ? this.rigTrack.latest : null;
1152
1194
  // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
@@ -1867,12 +1909,40 @@ export class PlayCanvasSplatViewer {
1867
1909
  } else perspectiveFov(this.mono.fov, aspect, this.mono.near, this.mono.far, this.mono.proj);
1868
1910
  }
1869
1911
 
1912
+ /**
1913
+ * handle.setViewOffset — a normalised eye offset in the mono camera's plane (see offsetMonoView),
1914
+ * clamped to the unit disc. Mono only: in woven 3D the head tracker owns the eyes and this is
1915
+ * stored but not drawn; on controls:'page' the page owns the camera and it is ignored. A snap,
1916
+ * like setPose, so the page eases it (and the frame loop draws it next frame).
1917
+ */
1918
+ setViewOffset(x, y) {
1919
+ const r = Math.hypot(x, y);
1920
+ const k = r > 1 ? 1 / r : 1;
1921
+ this._viewOffset[0] = x * k;
1922
+ this._viewOffset[1] = y * k;
1923
+ }
1924
+
1925
+ /** The mono camera as drawn: the offset one when setViewOffset is engaged, else mono itself. */
1926
+ _monoView() {
1927
+ const m = this.mono;
1928
+ const [ox, oy] = this._viewOffset;
1929
+ if (this.pageCamera || (!ox && !oy)) return { proj: m.proj, pose: m.pose };
1930
+ const o = this._orbitCentre;
1931
+ const p = m.pose;
1932
+ // The focus's distance along the view axis (−z): the pivot puts it at the orbit centre.
1933
+ const c = -((o.x - p[12]) * p[8] + (o.y - p[13]) * p[9] + (o.z + this._depthOffset - p[14]) * p[10]);
1934
+ return offsetMonoView(p, m.proj, ox, oy, c, this.orbitMaxDeg, this._offPose, this._offProj)
1935
+ ? { proj: this._offProj, pose: this._offPose }
1936
+ : { proj: m.proj, pose: m.pose };
1937
+ }
1938
+
1870
1939
  _drawMono() {
1871
1940
  const c = this.canvas;
1872
1941
  this._monoEntry ||= [{ proj: this.mono.proj, pose: this.mono.pose, x: 0, y: 0, width: 0, height: 0 }];
1873
1942
  const e = this._monoEntry[0];
1874
- e.proj = this.mono.proj;
1875
- e.pose = this.mono.pose;
1943
+ const v = this._monoView();
1944
+ e.proj = v.proj;
1945
+ e.pose = v.pose;
1876
1946
  e.width = c.width;
1877
1947
  e.height = c.height;
1878
1948
  this._drawEntries(this._monoEntry, null);
@@ -2135,7 +2205,7 @@ export class PlayCanvasSplatViewer {
2135
2205
  */
2136
2206
  eyeFrame() {
2137
2207
  const R = this.rigMatrix();
2138
- const es = this._mode === '3d' && this._lastGood ? this._lastGood.entries : [{ pose: this.mono.pose, proj: this.mono.proj }];
2208
+ const es = this._mode === '3d' && this._lastGood ? this._lastGood.entries : [this._monoView()];
2139
2209
  const o = [0, 0, 0];
2140
2210
  for (const e of es) {
2141
2211
  const M = mat4Mul(R, e.pose);
@@ -2352,7 +2422,7 @@ export class PlayCanvasSplatViewer {
2352
2422
  /** The camera currently on screen: [proj, pose] of the first eye in 3D, the mono camera else. */
2353
2423
  currentView() {
2354
2424
  if (this._mode === '3d' && this._lastGood) return this._lastGood.entries[0];
2355
- return { proj: this.mono.proj, pose: this.mono.pose };
2425
+ return this._monoView();
2356
2426
  }
2357
2427
  }
2358
2428
 
@@ -2984,6 +3054,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2984
3054
  // The current per-eye buffer scale (the `renderScale` option, or the last setRenderScale). A
2985
3055
  // real accessor: Object.assign below would copy a getter's value once.
2986
3056
  Object.defineProperty(out, 'renderScale', { get: () => viewer.renderScale, enumerable: true, configurable: true });
3057
+ // The current eye offset (after the unit-disc clamp), a fresh object per read.
3058
+ Object.defineProperty(out, 'viewOffset', {
3059
+ get: () => ({ x: viewer._viewOffset[0], y: viewer._viewOffset[1] }),
3060
+ enumerable: true,
3061
+ configurable: true,
3062
+ });
2987
3063
  Object.assign(out, {
2988
3064
  backend: 'playcanvas',
2989
3065
  engine: null, // { app, root, camera } once the engine has booted — see below
@@ -3083,6 +3159,23 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3083
3159
  }
3084
3160
  return out;
3085
3161
  },
3162
+ /**
3163
+ * The 2D tier's eye offset: `{x, y}` normalised in the mono camera's plane (+x right, +y up),
3164
+ * clamped to the unit disc; |offset| = 1 swings the line of sight to the focus by orbitMaxDeg
3165
+ * (15°), off-axis so the focus plane stays put — a head-parallax analogue (a phone's tilt)
3166
+ * where there are no tracked eyes. Composes with the orbit. A snap: ease it in the page.
3167
+ * `null` = {0, 0}. Mono only — a no-op for the pixels in woven 3D (the head tracker owns the
3168
+ * eyes; the value is kept for a later fall back to 2D) and on controls:'page'.
3169
+ */
3170
+ setViewOffset(o) {
3171
+ const x = o == null ? 0 : o.x ?? 0;
3172
+ const y = o == null ? 0 : o.y ?? 0;
3173
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) {
3174
+ throw new TypeError('@displayxr/inline3d/splat: setViewOffset takes {x, y} finite numbers (or null).');
3175
+ }
3176
+ viewer.setViewOffset(x, y);
3177
+ return out;
3178
+ },
3086
3179
  /** Change the layer rig's tile-wide options live (no layer change): merge, `null` clears. */
3087
3180
  setLayerRigOptions(o = {}) {
3088
3181
  layerRigs().setOptions(validateLayerRigOptions(o, 'setLayerRigOptions'));
@@ -3097,7 +3190,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3097
3190
  layerRigState() {
3098
3191
  const lr = viewer.layerRigs;
3099
3192
  if (lr) return lr.state();
3100
- 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 };
3101
3194
  },
3102
3195
  /**
3103
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.25.1",
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). */
@@ -1002,6 +1013,21 @@ export interface SplatHandle {
1002
1013
  setRenderScale(scale: number): SplatHandle;
1003
1014
  /** `engine: 'playcanvas'` only. The current per-eye buffer scale. */
1004
1015
  readonly renderScale: number;
1016
+ /**
1017
+ * `engine: 'playcanvas'` only. The 2D tier's EYE offset — a head-parallax analogue for a flat
1018
+ * screen with no tracked eyes (e.g. a phone's tilt). `{x, y}` is normalised in the mono camera's
1019
+ * plane (+x right, +y up) and clamped to the unit disc; |offset| = 1 moves the eye far enough to
1020
+ * swing its line of sight to the focus by `orbitMaxDeg` (15° by default), the drag orbit's comfort
1021
+ * cone. Off-axis: the focus plane stays put on screen while nearer content moves against the
1022
+ * farther. It moves the eye, not the scene, so it composes with the orbit (`setPose`, the drag)
1023
+ * rather than fighting it. A snap, like `setPose` — ease it in the page. `null` resets to
1024
+ * `{0, 0}`. Mono only: in woven 3D the head tracker owns the eyes, so the value is kept (it applies
1025
+ * again if the tile falls back to 2D) but draws nothing; ignored on `controls: 'page'`. Throws a
1026
+ * TypeError on a non-finite component.
1027
+ */
1028
+ setViewOffset(offset: { x?: number; y?: number } | null): SplatHandle;
1029
+ /** `engine: 'playcanvas'` only. The current eye offset (after the unit-disc clamp). */
1030
+ readonly viewOffset: { x: number; y: number };
1005
1031
  /** `engine: 'playcanvas'` only. Change the layer rig's tile-wide options live (merge; `null` clears). */
1006
1032
  setLayerRigOptions(opts: SplatLayerRigOptions): SplatHandle;
1007
1033
  /**