@needle-tools/engine 5.1.4 → 5.1.5

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/{needle-engine.bundle-CEba8eTv.js → needle-engine.bundle-CxsZfV6C.js} +5245 -5166
  3. package/dist/{needle-engine.bundle-BvxvjlSN.min.js → needle-engine.bundle-DUb8msPR.min.js} +140 -140
  4. package/dist/{needle-engine.bundle-GUaSFX8u.umd.cjs → needle-engine.bundle-D_h1sDs-.umd.cjs} +137 -137
  5. package/dist/needle-engine.d.ts +39 -30
  6. package/dist/needle-engine.js +242 -242
  7. package/dist/needle-engine.min.js +1 -1
  8. package/dist/needle-engine.umd.cjs +1 -1
  9. package/lib/engine/api.d.ts +1 -1
  10. package/lib/engine/api.js +1 -1
  11. package/lib/engine/api.js.map +1 -1
  12. package/lib/engine/debug/debug_console.js +39 -17
  13. package/lib/engine/debug/debug_console.js.map +1 -1
  14. package/lib/engine/debug/debug_spatial_console.js +14 -0
  15. package/lib/engine/debug/debug_spatial_console.js.map +1 -1
  16. package/lib/engine/engine_init.js +2 -2
  17. package/lib/engine/engine_init.js.map +1 -1
  18. package/lib/engine/engine_license.d.ts +7 -7
  19. package/lib/engine/engine_license.js +71 -71
  20. package/lib/engine/engine_license.js.map +1 -1
  21. package/lib/engine/engine_networking_blob.js +3 -3
  22. package/lib/engine/engine_utils_qrcode.js +2 -2
  23. package/lib/engine/engine_utils_screenshot.js +1 -0
  24. package/lib/engine/engine_utils_screenshot.js.map +1 -1
  25. package/lib/engine/engine_utils_screenshot.xr.js +1 -1
  26. package/lib/engine/engine_utils_screenshot.xr.js.map +1 -1
  27. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  28. package/lib/engine/webcomponents/needle menu/needle-menu.js +18 -5
  29. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  30. package/lib/engine/webcomponents/needle-engine.ar-overlay.js +33 -7
  31. package/lib/engine/webcomponents/needle-engine.ar-overlay.js.map +1 -1
  32. package/lib/engine/webcomponents/needle-engine.js +2 -2
  33. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  34. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  35. package/lib/engine/xr/NeedleXRSession.js +10 -4
  36. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  37. package/lib/engine/xr/NeedleXRSync.js +47 -47
  38. package/lib/engine/xr/NeedleXRSync.js.map +1 -1
  39. package/lib/engine/xr/TempXRContext.js +2 -2
  40. package/lib/engine-components/DragControls.d.ts +9 -0
  41. package/lib/engine-components/DragControls.js +174 -73
  42. package/lib/engine-components/DragControls.js.map +1 -1
  43. package/lib/engine-components/DragControlsConstraints.d.ts +11 -2
  44. package/lib/engine-components/DragControlsConstraints.js +18 -11
  45. package/lib/engine-components/DragControlsConstraints.js.map +1 -1
  46. package/lib/engine-components/OrbitControls.js +32 -6
  47. package/lib/engine-components/OrbitControls.js.map +1 -1
  48. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  49. package/package.json +1 -1
  50. package/plugins/common/license.js +4 -4
  51. package/plugins/vite/license.js +4 -4
  52. package/src/engine/api.ts +1 -1
  53. package/src/engine/debug/debug_console.ts +33 -17
  54. package/src/engine/debug/debug_spatial_console.ts +15 -0
  55. package/src/engine/engine_init.ts +2 -2
  56. package/src/engine/engine_license.ts +68 -68
  57. package/src/engine/engine_networking_blob.ts +3 -3
  58. package/src/engine/engine_utils_qrcode.ts +2 -2
  59. package/src/engine/engine_utils_screenshot.ts +1 -0
  60. package/src/engine/engine_utils_screenshot.xr.ts +1 -1
  61. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  62. package/src/engine/webcomponents/needle menu/needle-menu.ts +19 -5
  63. package/src/engine/webcomponents/needle-engine.ar-overlay.ts +33 -5
  64. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  65. package/src/engine/webcomponents/needle-engine.ts +2 -2
  66. package/src/engine/xr/NeedleXRSession.ts +10 -3
  67. package/src/engine/xr/NeedleXRSync.ts +47 -50
  68. package/src/engine/xr/TempXRContext.ts +2 -2
  69. package/src/engine-components/DragControls.ts +189 -80
  70. package/src/engine-components/DragControlsConstraints.ts +20 -12
  71. package/src/engine-components/OrbitControls.ts +31 -4
  72. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
@@ -1,7 +1,6 @@
1
1
  import type { Context } from "../engine_context.js";
2
2
  import { RoomEvents, UserJoinedOrLeftRoomModel } from "../engine_networking.js";
3
3
  import { getParam } from "../engine_utils.js";
4
- import { NeedleXRController } from "./NeedleXRController.js";
5
4
  import { NeedleXRSession } from "./NeedleXRSession.js";
6
5
 
7
6
  const debug = getParam("debugwebxr");
@@ -11,8 +10,9 @@ declare type XRControllerType = "hand" | "controller";
11
10
 
