@displayxr/inline3d 1.49.3 → 1.50.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.
@@ -380,13 +380,15 @@ const FLIP_Q = [1, 0, 0, 0];
380
380
  /**
381
381
  * SceneViewer's pivot as ONE matrix: content space → display space.
382
382
  *
383
- * M = T(orbitCentre + (0,0,depthOffset)) · Rx(pitch) · Ry(yaw) · S(fit × zoom) · T(−focus)
383
+ * M = T(orbitCentre + (0,0,depthOffset)) · Rz(roll) · Rx(pitch) · Ry(yaw) · S(fit × zoom) · T(−focus)
384
384
  *
385
- * which is exactly the product of its `_pivot` (position, 'XYZ' rotation, scale) and
385
+ * which is exactly the product of its `_pivot` (position, 'ZXY' rotation, scale) and
386
386
  * `_centering` (−focus) groups. Kept as a pure function so a test can hold the two together.
387
+ * Roll (the two-hand twist) is about the VIEW axis, so it sits outside yaw and pitch; at roll 0
388
+ * this is the old Rx · Ry product bit for bit.
387
389
  */
388
- export function pivotMatrix({ yaw = 0, pitch = 0, scale = 1, focus = [0, 0, 0], orbitCentre = [0, 0, 0], depthOffset = 0 }) {
389
- const q = pivotQuat(yaw, pitch);
390
+ export function pivotMatrix({ yaw = 0, pitch = 0, roll = 0, scale = 1, focus = [0, 0, 0], orbitCentre = [0, 0, 0], depthOffset = 0 }) {
391
+ const q = pivotQuat(yaw, pitch, roll);
390
392
  const R = poseMatrix([0, 0, 0], q);
391
393
  const out = new Float64Array(16);
392
394
  // columns of R·S
@@ -400,19 +402,25 @@ export function pivotMatrix({ yaw = 0, pitch = 0, scale = 1, focus = [0, 0, 0],
400
402
  return out;
401
403
  }
402
404
 
403
- /** R = Rx(pitch)·Ry(yaw) — three's Euler order 'XYZ' — as a quaternion. */
404
- function pivotQuat(yawDeg, pitchDeg) {
405
+ /**
406
+ * R = Rz(roll)·Rx(pitch)·Ry(yaw) — three's Euler order 'ZXY', SceneViewer's — as a quaternion.
407
+ * With roll 0 the Rz factor is the identity quaternion and the product is exactly Rx·Ry.
408
+ */
409
+ function pivotQuat(yawDeg, pitchDeg, rollDeg = 0) {
405
410
  const a = (pitchDeg * DEG) / 2;
406
411
  const b = (yawDeg * DEG) / 2;
407
- return qmul([Math.sin(a), 0, 0, Math.cos(a)], [0, Math.sin(b), 0, Math.cos(b)]);
412
+ const q = qmul([Math.sin(a), 0, 0, Math.cos(a)], [0, Math.sin(b), 0, Math.cos(b)]);
413
+ if (!rollDeg) return q;
414
+ const c = (rollDeg * DEG) / 2;
415
+ return qmul([0, 0, Math.sin(c), Math.cos(c)], q);
408
416
  }
409
417
 
410
418
  /**
411
419
  * The INVERSE pivot, as the TRS the engine's rig node takes. M⁻¹ = T(f)·S(1/s)·Rᵀ·T(−t), and a
412
420
  * uniform scale commutes with a rotation, so it is position f − Rᵀt/s, rotation Rᵀ, scale 1/s.
413
421
  */
414
- export function pivotInverseTRS({ yaw = 0, pitch = 0, scale = 1, focus = [0, 0, 0], orbitCentre = [0, 0, 0], depthOffset = 0 }) {
415
- const q = pivotQuat(yaw, pitch);
422
+ export function pivotInverseTRS({ yaw = 0, pitch = 0, roll = 0, scale = 1, focus = [0, 0, 0], orbitCentre = [0, 0, 0], depthOffset = 0 }) {
423
+ const q = pivotQuat(yaw, pitch, roll);
416
424
  const qi = [-q[0], -q[1], -q[2], q[3]];
417
425
  const t = [orbitCentre[0], orbitCentre[1], orbitCentre[2] + depthOffset];
418
426
  const rt = qrot(qi, t);
@@ -952,6 +960,11 @@ export class PlayCanvasSplatViewer {
952
960
  this._pitch = 0;
953
961
  this._targetYaw = 0;
954
962
  this._targetPitch = 0;
963
+ // Roll about the VIEW axis, degrees — SceneViewer's. Only the two-hand gesture
964
+ // (./inline3d-hands.js) and an explicit setPose({ roll }) write it, so every existing page is
965
+ // bit-identical at 0.
966
+ this._roll = 0;
967
+ this._targetRoll = 0;
955
968
  this._focus = { x: 0, y: 0, z: 0 };
956
969
  this._targetFocus = { x: 0, y: 0, z: 0 };
957
970
  this._orbitCentre = { x: 0, y: 0, z: 0 };
@@ -1066,7 +1079,7 @@ export class PlayCanvasSplatViewer {
1066
1079
  this._placeMonoForFit();
1067
1080
  }
1068
1081
 
1069
- setPose({ yaw, pitch, zoom, depthOffset } = {}) {
1082
+ setPose({ yaw, pitch, zoom, roll, depthOffset } = {}) {
1070
1083
  if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
1071
1084
  // A snap, as on SceneViewer; it also ends an orbit relax (the page is driving now).
1072
1085
  if (yaw !== undefined || pitch !== undefined) this._orbitMode = null;
@@ -1074,6 +1087,7 @@ export class PlayCanvasSplatViewer {
1074
1087
  if (pitch !== undefined) {
1075
1088
  this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
1076
1089
  }
1090
+ if (roll !== undefined) this._targetRoll = this._roll = roll;
1077
1091
  if (zoom !== undefined) {
1078
1092
  // A snap, and the new rest: a relax comes home HERE, not to 1×.
1079
1093
  this._targetZoom = this._zoom = this._restZoom = clamp(zoom, this.zoomOpts.min, this.zoomOpts.max);
@@ -1088,13 +1102,35 @@ export class PlayCanvasSplatViewer {
1088
1102
  yaw: target ? this._targetYaw : this._yaw,
1089
1103
  pitch: target ? this._targetPitch : this._pitch,
1090
1104
  zoom: target ? this._targetZoom : this._zoom,
1105
+ roll: target ? this._targetRoll : this._roll,
1091
1106
  depthOffset: this._depthOffset,
1092
1107
  };
1093
1108
  }
1094
1109
 
1110
+ /**
1111
+ * Move the pose by a delta, eased — SceneViewer.nudgePose's twin, what the hands
1112
+ * (`@displayxr/inline3d/hands`) drive. Degrees to add, zoom a ratio to multiply (within
1113
+ * `zoomOpts`, and it becomes the new REST, as a setPose zoom does, so a relax does not undo a
1114
+ * two-hand zoom). Roll (the two-hand twist) is degrees about the view axis, as on SceneViewer.
1115
+ * Counts as interaction for the idle turntable and ends an orbit relax, like a drag.
1116
+ */
1117
+ nudgePose({ yaw = 0, pitch = 0, zoom = 1, roll = 0 } = {}) {
1118
+ if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
1119
+ if (yaw || pitch) this._orbitMode = null;
1120
+ if (yaw) this._targetYaw += yaw;
1121
+ if (pitch) this._targetPitch = clamp(this._targetPitch + pitch, this.pitchLimit[0], this.pitchLimit[1]);
1122
+ if (roll) this._targetRoll += roll;
1123
+ if (zoom && zoom !== 1) {
1124
+ const from = this._zoomMode === 'rest' ? this._zoom : this._targetZoom;
1125
+ this._targetZoom = this._restZoom = clamp(from * zoom, this.zoomOpts.min, this.zoomOpts.max);
1126
+ this._zoomMode = null;
1127
+ }
1128
+ this._lastInput = now();
1129
+ }
1130
+
1095
1131
  resetPose() {
1096
1132
  if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
1097
- this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
1133
+ this.setPose({ yaw: 0, pitch: 0, zoom: 1, roll: 0, depthOffset: 0 });
1098
1134
  this._lastInput = now();
1099
1135
  }
1100
1136
 
@@ -1103,12 +1139,19 @@ export class PlayCanvasSplatViewer {
1103
1139
  const s = this._fitScale * this._zoom;
1104
1140
  const [hx, hy, hz] = this._subjectHalf;
1105
1141
  // SceneViewer's exact arithmetic (degrees → radians as `(d * Math.PI) / 180`), so the two
1106
- // viewers report bit-identical boxes (pinned by the behavioural-trace test).
1142
+ // viewers report bit-identical boxes (pinned by the behavioural-trace test). Rows of
1143
+ // R = Rz(roll) · Rx(pitch) · Ry(yaw), |row| · half = the AABB half-extent along that axis;
1144
+ // at roll 0 the first two rows are the old Rx · Ry rows exactly.
1107
1145
  const p = (this._pitch * Math.PI) / 180;
1108
1146
  const y = (this._yaw * Math.PI) / 180;
1147
+ const r = (this._roll * Math.PI) / 180;
1109
1148
  const cp = Math.cos(p), sp = Math.sin(p), cy = Math.cos(y), sy = Math.sin(y);
1110
- const ex = s * (Math.abs(cy) * hx + Math.abs(sy) * hz);
1111
- const ey = s * (Math.abs(sp * sy) * hx + Math.abs(cp) * hy + Math.abs(sp * cy) * hz);
1149
+ const cr = Math.cos(r), sr = Math.sin(r);
1150
+ // Rx . Ry rows
1151
+ const a0 = cy, a1 = 0, a2 = sy;
1152
+ const b0 = sp * sy, b1 = cp, b2 = -sp * cy;
1153
+ const ex = s * (Math.abs(cr * a0 - sr * b0) * hx + Math.abs(cr * a1 - sr * b1) * hy + Math.abs(cr * a2 - sr * b2) * hz);
1154
+ const ey = s * (Math.abs(sr * a0 + cr * b0) * hx + Math.abs(sr * a1 + cr * b1) * hy + Math.abs(sr * a2 + cr * b2) * hz);
1112
1155
  const ez = s * (Math.abs(cp * sy) * hx + Math.abs(sp) * hy + Math.abs(cp * cy) * hz);
1113
1156
  const cz = this._depthOffset;
1114
1157
  return {
@@ -2472,6 +2515,8 @@ export class PlayCanvasSplatViewer {
2472
2515
  const ko = tau > 0 ? (dt > 0 ? 1 - Math.exp(-dt / tau) : 0) : k;
2473
2516
  this._yaw += (this._targetYaw - this._yaw) * ko;
2474
2517
  this._pitch += (this._targetPitch - this._pitch) * ko;
2518
+ // Roll has no drag / relax phase (only the hands and setPose write it): the ordinary damping.
2519
+ if (this._roll !== this._targetRoll) this._roll += (this._targetRoll - this._roll) * k;
2475
2520
  if (
2476
2521
  this._orbitMode === 'rest' &&
2477
2522
  Math.abs(this._targetYaw - this._yaw) < 0.01 &&
@@ -2608,6 +2653,7 @@ export class PlayCanvasSplatViewer {
2608
2653
  return {
2609
2654
  yaw: this._yaw,
2610
2655
  pitch: this._pitch,
2656
+ roll: this._roll,
2611
2657
  scale: this._fitScale * this._zoom,
2612
2658
  focus: [f.x, f.y, f.z],
2613
2659
  orbitCentre: [o.x, o.y, o.z],
@@ -4922,6 +4968,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4922
4968
  mono: { fov: v.mono.fov, near: v.mono.near, far: v.mono.far, pose: Float64Array.from(v.mono.pose), capture: v.mono.capture },
4923
4969
  pose: {
4924
4970
  _yaw: v._yaw, _pitch: v._pitch, _targetYaw: v._targetYaw, _targetPitch: v._targetPitch,
4971
+ _roll: v._roll, _targetRoll: v._targetRoll,
4925
4972
  _zoom: v._zoom, _targetZoom: v._targetZoom, _depthOffset: v._depthOffset,
4926
4973
  _focusSettled: v._focusSettled, _focusRecentres: v._focusRecentres, _orbitMode: v._orbitMode, _lastInput: v._lastInput,
4927
4974
  },
@@ -13,6 +13,7 @@
13
13
  // test/splat-playcanvas.test.mjs (values, and a behavioural trace of both viewers side by side).
14
14
 
15
15
  import { comfortDepthFactor, isComfortZone, DEPTH_DEFAULT_VIEWER_M } from './inline3d-depth-budget.js';
16
+ import { attachHandsOption } from './inline3d-hands.js';
16
17
 
17
18
  /**
18
19
  * Double-attach detection around a scene subpath's public entry (`addSplat`, `addModel`).
@@ -25,9 +26,19 @@ import { comfortDepthFactor, isComfortZone, DEPTH_DEFAULT_VIEWER_M } from './inl
25
26
  */
26
27
  export function guardedAttach(wall, canvas, method, opts, attach) {
27
28
  const g = wall && typeof wall._attachGuard === 'function' ? wall._attachGuard(canvas, method, opts) : null;
28
- if (!g) return attach(opts);
29
+ if (!g) return withHands(wall, attach(opts), opts);
29
30
  const out = attach(g.opts);
30
31
  g.own(out);
32
+ return withHands(wall, out, opts);
33
+ }
34
+
35
+ /**
36
+ * The `hands` option (`./inline3d-hands.js`): pinch to orbit, two pinches to zoom. Wired here so
37
+ * every addModel / addSplat entry, on either engine, gets it from one place, after the core has
38
+ * claimed the handle (the hands' stop rides the same `remove` accessor).
39
+ */
40
+ function withHands(wall, out, opts) {
41
+ if (opts && opts.hands) attachHandsOption(wall, out, opts);
31
42
  return out;
32
43
  }
33
44
 
@@ -379,6 +379,10 @@ export class SceneViewer {
379
379
  this._pitch = 0;
380
380
  this._targetYaw = 0;
381
381
  this._targetPitch = 0;
382
+ // Roll about the VIEW axis, degrees. Only the two-hand gesture (./inline3d-hands.js) and an
383
+ // explicit setPose({ roll }) ever write it, so every existing page is bit-identical at 0.
384
+ this._roll = 0;
385
+ this._targetRoll = 0;
382
386
  this._lastInput = now(); // so the turntable waits out the load-in rather than starting mid-pop
383
387
  this._lastTick = 0;
384
388
  this._monoRaf = 0;
@@ -524,12 +528,13 @@ export class SceneViewer {
524
528
  * This SNAPS — it writes the eased value and its target together. The easing in _tick exists
525
529
  * for input, not for programmatic placement.
526
530
  */
527
- setPose({ yaw, pitch, zoom, depthOffset } = {}) {
531
+ setPose({ yaw, pitch, zoom, roll, depthOffset } = {}) {
528
532
  if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
529
533
  if (pitch !== undefined) {
530
534
  this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
531
535
  }
532
536
  if (zoom !== undefined) this._targetZoom = this._zoom = clamp(zoom, ZOOM_MIN, ZOOM_MAX);
537
+ if (roll !== undefined) this._targetRoll = this._roll = roll;
533
538
  if (depthOffset !== undefined) this._depthOffset = finite(depthOffset, this._depthOffset);
534
539
  this._applyTransform();
535
540
  }
@@ -553,6 +558,7 @@ export class SceneViewer {
553
558
  yaw: target ? this._targetYaw : this._yaw,
554
559
  pitch: target ? this._targetPitch : this._pitch,
555
560
  zoom: target ? this._targetZoom : this._zoom,
561
+ roll: target ? this._targetRoll : this._roll,
556
562
  depthOffset: this._depthOffset,
557
563
  };
558
564
  }
@@ -588,15 +594,22 @@ export class SceneViewer {
588
594
  const [hx, hy, hz] = this._subjectHalf;
589
595
  const p = (this._pitch * Math.PI) / 180;
590
596
  const y = (this._yaw * Math.PI) / 180;
591
- // Rows of R = Rx(pitch) . Ry(yaw) — the same product _applyTransform builds, and the same
592
- // order, which is the part that matters (see its comment on why 'XYZ' and not 'YXZ').
593
- // |row| . half gives the AABB half-extent along that world axis.
597
+ const r = (this._roll * Math.PI) / 180;
598
+ // Rows of R = Rz(roll) . Rx(pitch) . Ry(yaw) — the same product _applyTransform builds, and
599
+ // the same order, which is the part that matters (see its comment on why pitch is outside
600
+ // yaw, and roll outside both). |row| . half gives the AABB half-extent along that world axis.
601
+ // At roll 0 the first two rows reduce to the old Rx . Ry rows exactly.
594
602
  const cp = Math.cos(p);
595
603
  const sp = Math.sin(p);
596
604
  const cy = Math.cos(y);
597
605
  const sy = Math.sin(y);
598
- const ex = s * (Math.abs(cy) * hx + Math.abs(sy) * hz);
599
- const ey = s * (Math.abs(sp * sy) * hx + Math.abs(cp) * hy + Math.abs(sp * cy) * hz);
606
+ const cr = Math.cos(r);
607
+ const sr = Math.sin(r);
608
+ // Rx . Ry rows
609
+ const a0 = cy, a1 = 0, a2 = sy;
610
+ const b0 = sp * sy, b1 = cp, b2 = -sp * cy;
611
+ const ex = s * (Math.abs(cr * a0 - sr * b0) * hx + Math.abs(cr * a1 - sr * b1) * hy + Math.abs(cr * a2 - sr * b2) * hz);
612
+ const ey = s * (Math.abs(sr * a0 + cr * b0) * hx + Math.abs(sr * a1 + cr * b1) * hy + Math.abs(sr * a2 + cr * b2) * hz);
600
613
  const ez = s * (Math.abs(cp * sy) * hx + Math.abs(sp) * hy + Math.abs(cp * cy) * hz);
601
614
  const cz = this._depthOffset;
602
615
  return {
@@ -675,8 +688,22 @@ export class SceneViewer {
675
688
  }
676
689
 
677
690
  /** Return to the framed default pose, depth slide included. */
691
+ /**
692
+ * Move the pose by a delta, EASED — what an input device does, as opposed to `setPose`'s snap.
693
+ * Angles in degrees to add, `zoom` a ratio to multiply; pitch is clamped, zoom bounded. Counts
694
+ * as interaction for the idle turntable, exactly like a pointer drag. The hands
695
+ * (`@displayxr/inline3d/hands`) drive the viewer through this; a page may too.
696
+ */
697
+ nudgePose({ yaw = 0, pitch = 0, zoom = 1, roll = 0 } = {}) {
698
+ if (yaw) this._targetYaw += yaw;
699
+ if (pitch) this._targetPitch = clamp(this._targetPitch + pitch, this.pitchLimit[0], this.pitchLimit[1]);
700
+ if (zoom && zoom !== 1) this._targetZoom = clamp(this._targetZoom * zoom, ZOOM_MIN, ZOOM_MAX);
701
+ if (roll) this._targetRoll += roll;
702
+ this._lastInput = now();
703
+ }
704
+
678
705
  resetPose() {
679
- this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
706
+ this.setPose({ yaw: 0, pitch: 0, zoom: 1, roll: 0, depthOffset: 0 });
680
707
  this._lastInput = now();
681
708
  }
682
709
 
@@ -1035,7 +1062,10 @@ export class SceneViewer {
1035
1062
  // axis is itself yawed. At yaw 90° that axis has swung onto world Z and dragging up/down
1036
1063
  // rolls the subject instead of tilting it. Correct head-on, wrong the moment you turn it —
1037
1064
  // which is why it survived review and only showed up when two controls were combined.
1038
- this._pivot.rotation.set((this._pitch * Math.PI) / 180, (this._yaw * Math.PI) / 180, 0, 'XYZ');
1065
+ //
1066
+ // Roll (the two-hand twist) is about the VIEW axis, so it goes OUTSIDE both: 'ZXY' == R =
1067
+ // Rz(roll) · Rx(pitch) · Ry(yaw). At roll 0 that is the 'XYZ' product above, bit for bit.
1068
+ this._pivot.rotation.set((this._pitch * Math.PI) / 180, (this._yaw * Math.PI) / 180, (this._roll * Math.PI) / 180, 'ZXY');
1039
1069
  }
1040
1070
 
1041
1071
  /**
@@ -1136,6 +1166,7 @@ export class SceneViewer {
1136
1166
  const k = dt > 0 ? 1 - Math.pow(DAMP_BASE, dt) : 1;
1137
1167
  this._yaw += (this._targetYaw - this._yaw) * k;
1138
1168
  this._pitch += (this._targetPitch - this._pitch) * k;
1169
+ if (this._roll !== this._targetRoll) this._roll += (this._targetRoll - this._roll) * k;
1139
1170
  // Zoom eases on the same curve. Multiplicatively, because zoom is a ratio: approaching 2x
1140
1171
  // linearly spends most of its time near the start and then lurches, while a ratio approach
1141
1172
  // covers equal PERCEPTUAL steps per frame.
package/model.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { CursorOptions } from './cursor-depth.js';
5
5
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
6
+ import type { HandControlOptions, HandControls } from './hands.js';
6
7
  import type { FirstWovenResult, RectCoverOption, ViewerEaseOption } from './index.js';
7
8
  import type { PlayCanvasSplatViewer, SplatCameraPose, SplatFrameInfo } from './splat.js';
8
9
 
@@ -25,6 +26,13 @@ export interface ModelOptions {
25
26
  /** Degrees/second of turntable once idle (default 8). */
26
27
  idleSpin?: number;
27
28
  orbit?: boolean;
29
+ /**
30
+ * Tracked-hand controls (`@displayxr/inline3d/hands`): one pinch dragged turns the model, two
31
+ * pinches spread / dragged / twisted zoom / turn / roll it (either engine). Needs the
32
+ * DisplayXR Browser 1.8.0+ with a hand provider; inert elsewhere, and pointer / wheel keep
33
+ * working alongside. `true` or the controller options; exposed as `handle.hands`. Default off.
34
+ */
35
+ hands?: boolean | HandControlOptions;
28
36
  fit?: 'contain' | 'height' | 'cover' | 'none';
29
37
  /** Fraction of the tile the subject may occupy (default 0.8) — width AND height. */
30
38
  margin?: number;
@@ -212,6 +220,8 @@ export interface ModelHandle {
212
220
  * returns (1.11 returned it synchronously; `@displayxr/inline3d/model/three` still does).
213
221
  */
214
222
  readonly viewer: SceneViewer | PlayCanvasSplatViewer | null;
223
+ /** The hand controls, when the `hands` option asked for them (`stop()`, `state.gate`, `controller`). */
224
+ readonly hands?: HandControls;
215
225
  /**
216
226
  * The loaded glTF root — a three `Object3D`, or on PlayCanvas the render `pc.Entity`
217
227
  * (`instantiateRenderEntity()`). Null until `ready` resolves.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.49.3",
3
+ "version": "1.50.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",
@@ -27,6 +27,10 @@
27
27
  "types": "./viewer.d.ts",
28
28
  "import": "./js/inline3d-viewer.js"
29
29
  },
30
+ "./hands": {
31
+ "types": "./hands.d.ts",
32
+ "import": "./js/inline3d-hands.js"
33
+ },
30
34
  "./splat": {
31
35
  "types": "./splat.d.ts",
32
36
  "import": "./js/inline3d-splat.js"
@@ -91,6 +95,7 @@
91
95
  "js/inline3d-cursor-shape.js",
92
96
  "js/vendor/",
93
97
  "js/inline3d-viewer.js",
98
+ "js/inline3d-hands.js",
94
99
  "js/inline3d-splat.js",
95
100
  "js/inline3d-splat-pc-entry.js",
96
101
  "js/inline3d-splat-deferred.js",
@@ -133,6 +138,7 @@
133
138
  "depth.d.ts",
134
139
  "cursor-depth.d.ts",
135
140
  "viewer.d.ts",
141
+ "hands.d.ts",
136
142
  "splat.d.ts",
137
143
  "splat-playcanvas.d.ts",
138
144
  "model.d.ts",
package/splat.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { CursorOptions } from './cursor-depth.js';
5
5
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
6
+ import type { HandControlOptions, HandControls } from './hands.js';
6
7
  import type { FirstWovenResult, RectCoverOption, ViewerEaseOption, DisplayMetrics } from './index.js';
7
8
 
8
9
  /**
@@ -78,6 +79,12 @@ export interface PlayCanvasSplatViewer {
78
79
  fitTo(center: number[], extent: number[]): void;
79
80
  setPose(pose?: OrbitPose): void;
80
81
  getPose(opts?: { target?: boolean }): Required<OrbitPose>;
82
+ /**
83
+ * Move the pose by a delta, eased (SceneViewer.nudgePose's twin): degrees to add (`roll` about
84
+ * the view axis), zoom a ratio to multiply within `zoomOpts` (it becomes the new rest, as a
85
+ * `setPose` zoom does). Counts as interaction for the idle turntable and ends an orbit relax.
86
+ */
87
+ nudgePose(delta?: { yaw?: number; pitch?: number; zoom?: number; roll?: number }): void;
81
88
  resetPose(): void;
82
89
  getSubjectBounds(): SubjectBounds & { front: number; back: number; scale: number };
83
90
  setFocus(point: number[] | { x: number; y: number; z: number } | null, opts?: { snap?: boolean; recentre?: boolean }): PlayCanvasSplatViewer;
@@ -391,6 +398,13 @@ export interface SplatOptions {
391
398
  * it is still the cumulative turntable (a full-width drag = 180°).
392
399
  */
393
400
  orbit?: boolean;
401
+ /**
402
+ * Tracked-hand controls (`@displayxr/inline3d/hands`): one pinch dragged turns the splat, two
403
+ * pinches spread / dragged zoom / turn it (and twist rolls it on Spark). Needs the DisplayXR
404
+ * Browser 1.8.0+ with a hand provider; inert elsewhere. `true` or the controller options;
405
+ * exposed as `handle.hands`. Default off.
406
+ */
407
+ hands?: boolean | HandControlOptions;
394
408
  /** PlayCanvas: the largest drag tilt, degrees, either axis (default 15). */
395
409
  orbitMaxDeg?: number;
396
410
  /** PlayCanvas: easing time constants, seconds — `drag` while held (0.2), `rest` after release (0.6). */
@@ -1007,6 +1021,8 @@ export interface SplatHandle {
1007
1021
  * there until the backend module has loaded — one module fetch after addSplat returns).
1008
1022
  */
1009
1023
  readonly viewer: SceneViewer | PlayCanvasSplatViewer;
1024
+ /** The hand controls, when the `hands` option asked for them (`stop()`, `state.gate`, `controller`). */
1025
+ readonly hands?: HandControls;
1010
1026
  /**
1011
1027
  * Spark's SplatMesh; on `engine: 'playcanvas'` a `{ numSplats, entity, asset, resource }`
1012
1028
  * record of the engine objects. Null until the asset is loaded.
package/viewer.d.ts CHANGED
@@ -66,6 +66,12 @@ export interface OrbitPose {
66
66
  pitch?: number;
67
67
  /** Multiplier on the fit scale. */
68
68
  zoom?: number;
69
+ /**
70
+ * Degrees about the VIEW axis (counter-clockwise as the viewer sees it), applied outside yaw and
71
+ * pitch. Written only by the two-hand gesture (`@displayxr/inline3d/hands`) or an explicit call;
72
+ * 0 for every page that never touches it. Both viewers (three.js and PlayCanvas) carry it.
73
+ */
74
+ roll?: number;
69
75
  /** Display metres along the depth axis, `+` toward the viewer (out of the glass). */
70
76
  depthOffset?: number;
71
77
  }
@@ -136,6 +142,12 @@ export declare class SceneViewer {
136
142
 
137
143
  /** Snap the pose. Writes the eased value and its target together. */
138
144
  setPose(pose?: OrbitPose): void;
145
+ /**
146
+ * Move the pose by a delta, EASED — what an input device does. Degrees to add, `zoom` a ratio
147
+ * to multiply (pitch clamped, zoom bounded). Counts as interaction for the idle turntable, like
148
+ * a pointer drag. `@displayxr/inline3d/hands` drives the viewer through this.
149
+ */
150
+ nudgePose(delta?: { yaw?: number; pitch?: number; zoom?: number; roll?: number }): void;
139
151
  /**
140
152
  * Point the viewer at something: the orbit centre, the pivot plane and (on a camera rig) the
141
153
  * convergence distance are ONE point, and this is it. `recentre: true` (a display rig) brings