@displayxr/inline3d 1.25.1 → 1.26.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,20 @@ 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.26.0 — 2026-09-25
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; nothing changes
11
+ for a page that does not call it.
12
+
13
+ - **`handle.setViewOffset({x, y})`** moves the 2D tier's mono EYE in its own plane, off-axis with
14
+ the focus plane pinned — a head-parallax analogue for a flat screen with no tracked eyes (a
15
+ phone's tilt). Normalised and clamped to the unit disc; |offset| = 1 swings the line of sight to
16
+ the focus by `orbitMaxDeg` (15°), the drag orbit's cone. It moves the eye, not the scene, so it
17
+ composes with the orbit instead of fighting its relax. A snap (ease it in the page); `null`
18
+ resets. Mono only: a no-op for the pixels in woven 3D, where the head tracker owns the eyes, and
19
+ ignored on `controls: 'page'`. **`handle.viewOffset`** reads it back. `pick()` and the effects'
20
+ eye frame follow the offset camera.
21
+
8
22
  ## 1.25.1 — 2026-09-25
9
23
 
10
24
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; nothing changes
@@ -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. */
@@ -1867,12 +1905,40 @@ export class PlayCanvasSplatViewer {
1867
1905
  } else perspectiveFov(this.mono.fov, aspect, this.mono.near, this.mono.far, this.mono.proj);
1868
1906
  }
1869
1907
 
