@displayxr/inline3d 1.49.3 → 1.51.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);
@@ -579,14 +587,22 @@ export function capturePose(rest, flipY) {
579
587
  * (epic #36, P4d); until then the anchors are regexes (the npm build re-indents chunks with
580
588
  * tabs) and a miss warns once and renders unpatched rather than failing.
581
589
  *
590
+ * PlayCanvas 2.23 fixed it upstream: its perspective branch takes the focal length per axis
591
+ * (`vec2 focal = viewport_size.xy * vec2(center.projMat00, center.projMat11)`). That chunk is
592
+ * returned untouched with `native: true` and counts as fixed — it is not a miss, and must not
593
+ * warn that splats will draw at half height.
594
+ *
582
595
  * The rewritten lines are adapted from PlayCanvas engine src/scene/shader-lib/glsl/chunks/gsplat/vert/
583
596
  * gsplatCorner.js (the `gsplatCornerVS` chunk) @ v2.22.3, MIT (THIRD_PARTY_NOTICES.md).
584
597
  *
585
- * @returns {{src:string, ok:boolean}}
598
+ * @returns {{src:string, ok:boolean, native?:boolean}}
586
599
  */
587
600
  export function patchGsplatFootprint(src) {
588
601
  if (typeof src !== 'string') return { src, ok: false };
589
602
  if (src.includes('dxrFocalY')) return { src, ok: true };
603
+ if (/vec2\s+focal\s*=\s*viewport_size\.xy\s*\*\s*vec2\s*\(\s*center\.projMat00\s*,\s*center\.projMat11\s*\)/.test(src)) {
604
+ return { src, ok: true, native: true };
605
+ }
590
606
  const r1 = /vec2\s+J2\s*=\s*-J1\s*\/\s*vp\.z\s*\*\s*vp\.xy\s*;/;
591
607
  const r2 = /0\.0\s*,\s*J1\s*,\s*J2\.y\s*,/;
592
608
  if (!r1.test(src) || !r2.test(src)) return { src, ok: false };
@@ -952,6 +968,11 @@ export class PlayCanvasSplatViewer {
952
968
  this._pitch = 0;
953
969
  this._targetYaw = 0;
954
970
  this._targetPitch = 0;
971
+ // Roll about the VIEW axis, degrees — SceneViewer's. Only the two-hand gesture
972
+ // (./inline3d-hands.js) and an explicit setPose({ roll }) write it, so every existing page is
973
+ // bit-identical at 0.
974
+ this._roll = 0;
975
+ this._targetRoll = 0;
955
976
  this._focus = { x: 0, y: 0, z: 0 };
956
977
  this._targetFocus = { x: 0, y: 0, z: 0 };
957
978
  this._orbitCentre = { x: 0, y: 0, z: 0 };
@@ -1066,7 +1087,7 @@ export class PlayCanvasSplatViewer {
1066
1087
  this._placeMonoForFit();
1067
1088
  }
1068
1089
 
1069
- setPose({ yaw, pitch, zoom, depthOffset } = {}) {
1090
+ setPose({ yaw, pitch, zoom, roll, depthOffset } = {}) {
1070
1091
  if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
1071
1092
  // A snap, as on SceneViewer; it also ends an orbit relax (the page is driving now).
1072
1093
  if (yaw !== undefined || pitch !== undefined) this._orbitMode = null;
@@ -1074,6 +1095,7 @@ export class PlayCanvasSplatViewer {
1074
1095
  if (pitch !== undefined) {
1075
1096
  this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
1076
1097
  }
1098
+ if (roll !== undefined) this._targetRoll = this._roll = roll;
1077
1099
  if (zoom !== undefined) {
1078
1100
  // A snap, and the new rest: a relax comes home HERE, not to 1×.
1079
1101
  this._targetZoom = this._zoom = this._restZoom = clamp(zoom, this.zoomOpts.min, this.zoomOpts.max);
@@ -1088,13 +1110,35 @@ export class PlayCanvasSplatViewer {
1088
1110
  yaw: target ? this._targetYaw : this._yaw,
1089
1111
  pitch: target ? this._targetPitch : this._pitch,
1090
1112
  zoom: target ? this._targetZoom : this._zoom,
1113
+ roll: target ? this._targetRoll : this._roll,
1091
1114
  depthOffset: this._depthOffset,
1092
1115
  };
1093
1116
  }
1094
1117
 
1118
+ /**
1119
+ * Move the pose by a delta, eased — SceneViewer.nudgePose's twin, what the hands
1120
+ * (`@displayxr/inline3d/hands`) drive. Degrees to add, zoom a ratio to multiply (within
1121
+ * `zoomOpts`, and it becomes the new REST, as a setPose zoom does, so a relax does not undo a
1122
+ * two-hand zoom). Roll (the two-hand twist) is degrees about the view axis, as on SceneViewer.
1123
+ * Counts as interaction for the idle turntable and ends an orbit relax, like a drag.
1124
+ */
1125
+ nudgePose({ yaw = 0, pitch = 0, zoom = 1, roll = 0 } = {}) {
1126
+ if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
1127
+ if (yaw || pitch) this._orbitMode = null;
1128
+ if (yaw) this._targetYaw += yaw;
1129
+ if (pitch) this._targetPitch = clamp(this._targetPitch + pitch, this.pitchLimit[0], this.pitchLimit[1]);
1130
+ if (roll) this._targetRoll += roll;
1131
+ if (zoom && zoom !== 1) {
1132
+ const from = this._zoomMode === 'rest' ? this._zoom : this._targetZoom;
1133
+ this._targetZoom = this._restZoom = clamp(from * zoom, this.zoomOpts.min, this.zoomOpts.max);
1134
+ this._zoomMode = null;
1135
+ }
1136
+ this._lastInput = now();
1137
+ }
1138
+
1095
1139
  resetPose() {
1096
1140
  if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
1097
- this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
1141
+ this.setPose({ yaw: 0, pitch: 0, zoom: 1, roll: 0, depthOffset: 0 });
1098
1142
  this._lastInput = now();
1099
1143
  }
1100
1144
 
@@ -1103,12 +1147,19 @@ export class PlayCanvasSplatViewer {
1103
1147
  const s = this._fitScale * this._zoom;
1104
1148
  const [hx, hy, hz] = this._subjectHalf;
1105
1149
  // 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).
1150
+ // viewers report bit-identical boxes (pinned by the behavioural-trace test). Rows of
1151
+ // R = Rz(roll) · Rx(pitch) · Ry(yaw), |row| · half = the AABB half-extent along that axis;
1152
+ // at roll 0 the first two rows are the old Rx · Ry rows exactly.
1107
1153
  const p = (this._pitch * Math.PI) / 180;
1108
1154
  const y = (this._yaw * Math.PI) / 180;
1155
+ const r = (this._roll * Math.PI) / 180;
1109
1156
  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);
1157
+ const cr = Math.cos(r), sr = Math.sin(r);
1158
+ // Rx . Ry rows
1159
+ const a0 = cy, a1 = 0, a2 = sy;
1160
+ const b0 = sp * sy, b1 = cp, b2 = -sp * cy;
1161
+ const ex = s * (Math.abs(cr * a0 - sr * b0) * hx + Math.abs(cr * a1 - sr * b1) * hy + Math.abs(cr * a2 - sr * b2) * hz);
1162
+ 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
1163
  const ez = s * (Math.abs(cp * sy) * hx + Math.abs(sp) * hy + Math.abs(cp * cy) * hz);
1113
1164
  const cz = this._depthOffset;
1114
1165
  return {
@@ -1499,7 +1550,7 @@ export class PlayCanvasSplatViewer {
1499
1550
  // handle.engine.root on a model tile gets the engine's stock footprint (documented).
1500
1551
  const chunks = patchSplats ? pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL) : null;
1501
1552
  const corner = chunks ? patchGsplatFootprint(chunks.get('gsplatCornerVS')) : { ok: false, skipped: true };
1502
- if (corner.ok) chunks.set('gsplatCornerVS', corner.src);
1553
+ if (corner.ok && !corner.native) chunks.set('gsplatCornerVS', corner.src);
1503
1554
  else if (!corner.skipped && !warnedFootprint) {
1504
1555
  warnedFootprint = true;
1505
1556
  console.warn(
@@ -2472,6 +2523,8 @@ export class PlayCanvasSplatViewer {
2472
2523
  const ko = tau > 0 ? (dt > 0 ? 1 - Math.exp(-dt / tau) : 0) : k;
2473
2524
  this._yaw += (this._targetYaw - this._yaw) * ko;
2474
2525
  this._pitch += (this._targetPitch - this._pitch) * ko;
2526
+ // Roll has no drag / relax phase (only the hands and setPose write it): the ordinary damping.
2527
+ if (this._roll !== this._targetRoll) this._roll += (this._targetRoll - this._roll) * k;
2475
2528
  if (
2476
2529
  this._orbitMode === 'rest' &&
2477
2530
  Math.abs(this._targetYaw - this._yaw) < 0.01 &&
@@ -2608,6 +2661,7 @@ export class PlayCanvasSplatViewer {
2608
2661
  return {
2609
2662
  yaw: this._yaw,
2610
2663
  pitch: this._pitch,
2664
+ roll: this._roll,
2611
2665
  scale: this._fitScale * this._zoom,
2612
2666
  focus: [f.x, f.y, f.z],
2613
2667
  orbitCentre: [o.x, o.y, o.z],
@@ -4922,6 +4976,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4922
4976
  mono: { fov: v.mono.fov, near: v.mono.near, far: v.mono.far, pose: Float64Array.from(v.mono.pose), capture: v.mono.capture },
4923
4977
  pose: {
4924
4978
  _yaw: v._yaw, _pitch: v._pitch, _targetYaw: v._targetYaw, _targetPitch: v._targetPitch,
4979
+ _roll: v._roll, _targetRoll: v._targetRoll,
4925
4980
  _zoom: v._zoom, _targetZoom: v._targetZoom, _depthOffset: v._depthOffset,
4926
4981
  _focusSettled: v._focusSettled, _focusRecentres: v._focusRecentres, _orbitMode: v._orbitMode, _lastInput: v._lastInput,
4927
4982
  },
@@ -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/llms.txt CHANGED
@@ -4,8 +4,8 @@
4
4
  > canvas, the DisplayXR Browser weaves them for the panel; an ordinary browser shows the page in 2D.
5
5
 
6
6
  Read first: docs/making-3d-look-right.md — nine depth and comfort rules from real panels
7
- (keep the viewers' object placement and frame the first view, lay flat arrangements back into
8
- depth, keep moving things' forward swing small, fixed convergence and no near geometry in worlds,
7
+ (the default depth is the glass: centre the subject on z = 0 and frame the first view; tilt flat
8
+ arrangements about an axis through the glass, don't push them back; keep moving things' forward swing small, fixed convergence and no near geometry in worlds,
9
9
  low contrast behind the subject, text as HTML near the glass, no coplanar surfaces, no sub-pixel
10
10
  detail, a cover that always comes off). The SDK's defaults do the rest.
11
11
 
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.51.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