@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
@@ -585,6 +585,13 @@ __decorate([
585
585
  ], DragControls.prototype, "dragEnded", void 0);
586
586
  /** Scratch quaternion for {@link MultiTouchDragHandler}'s per-frame delta rotation. */
587
587
  const _mtRotDelta = new Quaternion();
588
+ /** Scratch vectors for the on-screen twist → yaw mapping (view-plane twist path). */
589
+ const _mtCross = new Vector3();
590
+ const _mtToEye = new Vector3();
591
+ const _mtCamOffset = new Vector3();
592
+ /** Scratch vectors for aiming the SnapToSurfaceConstraint ray in MultiTouchDragHandler.onDragUpdate. */
593
+ const _mtSnapRayOrigin = new Vector3();
594
+ const _mtSnapRayDir = new Vector3();
588
595
  // #region MultiTouchDragHandler
589
596
  /**
590
597
  * Handles two touch points affecting one object.
@@ -606,6 +613,9 @@ class MultiTouchDragHandler {
606
613
  _keepRotationConstraint = new KeepRotationConstraint();
607
614
  /** GrabPointPlaneConstraint returned by the active strategy (if any); used to sync snapResolution. */
608
615
  _planeConstraint = null;
616
+ /** SnapToSurfaceConstraint returned by the active strategy (SnapToSurfaces mode); its snap ray
617
+ * is re-aimed every frame in onDragUpdate so it targets what the fingers point at. */
618
+ _snapToSurfaceConstraint = null;
609
619
  _activeConstraints = [];