1908
+ /**
1909
+ * handle.setViewOffset — a normalised eye offset in the mono camera's plane (see offsetMonoView),
1910
+ * clamped to the unit disc. Mono only: in woven 3D the head tracker owns the eyes and this is
1911
+ * stored but not drawn; on controls:'page' the page owns the camera and it is ignored. A snap,
1912
+ * like setPose, so the page eases it (and the frame loop draws it next frame).
1913
+ */
1914
+ setViewOffset(x, y) {
1915
+ const r = Math.hypot(x, y);
1916
+ const k = r > 1 ? 1 / r : 1;
1917
+ this._viewOffset[0] = x * k;
1918
+ this._viewOffset[1] = y * k;
1919
+ }
1920
+
1921
+ /** The mono camera as drawn: the offset one when setViewOffset is engaged, else mono itself. */
1922
+ _monoView() {
1923
+ const m = this.mono;
1924
+ const [ox, oy] = this._viewOffset;
1925
+ if (this.pageCamera || (!ox && !oy)) return { proj: m.proj, pose: m.pose };
1926
+ const o = this._orbitCentre;
1927
+ const p = m.pose;
1928
+ // The focus's distance along the view axis (−z): the pivot puts it at the orbit centre.
1929
+ const c = -((o.x - p[12]) * p[8] + (o.y - p[13]) * p[9] + (o.z + this._depthOffset - p[14]) * p[10]);
1930
+ return offsetMonoView(p, m.proj, ox, oy, c, this.orbitMaxDeg, this._offPose, this._offProj)
1931
+ ? { proj: this._offProj, pose: this._offPose }
1932
+ : { proj: m.proj, pose: m.pose };
1933
+ }
1934
+
1870
1935
  _drawMono() {
1871
1936
  const c = this.canvas;
1872
1937
  this._monoEntry ||= [{ proj: this.mono.proj, pose: this.mono.pose, x: 0, y: 0, width: 0, height: 0 }];
1873
1938
  const e = this._monoEntry[0];
1874
- e.proj = this.mono.proj;
1875
- e.pose = this.mono.pose;
1939
+ const v = this._monoView();
1940
+ e.proj = v.proj;
1941
+ e.pose = v.pose;
1876
1942
  e.width = c.width;
1877
1943
  e.height = c.height;
1878
1944
  this._drawEntries(this._monoEntry, null);
@@ -2135,7 +2201,7 @@ export class PlayCanvasSplatViewer {
2135
2201
  */
2136
2202
  eyeFrame() {
2137
2203
  const R = this.rigMatrix();
2138
- const es = this._mode === '3d' && this._lastGood ? this._lastGood.entries : [{ pose: this.mono.pose, proj: this.mono.proj }];
2204
+ const es = this._mode === '3d' && this._lastGood ? this._lastGood.entries : [this._monoView()];
2139
2205
  const o = [0, 0, 0];
2140
2206
  for (const e of es) {
2141
2207
  const M = mat4Mul(R, e.pose);
@@ -2352,7 +2418,7 @@ export class PlayCanvasSplatViewer {
2352
2418
  /** The camera currently on screen: [proj, pose] of the first eye in 3D, the mono camera else. */
2353
2419
  currentView() {
2354
2420
  if (this._mode === '3d' && this._lastGood) return this._lastGood.entries[0];
2355
- return { proj: this.mono.proj, pose: this.mono.pose };
2421
+ return this._monoView();
2356
2422
  }
2357
2423
  }
2358
2424
 
@@ -2984,6 +3050,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2984
3050
  // The current per-eye buffer scale (the `renderScale` option, or the last setRenderScale). A
2985
3051
  // real accessor: Object.assign below would copy a getter's value once.
2986
3052
  Object.defineProperty(out, 'renderScale', { get: () => viewer.renderScale, enumerable: true, configurable: true });
3053
+ // The current eye offset (after the unit-disc clamp), a fresh object per read.
3054
+ Object.defineProperty(out, 'viewOffset', {
3055
+ get: () => ({ x: viewer._viewOffset[0], y: viewer._viewOffset[1] }),
3056
+ enumerable: true,
3057
+ configurable: true,
3058
+ });
2987
3059
  Object.assign(out, {
2988
3060
  backend: 'playcanvas',
2989
3061
  engine: null, // { app, root, camera } once the engine has booted — see below
@@ -3083,6 +3155,23 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3083
3155
  }
3084
3156
  return out;
3085
3157
  },
3158
+ /**
3159
+ * The 2D tier's eye offset: `{x, y}` normalised in the mono camera's plane (+x right, +y up),
3160
+ * clamped to the unit disc; |offset| = 1 swings the line of sight to the focus by orbitMaxDeg
3161
+ * (15°), off-axis so the focus plane stays put — a head-parallax analogue (a phone's tilt)
3162
+ * where there are no tracked eyes. Composes with the orbit. A snap: ease it in the page.
3163
+ * `null` = {0, 0}. Mono only — a no-op for the pixels in woven 3D (the head tracker owns the
3164
+ * eyes; the value is kept for a later fall back to 2D) and on controls:'page'.
3165
+ */
3166
+ setViewOffset(o) {
3167
+ const x = o == null ? 0 : o.x ?? 0;
3168
+ const y = o == null ? 0 : o.y ?? 0;
3169
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) {
3170
+ throw new TypeError('@displayxr/inline3d/splat: setViewOffset takes {x, y} finite numbers (or null).');
3171
+ }
3172
+ viewer.setViewOffset(x, y);
3173
+ return out;
3174
+ },
3086
3175
  /** Change the layer rig's tile-wide options live (no layer change): merge, `null` clears. */
3087
3176
  setLayerRigOptions(o = {}) {
3088
3177
  layerRigs().setOptions(validateLayerRigOptions(o, 'setLayerRigOptions'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.25.1",
3
+ "version": "1.26.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
@@ -1002,6 +1002,21 @@ export interface SplatHandle {
1002
1002
  setRenderScale(scale: number): SplatHandle;
1003
1003
  /** `engine: 'playcanvas'` only. The current per-eye buffer scale. */
1004
1004
  readonly renderScale: number;
1005
+ /**
1006
+ * `engine: 'playcanvas'` only. The 2D tier's EYE offset — a head-parallax analogue for a flat
1007
+ * screen with no tracked eyes (e.g. a phone's tilt). `{x, y}` is normalised in the mono camera's
1008
+ * plane (+x right, +y up) and clamped to the unit disc; |offset| = 1 moves the eye far enough to
1009
+ * swing its line of sight to the focus by `orbitMaxDeg` (15° by default), the drag orbit's comfort
1010
+ * cone. Off-axis: the focus plane stays put on screen while nearer content moves against the
1011
+ * farther. It moves the eye, not the scene, so it composes with the orbit (`setPose`, the drag)
1012
+ * rather than fighting it. A snap, like `setPose` — ease it in the page. `null` resets to
1013
+ * `{0, 0}`. Mono only: in woven 3D the head tracker owns the eyes, so the value is kept (it applies
1014
+ * again if the tile falls back to 2D) but draws nothing; ignored on `controls: 'page'`. Throws a
1015
+ * TypeError on a non-finite component.
1016
+ */
1017
+ setViewOffset(offset: { x?: number; y?: number } | null): SplatHandle;
1018
+ /** `engine: 'playcanvas'` only. The current eye offset (after the unit-disc clamp). */
1019
+ readonly viewOffset: { x: number; y: number };
1005
1020
  /** `engine: 'playcanvas'` only. Change the layer rig's tile-wide options live (merge; `null` clears). */
1006
1021
  setLayerRigOptions(opts: SplatLayerRigOptions): SplatHandle;
1007
1022
  /**