@displayxr/inline3d 1.18.0 → 1.19.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,28 @@ 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.19.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./splat` and `./model`, `engine: 'playcanvas'` only). The defaults
11
+ keep the wheel's pixels. One default does change: a two-finger touch now pinch-zooms (within the
12
+ old 0.2–6 range, with no relax) where it used to act as an erratic one-finger drag.
13
+
14
+ ### Added
15
+
16
+ - **`zoom: { min, max, relax, ease }`** (#36), bounded zoom that relaxes back to rest.
17
+ - Wheel, pinch and `setPose` clamp to `[min, max]`. Defaults are 0.2 and 6, as before.
18
+ - `relax: true` eases back to rest (1×, or the last `setPose` zoom) once the wheel has been idle
19
+ 150 ms or the pinch ends. It uses the orbit's τ 0.6 s, with a landing floor so 2× is exactly
20
+ home in about 2.9 s, and it never fights a live gesture.
21
+ - The zoom scales about the focus, so the focus keeps its place and its disparity in both eyes.
22
+ - The gallery's Spatial View passes `{ min: 1, max: 2, relax: true }`: you can't zoom out past
23
+ the frame's edges, and a peek comes back on its own.
24
+ - **Two-finger pinch zoom** on the PlayCanvas viewer, with Pointer Events and the same bounds.
25
+ - Gates, on a real GPU (1280×720):
26
+ - Zooming out is refused: MAE 0 against the rest render.
27
+ - Wheel and pinch reach the 2× cap and return to **MAE 0** against rest 3 s after release.
28
+ - In fake stereo, the focus's disparity is unchanged at 1×, 1.5× and 2×.
29
+
8
30
  ## 1.18.0 — 2026-09-24
9
31
 
10
32
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive: a page that never
package/README.md CHANGED
@@ -45,7 +45,8 @@ API is not yet covered by the semver promise below.
45
45
  renders the same splat window with the [PlayCanvas](https://playcanvas.com/) engine instead of
46
46
  Spark — same handle, same rig/focus/camera-block behaviour, same `perf` presets (mapped onto the
47
47
  engine's knobs) — and adds `setSource(src, { fadeMs })` (crossfading asset swaps), `feather`, a
48
- tilt-and-relax orbit, `handle.engine` (the engine objects, for advanced pages) and
48
+ tilt-and-relax orbit, two-finger pinch plus bounded zoom that relaxes back to rest
49
+ (`zoom: { min, max, relax }`, [§Zoom bounds and relax](docs/playcanvas-adapter.md#zoom-bounds-and-relax)), `handle.engine` (the engine objects, for advanced pages) and
49
50
  `handle.setRig('display' | 'camera' | 'auto')`: a live, reversible rig switch, so one persistent tile
50
51
  can show a glTF under `handle.engine.root` exactly as `addModel` would and then go back to the photo's
51
52
  capture rig ([§setRig](docs/playcanvas-adapter.md#setrig--switching-between-the-display-rig-and-the-camera-rig-36)), and
@@ -54,7 +54,7 @@ export function resolveModelEngine(opts = {}) {
54
54
  }
55
55
 
56
56
  /** Options that exist only on the PlayCanvas backend: an error on an explicit engine:'three'. */
57
- const PLAYCANVAS_ONLY = ['playcanvas', 'environmentRotation', 'controls', 'comfortDepth', 'onBeforeFrame', 'antialias', 'preserveDrawingBuffer', 'nearClip', 'farClip', 'orbitMaxDeg', 'orbitEase'];
57
+ const PLAYCANVAS_ONLY = ['playcanvas', 'environmentRotation', 'controls', 'comfortDepth', 'onBeforeFrame', 'antialias', 'preserveDrawingBuffer', 'nearClip', 'farClip', 'orbitMaxDeg', 'orbitEase', 'zoom'];
58
58
 
59
59
  /**
60
60
  * Everything that can be checked before a module loads. Throws at CALL time — a page bug is true
@@ -903,6 +903,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
903
903
  flipY: false, // glTF is already Y-up
904
904
  orbitMaxDeg: opts.orbitMaxDeg,
905
905
  orbitEase: opts.orbitEase,
906
+ zoom: opts.zoom,
906
907
  feather,
907
908
  nearClip: opts.nearClip,
908
909
  farClip: opts.farClip,
@@ -84,8 +84,9 @@ import {
84
84
  WHEEL_PAGE_PX,
85
85
  WHEEL_MAX_PX,
86
86
  ZOOM_PER_PX,
87
- ZOOM_MIN,
88
- ZOOM_MAX,
87
+ ZOOM_WHEEL_IDLE_MS,
88
+ ZOOM_RELAX_MIN_RATE,
89
+ resolveZoomOption,
89
90
  MONO_FOV,
90
91
  MONO_NEAR,
91
92
  MONO_FAR,
@@ -628,6 +629,7 @@ export class PlayCanvasSplatViewer {
628
629
  pitchLimit = PITCH_LIMIT,
629
630
  orbitMaxDeg = ORBIT_MAX_DEG,
630
631
  orbitEase = {},
632
+ zoom,
631
633
  feather = 0,
632
634
  captureFit = 'height',
633
635
  nearClip,
@@ -665,6 +667,15 @@ export class PlayCanvasSplatViewer {
665
667
  this.orbitMaxDeg = orbitMaxDeg;
666
668
  this.orbitEase = { drag: orbitEase.drag ?? ORBIT_TAU_DRAG_S, rest: orbitEase.rest ?? ORBIT_TAU_REST_S };
667
669
  this._orbitMode = null;
670
+ // Zoom bounds + relax (./inline3d-splat-shared.js §ZOOM). Wheel and pinch clamp to
671
+ // [zoomOpts.min, zoomOpts.max]; with relax, the zoom eases back to `_restZoom` (1×, or the
672
+ // last setPose zoom) once the wheel is idle or the pinch ends. `_zoomMode` is 'rest' while
673
+ // relaxing, else null (the ordinary damping). `_pinching` holds the relax off.
674
+ this.zoomOpts = resolveZoomOption(zoom);
675
+ this._zoomMode = null;
676
+ this._restZoom = clamp(1, this.zoomOpts.min, this.zoomOpts.max);
677
+ this._lastWheel = 0;
678
+ this._pinching = false;
668
679
  /** handle.setVideo: orbit/zoom input ignored while a video plane is on. */
669
680
  this.inputLocked = false;
670
681
  /** handle.setVideo's plane (./inline3d-splat-video.js) while a video is on, else null. */
@@ -693,8 +704,8 @@ export class PlayCanvasSplatViewer {
693
704
  this.flipY = opts.flipY !== false;
694
705
 
695
706
  this._fitScale = 1;
696
- this._zoom = 1;
697
- this._targetZoom = 1;
707
+ this._zoom = this._restZoom;
708
+ this._targetZoom = this._restZoom;
698
709
  this._depthOffset = 0;
699
710
  this._subjectHalf = [0, 0, 0];
700
711
  this._yaw = 0;
@@ -815,7 +826,11 @@ export class PlayCanvasSplatViewer {
815
826
  if (pitch !== undefined) {
816
827
  this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
817
828
  }
818
- if (zoom !== undefined) this._targetZoom = this._zoom = clamp(zoom, ZOOM_MIN, ZOOM_MAX);
829
+ if (zoom !== undefined) {
830
+ // A snap, and the new rest: a relax comes home HERE, not to 1×.
831
+ this._targetZoom = this._zoom = this._restZoom = clamp(zoom, this.zoomOpts.min, this.zoomOpts.max);
832
+ this._zoomMode = null;
833
+ }
819
834
  if (depthOffset !== undefined) this._depthOffset = finite(depthOffset, this._depthOffset);
820
835
  this._applyTransform();
821
836
  }
@@ -1748,7 +1763,33 @@ export class PlayCanvasSplatViewer {
1748
1763
  ) {
1749
1764
  this._orbitMode = null; // at rest: the ordinary damping (and the idle turntable) take over
1750
1765
  }
1751
- if (Math.abs(this._targetZoom - this._zoom) > 1e-4) {
1766
+ // Zoom relax: the wheel has gone idle (or the pinch ended) away from rest — head home.
1767
+ if (
1768
+ this.zoomOpts.relax &&
1769
+ !this._zoomMode &&
1770
+ !this._pinching &&
1771
+ t - this._lastWheel > ZOOM_WHEEL_IDLE_MS &&
1772
+ Math.abs(this._targetZoom - this._restZoom) > 1e-6
1773
+ ) {
1774
+ this._targetZoom = this._restZoom;
1775
+ this._zoomMode = 'rest';
1776
+ }
1777
+ // Relaxing eases with τ = zoomOpts.ease (the orbit's 0.6 s by default); the wheel's own
1778
+ // damping otherwise. Both in log space, so 2× → 1× reads like 1× → ½×.
1779
+ if (this._zoomMode === 'rest') {
1780
+ // The exponential (τ = zoomOpts.ease) with a landing floor: it never moves slower than
1781
+ // ZOOM_RELAX_MIN_RATE (log-zoom per second), so it ARRIVES — 2× is home in ≈3 s — instead of
1782
+ // creeping in forever or popping at a snap. Above ~1 % from home the floor never binds.
1783
+ const gap = Math.log(this._targetZoom / this._zoom);
1784
+ const ko = dt > 0 ? 1 - Math.exp(-dt / this.zoomOpts.ease) : 0;
1785
+ const step = Math.min(Math.abs(gap), Math.max(Math.abs(gap) * ko, ZOOM_RELAX_MIN_RATE * dt));
1786
+ if (Math.abs(gap) - step < 1e-6) {
1787
+ this._zoom = this._targetZoom;
1788
+ this._zoomMode = null;
1789
+ } else {
1790
+ this._zoom *= Math.exp(Math.sign(gap) * step);
1791
+ }
1792
+ } else if (Math.abs(this._targetZoom - this._zoom) > 1e-4) {
1752
1793
  this._zoom *= Math.pow(this._targetZoom / this._zoom, k);
1753
1794
  } else {
1754
1795
  this._zoom = this._targetZoom;
@@ -1869,8 +1910,41 @@ export class PlayCanvasSplatViewer {
1869
1910
  let startY = 0;
1870
1911
  let restYaw = 0;
1871
1912
  let restPitch = 0;
1913
+ // Pinch: two pointers down zoom by the ratio of their spread, about the focus (the pivot's
1914
+ // scale is S(fit × zoom) about it), within zoomOpts' bounds. The drag ends when the second
1915
+ // finger lands (its tilt relaxes); the finger left after a pinch does not orbit.
1916
+ const pointers = new Map(); // pointerId → [clientX, clientY]
1917
+ let pinchD0 = 0;
1918
+ let pinchZ0 = 1;
1919
+ const spread = () => {
1920
+ const [a, b] = [...pointers.values()];
1921
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
1922
+ };
1923
+ const endDrag = () => {
1924
+ dragging = false;
1925
+ this._targetYaw = restYaw;
1926
+ this._targetPitch = clamp(restPitch, this.pitchLimit[0], this.pitchLimit[1]);
1927
+ this._orbitMode = 'rest';
1928
+ };
1872
1929
  this._onDown = (ev) => {
1873
1930
  if (this.inputLocked) return; // handle.setVideo: a screen-locked plane has nothing to orbit
1931
+ if (ev.pointerId !== undefined) pointers.set(ev.pointerId, [ev.clientX, ev.clientY]);
1932
+ try {
1933
+ el.setPointerCapture?.(ev.pointerId);
1934
+ } catch {
1935
+ // An inactive pointer id (a synthetic event) throws; the gesture works without capture.
1936
+ }
1937
+ if (pointers.size === 2) {
1938
+ if (dragging) endDrag();
1939
+ this._pinching = true;
1940
+ this._zoomMode = null; // a new gesture takes over from a relax
1941
+ pinchD0 = Math.max(spread(), 1);
1942
+ pinchZ0 = this._zoom;
1943
+ this._targetZoom = this._zoom;
1944
+ this._lastInput = now();
1945
+ return;
1946
+ }
1947
+ if (pointers.size > 2) return;
1874
1948
  dragging = true;
1875
1949
  startX = ev.clientX;
1876
1950
  startY = ev.clientY;
@@ -1879,9 +1953,16 @@ export class PlayCanvasSplatViewer {
1879
1953
  restPitch = this._orbitMode ? restPitch : this._targetPitch;
1880
1954
  this._orbitMode = 'drag';
1881
1955
  this._lastInput = now();
1882
- el.setPointerCapture?.(ev.pointerId);
1883
1956
  };
1884
1957
  this._onMove = (ev) => {
1958
+ if (pointers.has(ev.pointerId)) pointers.set(ev.pointerId, [ev.clientX, ev.clientY]);
1959
+ if (this._pinching) {
1960
+ if (pointers.size < 2) return;
1961
+ const { min, max } = this.zoomOpts;
1962
+ this._targetZoom = clamp(pinchZ0 * (spread() / pinchD0), min, max);
1963
+ this._lastInput = now();
1964
+ return;
1965
+ }
1885
1966
  if (!dragging) return;
1886
1967
  const box = el.getBoundingClientRect();
1887
1968
  const dx = (ev.clientX - startX) / Math.max(box.width, 1);
@@ -1896,13 +1977,21 @@ export class PlayCanvasSplatViewer {
1896
1977
  this._lastInput = now();
1897
1978
  };
1898
1979
  this._onUp = (ev) => {
1980
+ const had = pointers.delete(ev.pointerId);
1981
+ if (this._pinching && pointers.size < 2) {
1982
+ // The pinch ends when either finger lifts: the zoom relaxes (zoomOpts.relax) from here.
1983
+ this._pinching = false;
1984
+ this._lastWheel = now() - ZOOM_WHEEL_IDLE_MS - 1;
1985
+ this._lastInput = now();
1986
+ }
1987
+ try {
1988
+ if (had || ev.pointerId === undefined) el.releasePointerCapture?.(ev.pointerId);
1989
+ } catch {
1990
+ // see pointerdown
1991
+ }
1899
1992
  if (!dragging) return;
1900
- dragging = false;
1901
- this._targetYaw = restYaw;
1902
- this._targetPitch = clamp(restPitch, this.pitchLimit[0], this.pitchLimit[1]);
1903
- this._orbitMode = 'rest';
1993
+ endDrag();
1904
1994
  this._lastInput = now();
1905
- el.releasePointerCapture?.(ev.pointerId);
1906
1995
  };
1907
1996
  this._onWheel = (ev) => {
1908
1997
  if (this.inputLocked) return;
@@ -1911,7 +2000,11 @@ export class PlayCanvasSplatViewer {
1911
2000
  if (ev.deltaMode === 1) px *= WHEEL_LINE_PX;
1912
2001
  else if (ev.deltaMode === 2) px *= WHEEL_PAGE_PX;
1913
2002
  px = clamp(px, -WHEEL_MAX_PX, WHEEL_MAX_PX);
1914
- this._targetZoom = clamp(this._targetZoom * Math.exp(-px * ZOOM_PER_PX), ZOOM_MIN, ZOOM_MAX);
2003
+ // A wheel tick during a relax restarts from where the zoom IS, not from the rest target.
2004
+ const from = this._zoomMode === 'rest' ? this._zoom : this._targetZoom;
2005
+ this._zoomMode = null;
2006
+ this._targetZoom = clamp(from * Math.exp(-px * ZOOM_PER_PX), this.zoomOpts.min, this.zoomOpts.max);
2007
+ this._lastWheel = now();
1915
2008
  this._lastInput = now();
1916
2009
  };
1917
2010
  if (el.style) el.style.touchAction = 'none';
@@ -1933,6 +2026,7 @@ export class PlayCanvasSplatViewer {
1933
2026
  el.removeEventListener('pointerleave', this._onUp);
1934
2027
  el.removeEventListener('wheel', this._onWheel);
1935
2028
  this._onDown = null;
2029
+ this._pinching = false;
1936
2030
  }
1937
2031
 
1938
2032
  /** The camera currently on screen: [proj, pose] of the first eye in 3D, the mono camera else. */
@@ -2425,6 +2519,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2425
2519
  flipY,
2426
2520
  orbitMaxDeg: opts.orbitMaxDeg,
2427
2521
  orbitEase: opts.orbitEase,
2522
+ zoom: opts.zoom,
2428
2523
  feather,
2429
2524
  captureFit,
2430
2525
  nearClip: opts.nearClip,
@@ -238,6 +238,49 @@ export const ORBIT_TAU_DRAG_S = 0.2;
238
238
  /** Time constant of the relax back to rest after release, seconds. */
239
239
  export const ORBIT_TAU_REST_S = 0.6;
240
240
 
241
+ // ── ZOOM: bounds + relax (the PlayCanvas backend's `zoom` option) ───────────────────────────
242
+
243
+ /** Wheel "release": this long with no wheel event ends the gesture and starts the relax, ms. */
244
+ export const ZOOM_WHEEL_IDLE_MS = 150;
245
+ /**
246
+ * The zoom relax's landing floor, log-zoom per second. The relax is the orbit's exponential
247
+ * (τ = 0.6 s), except it never moves slower than this, so it lands in finite time: from 2× the
248
+ * exponential carries it to 1.5 % from home in ≈2.3 s and the floor finishes in ≈0.6 s more.
249
+ */
250
+ export const ZOOM_RELAX_MIN_RATE = 0.025;
251
+
252
+ /**
253
+ * Validate + default the `zoom` option: `{ min, max, relax, ease }`.
254
+ *
255
+ * Defaults are today's behaviour — ZOOM_MIN..ZOOM_MAX, no relax. `relax: true` eases the zoom
256
+ * back to its rest (1×, or the last setPose zoom) once the wheel has been idle
257
+ * ZOOM_WHEEL_IDLE_MS or a pinch ends, with τ = `ease` (default ORBIT_TAU_REST_S, the orbit's
258
+ * relax — the two gestures come home together).
259
+ *
260
+ * @returns {{min:number, max:number, relax:boolean, ease:number}}
261
+ */
262
+ export function resolveZoomOption(zoom) {
263
+ if (zoom === undefined || zoom === null) return { min: ZOOM_MIN, max: ZOOM_MAX, relax: false, ease: ORBIT_TAU_REST_S };
264
+ if (typeof zoom !== 'object') {
265
+ throw new Error(`@displayxr/inline3d/splat: zoom ${zoom} — expected { min, max, relax, ease }.`);
266
+ }
267
+ const num = (k, v, d) => {
268
+ if (v === undefined) return d;
269
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
270
+ throw new Error(`@displayxr/inline3d/splat: zoom.${k} ${v} — expected a positive number.`);
271
+ }
272
+ return v;
273
+ };
274
+ const min = num('min', zoom.min, ZOOM_MIN);
275
+ const max = num('max', zoom.max, ZOOM_MAX);
276
+ if (max < min) throw new Error(`@displayxr/inline3d/splat: zoom.max ${max} is below zoom.min ${min}.`);
277
+ const ease = num('ease', zoom.ease, ORBIT_TAU_REST_S);
278
+ if (zoom.relax !== undefined && typeof zoom.relax !== 'boolean') {
279
+ throw new Error(`@displayxr/inline3d/splat: zoom.relax ${zoom.relax} — expected true or false.`);
280
+ }
281
+ return { min, max, relax: zoom.relax === true, ease };
282
+ }
283
+
241
284
  /**
242
285
  * The capture camera's off-axis WINDOW at the near plane — the one projection both backends'
243
286
  * camera rigs draw the mono (flat) view through. Principal point honoured, so a deconverged
@@ -346,6 +389,7 @@ export const PAGE_IGNORED_OPTIONS = Object.freeze([
346
389
  'depthLimit',
347
390
  'orbitMaxDeg',
348
391
  'orbitEase',
392
+ 'zoom',
349
393
  'captureFit',
350
394
  ]);
351
395
 
package/model.d.ts CHANGED
@@ -117,6 +117,8 @@ export interface ModelOptions {
117
117
  orbitMaxDeg?: number;
118
118
  /** PlayCanvas only: the orbit's easing time constants, seconds. */
119
119
  orbitEase?: { drag?: number; rest?: number };
120
+ /** PlayCanvas only: zoom bounds + relax (wheel, pinch, setPose) — see the splat option. */
121
+ zoom?: { min?: number; max?: number; relax?: boolean; ease?: number };
120
122
  /**
121
123
  * PlayCanvas only: who owns the camera — the SDK's viewer (default) or the page, through
122
124
  * {@link ModelHandle.setCameraPose} every frame (the attach rig; converges on the model's bounds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.18.0",
3
+ "version": "1.19.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
@@ -259,6 +259,15 @@ export interface SplatOptions {
259
259
  orbitMaxDeg?: number;
260
260
  /** PlayCanvas: easing time constants, seconds — `drag` while held (0.2), `rest` after release (0.6). */
261
261
  orbitEase?: { drag?: number; rest?: number };
262
+ /**
263
+ * PlayCanvas: zoom bounds + relax, for the wheel, the two-finger pinch and `setPose`. `min` /
264
+ * `max` clamp the zoom (defaults 0.2 / 6, the pre-1.19 range); `relax: true` eases it back to
265
+ * rest — 1×, or the last `setPose` zoom — once the wheel has been idle 150 ms or the pinch ends,
266
+ * with the orbit's time constant (`ease`, default 0.6 s) and a landing floor so it arrives (2×
267
+ * is home in ≈3 s). The zoom scales the subject about the focus, which keeps its screen position
268
+ * and its disparity. `{ min: 1, max: 2, relax: true }` is a peek that never shows the splat's edges.
269
+ */
270
+ zoom?: { min?: number; max?: number; relax?: boolean; ease?: number };
262
271
  fit?: 'contain' | 'height' | 'cover' | 'none';
263
272
  /** Fraction of the tile the subject may occupy (default 0.8) — width AND height. */
264
273
  margin?: number;