610
620
  constructor(dragControls, gameObject, pointerA, pointerB) {
611
621
  this.context = dragControls.context;
@@ -689,6 +699,7 @@ class MultiTouchDragHandler {
689
699
  };
690
700
  const strategyConstraints = this.handlerA.currentStrategy.getConstraints?.(constraintCx) ?? [];
691
701
  this._planeConstraint = strategyConstraints.find(c => c instanceof GrabPointPlaneConstraint) ?? null;
702
+ this._snapToSurfaceConstraint = strategyConstraints.find(c => c instanceof SnapToSurfaceConstraint) ?? null;
692
703
  const hasStrategyConstraints = strategyConstraints.length > 0;
693
704
  this._activeConstraints = [
694
705
  ...strategyConstraints,
@@ -699,6 +710,12 @@ class MultiTouchDragHandler {
699
710
  this._activeConstraints.push(this._keepRotationConstraint);
700
711
  }
701
712
  this._scaleLimitConstraint.init(constraintCx);
713
+ // Yaw-locked strategies (XZPlane) expose their world-space rotation axis. For screen
714
+ // two-finger input we drive an intuitive on-screen twist → yaw mapping around it (see
715
+ // alignManipulator). XR keeps the free 3D-delta path for now.
716
+ this._yawConstraintAxis = !isSpatialInput(this._deviceMode)
717
+ ? (this.handlerA.currentStrategy.yawConstraintAxis ?? null)
718
+ : null;
702
719
  // align _manipulatorObject in the same way it would if this was a drag update
703
720
  this.alignManipulator();
704
721
  // and then parent it to the space object so it follows along.
@@ -713,7 +730,15 @@ class MultiTouchDragHandler {
713
730
  return;
714
731
  }
715
732
  // we want to initialize the drag points for these handlers again.
716
- // one of them will be removed, but we don't know here which one
733
+ // one of them will be removed, but we don't know here which one.
734
+ // Re-derive each handler's grab point from its current pointer ray BEFORE recentering:
735
+ // during the two-pointer drag the object moved relative to each pointer, so the grab
736
+ // point captured at pointer-down is stale — without the rebase the surviving handler
737
+ // would yank the object until that old grab point is back under the pointer (visible
738
+ // jump on release). recenter() then re-captures start transforms and re-initializes
739
+ // constraints using the rebased grab point.
740
+ this.handlerA.rebaseGrabPointToPointer();
741
+ this.handlerB.rebaseGrabPointToPointer();
717
742
  this.handlerA.recenter();
718
743
  this.handlerB.recenter();
719
744
  // destroy helper objects
@@ -742,6 +767,15 @@ class MultiTouchDragHandler {
742
767
  _currABDir = new Vector3();
743
768
  /** True only for the very first `alignManipulator` call; triggers the one-time lookAt seed. */
744
769
  _isFirstAlignFrame = true;
770
+ /** World-space yaw axis when the active strategy locks rotation to a single axis (XZPlane),
771
+ * for screen input only. Null ⇒ use the free 3D-delta rotation path. Set in onDragStart. */
772
+ _yawConstraintAxis = null;
773
+ /** Camera forward captured while computing the view-plane twist (scratch). */
774
+ _viewForward = new Vector3();
775
+ /** A→B projected into the view plane on the previous frame; reference for the twist delta. */
776
+ _prevViewTwistDir = new Vector3();
777
+ /** A→B projected into the view plane on the current frame (scratch). */
778
+ _currViewTwistDir = new Vector3();
745
779
  alignManipulator() {
746
780
  if (!this.handlerA || !this.handlerB) {
747
781
  console.error("alignManipulator called on MultiTouchDragHandler without valid handlers. This is likely a bug.", this);
@@ -763,34 +797,41 @@ class MultiTouchDragHandler {
763
797
  // separately and apply it directly to the dragged object in onDragUpdate.
764
798
  const dist = this._tempVec1.distanceTo(this._tempVec2);
765
799
  this._currentScaleRatio = dist / this._initialDistance;
766
- // Rotation: delta-frame approach instead of per-frame lookAt.
767
- // lookAt recomputes the full world orientation every frame using camera.worldUp as the
768
- // stabilising axis, which breaks when the A→B vector aligns with that up and causes
769
- // flips or jitter. Here we instead accumulate only the angular delta of A→B between
770
- // consecutive frames via setFromUnitVectors, so translation never contaminates rotation
771
- // and camera tilt has no effect after the initial seed.
800
+ // Rotation: accumulate a per-frame delta so translation never contaminates rotation.
801
+ // Two paths:
802
+ // - Yaw-locked screen drag (XZPlane, _yawConstraintAxis set): map the on-screen finger
803
+ // twist directly to yaw around the constraint axis, so the gesture stays usable from
804
+ // any view angle a side-on view no longer collapses it. See _applyViewPlaneTwist.
805
+ // - Free rotation (XR / other modes): accumulate the full 3D delta of the A→B direction
806
+ // via setFromUnitVectors (camera tilt has no effect after the initial seed).
772
807
  this._currABDir.subVectors(this._tempVec2, this._tempVec1);
773
808
  if (this._currABDir.lengthSq() > 1e-10) {
774
- this._currABDir.normalize();
775
809
  if (this._isFirstAlignFrame) {
776
- // Seed the initial orientation once with lookAt so _manipulatorRotOffset
777
- // (captured right after this call in onDragStart) is consistent with the
778
- // dragged object's starting rotation.
810
+ // Seed the manipulator orientation once. Only the manipulator's *change* since
811
+ // this frame drives the object (the follow-object offset captured in onDragStart
812
+ // absorbs the absolute seed), so the seed value itself is arbitrary — lookAt just
813
+ // gives a well-defined starting frame. Seed both rotation-source histories too.
779
814
  const camera = this.context.mainCamera;
780
815
  this.tempLookMatrix.lookAt(this._tempVec3, this._tempVec2, camera.worldUp);
781
816
  this._manipulatorObject.quaternion.setFromRotationMatrix(this.tempLookMatrix);
782
817
  this._isFirstAlignFrame = false;
818
+ this._prevABDirection.copy(this._currABDir).normalize();
819
+ this._computeViewPlaneAB(this._prevViewTwistDir);
820
+ }
821
+ else if (this._yawConstraintAxis) {
822
+ this._applyViewPlaneTwist(this._yawConstraintAxis);
783
823
  }
784
824
  else {
785
825
  // Accumulate the rotation delta from previous A→B to current A→B.
786
826
  // Guard against the degenerate ~180° flip case where setFromUnitVectors
787
827
  // is undefined (dot ≈ -1).
828
+ this._currABDir.normalize();
788
829
  if (this._prevABDirection.dot(this._currABDir) > -0.9999) {
789
830
  _mtRotDelta.setFromUnitVectors(this._prevABDirection, this._currABDir);
790
831
  this._manipulatorObject.quaternion.premultiply(_mtRotDelta);
791
832
  }
833
+ this._prevABDirection.copy(this._currABDir);
792
834
  }
793
- this._prevABDirection.copy(this._currABDir);
794
835
  }
795
836
  this._manipulatorObject.updateMatrix();
796
837
  this._manipulatorObject.updateMatrixWorld(true);
@@ -801,6 +842,49 @@ class MultiTouchDragHandler {
801
842
  // Gizmos.DrawWireSphere(wp, this._initialScale.length() * dist / this._initialDistance, 0x00ff00, 0, false);
802
843
  }
803
844
  }
845
+ /** Projects the current world-space A→B direction (_currABDir) onto the plane perpendicular
846
+ * to the camera forward — i.e. the grab line as it appears on screen — into `out`.
847
+ * Also refreshes _viewForward. */
848
+ _computeViewPlaneAB(out) {
849
+ const camera = this.context.mainCamera;
850
+ this._viewForward.copy(camera.worldForward);
851
+ out.copy(this._currABDir);
852
+ out.addScaledVector(this._viewForward, -out.dot(this._viewForward));
853
+ }
854
+ /** Maps the frame-to-frame on-screen twist of the two grab points to a yaw delta around
855
+ * `axis` and premultiplies it onto the manipulator. Decouples gesture magnitude (screen
856
+ * twist) from the applied axis (plane normal), so yaw stays controllable from any view. */
857
+ _applyViewPlaneTwist(axis) {
858
+ this._computeViewPlaneAB(this._currViewTwistDir);
859
+ const prev = this._prevViewTwistDir;
860
+ const curr = this._currViewTwistDir;
861
+ const eps = 1e-8;
862
+ // Grab line points into the screen (fingers overlap) — no usable twist this frame.
863
+ if (curr.lengthSq() < eps)
864
+ return;
865
+ if (prev.lengthSq() >= eps) {
866
+ // Signed twist of the grab line in the view plane, measured about the axis pointing
867
+ // toward the camera so a CCW-on-screen twist is positive.
868
+ _mtToEye.copy(this._viewForward).multiplyScalar(-1);
869
+ _mtCross.crossVectors(prev, curr);
870
+ const screenTwist = Math.atan2(_mtCross.dot(_mtToEye), prev.dot(curr));
871
+ // Sign by which side of the plane the camera sits on. This is stable through a
872
+ // side-on view (where "CCW on screen" is otherwise ill-defined) and correctly
873
+ // inverts the gesture when viewing the plane from below. It also agrees with the
874
+ // visual sense wherever that is well-defined, so tilting the view never flips it.
875
+ const camPos = this.context.mainCamera.worldPosition;
876
+ _mtCamOffset.copy(camPos).sub(this._tempVec3);
877
+ const side = axis.dot(_mtCamOffset) >= 0 ? -1 : 1;
878
+ const yaw = screenTwist * side;
879
+ if (yaw !== 0) {
880
+ _mtRotDelta.setFromAxisAngle(axis, yaw);
881
+ this._manipulatorObject.quaternion.premultiply(_mtRotDelta);
882
+ }
883
+ if (debug)
884
+ Gizmos.DrawLabel(this._tempVec3, `twist ${yaw.toFixed(3)}`, 0.03);
885
+ }
886
+ prev.copy(curr);
887
+ }
804
888
  onDragUpdate() {
805
889
  // At this point we've run both the other handlers, but their effects have been suppressed because they can't handle
806
890
  // two events at the same time. They're basically providing us with two Object3D's and we can combine these here
@@ -840,6 +924,23 @@ class MultiTouchDragHandler {
840
924
  // scale-aware constraints (e.g. XZPlaneDragStrategy's bounds-bottom height lock).
841
925
  // Pass 1 when keepScale is true so the correction is a no-op.
842
926
  this.handlerA.currentStrategy.onTwoPointerScaleUpdate?.(keepScale ? 1 : this._currentScaleRatio);
927
+ // SnapToSurfaces: aim the constraint's snap ray from the pointer origins through the
928
+ // current grab midpoint, so the two-finger snap targets what the fingers point at —
929
+ // the same semantics as the single-pointer surface raycast (which uses the pointer ray).
930
+ if (this._snapToSurfaceConstraint) {
931
+ const spaceA = this.handlerA.followObject.parent;
932
+ const spaceB = this.handlerB.followObject.parent;
933
+ if (spaceA && spaceB) {
934
+ spaceA.getWorldPosition(_mtSnapRayOrigin);
935
+ _mtSnapRayOrigin.add(spaceB.getWorldPosition(_mtSnapRayDir)).multiplyScalar(0.5);
936
+ // _manipulatorObject is a scene child positioned at the world-space midpoint of
937
+ // the two grab points (set in alignManipulator earlier this frame).
938
+ _mtSnapRayDir.copy(this._manipulatorObject.position).sub(_mtSnapRayOrigin);
939
+ if (_mtSnapRayDir.lengthSq() > 1e-10) {
940
+ this._snapToSurfaceConstraint.setRay(_mtSnapRayOrigin, _mtSnapRayDir.normalize());
941
+ }
942
+ }
943
+ }
843
944
  applyFollowObjectConstraints(this._followObject, this._activeConstraints);
844
945
  // TODO should use unscaled time here // some test for lerp speed depending on distance
845
946
  const t = Mathf.clamp01(this.context.time.deltaTime * lerpStrength * lerpFactor); // / (currentDist - 1 + 0.01));
@@ -877,9 +978,6 @@ class SnapToSurfacesDragStrategy {
877
978
  _lastSurfacePlaneRefreshTime = 0;
878
979
  _hasLastSurfaceHitPoint = false;
879
980
  _lastSurfaceHitPoint = new Vector3();
880
- /** Original grab point (object local space) captured at drag start. Used to preserve the
881
- * surface-plane component of the grab offset when snapping to surfaces. */
882
- _originalHitPointInLocalSpace = new Vector3();
883
981
  /** Context stored at initialize() time so getConstraints() can pass it to the multi-touch constraint. */
884
982
  _context = null;
885
983
  reset() {
@@ -889,17 +987,12 @@ class SnapToSurfacesDragStrategy {
889
987
  this._draggedOverObjectLastNormal.set(0, 0, 0);
890
988
  this._lastSurfacePlaneRefreshTime = 0;
891
989
  this._hasLastSurfaceHitPoint = false;
892
- this._originalHitPointInLocalSpace.set(0, 0, 0);
893
990
  }
894
991
  requiresPlaneIntersection = true;
895
992
  initialize(cx, hitWP, _rayDirection) {
896
993
  this._context = cx.context;
897
994
  cx.setPlaneViewAligned(hitWP, false);
898
995
  this.reset();
899
- // Capture the exact point the user grabbed (in object local space). This is used
900
- // to preserve the surface-plane component of the grab offset while only adjusting
901
- // the normal-direction component to rest the object on the target surface.
902
- this._originalHitPointInLocalSpace.copy(cx.hitPointInLocalSpace);
903
996
  }
904
997
  update(cx, ray, dragSource, draggedObject) {
905
998
  const didHaveSurfaceHitPointLastFrame = this._hasLastSurfaceHitPoint;
@@ -928,69 +1021,52 @@ class SnapToSurfacesDragStrategy {
928
1021
  didHit = true;
929
1022
  this._hasLastSurfaceHitPoint = true;
930
1023
  this._lastSurfaceHitPoint.copy(hit.point);
931
- const dragTimeThreshold = 0.15;
932
- const dragTimeSatisfied = this._draggedOverObjectDuration >= dragTimeThreshold;
933
- const dragDistance = 0.001;
934
- const dragDistanceSatisfied = cx.totalMovement.length() >= dragDistance;
935
1024
  // TODO: if the "hit.normal" is undefined we use the hit.face.normal which is still localspace
936
1025
  const worldNormal = getTempVector(hit.normal || hit.face.normal).applyQuaternion(hit.object.worldQuaternion);
937
- // Detect whether the surface object or its normal has changed.
938
- const needsAnchorUpdate = this._draggedOverObjectLastSetUp !== this._draggedOverObject
1026
+ // Wait until the drag has actually started (enough hover time or pointer movement)
1027
+ // before snapping to the surface, so a click that never becomes a drag doesn't move
1028
+ // the object. Bail out BEFORE touching any snap state: the surface/normal bookkeeping
1029
+ // below must only advance once a real drag is underway, otherwise the change-detection
1030
+ // trigger is consumed on a pre-threshold frame and the anchor/plane refresh is skipped
1031
+ // on the first frame that actually moves the object (leaving it on the stale
1032
+ // view-aligned plane until the 1 s periodic refresh).
1033
+ const dragTimeSatisfied = this._draggedOverObjectDuration >= 0.15;
1034
+ const dragDistanceSatisfied = cx.totalMovement.length() >= 0.001;
1035
+ if (!(dragTimeSatisfied || dragDistanceSatisfied)) {
1036
+ return null; // abort frame update — drag hasn't really started yet
1037
+ }
1038
+ // Refresh the anchor + drag plane when the surface object or its normal changes, and
1039
+ // periodically on flat same-normal surfaces (e.g. stepping across multi-level floors).
1040
+ // _draggedOverObjectLastNormal is stored in WORLD space to match worldNormal — it used
1041
+ // to store the local face normal, so on any rotated surface the dot never reached 1 and
1042
+ // this fired every frame.
1043
+ const surfaceChanged = this._draggedOverObjectLastSetUp !== this._draggedOverObject
939
1044
  || this._draggedOverObjectLastNormal.dot(worldNormal) < 0.999999;
940
- // Periodically refresh the drag plane on flat same-normal surfaces (e.g. multi-level floors).
941
- const needsPlaneRefresh = needsAnchorUpdate
1045
+ const needsRefresh = surfaceChanged
942
1046
  || (cx.context.time.time - this._lastSurfacePlaneRefreshTime) >= 1.0;
943
- // Always update the grab anchor when the surface or normal changes — even BEFORE the
944
- // movement threshold — so the first movement frame uses the correct anchor (no jump).
945
- // The anchor preserves the original grab point's surface-plane components; only the
946
- // normal-direction component is adjusted so the object rests flush on the surface.
947
- if (needsAnchorUpdate) {
1047
+ if (needsRefresh) {
1048
+ this._lastSurfacePlaneRefreshTime = cx.context.time.time;
948
1049
  this._draggedOverObjectLastSetUp = this._draggedOverObject;
949
- this._draggedOverObjectLastNormal.copy(hit.face.normal);
1050
+ this._draggedOverObjectLastNormal.copy(worldNormal);
1051
+ // Snap the OBJECT ORIGIN under the cursor: the origin's lateral position follows the
1052
+ // pointer→surface hit while the object's contact face rests on the surface. Where the
1053
+ // user grabbed is ignored, so placement is consistent regardless of grab point.
1054
+ // The origin is (0,0,0) in local space, so origin·normal = 0 and the anchor collapses
1055
+ // to the contact face projected onto the normal — the same anchor the multi-touch
1056
+ // SnapToSurfaceConstraint uses, so single- and two-pointer snapping now match.
950
1057
  const center = getTempVector();
951
1058
  const size = getTempVector();
952
1059
  cx.bounds.getCenter(center);
953
1060
  cx.bounds.getSize(size);
954
- // Surface-contact point: the face of the bounding box that touches the surface.
1061
+ // Contact face of the bounds along the surface normal (the face that rests on it).
1062
+ // NOTE: bounds are local/axis-aligned, so this assumes the object is upright w.r.t. the
1063
+ // surface normal — exact for the common keepRotation case, approximate if tilted.
955
1064
  center.sub(size.multiplyScalar(0.5).multiply(worldNormal));
956
- // Scalar projection of contact point onto the surface normal.
957
1065
  const contactAlongNormal = center.dot(worldNormal);
958
- // Scalar projection of the original grab point onto the same normal.
959
- const grabAlongNormal = this._originalHitPointInLocalSpace.dot(worldNormal);
960
- // Build the new anchor: keep the grab point's surface-plane offset, shift only
961
- // its normal component to match the contact surface (object rests on surface).
962
- cx.hitPointInLocalSpace
963
- .copy(this._originalHitPointInLocalSpace)
964
- .addScaledVector(worldNormal, contactAlongNormal - grabAlongNormal);
1066
+ cx.hitPointInLocalSpace.copy(worldNormal).multiplyScalar(contactAlongNormal);
965
1067
  cx.hitNormalInLocalSpace.copy(hit.face.normal);
966
- }
967
- // If the drag has just started and we're not yet really starting to update the
968
- // object's position, wait until there's been enough movement or time.
969
- // The anchor is already set correctly above so movement will begin without a jump.
970
- if (!(dragTimeSatisfied || dragDistanceSatisfied)) {
971
- return null; // abort frame update
972
- }
973
- // Update the drag plane when the surface/normal changes or periodically.
974
- if (needsPlaneRefresh) {
975
- this._lastSurfacePlaneRefreshTime = cx.context.time.time;
976
- // ensure plane is far enough up that we don't drag into the surface
977
- // Which offset we use here depends on the face normal direction we hit
978
- // If we hit the bottom, we want to use the top, and vice versa
979
- // To do this dynamically, we can find the intersection between our local bounds and the hit face normal (which is already in local space)
980
- const center = getTempVector();
981
- const size = getTempVector();
982
- cx.bounds.getCenter(center);
983
- cx.bounds.getSize(size);
984
- center.add(size.multiplyScalar(0.5).multiply(hit.face.normal));
985
- const offset = getTempVector(cx.hitPointInLocalSpace).add(center);
986
- cx.followObject.localToWorld(offset);
987
- // See https://linear.app/needle/issue/NE-5004
988
- // const offsetWP = this._followObject.worldPosition.sub(offset);
989
- const point = hit.point; //.sub(offsetWP);
990
- // Gizmos.DrawWireSphere(point, 2, 0xff0000, .3);
991
- // Gizmos.DrawDirection(point, worldNormal, 0xffff00, 1);
992
- // console.log(hit.normal)
993
- cx.dragPlane.setFromNormalAndCoplanarPoint(worldNormal, point);
1068
+ // Drag plane sits on the surface; the origin-anchor lands on the pointer→surface hit.
1069
+ cx.dragPlane.setFromNormalAndCoplanarPoint(worldNormal, hit.point);
994
1070
  }
995
1071
  }
996
1072
  }
@@ -1003,8 +1079,9 @@ class SnapToSurfacesDragStrategy {
1003
1079
  getConstraints(ctx) {
1004
1080
  // Single-pointer (boundsAtScaleOne === null): the existing update() + plane-intersection
1005
1081
  // path handles surface snapping; no constraint needed here.
1006
- // Multi-touch (boundsAtScaleOne !== null): inject a constraint that casts a downward
1007
- // ray each frame and directly snaps the follow object to the detected surface.
1082
+ // Multi-touch (boundsAtScaleOne !== null): inject a constraint that snaps the follow
1083
+ // object onto the surface the fingers point at. MultiTouchDragHandler aims its ray
1084
+ // (pointer origins → grab midpoint) every frame, mirroring the single-pointer raycast.
1008
1085
  if (!ctx.boundsAtScaleOne || !this._context)
1009
1086
  return [];
1010
1087
  const c = new SnapToSurfaceConstraint(this._context);
@@ -1030,6 +1107,8 @@ class XZPlaneDragStrategy {
1030
1107
  requiresPlaneIntersection = true;
1031
1108
  /** The fixed XZ plane established at drag start. Used as the clamping target. */
1032
1109
  xzPlane = new Plane();
1110
+ /** Two-finger twist maps to yaw around this axis — the plane normal (parent's world up). */
1111
+ get yawConstraintAxis() { return this.xzPlane.normal; }
1033
1112
  _inFallback = false;
1034
1113
  /** Stable world-space point on xzPlane, captured once when entering fallback mode. */
1035
1114
  _fallbackAnchor = new Vector3();
@@ -1234,6 +1313,28 @@ class DragPointerHandler {
1234
1313
  this._gridSnapConstraint = new GridSnapConstraint(0);
1235
1314
  this._keepRotationConstraint = new KeepRotationConstraint();
1236
1315
  }
1316
+ /**
1317
+ * Re-derives the grab point from the current pointer ray ∩ drag plane so that when a
1318
+ * two-pointer drag hands control back to this single pointer, the object keeps its current
1319
+ * offset to the pointer instead of snapping the original (now stale) grab point back under it.
1320
+ * Only needed for plane-intersection strategies — Attached-style modes re-base fully via
1321
+ * {@link recenter}. Call BEFORE {@link recenter} so the recomputed grab-start distance and
1322
+ * the constraint re-initialization use the rebased grab point.
1323
+ */
1324
+ rebaseGrabPointToPointer() {
1325
+ if (!this._currentStrategy.requiresPlaneIntersection)
1326
+ return;
1327
+ if (!this.gameObject || !this._followObject.parent)
1328
+ return;
1329
+ const dragSource = this._followObject.parent;
1330
+ const ray = new Ray(dragSource.worldPosition, dragSource.worldForward);
1331
+ if (!ray.intersectPlane(this._dragPlane, this._tempVec))
1332
+ return;
1333
+ // Keep the new grab point on the constraint plane (it differs from _dragPlane in the
1334
+ // XZPlane side-view fallback) so GrabPointPlaneConstraint doesn't add a one-time offset.
1335
+ this._grabPointPlaneConstraint?.plane.projectPoint(this._tempVec, this._tempVec);
1336
+ this._hitPointInLocalSpace.copy(this.gameObject.worldToLocal(this._tempVec));
1337
+ }
1237
1338
  recenter() {
1238
1339
  if (!this._followObject.parent) {
1239
1340
  console.warn("Error: space follow object doesn't have parent but recenter() is called. This is likely a bug");