@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.
- package/CHANGELOG.md +6 -0
- package/dist/{needle-engine.bundle-CEba8eTv.js → needle-engine.bundle-CxsZfV6C.js} +5245 -5166
- package/dist/{needle-engine.bundle-BvxvjlSN.min.js → needle-engine.bundle-DUb8msPR.min.js} +140 -140
- package/dist/{needle-engine.bundle-GUaSFX8u.umd.cjs → needle-engine.bundle-D_h1sDs-.umd.cjs} +137 -137
- package/dist/needle-engine.d.ts +39 -30
- package/dist/needle-engine.js +242 -242
- package/dist/needle-engine.min.js +1 -1
- package/dist/needle-engine.umd.cjs +1 -1
- package/lib/engine/api.d.ts +1 -1
- package/lib/engine/api.js +1 -1
- package/lib/engine/api.js.map +1 -1
- package/lib/engine/debug/debug_console.js +39 -17
- package/lib/engine/debug/debug_console.js.map +1 -1
- package/lib/engine/debug/debug_spatial_console.js +14 -0
- package/lib/engine/debug/debug_spatial_console.js.map +1 -1
- package/lib/engine/engine_init.js +2 -2
- package/lib/engine/engine_init.js.map +1 -1
- package/lib/engine/engine_license.d.ts +7 -7
- package/lib/engine/engine_license.js +71 -71
- package/lib/engine/engine_license.js.map +1 -1
- package/lib/engine/engine_networking_blob.js +3 -3
- package/lib/engine/engine_utils_qrcode.js +2 -2
- package/lib/engine/engine_utils_screenshot.js +1 -0
- package/lib/engine/engine_utils_screenshot.js.map +1 -1
- package/lib/engine/engine_utils_screenshot.xr.js +1 -1
- package/lib/engine/engine_utils_screenshot.xr.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
- package/lib/engine/webcomponents/needle menu/needle-menu.js +18 -5
- package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.ar-overlay.js +33 -7
- package/lib/engine/webcomponents/needle-engine.ar-overlay.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.js +2 -2
- package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
- package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
- package/lib/engine/xr/NeedleXRSession.js +10 -4
- package/lib/engine/xr/NeedleXRSession.js.map +1 -1
- package/lib/engine/xr/NeedleXRSync.js +47 -47
- package/lib/engine/xr/NeedleXRSync.js.map +1 -1
- package/lib/engine/xr/TempXRContext.js +2 -2
- package/lib/engine-components/DragControls.d.ts +9 -0
- package/lib/engine-components/DragControls.js +174 -73
- package/lib/engine-components/DragControls.js.map +1 -1
- package/lib/engine-components/DragControlsConstraints.d.ts +11 -2
- package/lib/engine-components/DragControlsConstraints.js +18 -11
- package/lib/engine-components/DragControlsConstraints.js.map +1 -1
- package/lib/engine-components/OrbitControls.js +32 -6
- package/lib/engine-components/OrbitControls.js.map +1 -1
- package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
- package/package.json +1 -1
- package/plugins/common/license.js +4 -4
- package/plugins/vite/license.js +4 -4
- package/src/engine/api.ts +1 -1
- package/src/engine/debug/debug_console.ts +33 -17
- package/src/engine/debug/debug_spatial_console.ts +15 -0
- package/src/engine/engine_init.ts +2 -2
- package/src/engine/engine_license.ts +68 -68
- package/src/engine/engine_networking_blob.ts +3 -3
- package/src/engine/engine_utils_qrcode.ts +2 -2
- package/src/engine/engine_utils_screenshot.ts +1 -0
- package/src/engine/engine_utils_screenshot.xr.ts +1 -1
- package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu.ts +19 -5
- package/src/engine/webcomponents/needle-engine.ar-overlay.ts +33 -5
- package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
- package/src/engine/webcomponents/needle-engine.ts +2 -2
- package/src/engine/xr/NeedleXRSession.ts +10 -3
- package/src/engine/xr/NeedleXRSync.ts +47 -50
- package/src/engine/xr/TempXRContext.ts +2 -2
- package/src/engine-components/DragControls.ts +189 -80
- package/src/engine-components/DragControlsConstraints.ts +20 -12
- package/src/engine-components/OrbitControls.ts +31 -4
- 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
|
-
|
|
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
|
-
|
|
47
|
-
if (ctrl.index === state.index) {
|
|
48
|
+
if (this.controllerStates[i].handedness === state.handedness) {
|
|
48
49
|
this.controllerStates[i] = state;
|
|
49
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 + "-" +
|
|
128
|
-
isTracking
|
|
129
|
-
handedness
|
|
130
|
-
|
|
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
|
|
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
|
-
//
|
|
155
|
-
//
|
|
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 {
|
|
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 (
|
|
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:
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
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
|
|
854
|
-
//
|
|
855
|
-
//
|
|
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
|
-
//
|
|
1116
|
-
|
|
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
|
-
|
|
1119
|
-
const needsPlaneRefresh = needsAnchorUpdate
|
|
1238
|
+
const needsRefresh = surfaceChanged
|
|
1120
1239
|
|| (cx.context.time.time - this._lastSurfacePlaneRefreshTime) >= 1.0;
|
|
1121
1240
|
|
|
1122
|
-
|
|
1123
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
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
|
|
1196
|
-
//
|
|
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");
|