12
11
  declare type XRControllerState = {
13
12
  // adding a guid so it's saved on the server, ideally we have a "room lifetime" store that doesnt save state forever on disc but just until the room is disposed (we have to add support for this in the networking backend tho)
13
+ // The guid is derived from the handedness (see XRUserState) so it is stable across input-type
14
+ // switches (hand <-> controller), which reuse/reorder input-source indices.
14
15
  guid: string;
15
- index: number;
16
16
  handedness: XRHandedness;
17
17
  isTracking: boolean;
18
18
  type: XRControllerType;
@@ -41,55 +41,37 @@ class XRUserState {
41
41
  onReceivedControllerState = (state: XRControllerState) => {
42
42
  if (debug) console.log(`XRSync: Received change for ${this.userId}: ${state.type} ${state.handedness}; tracked=${state.isTracking}`);
43
43
 
44
- let found = false;
44
+ // Identity is the handedness: there is exactly one left and one right hand per user. Keying by
45
+ // handedness (instead of the input-source index, which is reused/reordered when switching
46
+ // hand <-> controller) keeps the tracking state correct across input-type switches.
45
47
  for (let i = 0; i < this.controllerStates.length; i++) {
46
- const ctrl = this.controllerStates[i];
47
- if (ctrl.index === state.index) {
48
+ if (this.controllerStates[i].handedness === state.handedness) {
48
49
  this.controllerStates[i] = state;
49
- found = true;
50
- break;
51
- }
52
- }
53
- if (!found) {
54
- this.controllerStates.push(state);
55
- }
56
-
57
- // When switching input type (e.g. hand -> controller), the new input source gets a new index.
58
- // The old controller's state lingers here (we only ever add/update by index), and a stale
59
- // non-tracking entry for the same handedness would otherwise mask the new tracked one. Once a
60
- // tracking state arrives for a handedness, drop any other (stale) states for that handedness.
61
- if (state.isTracking) {
62
- for (let i = this.controllerStates.length - 1; i >= 0; i--) {
63
- const other = this.controllerStates[i];
64
- if (other.index !== state.index && other.handedness === state.handedness) {
65
- this.controllerStates.splice(i, 1);
66
- }
50
+ return;
67
51
  }
68
52
  }
53
+ this.controllerStates.push(state);
69
54
  }
70
55
 
71
56
  update(session: NeedleXRSession) {
72
57
  if (this.context.connection.isConnected == false) return;
73
58
 
59
+ // Remove states whose handedness no longer has ANY input source in the session (the hand/
60
+ // controller was fully removed), broadcasting a not-tracking + deleting the persisted state.
74
61
  for (let i = this.controllerStates.length - 1; i >= 0; i--) {
75
62
  const state = this.controllerStates[i];
76
- let foundController = false;
77
- for (let i = 0; i < session.controllers.length; i++) {
78
- const ctrl = session.controllers[i];
79
- if (ctrl.index === state.index) {
80
- foundController = true;
81
- }
82
- }
83
- if (!foundController) {
84
- // controller was removed
85
- if (debug) console.log(`XRSync: ${state.type} ${state.handedness} removed`, state.index);
63
+ const stillPresent = session.controllers.some(ctrl => ctrl.side === state.handedness);
64
+ if (!stillPresent) {
65
+ if (debug) console.log(`XRSync: ${state.type} ${state.handedness} removed`);
86
66
  this.controllerStates.splice(i, 1);
87
67
  this.sendControllerRemoved(state);
88
68
  }
89
69
  }
90
70
 
71
+ // Update per-handedness tracking. Calling this once per controller is idempotent: it aggregates
72
+ // across all sources of that handedness, so a second call for the same side is a no-op.
91
73
  for (const ctrl of session.controllers) {
92
- this.updateControllerStates(ctrl);
74
+ this.updateHandednessState(ctrl.side, session);
93
75
  }
94
76
  }
95
77
 
@@ -110,29 +92,45 @@ class XRUserState {
110
92
  this.context.connection.sendDeleteRemoteState(state.guid);
111
93
  }
112
94
 
113
- private updateControllerStates(ctrl: NeedleXRController) {
95
+ private updateHandednessState(handedness: XRHandedness, session: NeedleXRSession) {
96
+ // Aggregate across all input sources of this handedness: the side is "tracking" if ANY of its
97
+ // sources is tracking. During a hand <-> controller switch both can momentarily coexist; the
98
+ // avatar has a single hand object per side, so any tracked source keeps it visible.
99
+ let isTracking = false;
100
+ let type: XRControllerType | undefined = undefined;
101
+ let anyOfSide = false;
102
+ for (const ctrl of session.controllers) {
103
+ if (ctrl.side !== handedness) continue;
104
+ anyOfSide = true;
105
+ const ctrlType: XRControllerType = ctrl.hand ? "hand" : "controller";
106
+ if (ctrl.isTracking) {
107
+ isTracking = true;
108
+ type = ctrlType; // prefer the type of a currently-tracking source
109
+ }
110
+ else if (type === undefined) {
111
+ type = ctrlType;
112
+ }
113
+ }
114
+ if (!anyOfSide) return;
114
115
 
115
- // this.context.connection.send(this.userStateEvtName, {});
116
- const existing = this.controllerStates.find(x => x.index === ctrl.index);
116
+ const existing = this.controllerStates.find(x => x.handedness === handedness);
117
117
  if (existing) {
118
- let hasChanged = false;
119
- hasChanged ||= existing.isTracking != ctrl.isTracking;
120
- if (hasChanged) {
121
- existing.isTracking = ctrl.isTracking;
118
+ if (existing.isTracking !== isTracking || existing.type !== type) {
119
+ existing.isTracking = isTracking;
120
+ existing.type = type!;
122
121
  this.context.connection.send(this.userStateEvtName, existing);
123
122
  }
124
123
  }
125
124
  else {
126
125
  const state: XRControllerState = {
127
- guid: this.userId + "-" + ctrl.index,
128
- isTracking: ctrl.isTracking,
129
- handedness: ctrl.side,
130
- index: ctrl.index,
131
- type: ctrl.hand ? "hand" : "controller"
126
+ guid: this.userId + "-" + handedness,
127
+ isTracking,
128
+ handedness,
129
+ type: type!,
132
130
  }
133
131
  this.controllerStates.push(state);
134
132
  this.context.connection.send(this.userStateEvtName, state);
135
- if (debug) console.log(`XRSync: ${state.type} ${state.handedness} added`, state.index);
133
+ if (debug) console.log(`XRSync: ${state.type} ${state.handedness} added`);
136
134
  }
137
135
  }
138
136
 
@@ -151,9 +149,8 @@ export class NeedleXRSync {
151
149
  if (!userId) return undefined;
152
150
  const user = this._states.get(userId);
153
151
  if (!user) return undefined;
154
- // Consider it tracked if ANY controller state for this handedness is tracking. After switching
155
- // input type a stale non-tracking entry can still exist for a frame before it's pruned - this
156
- // makes sure it doesn't mask the new tracked controller/hand.
152
+ // There is one state per handedness (keyed by side), so this finds that side's state and
153
+ // reports whether it is currently tracking.
157
154
  return user.controllerStates.some(x => x.handedness === handedness && x.isTracking);
158
155
  }
159
156
 
@@ -3,7 +3,7 @@ import { ArrayCamera, AxesHelper, Camera, Color, DirectionalLight, Fog, GridHelp
3
3
  import { needleLogoOnlySVG } from "../assets/index.js";
4
4
  import { isDevEnvironment } from "../debug/index.js";
5
5
  import { ObjectUtils, PrimitiveType } from "../engine_create_objects.js";
6
- import { __QhCnOwd } from "../engine_license.js";
6
+ import { $PLKRCQIf } from "../engine_license.js";
7
7
  import { Mathf } from "../engine_math.js";
8
8
  import { delay, DeviceUtilities } from "../engine_utils.js";
9
9
 
@@ -215,7 +215,7 @@ export class TemporaryXRContext {
215
215
  this._scene.background = new Color(0x000000);
216
216
 
217
217
  let logoSrc = needleLogoOnlySVG;
218
- if (__QhCnOwd()) {
218
+ if ($PLKRCQIf()) {
219
219
  const htmlComponent = document.querySelector("needle-engine");
220
220
  if (htmlComponent) {
221
221
  const licenseLogo = htmlComponent.getAttribute("logo-src");
@@ -627,6 +627,13 @@ interface IDragHandler {
627
627
 
628
628
  /** Scratch quaternion for {@link MultiTouchDragHandler}'s per-frame delta rotation. */
629
629
  const _mtRotDelta = new Quaternion();
630
+ /** Scratch vectors for the on-screen twist → yaw mapping (view-plane twist path). */
631
+ const _mtCross = new Vector3();
632
+ const _mtToEye = new Vector3();
633
+ const _mtCamOffset = new Vector3();
634
+ /** Scratch vectors for aiming the SnapToSurfaceConstraint ray in MultiTouchDragHandler.onDragUpdate. */
635
+ const _mtSnapRayOrigin = new Vector3();
636
+ const _mtSnapRayDir = new Vector3();
630
637
 
631
638
  // #region MultiTouchDragHandler
632
639
  /**
@@ -652,6 +659,9 @@ class MultiTouchDragHandler implements IDragHandler {
652
659
  private _keepRotationConstraint: KeepRotationConstraint = new KeepRotationConstraint();
653
660
  /** GrabPointPlaneConstraint returned by the active strategy (if any); used to sync snapResolution. */
654
661
  private _planeConstraint: GrabPointPlaneConstraint | null = null;
662
+ /** SnapToSurfaceConstraint returned by the active strategy (SnapToSurfaces mode); its snap ray
663
+ * is re-aimed every frame in onDragUpdate so it targets what the fingers point at. */
664
+ private _snapToSurfaceConstraint: SnapToSurfaceConstraint | null = null;
655
665
  private _activeConstraints: IDragConstraint[] = [];
656
666
 
657
667
  constructor(dragControls: DragControls, gameObject: Object3D, pointerA: DragPointerHandler, pointerB: DragPointerHandler) {
@@ -751,6 +761,9 @@ class MultiTouchDragHandler implements IDragHandler {
751
761
  this._planeConstraint = strategyConstraints.find(
752
762
  c => c instanceof GrabPointPlaneConstraint
753
763
  ) as GrabPointPlaneConstraint ?? null;
764
+ this._snapToSurfaceConstraint = strategyConstraints.find(
765
+ c => c instanceof SnapToSurfaceConstraint
766
+ ) as SnapToSurfaceConstraint ?? null;
754
767
  const hasStrategyConstraints = strategyConstraints.length > 0;
755
768
  this._activeConstraints = [
756
769
  ...strategyConstraints,
@@ -762,6 +775,13 @@ class MultiTouchDragHandler implements IDragHandler {
762
775
  }
763
776
  this._scaleLimitConstraint.init(constraintCx);
764
777
 
778
+ // Yaw-locked strategies (XZPlane) expose their world-space rotation axis. For screen
779
+ // two-finger input we drive an intuitive on-screen twist → yaw mapping around it (see
780
+ // alignManipulator). XR keeps the free 3D-delta path for now.
781
+ this._yawConstraintAxis = !isSpatialInput(this._deviceMode)
782
+ ? (this.handlerA.currentStrategy.yawConstraintAxis ?? null)
783
+ : null;
784
+
765
785
  // align _manipulatorObject in the same way it would if this was a drag update
766
786
  this.alignManipulator();
767
787
 
@@ -780,7 +800,15 @@ class MultiTouchDragHandler implements IDragHandler {
780
800
  }
781
801
 
782
802
  // we want to initialize the drag points for these handlers again.
783
- // one of them will be removed, but we don't know here which one
803
+ // one of them will be removed, but we don't know here which one.
804
+ // Re-derive each handler's grab point from its current pointer ray BEFORE recentering:
805
+ // during the two-pointer drag the object moved relative to each pointer, so the grab
806
+ // point captured at pointer-down is stale — without the rebase the surviving handler
807
+ // would yank the object until that old grab point is back under the pointer (visible
808
+ // jump on release). recenter() then re-captures start transforms and re-initializes
809
+ // constraints using the rebased grab point.
810
+ this.handlerA.rebaseGrabPointToPointer();
811
+ this.handlerB.rebaseGrabPointToPointer();
784
812
  this.handlerA.recenter();
785
813
  this.handlerB.recenter();
786
814
 
@@ -814,6 +842,16 @@ class MultiTouchDragHandler implements IDragHandler {
814
842
  /** True only for the very first `alignManipulator` call; triggers the one-time lookAt seed. */
815
843
  private _isFirstAlignFrame: boolean = true;
816
844
 
845
+ /** World-space yaw axis when the active strategy locks rotation to a single axis (XZPlane),
846
+ * for screen input only. Null ⇒ use the free 3D-delta rotation path. Set in onDragStart. */
847
+ private _yawConstraintAxis: Vector3 | null = null;
848
+ /** Camera forward captured while computing the view-plane twist (scratch). */
849
+ private readonly _viewForward = new Vector3();
850
+ /** A→B projected into the view plane on the previous frame; reference for the twist delta. */
851
+ private readonly _prevViewTwistDir = new Vector3();
852
+ /** A→B projected into the view plane on the current frame (scratch). */
853
+ private readonly _currViewTwistDir = new Vector3();
854
+
817
855
  private alignManipulator() {
818
856
  if (!this.handlerA || !this.handlerB) {
819
857
  console.error("alignManipulator called on MultiTouchDragHandler without valid handlers. This is likely a bug.", this);
@@ -840,33 +878,39 @@ class MultiTouchDragHandler implements IDragHandler {
840
878
  const dist = this._tempVec1.distanceTo(this._tempVec2);
841
879
  this._currentScaleRatio = dist / this._initialDistance;
842
880
 
843
- // Rotation: delta-frame approach instead of per-frame lookAt.
844
- // lookAt recomputes the full world orientation every frame using camera.worldUp as the
845
- // stabilising axis, which breaks when the A→B vector aligns with that up and causes
846
- // flips or jitter. Here we instead accumulate only the angular delta of A→B between
847
- // consecutive frames via setFromUnitVectors, so translation never contaminates rotation
848
- // and camera tilt has no effect after the initial seed.
881
+ // Rotation: accumulate a per-frame delta so translation never contaminates rotation.
882
+ // Two paths:
883
+ // - Yaw-locked screen drag (XZPlane, _yawConstraintAxis set): map the on-screen finger
884
+ // twist directly to yaw around the constraint axis, so the gesture stays usable from
885
+ // any view angle a side-on view no longer collapses it. See _applyViewPlaneTwist.
886
+ // - Free rotation (XR / other modes): accumulate the full 3D delta of the A→B direction
887
+ // via setFromUnitVectors (camera tilt has no effect after the initial seed).
849
888
  this._currABDir.subVectors(this._tempVec2, this._tempVec1);
850
889
  if (this._currABDir.lengthSq() > 1e-10) {
851
- this._currABDir.normalize();
852
890
  if (this._isFirstAlignFrame) {
853
- // Seed the initial orientation once with lookAt so _manipulatorRotOffset
854
- // (captured right after this call in onDragStart) is consistent with the
855
- // dragged object's starting rotation.
891
+ // Seed the manipulator orientation once. Only the manipulator's *change* since
892
+ // this frame drives the object (the follow-object offset captured in onDragStart
893
+ // absorbs the absolute seed), so the seed value itself is arbitrary — lookAt just
894
+ // gives a well-defined starting frame. Seed both rotation-source histories too.
856
895
  const camera = this.context.mainCamera;
857
896
  this.tempLookMatrix.lookAt(this._tempVec3, this._tempVec2, (camera as any as IGameObject).worldUp);
858
897
  this._manipulatorObject.quaternion.setFromRotationMatrix(this.tempLookMatrix);
859
898
  this._isFirstAlignFrame = false;
899
+ this._prevABDirection.copy(this._currABDir).normalize();
900
+ this._computeViewPlaneAB(this._prevViewTwistDir);
901
+ } else if (this._yawConstraintAxis) {
902
+ this._applyViewPlaneTwist(this._yawConstraintAxis);
860
903
  } else {
861
904
  // Accumulate the rotation delta from previous A→B to current A→B.
862
905
  // Guard against the degenerate ~180° flip case where setFromUnitVectors
863
906
  // is undefined (dot ≈ -1).
907
+ this._currABDir.normalize();
864
908
  if (this._prevABDirection.dot(this._currABDir) > -0.9999) {
865
909
  _mtRotDelta.setFromUnitVectors(this._prevABDirection, this._currABDir);
866
910
  this._manipulatorObject.quaternion.premultiply(_mtRotDelta);
867
911
  }
912
+ this._prevABDirection.copy(this._currABDir);
868
913
  }
869
- this._prevABDirection.copy(this._currABDir);
870
914
  }
871
915
 
872
916
  this._manipulatorObject.updateMatrix();
@@ -881,6 +925,51 @@ class MultiTouchDragHandler implements IDragHandler {
881
925
  }
882
926
  }
883
927
 
928
+ /** Projects the current world-space A→B direction (_currABDir) onto the plane perpendicular
929
+ * to the camera forward — i.e. the grab line as it appears on screen — into `out`.
930
+ * Also refreshes _viewForward. */
931
+ private _computeViewPlaneAB(out: Vector3): void {
932
+ const camera = this.context.mainCamera as unknown as IGameObject;
933
+ this._viewForward.copy(camera.worldForward);
934
+ out.copy(this._currABDir);
935
+ out.addScaledVector(this._viewForward, -out.dot(this._viewForward));
936
+ }
937
+
938
+ /** Maps the frame-to-frame on-screen twist of the two grab points to a yaw delta around
939
+ * `axis` and premultiplies it onto the manipulator. Decouples gesture magnitude (screen
940
+ * twist) from the applied axis (plane normal), so yaw stays controllable from any view. */
941
+ private _applyViewPlaneTwist(axis: Vector3): void {
942
+ this._computeViewPlaneAB(this._currViewTwistDir);
943
+ const prev = this._prevViewTwistDir;
944
+ const curr = this._currViewTwistDir;
945
+ const eps = 1e-8;
946
+ // Grab line points into the screen (fingers overlap) — no usable twist this frame.
947
+ if (curr.lengthSq() < eps) return;
948
+ if (prev.lengthSq() >= eps) {
949
+ // Signed twist of the grab line in the view plane, measured about the axis pointing
950
+ // toward the camera so a CCW-on-screen twist is positive.
951
+ _mtToEye.copy(this._viewForward).multiplyScalar(-1);
952
+ _mtCross.crossVectors(prev, curr);
953
+ const screenTwist = Math.atan2(_mtCross.dot(_mtToEye), prev.dot(curr));
954
+
955
+ // Sign by which side of the plane the camera sits on. This is stable through a
956
+ // side-on view (where "CCW on screen" is otherwise ill-defined) and correctly
957
+ // inverts the gesture when viewing the plane from below. It also agrees with the
958
+ // visual sense wherever that is well-defined, so tilting the view never flips it.
959
+ const camPos = (this.context.mainCamera as unknown as IGameObject).worldPosition;
960
+ _mtCamOffset.copy(camPos).sub(this._tempVec3);
961
+ const side = axis.dot(_mtCamOffset) >= 0 ? -1 : 1;
962
+
963
+ const yaw = screenTwist * side;
964
+ if (yaw !== 0) {
965
+ _mtRotDelta.setFromAxisAngle(axis, yaw);
966
+ this._manipulatorObject.quaternion.premultiply(_mtRotDelta);
967
+ }
968
+ if (debug) Gizmos.DrawLabel(this._tempVec3, `twist ${yaw.toFixed(3)}`, 0.03);
969
+ }
970
+ prev.copy(curr);
971
+ }
972
+
884
973
  onDragUpdate() {
885
974
  // At this point we've run both the other handlers, but their effects have been suppressed because they can't handle
886
975
  // two events at the same time. They're basically providing us with two Object3D's and we can combine these here
@@ -927,6 +1016,23 @@ class MultiTouchDragHandler implements IDragHandler {
927
1016
  // scale-aware constraints (e.g. XZPlaneDragStrategy's bounds-bottom height lock).
928
1017
  // Pass 1 when keepScale is true so the correction is a no-op.
929
1018
  this.handlerA.currentStrategy.onTwoPointerScaleUpdate?.(keepScale ? 1 : this._currentScaleRatio);
1019
+ // SnapToSurfaces: aim the constraint's snap ray from the pointer origins through the
1020
+ // current grab midpoint, so the two-finger snap targets what the fingers point at —
1021
+ // the same semantics as the single-pointer surface raycast (which uses the pointer ray).
1022
+ if (this._snapToSurfaceConstraint) {
1023
+ const spaceA = this.handlerA.followObject.parent;
1024
+ const spaceB = this.handlerB.followObject.parent;
1025
+ if (spaceA && spaceB) {
1026
+ spaceA.getWorldPosition(_mtSnapRayOrigin);
1027
+ _mtSnapRayOrigin.add(spaceB.getWorldPosition(_mtSnapRayDir)).multiplyScalar(0.5);
1028
+ // _manipulatorObject is a scene child positioned at the world-space midpoint of
1029
+ // the two grab points (set in alignManipulator earlier this frame).
1030
+ _mtSnapRayDir.copy(this._manipulatorObject.position).sub(_mtSnapRayOrigin);
1031
+ if (_mtSnapRayDir.lengthSq() > 1e-10) {
1032
+ this._snapToSurfaceConstraint.setRay(_mtSnapRayOrigin, _mtSnapRayDir.normalize());
1033
+ }
1034
+ }
1035
+ }
930
1036
  applyFollowObjectConstraints(this._followObject, this._activeConstraints);
931
1037
 
932
1038
  // TODO should use unscaled time here // some test for lerp speed depending on distance
@@ -1033,6 +1139,15 @@ export interface IDragPlaneStrategy {
1033
1139
  * implement this. Pass `1` when `keepScale` is true so the correction is a no-op.
1034
1140
  */
1035
1141
  onTwoPointerScaleUpdate?(ratio: number): void;
1142
+ /**
1143
+ * Optional: if this strategy locks the dragged object's rotation to a single world-space
1144
+ * axis (e.g. {@link XZPlaneDragStrategy} yaw around the plane normal), return that axis.
1145
+ * {@link MultiTouchDragHandler} uses it to map the on-screen two-finger twist directly to
1146
+ * yaw around this axis, keeping rotation controllable from any view angle (including
1147
+ * side-on). The vector is read live (not copied) and must stay unit-length for the drag.
1148
+ * Undefined ⇒ free rotation.
1149
+ */
1150
+ readonly yawConstraintAxis?: Vector3;
1036
1151
  }
1037
1152
 
1038
1153
  /**
@@ -1046,9 +1161,6 @@ class SnapToSurfacesDragStrategy implements IDragPlaneStrategy {
1046
1161
  private _lastSurfacePlaneRefreshTime: number = 0;
1047
1162
  private _hasLastSurfaceHitPoint: boolean = false;
1048
1163
  private readonly _lastSurfaceHitPoint: Vector3 = new Vector3();
1049
- /** Original grab point (object local space) captured at drag start. Used to preserve the
1050
- * surface-plane component of the grab offset when snapping to surfaces. */
1051
- private readonly _originalHitPointInLocalSpace: Vector3 = new Vector3();
1052
1164
  /** Context stored at initialize() time so getConstraints() can pass it to the multi-touch constraint. */
1053
1165
  private _context: Context | null = null;
1054
1166
 
@@ -1059,7 +1171,6 @@ class SnapToSurfacesDragStrategy implements IDragPlaneStrategy {
1059
1171
  this._draggedOverObjectLastNormal.set(0, 0, 0);
1060
1172
  this._lastSurfacePlaneRefreshTime = 0;
1061
1173
  this._hasLastSurfaceHitPoint = false;
1062
- this._originalHitPointInLocalSpace.set(0, 0, 0);
1063
1174
  }
1064
1175
 
1065
1176
  readonly requiresPlaneIntersection = true;
@@ -1068,10 +1179,6 @@ class SnapToSurfacesDragStrategy implements IDragPlaneStrategy {
1068
1179
  this._context = cx.context;
1069
1180
  cx.setPlaneViewAligned(hitWP, false);
1070
1181
  this.reset();
1071
- // Capture the exact point the user grabbed (in object local space). This is used
1072
- // to preserve the surface-plane component of the grab offset while only adjusting
1073
- // the normal-direction component to rest the object on the target surface.
1074
- this._originalHitPointInLocalSpace.copy(cx.hitPointInLocalSpace);
1075
1182
  }
1076
1183
 
1077
1184
  update(cx: IDragStrategyContext, ray: Ray, dragSource: IGameObject, draggedObject: Object3D | null): boolean | null {
@@ -1105,79 +1212,57 @@ class SnapToSurfacesDragStrategy implements IDragPlaneStrategy {
1105
1212
  this._hasLastSurfaceHitPoint = true;
1106
1213
  this._lastSurfaceHitPoint.copy(hit.point);
1107
1214
 
1108
- const dragTimeThreshold = 0.15;
1109
- const dragTimeSatisfied = this._draggedOverObjectDuration >= dragTimeThreshold;
1110
- const dragDistance = 0.001;
1111
- const dragDistanceSatisfied = cx.totalMovement.length() >= dragDistance;
1112
1215
  // TODO: if the "hit.normal" is undefined we use the hit.face.normal which is still localspace
1113
1216
  const worldNormal = getTempVector(hit.normal || hit.face.normal).applyQuaternion(hit.object.worldQuaternion);
1114
1217
 
1115
- // Detect whether the surface object or its normal has changed.
1116
- const needsAnchorUpdate = this._draggedOverObjectLastSetUp !== this._draggedOverObject
1218
+ // Wait until the drag has actually started (enough hover time or pointer movement)
1219
+ // before snapping to the surface, so a click that never becomes a drag doesn't move
1220
+ // the object. Bail out BEFORE touching any snap state: the surface/normal bookkeeping
1221
+ // below must only advance once a real drag is underway, otherwise the change-detection
1222
+ // trigger is consumed on a pre-threshold frame and the anchor/plane refresh is skipped
1223
+ // on the first frame that actually moves the object (leaving it on the stale
1224
+ // view-aligned plane until the 1 s periodic refresh).
1225
+ const dragTimeSatisfied = this._draggedOverObjectDuration >= 0.15;
1226
+ const dragDistanceSatisfied = cx.totalMovement.length() >= 0.001;
1227
+ if (!(dragTimeSatisfied || dragDistanceSatisfied)) {
1228
+ return null; // abort frame update — drag hasn't really started yet
1229
+ }
1230
+
1231
+ // Refresh the anchor + drag plane when the surface object or its normal changes, and
1232
+ // periodically on flat same-normal surfaces (e.g. stepping across multi-level floors).
1233
+ // _draggedOverObjectLastNormal is stored in WORLD space to match worldNormal — it used
1234
+ // to store the local face normal, so on any rotated surface the dot never reached 1 and
1235
+ // this fired every frame.
1236
+ const surfaceChanged = this._draggedOverObjectLastSetUp !== this._draggedOverObject
1117
1237
  || this._draggedOverObjectLastNormal.dot(worldNormal) < 0.999999;
1118
- // Periodically refresh the drag plane on flat same-normal surfaces (e.g. multi-level floors).
1119
- const needsPlaneRefresh = needsAnchorUpdate
1238
+ const needsRefresh = surfaceChanged
1120
1239
  || (cx.context.time.time - this._lastSurfacePlaneRefreshTime) >= 1.0;
1121
1240
 
1122
- // Always update the grab anchor when the surface or normal changes — even BEFORE the
1123
- // movement threshold — so the first movement frame uses the correct anchor (no jump).
1124
- // The anchor preserves the original grab point's surface-plane components; only the
1125
- // normal-direction component is adjusted so the object rests flush on the surface.
1126
- if (needsAnchorUpdate) {
1241
+ if (needsRefresh) {
1242
+ this._lastSurfacePlaneRefreshTime = cx.context.time.time;
1127
1243
  this._draggedOverObjectLastSetUp = this._draggedOverObject;
1128
- this._draggedOverObjectLastNormal.copy(hit.face.normal);
1129
-
1244
+ this._draggedOverObjectLastNormal.copy(worldNormal);
1245
+
1246
+ // Snap the OBJECT ORIGIN under the cursor: the origin's lateral position follows the
1247
+ // pointer→surface hit while the object's contact face rests on the surface. Where the
1248
+ // user grabbed is ignored, so placement is consistent regardless of grab point.
1249
+ // The origin is (0,0,0) in local space, so origin·normal = 0 and the anchor collapses
1250
+ // to the contact face projected onto the normal — the same anchor the multi-touch
1251
+ // SnapToSurfaceConstraint uses, so single- and two-pointer snapping now match.
1130
1252
  const center = getTempVector();
1131
1253
  const size = getTempVector();
1132
1254
  cx.bounds.getCenter(center);
1133
1255
  cx.bounds.getSize(size);
1134
- // Surface-contact point: the face of the bounding box that touches the surface.
1256
+ // Contact face of the bounds along the surface normal (the face that rests on it).
1257
+ // NOTE: bounds are local/axis-aligned, so this assumes the object is upright w.r.t. the
1258
+ // surface normal — exact for the common keepRotation case, approximate if tilted.
1135
1259
  center.sub(size.multiplyScalar(0.5).multiply(worldNormal));
1136
- // Scalar projection of contact point onto the surface normal.
1137
1260
  const contactAlongNormal = center.dot(worldNormal);
1138
- // Scalar projection of the original grab point onto the same normal.
1139
- const grabAlongNormal = this._originalHitPointInLocalSpace.dot(worldNormal);
1140
- // Build the new anchor: keep the grab point's surface-plane offset, shift only
1141
- // its normal component to match the contact surface (object rests on surface).
1142
- cx.hitPointInLocalSpace
1143
- .copy(this._originalHitPointInLocalSpace)
1144
- .addScaledVector(worldNormal, contactAlongNormal - grabAlongNormal);
1261
+ cx.hitPointInLocalSpace.copy(worldNormal).multiplyScalar(contactAlongNormal);
1145
1262
  cx.hitNormalInLocalSpace.copy(hit.face.normal);
1146
- }
1147
1263
 
1148
- // If the drag has just started and we're not yet really starting to update the
1149
- // object's position, wait until there's been enough movement or time.
1150
- // The anchor is already set correctly above so movement will begin without a jump.
1151
- if (!(dragTimeSatisfied || dragDistanceSatisfied)) {
1152
- return null; // abort frame update
1153
- }
1154
-
1155
- // Update the drag plane when the surface/normal changes or periodically.
1156
- if (needsPlaneRefresh) {
1157
- this._lastSurfacePlaneRefreshTime = cx.context.time.time;
1158
-
1159
- // ensure plane is far enough up that we don't drag into the surface
1160
- // Which offset we use here depends on the face normal direction we hit
1161
- // If we hit the bottom, we want to use the top, and vice versa
1162
- // To do this dynamically, we can find the intersection between our local bounds and the hit face normal (which is already in local space)
1163
- const center = getTempVector();
1164
- const size = getTempVector();
1165
- cx.bounds.getCenter(center);
1166
- cx.bounds.getSize(size);
1167
- center.add(size.multiplyScalar(0.5).multiply(hit.face.normal));
1168
-
1169
- const offset = getTempVector(cx.hitPointInLocalSpace).add(center);
1170
- cx.followObject.localToWorld(offset);
1171
-
1172
- // See https://linear.app/needle/issue/NE-5004
1173
- // const offsetWP = this._followObject.worldPosition.sub(offset);
1174
- const point = hit.point;//.sub(offsetWP);
1175
-
1176
- // Gizmos.DrawWireSphere(point, 2, 0xff0000, .3);
1177
- // Gizmos.DrawDirection(point, worldNormal, 0xffff00, 1);
1178
- // console.log(hit.normal)
1179
-
1180
- cx.dragPlane.setFromNormalAndCoplanarPoint(worldNormal, point);
1264
+ // Drag plane sits on the surface; the origin-anchor lands on the pointer→surface hit.
1265
+ cx.dragPlane.setFromNormalAndCoplanarPoint(worldNormal, hit.point);
1181
1266
  }
1182
1267
  }
1183
1268
  }
@@ -1192,8 +1277,9 @@ class SnapToSurfacesDragStrategy implements IDragPlaneStrategy {
1192
1277
  getConstraints(ctx: IDragConstraintContext): IDragConstraint[] {
1193
1278
  // Single-pointer (boundsAtScaleOne === null): the existing update() + plane-intersection
1194
1279
  // path handles surface snapping; no constraint needed here.
1195
- // Multi-touch (boundsAtScaleOne !== null): inject a constraint that casts a downward
1196
- // ray each frame and directly snaps the follow object to the detected surface.
1280
+ // Multi-touch (boundsAtScaleOne !== null): inject a constraint that snaps the follow
1281
+ // object onto the surface the fingers point at. MultiTouchDragHandler aims its ray
1282
+ // (pointer origins → grab midpoint) every frame, mirroring the single-pointer raycast.
1197
1283
  if (!ctx.boundsAtScaleOne || !this._context) return [];
1198
1284
  const c = new SnapToSurfaceConstraint(this._context);
1199
1285
  c.init(ctx);
@@ -1220,6 +1306,9 @@ class XZPlaneDragStrategy implements IDragPlaneStrategy {
1220
1306
  /** The fixed XZ plane established at drag start. Used as the clamping target. */
1221
1307
  readonly xzPlane = new Plane();
1222
1308
 
1309
+ /** Two-finger twist maps to yaw around this axis — the plane normal (parent's world up). */
1310
+ get yawConstraintAxis(): Vector3 { return this.xzPlane.normal; }
1311
+
1223
1312
  private _inFallback = false;
1224
1313
  /** Stable world-space point on xzPlane, captured once when entering fallback mode. */
1225
1314
  private readonly _fallbackAnchor = new Vector3();
@@ -1448,6 +1537,26 @@ class DragPointerHandler implements IDragHandler {
1448
1537
  this._keepRotationConstraint = new KeepRotationConstraint();
1449
1538
  }
1450
1539
 
1540
+ /**
1541
+ * Re-derives the grab point from the current pointer ray ∩ drag plane so that when a
1542
+ * two-pointer drag hands control back to this single pointer, the object keeps its current
1543
+ * offset to the pointer instead of snapping the original (now stale) grab point back under it.
1544
+ * Only needed for plane-intersection strategies — Attached-style modes re-base fully via
1545
+ * {@link recenter}. Call BEFORE {@link recenter} so the recomputed grab-start distance and
1546
+ * the constraint re-initialization use the rebased grab point.
1547
+ */
1548
+ rebaseGrabPointToPointer() {
1549
+ if (!this._currentStrategy.requiresPlaneIntersection) return;
1550
+ if (!this.gameObject || !this._followObject.parent) return;
1551
+ const dragSource = this._followObject.parent as IGameObject;
1552
+ const ray = new Ray(dragSource.worldPosition, dragSource.worldForward);
1553
+ if (!ray.intersectPlane(this._dragPlane, this._tempVec)) return;
1554
+ // Keep the new grab point on the constraint plane (it differs from _dragPlane in the
1555
+ // XZPlane side-view fallback) so GrabPointPlaneConstraint doesn't add a one-time offset.
1556
+ this._grabPointPlaneConstraint?.plane.projectPoint(this._tempVec, this._tempVec);
1557
+ this._hitPointInLocalSpace.copy(this.gameObject.worldToLocal(this._tempVec));
1558
+ }
1559
+
1451
1560
  recenter() {
1452
1561
  if (!this._followObject.parent) {
1453
1562
  console.warn("Error: space follow object doesn't have parent but recenter() is called. This is likely a bug");