import { AxesHelper, Box3, Euler, Matrix4, Object3D, Plane, Quaternion, Ray, Vector3 } from "three"; export type { IDragConstraint, IDragConstraintContext } from "./DragControlsConstraints.js"; export { AxisRotationConstraint, GridSnapConstraint, GrabPointPlaneConstraint, KeepRotationConstraint, KeepScaleConstraint, ScaleLimitConstraint, RotationAxis, FixedRotationAxesConstraint, PlaneHeightLockConstraint, SnapToSurfaceConstraint, applyFollowObjectConstraints } from "./DragControlsConstraints.js"; import { IDragConstraint, IDragConstraintContext, AxisRotationConstraint, GridSnapConstraint, GrabPointPlaneConstraint, KeepRotationConstraint, ScaleLimitConstraint, FixedRotationAxesConstraint, PlaneHeightLockConstraint, SnapToSurfaceConstraint, applyFollowObjectConstraints } from "./DragControlsConstraints.js"; import { Gizmos } from "../engine/engine_gizmos.js"; import { InstancingUtil } from "../engine/engine_instancing.js"; import { Mathf } from "../engine/engine_math.js"; import { serializable } from "../engine/engine_serialization_decorator.js"; import { Context } from "../engine/engine_setup.js"; import { getBoundingBox, getTempVector, getWorldPosition, setWorldPosition } from "../engine/engine_three_utils.js"; import { type IGameObject } from "../engine/engine_types.js"; import { getParam } from "../engine/engine_utils.js"; import { NeedleXRSession } from "../engine/engine_xr.js"; import { Behaviour, GameObject } from "./Component.js"; import { EventList } from "./EventList.js"; import { UsageMarker } from "./Interactable.js"; import { Rigidbody } from "./RigidBody.js"; import { SyncedTransform } from "./SyncedTransform.js"; import type { IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js"; import { ObjectRaycaster } from "./ui/Raycaster.js"; /** Enable debug visualization and logging for DragControls by using the URL parameter `?debugdrag`. */ const debug = getParam("debugdrag"); /** Buffer to store currently active DragControls components */ const dragControlsBuffer: DragControls[] = []; /** * Returns true when the pointer input mode should use the XR drag profile. * Covers tracked-pointer / transient-pointer (XR controllers and hands) as well as * screen-based AR sessions (phone/tablet camera AR where the device mode is "screen" * but the XR profile settings are still appropriate). */ function isSpatialInput(mode: XRTargetRayMode | "transient-pointer"): boolean { return mode === "tracked-pointer" || mode === "transient-pointer" || (NeedleXRSession.active?.isScreenBasedAR ?? false); } /** * The DragMode determines how an object is dragged around in the scene. */ export enum DragMode { /** Object stays at the same horizontal plane as it started. Commonly used for objects on the floor */ XZPlane = 0, /** Object is dragged as if it was attached to the pointer. In 2D, that means it's dragged along the camera screen plane. In XR, it's dragged by the controller/hand. */ Attached = 1, /** Object is dragged along the initial raycast hit normal. */ HitNormal = 2, /** Combination of XZ and Screen based on the viewing angle. Low angles result in Screen dragging and higher angles in XZ dragging. */ DynamicViewAngle = 3, /** The drag plane is snapped to surfaces in the scene while dragging. */ SnapToSurfaces = 4, /** Don't allow dragging the object */ None = 5, } /** * Runtime view over the active drag settings for one input type (screen or XR). * `DragControls` constructs one instance per input type (`screenProfile` / `xrProfile`). * All properties are lazy getters over the flat serialized fields on the owning `DragControls`, * so runtime writes to those fields are reflected immediately without any extra bookkeeping. */ export class DragProfile { /** @internal — use {@link DragControls.screenProfile} or {@link DragControls.xrProfile} */ constructor(private readonly _dc: DragControls, private readonly _xr: boolean) {} /** Active drag mode for this input type. */ get dragMode(): DragMode { return this._xr ? this._dc.xrDragMode : this._dc.dragMode; } /** Whether the dragged object's rotation is frozen during drag. */ get keepRotation(): boolean { return this._xr ? this._dc.xrKeepRotation : this._dc.keepRotation; } /** Whether the dragged object's scale is frozen during two-pointer drag. */ get keepScale(): boolean { return this._xr ? this._dc.xrKeepScale : this._dc.keepScale; } /** Multiplier for push/pull distance in XR; always 1 for screen input. */ get distanceDragFactor(): number { return this._xr ? this._dc.xrDistanceDragFactor : 1; } } /** * [DragControls](https://engine.needle.tools/docs/api/DragControls) enables interactive dragging of objects in 2D (screen space) or 3D (world space). * * ![](https://cloud.needle.tools/-/media/HyrtRDLjdmndr23_SR4mYw.gif) * * **Drag modes:** * - `XZPlane` - Drag on horizontal plane (good for floor objects) * - `Attached` - Follow pointer directly (screen plane in 2D, controller in XR) * - `HitNormal` - Drag along the surface normal where clicked * - `DynamicViewAngle` - Auto-switch between XZ and screen based on view angle * - `SnapToSurfaces` - Snap to scene geometry while dragging * * **Features:** * - Works across desktop, mobile, VR, and AR * - Optional grid snapping (`snapGridResolution`) * - Rotation preservation (`keepRotation`) * - Automatic networking with {@link SyncedTransform} * * * **Debug:** Use `?debugdrag` URL parameter for visual helpers. * * @example Basic draggable object * ```ts * const drag = myObject.addComponent(DragControls); * drag.dragMode = DragMode.XZPlane; * drag.snapGridResolution = 0.5; // Snap to 0.5 unit grid * ``` * * - Example: https://engine.needle.tools/samples/collaborative-sandbox * * @summary Enables dragging of objects in 2D or 3D space * @category Interactivity * @group Components * @see {@link DragMode} for available drag behaviors * @see {@link Duplicatable} for drag-to-duplicate functionality * @see {@link SyncedTransform} for networked dragging * @see {@link ObjectRaycaster} for pointer detection */ export class DragControls extends Behaviour implements IPointerEventHandler { /** * Checks if any DragControls component is currently active with selected objects * @returns True if any DragControls component is currently active */ public static get HasAnySelected(): boolean { return this._activePointers.size > 0; } /** Tracks individual pointer spaces that are currently dragging, preventing counter desync on missed pointer-up events. */ private static _activePointers: Set = new Set(); /** * Retrieves a list of all DragControl components that are currently dragging objects. * @returns Array of currently active DragControls components */ public static get CurrentlySelected() { dragControlsBuffer.length = 0; for (const dc of this._instances) { if (dc._isDragging) { dragControlsBuffer.push(dc); } } return dragControlsBuffer; } /** Registry of currently active and enabled DragControls components */ private static _instances: DragControls[] = []; /** * Determines how and where the object is dragged along. Different modes include * dragging along a plane, attached to the pointer, or following surface normals. */ @serializable() public dragMode: DragMode = DragMode.DynamicViewAngle; /** * Snaps dragged objects to a 3D grid with the specified resolution. * Set to 0 to disable snapping. */ @serializable() public snapGridResolution: number = 0.0; /** * When true, maintains the original rotation of the dragged object while moving it. * When false, allows the object to rotate freely during dragging. */ @serializable() public keepRotation: boolean = true; /** * When true, maintains the original scale of the dragged object while dragging it with two XR inputs. * When false, allows the object to scale freely during dragging with two XR inputs. */ @serializable() public keepScale: boolean = false; /** * Determines how and where the object is dragged along while dragging in XR. * Uses a separate setting from regular drag mode for better XR interaction. */ @serializable() public xrDragMode: DragMode = DragMode.Attached; /** * When true, maintains the original rotation of the dragged object during XR dragging. * When false, allows the object to rotate freely during XR dragging. */ @serializable() public xrKeepRotation: boolean = false; /** * When true, maintains the original scale of the dragged object while dragging it with two XR inputs. * When false, allows the object to scale freely during dragging with two XR inputs. */ @serializable() public xrKeepScale: boolean = false; /** * Multiplier that affects how quickly objects move closer or further away when dragging in XR. * Higher values make distance changes more pronounced. * This is similar to mouse acceleration on a screen. */ @serializable() public xrDistanceDragFactor: number = 1; /** * When enabled, draws a visual line from the dragged object downwards to the next raycast hit, * providing visual feedback about the object's position relative to surfaces below it. */ @serializable() public showGizmo: boolean = false; /** Drag profile for screen / touch / mouse input. Reads live from the flat serialized fields. */ readonly screenProfile: DragProfile = new DragProfile(this, false); /** Drag profile for XR tracked-pointer and transient-pointer input. Reads live from the flat `xr*` serialized fields. */ readonly xrProfile: DragProfile = new DragProfile(this, true); /** Invoked once when a drag begins (after the minimum drag distance threshold is met). */ @serializable(EventList) dragStarted: EventList = new EventList(); /** Invoked every frame while the object is being dragged. */ @serializable(EventList) dragUpdated: EventList = new EventList(); /** Invoked once when the last pointer is released and the drag ends. */ @serializable(EventList) dragEnded: EventList = new EventList(); /** * Returns the object currently being dragged by this DragControls component, if any. * @returns The object being dragged or null if no object is currently dragged */ get draggedObject() { return this._targetObject; } /** * Updates the object that is being dragged by the DragControls. * This can be used to change the target during a drag operation. * @param obj The new object to drag, or null to stop dragging */ setTargetObject(obj: Object3D | null) { this._targetObject = obj; for (const handler of this._dragHandlers.values()) { handler.setTargetObject(obj); } // If the object was kinematic we want to reset it const wasKinematicKey = "_rigidbody-was-kinematic"; if (this._rigidbody?.[wasKinematicKey] === false) { this._rigidbody.isKinematic = false; this._rigidbody[wasKinematicKey] = undefined; } this._rigidbody = null; // If we have a object that is being dragged we want to get the Rigidbody component // and we set kinematic to false while it's being dragged if (obj) { this._rigidbody = GameObject.getComponentInChildren(obj, Rigidbody); if (this._rigidbody?.isKinematic === false) { this._rigidbody.isKinematic = true; this._rigidbody[wasKinematicKey] = false; } } } private _rigidbody: Rigidbody | null = null; /** The object to be dragged – we pass this to handlers when they are created */ private _targetObject: Object3D | null = null; private static lastHovered: Object3D; private _draggingRigidbodies: Rigidbody[] = []; private _potentialDragStartEvt: PointerEventData | null = null; private _dragHandlers: Map = new Map(); private _totalMovement: Vector3 = new Vector3(); /** A marker is attached to components that are currently interacted with, to e.g. prevent them from being deleted. */ private _marker: UsageMarker | null = null; private _isDragging: boolean = false; private _didDrag: boolean = false; /** @internal */ awake() { // initialize all data that may be cloned incorrectly otherwise this._potentialDragStartEvt = null; this._dragHandlers = new Map(); this._totalMovement = new Vector3(); this._marker = null; this._isDragging = false; this._didDrag = false; this._draggingRigidbodies = []; } /** @internal */ start() { if (!this.gameObject.getComponentInParent(ObjectRaycaster)) this.gameObject.addComponent(ObjectRaycaster); } /** @internal */ onEnable(): void { DragControls._instances.push(this); this.context.accessibility.updateElement(this, { role: "button", label: "Drag " + (this.gameObject.name || "object"), hidden: false, }); } /** @internal */ onDisable(): void { this.context.accessibility.updateElement(this, { hidden: true }); DragControls._instances = DragControls._instances.filter(i => i !== this); } onDestroy(): void { this.context.accessibility.removeElement(this); if (this._isDragging) this._cancelDrag(); } /** * Checks if editing is allowed for the current networking connection. * @param _obj Optional object to check edit permissions for * @returns True if editing is allowed */ private allowEdit(_obj: Object3D | null = null) { return this.context.connection.allowEditing; } /** * Handles pointer enter events. Sets the cursor style and tracks the hovered object. * @param evt Pointer event data containing information about the interaction * @internal */ onPointerEnter?(evt: PointerEventData) { if (!this.allowEdit(this.gameObject)) return; if (evt.mode !== "screen") return; // get the drag mode and check if we need to abort early here const dragMode = isSpatialInput(evt.event.mode) ? this.xrDragMode : this.dragMode; if (dragMode === DragMode.None) return; const dc = GameObject.getComponentInParent(evt.object, DragControls); if (!dc || dc !== this) return; DragControls.lastHovered = evt.object; this.context.domElement.style.cursor = 'pointer'; this.context.accessibility.hover(this, `Draggable ${evt.object?.name}`); } /** * Handles pointer movement events. Marks the event as used if dragging is active. * @param args Pointer event data containing information about the movement * @internal */ onPointerMove?(args: PointerEventData) { if (this._isDragging || this._potentialDragStartEvt !== null) args.use(); } /** * Handles pointer exit events. Resets the cursor style when the pointer leaves a draggable object. * @param evt Pointer event data containing information about the interaction * @internal */ onPointerExit?(evt: PointerEventData) { if (!this.allowEdit(this.gameObject)) return; if (evt.mode !== "screen") return; if (DragControls.lastHovered !== evt.object) return; this.context.domElement.style.cursor = 'auto'; } /** * Handles pointer down events. Initiates the potential drag operation if conditions are met. * @param args Pointer event data containing information about the interaction * @internal */ onPointerDown(args: PointerEventData) { if (!this.allowEdit(this.gameObject)) return; if (args.used) return; // get the drag mode and check if we need to abort early here const dragMode = isSpatialInput(args.mode) ? this.xrDragMode : this.dragMode; if (dragMode === DragMode.None) return; DragControls.lastHovered = args.object; if (args.button === 0) { if (this._dragHandlers.size === 0) { this._didDrag = false; this._totalMovement.set(0, 0, 0); this._potentialDragStartEvt = args; } if (!this._targetObject) { this.setTargetObject(this.gameObject); } DragControls._activePointers.add(args.event.space); const newDragHandler = new DragPointerHandler(this, this._targetObject!); this._dragHandlers.set(args.event.space, newDragHandler); newDragHandler.onDragStart(args); if (this._dragHandlers.size === 2) { const iterator = this._dragHandlers.values(); const a = iterator.next().value; const b = iterator.next().value; if (a instanceof DragPointerHandler && b instanceof DragPointerHandler) { const mtHandler = new MultiTouchDragHandler(this, this._targetObject!, a, b); this._dragHandlers.set(this.gameObject, mtHandler); mtHandler.onDragStart(args); } else { console.error("Attempting to construct a MultiTouchDragHandler with invalid DragPointerHandlers. This is likely a bug.", { a, b }); } } args.use(); this.context.accessibility.updateElement(this, { role: "button", label: "Dragging " + (this.gameObject.name || "object"), hidden: false, busy: true, }); this.context.accessibility.focus(this); } } /** * Handles pointer up events. Finalizes or cancels the drag operation. * @param args Pointer event data containing information about the interaction * @internal */ onPointerUp(args: PointerEventData) { if (debug) Gizmos.DrawLabel(args.point ?? this.gameObject.worldPosition, "POINTERUP:" + args.pointerId + ", " + args.button, .03, 3); if (!this.allowEdit(this.gameObject)) return; if (args.button !== 0) return; this._potentialDragStartEvt = null; const handler = this._dragHandlers.get(args.event.space); const mtHandler = this._dragHandlers.get(this.gameObject) as MultiTouchDragHandler; if (mtHandler && (mtHandler.handlerA === handler || mtHandler.handlerB === handler)) { // any of the two handlers has been released, so we can remove the multi-touch handler this._dragHandlers.delete(this.gameObject); mtHandler.onDragEnd(args); } if (handler) { DragControls._activePointers.delete(args.event.space); if (handler.onDragEnd) handler.onDragEnd(args); this._dragHandlers.delete(args.event.space); if (this._dragHandlers.size === 0) { // Only clear the target and fire drag-end when the last handler is removed. // Clearing earlier would null out the gameObject reference on any still-active // handler (e.g. switching an object from one XR controller to the other). this.setTargetObject(null); this.onLastDragEnd(args); } // Don't consume a double-click that never turned into a drag so that // other components (e.g. OrbitControls focus-on-double-click) can still handle it. if (!args.isDoubleClick || this._didDrag) { args.use(); } } this.context.accessibility.unfocus(this); this.context.accessibility.updateElement(this, { busy: false, }); } /** * Updates the drag operation every frame. Processes pointer movement, accumulates drag distance * and triggers drag start once there's enough movement. * @internal */ update(): void { // Safety: end drag cleanly if the target object was removed from the scene while dragging. // Fall back to this.gameObject in case _targetObject was nulled externally mid-drag. const dragTarget = this._targetObject ?? this.gameObject; if (this._isDragging && !dragTarget.parent) { this._cancelDrag(); return; } for (const handler of this._dragHandlers.values()) { if (handler.collectMovementInfo) handler.collectMovementInfo(); if (handler.getTotalMovement) { const m = handler.getTotalMovement(); if (m.length() > this._totalMovement.length()) this._totalMovement.copy(m); } } // drag start only after having dragged for some pixels if (this._potentialDragStartEvt) { if (!this._didDrag) { // this is so we can e.g. process clicks without having a drag change the position, e.g. a click to call a method. // TODO probably needs to be treated differently for spatial (3D motion) and screen (2D pixel motion) drags if (this._totalMovement.length() > 0.0003) this._didDrag = true; else return; } const args = this._potentialDragStartEvt; this._potentialDragStartEvt = null; this.onFirstDragStart(args); } for (const handler of this._dragHandlers.values()) if (handler.onDragUpdate) handler.onDragUpdate(this._dragHandlers.size); if (this._isDragging) this.onAnyDragUpdate(); } /** * Called when the first pointer starts dragging on this object. * Sets up network synchronization and marks rigidbodies for dragging. * Not called for subsequent pointers on the same object. * @param evt Pointer event data that initiated the drag */ private onFirstDragStart(evt: PointerEventData) { if (!evt || !evt.object) return; const dc = GameObject.getComponentInParent(evt.object, DragControls); // if a DragControls is in parent (e.g. when we have nested DragControls) and the parent DragControls is currently active // then we will ignore this DragControls and not select it. // But if the parent DragControls isn't dragging then we allow this to run because we want to start networking if (!dc || (dc !== this && dc._isDragging)) return; const object = this._targetObject || this.gameObject; if (!object) return; this._isDragging = true; this.dragStarted?.invoke(); const sync = GameObject.getComponentInChildren(object, SyncedTransform); if (debug) console.log("DRAG START", sync, object); if (sync) { sync.fastMode = true; sync?.requestOwnership(); } this._marker = GameObject.addComponent(object, UsageMarker); this._draggingRigidbodies.length = 0; const rbs = GameObject.getComponentsInChildren(object, Rigidbody); if (rbs) this._draggingRigidbodies.push(...rbs); if (object.matrixAutoUpdate === false && !globalThis["DragControls:MatrixWarningShown"]) { globalThis["DragControls:MatrixWarningShown"] = true; console.warn("Dragging an object with matrixAutoUpdate=false can lead to unexpected behavior. Consider enabling matrixAutoUpdate or updating the matrix manually during dragging."); } } /** * Called each frame as long as any pointer is dragging this object. * Keeps rigidbodies awake and fires the dragUpdated event. */ private onAnyDragUpdate() { for (const rb of this._draggingRigidbodies) { rb.wakeUp(); rb.resetVelocities(); rb.resetForcesAndTorques(); } const object = this._targetObject || this.gameObject; InstancingUtil.markDirty(object); this.dragUpdated?.invoke(); } /** Releases all active drag handlers and pointer tracking, then fires the drag-end lifecycle. */ private _cancelDrag(): void { for (const key of this._dragHandlers.keys()) { if (key !== this.gameObject) DragControls._activePointers.delete(key); } this._dragHandlers.clear(); this._potentialDragStartEvt = null; this.setTargetObject(null); this.onLastDragEnd(null); } /** * Called when the last pointer has been removed from this object. * Cleans up drag state and applies final velocities to rigidbodies. * @param evt Pointer event data for the last pointer that was lifted */ private onLastDragEnd(evt: PointerEventData | null) { if (!this || !this._isDragging) return; this._isDragging = false; this.dragEnded?.invoke(); for (const rb of this._draggingRigidbodies) { rb.setVelocity(rb.smoothedVelocity.multiplyScalar(this.context.time.deltaTime)); } this._draggingRigidbodies.length = 0; this._targetObject = null; if (evt?.object) { const sync = GameObject.getComponentInChildren(evt.object, SyncedTransform); if (sync) { sync.fastMode = false; } } if (this._marker) this._marker.destroy(); } } /** * Common interface for pointer handlers (single touch and multi touch). * Defines methods for tracking movement and managing target objects during drag operations. */ interface IDragHandler { /** Used to determine if a drag has happened for this handler */ getTotalMovement?(): Vector3; /** Target object can change mid-flight (e.g. in Duplicatable), handlers should react properly to that */ setTargetObject(obj: Object3D | null): void; /** Prewarms the drag – can already move internal points around here but should not move the object itself */ collectMovementInfo?(): void; onDragStart?(args: PointerEventData): void; onDragEnd?(args: PointerEventData): void; /** The target object is moved around */ onDragUpdate?(numberOfPointers: number): void; } /** Scratch quaternion for {@link MultiTouchDragHandler}'s per-frame delta rotation. */ const _mtRotDelta = new Quaternion(); /** Scratch vectors for the on-screen twist → yaw mapping (view-plane twist path). */ const _mtCross = new Vector3(); const _mtToEye = new Vector3(); const _mtCamOffset = new Vector3(); /** Scratch vectors for aiming the SnapToSurfaceConstraint ray in MultiTouchDragHandler.onDragUpdate. */ const _mtSnapRayOrigin = new Vector3(); const _mtSnapRayDir = new Vector3(); // #region MultiTouchDragHandler /** * Handles two touch points affecting one object. * Enables multi-touch interactions that allow movement, scaling, and rotation of objects. */ class MultiTouchDragHandler implements IDragHandler { handlerA: DragPointerHandler; handlerB: DragPointerHandler; private context: Context; private settings: DragControls; private gameObject: Object3D; private _handlerAAttachmentPoint: Vector3 = new Vector3(); private _handlerBAttachmentPoint: Vector3 = new Vector3(); private _followObject: GameObject; private _manipulatorObject: GameObject; private _deviceMode!: XRTargetRayMode | "transient-pointer"; private _followObjectStartWorldQuaternion: Quaternion = new Quaternion(); private _gridSnapConstraint: GridSnapConstraint = new GridSnapConstraint(0); private _keepRotationConstraint: KeepRotationConstraint = new KeepRotationConstraint(); /** GrabPointPlaneConstraint returned by the active strategy (if any); used to sync snapResolution. */ private _planeConstraint: GrabPointPlaneConstraint | null = null; /** SnapToSurfaceConstraint returned by the active strategy (SnapToSurfaces mode); its snap ray * is re-aimed every frame in onDragUpdate so it targets what the fingers point at. */ private _snapToSurfaceConstraint: SnapToSurfaceConstraint | null = null; private _activeConstraints: IDragConstraint[] = []; constructor(dragControls: DragControls, gameObject: Object3D, pointerA: DragPointerHandler, pointerB: DragPointerHandler) { this.context = dragControls.context; this.settings = dragControls; this.gameObject = gameObject; this.handlerA = pointerA; this.handlerB = pointerB; this._followObject = new Object3D() as GameObject; this._manipulatorObject = new Object3D() as GameObject; this.context.scene.add(this._manipulatorObject); const rig = NeedleXRSession.active?.rig?.gameObject; if (!this.handlerA || !this.handlerB || !this.handlerA.hitPointInLocalSpace || !this.handlerB.hitPointInLocalSpace) { console.error("Invalid: MultiTouchDragHandler needs two valid DragPointerHandlers with hitPointInLocalSpace set."); return; } this._tempVec1.copy(this.handlerA.hitPointInLocalSpace); this._tempVec2.copy(this.handlerB.hitPointInLocalSpace); this.gameObject.localToWorld(this._tempVec1); this.gameObject.localToWorld(this._tempVec2); if (rig) { rig.worldToLocal(this._tempVec1); rig.worldToLocal(this._tempVec2); } this._initialDistance = this._tempVec1.distanceTo(this._tempVec2); if (this._initialDistance < 0.02) { if (debug) { console.log("Finding alternative drag attachment points since initial distance is too low: " + this._initialDistance.toFixed(2)); } // We want two reasonable pointer attachment points here. // But if the hitPointInLocalSpace are very close to each other, we instead fall back to controller positions. this.handlerA.followObject.parent!.getWorldPosition(this._tempVec1); this.handlerB.followObject.parent!.getWorldPosition(this._tempVec2); this._handlerAAttachmentPoint.copy(this._tempVec1); this._handlerBAttachmentPoint.copy(this._tempVec2); this.gameObject.worldToLocal(this._handlerAAttachmentPoint); this.gameObject.worldToLocal(this._handlerBAttachmentPoint); this._initialDistance = this._tempVec1.distanceTo(this._tempVec2); if (this._initialDistance < 0.001) { console.warn("Not supported right now - controller drag points for multitouch are too close!"); this._initialDistance = 1; } } else { this._handlerAAttachmentPoint.copy(this.handlerA.hitPointInLocalSpace); this._handlerBAttachmentPoint.copy(this.handlerB.hitPointInLocalSpace); } this._tempVec3.lerpVectors(this._tempVec1, this._tempVec2, 0.5); if (debug) { this._followObject.add(new AxesHelper(2)); this._manipulatorObject.add(new AxesHelper(5)); const formatVec = (v: Vector3) => `${v.x.toFixed(2)}, ${v.y.toFixed(2)}, ${v.z.toFixed(2)}`; Gizmos.DrawLine(this._tempVec1, this._tempVec2, 0x00ffff, 0, false); Gizmos.DrawLabel(this._tempVec3, "A:B " + this._initialDistance.toFixed(2) + "\n" + formatVec(this._tempVec1) + "\n" + formatVec(this._tempVec2), 0.03, 5); } } onDragStart(_args: PointerEventData): void { // align _followObject with the object we want to drag this.gameObject.add(this._followObject); this._followObject.matrixAutoUpdate = false; this._followObject.matrix.identity(); this._deviceMode = _args.mode; this._followObjectStartWorldQuaternion.copy(this._followObject.worldQuaternion); // Build the constraint pipeline for this drag const profile = isSpatialInput(this._deviceMode) ? this.settings.xrProfile : this.settings.screenProfile; // Build the constraint pipeline for this drag. // Delegate entirely to handlerA's active strategy via the same IDragConstraintContext // API used by the single-pointer path. The strategy allocates fresh constraint instances // so this pipeline is fully independent from the single-pointer pipeline. // hitPointInLocalSpace = (0,0,0) projects the _followObject's own world position onto the // plane — correct for two-finger dragging where there is no single attachment point. // Capture initial world scale before building constraints — the context passes it // to the strategy so it can self-configure scale-aware height locking. this._initialWorldScale.copy((this.gameObject as unknown as IGameObject).worldScale); this._currentScaleRatio = 1; const constraintCx: IDragConstraintContext = { hitPointInLocalSpace: new Vector3(0, 0, 0), hitNormalInLocalSpace: new Vector3(0, 1, 0), gameObject: this.gameObject, boundsAtScaleOne: this.handlerA.boundsAtScaleOne, initialWorldScale: this._initialWorldScale, }; const strategyConstraints = this.handlerA.currentStrategy.getConstraints?.(constraintCx) ?? []; this._planeConstraint = strategyConstraints.find( c => c instanceof GrabPointPlaneConstraint ) as GrabPointPlaneConstraint ?? null; this._snapToSurfaceConstraint = strategyConstraints.find( c => c instanceof SnapToSurfaceConstraint ) as SnapToSurfaceConstraint ?? null; const hasStrategyConstraints = strategyConstraints.length > 0; this._activeConstraints = [ ...strategyConstraints, ...(hasStrategyConstraints ? [] : [this._gridSnapConstraint]), ]; if (profile.keepRotation) { this._keepRotationConstraint.init(constraintCx); this._activeConstraints.push(this._keepRotationConstraint); } this._scaleLimitConstraint.init(constraintCx); // Yaw-locked strategies (XZPlane) expose their world-space rotation axis. For screen // two-finger input we drive an intuitive on-screen twist → yaw mapping around it (see // alignManipulator). XR keeps the free 3D-delta path for now. this._yawConstraintAxis = !isSpatialInput(this._deviceMode) ? (this.handlerA.currentStrategy.yawConstraintAxis ?? null) : null; // align _manipulatorObject in the same way it would if this was a drag update this.alignManipulator(); // and then parent it to the space object so it follows along. this._manipulatorObject.attach(this._followObject); // store offsets in local space this._manipulatorPosOffset.copy(this._followObject.position); this._manipulatorRotOffset.copy(this._followObject.quaternion); } onDragEnd(_args: PointerEventData): void { if (!this.handlerA || !this.handlerB) { console.error("onDragEnd called on MultiTouchDragHandler without valid handlers. This is likely a bug."); return; } // we want to initialize the drag points for these handlers again. // one of them will be removed, but we don't know here which one. // Re-derive each handler's grab point from its current pointer ray BEFORE recentering: // during the two-pointer drag the object moved relative to each pointer, so the grab // point captured at pointer-down is stale — without the rebase the surviving handler // would yank the object until that old grab point is back under the pointer (visible // jump on release). recenter() then re-captures start transforms and re-initializes // constraints using the rebased grab point. this.handlerA.rebaseGrabPointToPointer(); this.handlerB.rebaseGrabPointToPointer(); this.handlerA.recenter(); this.handlerB.recenter(); // destroy helper objects this._manipulatorObject.removeFromParent(); this._followObject.removeFromParent(); this._manipulatorObject.destroy(); this._followObject.destroy(); } private _manipulatorPosOffset: Vector3 = new Vector3(); private _manipulatorRotOffset: Quaternion = new Quaternion(); /** Scale ratio between current two-hand distance and initial distance. Updated in alignManipulator(). */ private _currentScaleRatio: number = 1; /** World-scale of the dragged object captured at drag start. Used to apply scale directly. */ private readonly _initialWorldScale: Vector3 = new Vector3(1, 1, 1); /** Clamps the dragged object's world scale during two-pointer scaling. */ private readonly _scaleLimitConstraint: ScaleLimitConstraint = new ScaleLimitConstraint(0.0001, 1_000_000, true); private _tempVec1: Vector3 = new Vector3(); private _tempVec2: Vector3 = new Vector3(); private _tempVec3: Vector3 = new Vector3(); private tempLookMatrix: Matrix4 = new Matrix4(); private _initialDistance: number = 0; /** Normalised A→B direction captured at the end of the previous `alignManipulator` call. * Used by the delta-rotation path to avoid camera-up instability. */ private _prevABDirection: Vector3 = new Vector3(); /** Re-used scratch vector for the current-frame A→B direction inside `alignManipulator`. */ private _currABDir: Vector3 = new Vector3(); /** True only for the very first `alignManipulator` call; triggers the one-time lookAt seed. */ private _isFirstAlignFrame: boolean = true; /** World-space yaw axis when the active strategy locks rotation to a single axis (XZPlane), * for screen input only. Null ⇒ use the free 3D-delta rotation path. Set in onDragStart. */ private _yawConstraintAxis: Vector3 | null = null; /** Camera forward captured while computing the view-plane twist (scratch). */ private readonly _viewForward = new Vector3(); /** A→B projected into the view plane on the previous frame; reference for the twist delta. */ private readonly _prevViewTwistDir = new Vector3(); /** A→B projected into the view plane on the current frame (scratch). */ private readonly _currViewTwistDir = new Vector3(); private alignManipulator() { if (!this.handlerA || !this.handlerB) { console.error("alignManipulator called on MultiTouchDragHandler without valid handlers. This is likely a bug.", this); return; } if (!this.handlerA.followObject || !this.handlerB.followObject) { console.error("alignManipulator called on MultiTouchDragHandler without valid follow objects. This is likely a bug.", this.handlerA, this.handlerB); return; } this._tempVec1.copy(this._handlerAAttachmentPoint); this._tempVec2.copy(this._handlerBAttachmentPoint); this.handlerA.followObject.localToWorld(this._tempVec1); this.handlerB.followObject.localToWorld(this._tempVec2); this._tempVec3.lerpVectors(this._tempVec1, this._tempVec2, 0.5); this._manipulatorObject.position.copy(this._tempVec3); // - track the scale ratio without baking it into _manipulatorObject's hierarchy. // Applying scale to _manipulatorObject contaminates _followObject.worldPosition (because // the child's local position is transformed by the parent scale). We compute scale // separately and apply it directly to the dragged object in onDragUpdate. const dist = this._tempVec1.distanceTo(this._tempVec2); this._currentScaleRatio = dist / this._initialDistance; // Rotation: accumulate a per-frame delta so translation never contaminates rotation. // Two paths: // - Yaw-locked screen drag (XZPlane, _yawConstraintAxis set): map the on-screen finger // twist directly to yaw around the constraint axis, so the gesture stays usable from // any view angle — a side-on view no longer collapses it. See _applyViewPlaneTwist. // - Free rotation (XR / other modes): accumulate the full 3D delta of the A→B direction // via setFromUnitVectors (camera tilt has no effect after the initial seed). this._currABDir.subVectors(this._tempVec2, this._tempVec1); if (this._currABDir.lengthSq() > 1e-10) { if (this._isFirstAlignFrame) { // Seed the manipulator orientation once. Only the manipulator's *change* since // this frame drives the object (the follow-object offset captured in onDragStart // absorbs the absolute seed), so the seed value itself is arbitrary — lookAt just // gives a well-defined starting frame. Seed both rotation-source histories too. const camera = this.context.mainCamera; this.tempLookMatrix.lookAt(this._tempVec3, this._tempVec2, (camera as any as IGameObject).worldUp); this._manipulatorObject.quaternion.setFromRotationMatrix(this.tempLookMatrix); this._isFirstAlignFrame = false; this._prevABDirection.copy(this._currABDir).normalize(); this._computeViewPlaneAB(this._prevViewTwistDir); } else if (this._yawConstraintAxis) { this._applyViewPlaneTwist(this._yawConstraintAxis); } else { // Accumulate the rotation delta from previous A→B to current A→B. // Guard against the degenerate ~180° flip case where setFromUnitVectors // is undefined (dot ≈ -1). this._currABDir.normalize(); if (this._prevABDirection.dot(this._currABDir) > -0.9999) { _mtRotDelta.setFromUnitVectors(this._prevABDirection, this._currABDir); this._manipulatorObject.quaternion.premultiply(_mtRotDelta); } this._prevABDirection.copy(this._currABDir); } } this._manipulatorObject.updateMatrix(); this._manipulatorObject.updateMatrixWorld(true); if (debug) { Gizmos.DrawLabel(this._tempVec3.clone().add(new Vector3(0, 0.2, 0)), "A:B " + dist.toFixed(2), 0.03); Gizmos.DrawLine(this._tempVec1, this._tempVec2, 0x00ff00, 0, false); // const wp = this._manipulatorObject.worldPosition; // Gizmos.DrawWireSphere(wp, this._initialScale.length() * dist / this._initialDistance, 0x00ff00, 0, false); } } /** Projects the current world-space A→B direction (_currABDir) onto the plane perpendicular * to the camera forward — i.e. the grab line as it appears on screen — into `out`. * Also refreshes _viewForward. */ private _computeViewPlaneAB(out: Vector3): void { const camera = this.context.mainCamera as unknown as IGameObject; this._viewForward.copy(camera.worldForward); out.copy(this._currABDir); out.addScaledVector(this._viewForward, -out.dot(this._viewForward)); } /** Maps the frame-to-frame on-screen twist of the two grab points to a yaw delta around * `axis` and premultiplies it onto the manipulator. Decouples gesture magnitude (screen * twist) from the applied axis (plane normal), so yaw stays controllable from any view. */ private _applyViewPlaneTwist(axis: Vector3): void { this._computeViewPlaneAB(this._currViewTwistDir); const prev = this._prevViewTwistDir; const curr = this._currViewTwistDir; const eps = 1e-8; // Grab line points into the screen (fingers overlap) — no usable twist this frame. if (curr.lengthSq() < eps) return; if (prev.lengthSq() >= eps) { // Signed twist of the grab line in the view plane, measured about the axis pointing // toward the camera so a CCW-on-screen twist is positive. _mtToEye.copy(this._viewForward).multiplyScalar(-1); _mtCross.crossVectors(prev, curr); const screenTwist = Math.atan2(_mtCross.dot(_mtToEye), prev.dot(curr)); // Sign by which side of the plane the camera sits on. This is stable through a // side-on view (where "CCW on screen" is otherwise ill-defined) and correctly // inverts the gesture when viewing the plane from below. It also agrees with the // visual sense wherever that is well-defined, so tilting the view never flips it. const camPos = (this.context.mainCamera as unknown as IGameObject).worldPosition; _mtCamOffset.copy(camPos).sub(this._tempVec3); const side = axis.dot(_mtCamOffset) >= 0 ? -1 : 1; const yaw = screenTwist * side; if (yaw !== 0) { _mtRotDelta.setFromAxisAngle(axis, yaw); this._manipulatorObject.quaternion.premultiply(_mtRotDelta); } if (debug) Gizmos.DrawLabel(this._tempVec3, `twist ${yaw.toFixed(3)}`, 0.03); } prev.copy(curr); } onDragUpdate() { // At this point we've run both the other handlers, but their effects have been suppressed because they can't handle // two events at the same time. They're basically providing us with two Object3D's and we can combine these here // into a reasonable two-handed translation/rotation/scale. // One approach: // - position our control object on the center between the two pointer control objects // TODO close grab needs to be handled differently because there we don't have a hit point - // Hit point is just the center of the object // So probably we should fix that close grab has a better hit point approximation (point on bounds?) this.alignManipulator(); // apply (smoothed) to the gameObject const lerpStrength = 30; const lerpFactor = 1.0; this._followObject.position.copy(this._manipulatorPosOffset); this._followObject.quaternion.copy(this._manipulatorRotOffset); const draggedObject = this.gameObject; const targetObject = this._followObject; if (!draggedObject) { console.error("MultiTouchDragHandler has no dragged object. This is likely a bug."); return; } // Safety: the object may have been deleted while dragging. if (!draggedObject.parent) return; targetObject.updateMatrix(); targetObject.updateMatrixWorld(true); const profile = isSpatialInput(this._deviceMode) ? this.settings.xrProfile : this.settings.screenProfile; const keepRotation = profile.keepRotation; const keepScale = profile.keepScale; if (this._planeConstraint) { this._planeConstraint.snapResolution = this.settings.snapGridResolution; } else { this._gridSnapConstraint.snapGridResolution = this.settings.snapGridResolution; } // Notify the active strategy of the current scale ratio so it can adjust any // scale-aware constraints (e.g. XZPlaneDragStrategy's bounds-bottom height lock). // Pass 1 when keepScale is true so the correction is a no-op. this.handlerA.currentStrategy.onTwoPointerScaleUpdate?.(keepScale ? 1 : this._currentScaleRatio); // SnapToSurfaces: aim the constraint's snap ray from the pointer origins through the // current grab midpoint, so the two-finger snap targets what the fingers point at — // the same semantics as the single-pointer surface raycast (which uses the pointer ray). if (this._snapToSurfaceConstraint) { const spaceA = this.handlerA.followObject.parent; const spaceB = this.handlerB.followObject.parent; if (spaceA && spaceB) { spaceA.getWorldPosition(_mtSnapRayOrigin); _mtSnapRayOrigin.add(spaceB.getWorldPosition(_mtSnapRayDir)).multiplyScalar(0.5); // _manipulatorObject is a scene child positioned at the world-space midpoint of // the two grab points (set in alignManipulator earlier this frame). _mtSnapRayDir.copy(this._manipulatorObject.position).sub(_mtSnapRayOrigin); if (_mtSnapRayDir.lengthSq() > 1e-10) { this._snapToSurfaceConstraint.setRay(_mtSnapRayOrigin, _mtSnapRayDir.normalize()); } } } applyFollowObjectConstraints(this._followObject, this._activeConstraints); // TODO should use unscaled time here // some test for lerp speed depending on distance const t = Mathf.clamp01(this.context.time.deltaTime * lerpStrength * lerpFactor);// / (currentDist - 1 + 0.01)); const wp = draggedObject.worldPosition; wp.lerp(targetObject.worldPosition, t); draggedObject.worldPosition = wp; const rot = draggedObject.worldQuaternion; rot.slerp(targetObject.worldQuaternion, t); draggedObject.worldQuaternion = rot; if (!keepScale) { // Apply scale directly from initial world scale × ratio — independent of the // manipulator hierarchy so that position is not contaminated by scale. const targetWorldScale = getTempVector( this._initialWorldScale.x * this._currentScaleRatio, this._initialWorldScale.y * this._currentScaleRatio, this._initialWorldScale.z * this._currentScaleRatio, ); const scl = draggedObject.worldScale; scl.lerp(targetWorldScale, t); draggedObject.worldScale = scl; this._scaleLimitConstraint.apply(draggedObject as unknown as GameObject); } if (draggedObject.matrixAutoUpdate === false) { draggedObject.updateMatrix(); } } setTargetObject(obj: Object3D | null): void { this.gameObject = obj as GameObject; } } // #endregion // #region Drag plane strategies /** * Mutable context bag passed to an {@link IDragPlaneStrategy} on every frame update. * All object-typed properties are shared references — mutations made by the strategy * are immediately reflected in the owning {@link DragPointerHandler}. */ export interface IDragStrategyContext { readonly context: Context; /** The intermediate object whose position drives the dragged object. */ readonly followObject: GameObject; /** Current dragged / target object. Refreshed before each strategy update call. */ gameObject: Object3D | null; /** Accumulated world-space movement since drag start. */ readonly totalMovement: Vector3; /** Local-space bounding box computed once at drag start. */ readonly bounds: Box3; /** Active drag plane. Mutate in-place (e.g. setFromNormalAndCoplanarPoint). */ readonly dragPlane: Plane; /** Drag-attachment hit point in the dragged object's local space. Mutate to reposition. */ readonly hitPointInLocalSpace: Vector3; /** Surface normal at the drag-attachment point in the dragged object's local space. */ readonly hitNormalInLocalSpace: Vector3; /** Realigns the drag plane to the current view direction. */ setPlaneViewAligned(worldPoint: Vector3, useUpAngle: boolean): boolean; } /** * Extension point for per-mode drag plane setup and per-frame updates. * All built-in modes have a registered strategy in `_dragStrategyRegistry`. * {@link SnapToSurfacesDragStrategy} is the stateful reference implementation. */ export interface IDragPlaneStrategy { /** * Whether the handler should ray-cast into `dragPlane` to position the follow object. * Return `false` for modes (e.g. Attached) that move the follow object by another means. */ readonly requiresPlaneIntersection: boolean; /** * Set the initial drag plane at drag start. Called once per drag. * @param context Mutable handler state bag. * @param hitWP World-space point where the pointer hit the object. * @param rayDirection World-space forward direction of the drag source. */ initialize(context: IDragStrategyContext, hitWP: Vector3, rayDirection: Vector3): void; /** Reset all per-drag state. Called internally by initialize(). */ reset(): void; /** * Update the drag plane for the current frame. Most modes are a no-op. * @returns `true` if a surface hit was found, `false` if not, `null` to abort * the remainder of the frame's position update (drag hasn't started yet). */ update(context: IDragStrategyContext, ray: Ray, dragSource: IGameObject, draggedObject: Object3D | null): boolean | null; /** * Optional: return the constraints this strategy needs injected into the pipeline. * Called once per drag in onDragStart (for single-pointer) and at multi-touch drag * start (for two-pointer). Each call must return **fresh** constraint instances so * callers do not share internal state. * @param cx Snapshot of the dragged object and attachment point at drag start. */ getConstraints?(cx: IDragConstraintContext): IDragConstraint[]; /** * Optional: called by {@link MultiTouchDragHandler} every frame with the current * pinch/two-pointer scale ratio. Strategies that adjust constraints based on scale * (e.g. {@link XZPlaneDragStrategy} keeping the bounds bottom grounded) should * implement this. Pass `1` when `keepScale` is true so the correction is a no-op. */ onTwoPointerScaleUpdate?(ratio: number): void; /** * Optional: if this strategy locks the dragged object's rotation to a single world-space * axis (e.g. {@link XZPlaneDragStrategy} yaw around the plane normal), return that axis. * {@link MultiTouchDragHandler} uses it to map the on-screen two-finger twist directly to * yaw around this axis, keeping rotation controllable from any view angle (including * side-on). The vector is read live (not copied) and must stay unit-length for the drag. * Undefined ⇒ free rotation. */ readonly yawConstraintAxis?: Vector3; } /** * Manages the per-frame surface-detection and drag-plane updates for {@link DragMode.SnapToSurfaces}. */ class SnapToSurfacesDragStrategy implements IDragPlaneStrategy { private _draggedOverObject: Object3D | null = null; private _draggedOverObjectDuration: number = 0; private _draggedOverObjectLastSetUp: Object3D | null = null; private _draggedOverObjectLastNormal: Vector3 = new Vector3(); private _lastSurfacePlaneRefreshTime: number = 0; private _hasLastSurfaceHitPoint: boolean = false; private readonly _lastSurfaceHitPoint: Vector3 = new Vector3(); /** Context stored at initialize() time so getConstraints() can pass it to the multi-touch constraint. */ private _context: Context | null = null; reset(): void { this._draggedOverObject = null; this._draggedOverObjectDuration = 0; this._draggedOverObjectLastSetUp = null; this._draggedOverObjectLastNormal.set(0, 0, 0); this._lastSurfacePlaneRefreshTime = 0; this._hasLastSurfaceHitPoint = false; } readonly requiresPlaneIntersection = true; initialize(cx: IDragStrategyContext, hitWP: Vector3, _rayDirection: Vector3): void { this._context = cx.context; cx.setPlaneViewAligned(hitWP, false); this.reset(); } update(cx: IDragStrategyContext, ray: Ray, dragSource: IGameObject, draggedObject: Object3D | null): boolean | null { const didHaveSurfaceHitPointLastFrame = this._hasLastSurfaceHitPoint; this._hasLastSurfaceHitPoint = false; let didHit = false; // Idea: Do a sphere cast if we're still in the proximity of the current draggedObject. // This would allow dragging slightly out of the object's bounds and still continue snapping to it. // Do a regular raycast (without the dragged object) to determine if we should change what is dragged onto. const hits = cx.context.physics.raycastFromRay(ray, { testObject: (o: Object3D) => o !== cx.followObject && o !== dragSource && o !== draggedObject// && !(o instanceof GroundedSkybox) }); if (hits.length > 0) { const hit = hits[0]; // if we're above the same surface for a specified time, adjust drag options: // - set that surface as the drag "plane". We will follow that object's surface instead now (raycast onto only that) // - if the drag plane is an object, we also want to // - calculate an initial rotation offset matching what surface/face the user originally started the drag on // - rotate the dragged object to match the surface normal if (this._draggedOverObject === hit.object) this._draggedOverObjectDuration += cx.context.time.deltaTime; else { this._draggedOverObject = hit.object; this._draggedOverObjectDuration = 0; } if (hit.face) { didHit = true; this._hasLastSurfaceHitPoint = true; this._lastSurfaceHitPoint.copy(hit.point); // TODO: if the "hit.normal" is undefined we use the hit.face.normal which is still localspace const worldNormal = getTempVector(hit.normal || hit.face.normal).applyQuaternion(hit.object.worldQuaternion); // Wait until the drag has actually started (enough hover time or pointer movement) // before snapping to the surface, so a click that never becomes a drag doesn't move // the object. Bail out BEFORE touching any snap state: the surface/normal bookkeeping // below must only advance once a real drag is underway, otherwise the change-detection // trigger is consumed on a pre-threshold frame and the anchor/plane refresh is skipped // on the first frame that actually moves the object (leaving it on the stale // view-aligned plane until the 1 s periodic refresh). const dragTimeSatisfied = this._draggedOverObjectDuration >= 0.15; const dragDistanceSatisfied = cx.totalMovement.length() >= 0.001; if (!(dragTimeSatisfied || dragDistanceSatisfied)) { return null; // abort frame update — drag hasn't really started yet } // Refresh the anchor + drag plane when the surface object or its normal changes, and // periodically on flat same-normal surfaces (e.g. stepping across multi-level floors). // _draggedOverObjectLastNormal is stored in WORLD space to match worldNormal — it used // to store the local face normal, so on any rotated surface the dot never reached 1 and // this fired every frame. const surfaceChanged = this._draggedOverObjectLastSetUp !== this._draggedOverObject || this._draggedOverObjectLastNormal.dot(worldNormal) < 0.999999; const needsRefresh = surfaceChanged || (cx.context.time.time - this._lastSurfacePlaneRefreshTime) >= 1.0; if (needsRefresh) { this._lastSurfacePlaneRefreshTime = cx.context.time.time; this._draggedOverObjectLastSetUp = this._draggedOverObject; this._draggedOverObjectLastNormal.copy(worldNormal); // Snap the OBJECT ORIGIN under the cursor: the origin's lateral position follows the // pointer→surface hit while the object's contact face rests on the surface. Where the // user grabbed is ignored, so placement is consistent regardless of grab point. // The origin is (0,0,0) in local space, so origin·normal = 0 and the anchor collapses // to the contact face projected onto the normal — the same anchor the multi-touch // SnapToSurfaceConstraint uses, so single- and two-pointer snapping now match. const center = getTempVector(); const size = getTempVector(); cx.bounds.getCenter(center); cx.bounds.getSize(size); // Contact face of the bounds along the surface normal (the face that rests on it). // NOTE: bounds are local/axis-aligned, so this assumes the object is upright w.r.t. the // surface normal — exact for the common keepRotation case, approximate if tilted. center.sub(size.multiplyScalar(0.5).multiply(worldNormal)); const contactAlongNormal = center.dot(worldNormal); cx.hitPointInLocalSpace.copy(worldNormal).multiplyScalar(contactAlongNormal); cx.hitNormalInLocalSpace.copy(hit.face.normal); // Drag plane sits on the surface; the origin-anchor lands on the pointer→surface hit. cx.dragPlane.setFromNormalAndCoplanarPoint(worldNormal, hit.point); } } } else if (didHaveSurfaceHitPointLastFrame) { if (cx.gameObject) cx.setPlaneViewAligned(cx.gameObject.worldPosition, false); } return didHit; } getConstraints(ctx: IDragConstraintContext): IDragConstraint[] { // Single-pointer (boundsAtScaleOne === null): the existing update() + plane-intersection // path handles surface snapping; no constraint needed here. // Multi-touch (boundsAtScaleOne !== null): inject a constraint that snaps the follow // object onto the surface the fingers point at. MultiTouchDragHandler aims its ray // (pointer origins → grab midpoint) every frame, mirroring the single-pointer raycast. if (!ctx.boundsAtScaleOne || !this._context) return []; const c = new SnapToSurfaceConstraint(this._context); c.init(ctx); return [c]; } } /** Drags the object along the parent's local XZ plane at the height where the pointer hit. * Falls back to world XZ when the object has no parent. * * Side-view fallback: when the view ray is nearly parallel to the XZ plane (angle < ~6°) * the active drag plane is switched to a vertical plane whose normal is the horizontal * component of the ray direction (Y stripped, re-normalised). The GrabPointPlaneConstraint * then collapses the intersection back onto xzPlane, so the object can only slide along * the one visible horizontal axis. Hysteresis prevents flickering at the threshold boundary. * * The fallback coplanar point is captured once when entering fallback mode (projected from * the object's world position onto xzPlane). Using a stable anchor avoids the feedback loop * where a lagging lerp position would shift the plane each frame and amplify the lag. * * Must be per-handler (not a shared singleton) because it holds per-drag state. */ class XZPlaneDragStrategy implements IDragPlaneStrategy { readonly requiresPlaneIntersection = true; /** The fixed XZ plane established at drag start. Used as the clamping target. */ readonly xzPlane = new Plane(); /** Two-finger twist maps to yaw around this axis — the plane normal (parent's world up). */ get yawConstraintAxis(): Vector3 { return this.xzPlane.normal; } private _inFallback = false; /** Stable world-space point on xzPlane, captured once when entering fallback mode. */ private readonly _fallbackAnchor = new Vector3(); /** Reference to the most recently allocated PlaneHeightLockConstraint (from the last * getConstraints call). Updated by onTwoPointerScaleUpdate to keep bounds grounded. */ private _heightLock: PlaneHeightLockConstraint | null = null; initialize(cx: IDragStrategyContext, hitWP: Vector3, _rayDirection: Vector3): void { const up = getTempVector(0, 1, 0); if (cx.gameObject?.parent) { up.transformDirection(cx.gameObject.parent.matrixWorld); } cx.dragPlane.setFromNormalAndCoplanarPoint(up, hitWP); this.xzPlane.copy(cx.dragPlane); this._inFallback = false; } reset(): void { this._inFallback = false; } /** * Allocates fresh constraint instances and initializes each via {@link IDragConstraint.init}. * Each call produces independent objects so single-pointer and multi-touch * pipelines never share constraint state. */ getConstraints(cx: IDragConstraintContext): IDragConstraint[] { const planeConstraint = new GrabPointPlaneConstraint(this.xzPlane); planeConstraint.init(cx); const heightLock = new PlaneHeightLockConstraint(this.xzPlane); heightLock.init(cx); this._heightLock = heightLock; // Restrict the object to rotate only around the plane normal (yaw on the surface). // AxisRotationConstraint uses swing-twist decomposition so it works correctly even // when the parent is tilted and the plane normal is not world Y. const rotLock = new AxisRotationConstraint(this.xzPlane.normal); rotLock.init(cx); return [planeConstraint, heightLock, rotLock]; } onTwoPointerScaleUpdate(ratio: number): void { if (this._heightLock) this._heightLock.currentScaleRatio = ratio; } update(cx: IDragStrategyContext, ray: Ray): false { // dot(rayDir, planeNormal) near 0 → ray nearly parallel → side-on view. const parallelness = Math.abs(ray.direction.dot(this.xzPlane.normal)); // Hysteresis: enter side-view mode below 0.1 (~6°), exit above 0.2 (~12°). if (!this._inFallback && parallelness < 0.1) { this._inFallback = true; // Capture a stable anchor on xzPlane projected from the object's current position. // Using a one-time snapshot avoids a feedback loop with the lerping dragged object. this.xzPlane.projectPoint( cx.gameObject ? cx.gameObject.worldPosition : ray.origin, this._fallbackAnchor ); } else if (this._inFallback && parallelness > 0.2) this._inFallback = false; if (this._inFallback) { // Use the horizontal component of the ray direction as the plane normal. // Stripping Y prevents vertical cursor movement from bleeding into depth. const hFwd = getTempVector(ray.direction.x, 0, ray.direction.z); if (hFwd.lengthSq() > 1e-6) hFwd.normalize(); cx.dragPlane.setFromNormalAndCoplanarPoint(hFwd, this._fallbackAnchor); } else { cx.dragPlane.copy(this.xzPlane); } return false; } } /** Drags the object along the surface normal at the initial raycast hit point. */ class HitNormalDragStrategy implements IDragPlaneStrategy { readonly requiresPlaneIntersection = true; initialize(cx: IDragStrategyContext, hitWP: Vector3, _rayDirection: Vector3): void { if (!cx.gameObject) return; const hitNormal = cx.hitNormalInLocalSpace.clone(); hitNormal.transformDirection(cx.gameObject.matrixWorld); cx.dragPlane.setFromNormalAndCoplanarPoint(hitNormal, hitWP); } reset(): void {} update(): false { return false; } } /** Drags the object attached to the pointer — screen plane in 2D, controller plane in XR. */ class AttachedDragStrategy implements IDragPlaneStrategy { readonly requiresPlaneIntersection = false; initialize(cx: IDragStrategyContext, hitWP: Vector3, rayDirection: Vector3): void { cx.dragPlane.setFromNormalAndCoplanarPoint(rayDirection, hitWP); } reset(): void {} update(): false { return false; } } /** Auto-selects XZ or screen plane based on the viewing angle. */ class DynamicViewAngleDragStrategy implements IDragPlaneStrategy { readonly requiresPlaneIntersection = true; initialize(cx: IDragStrategyContext, hitWP: Vector3, _rayDirection: Vector3): void { cx.setPlaneViewAligned(hitWP, true); } reset(): void {} update(): false { return false; } } /** No-op strategy for {@link DragMode.None}. */ class NoDragStrategy implements IDragPlaneStrategy { readonly requiresPlaneIntersection = false; initialize(): void {} reset(): void {} update(): false { return false; } } /** * Module-level registry of strategy factories keyed by {@link DragMode}. * Each call produces a fresh instance, so stateful strategies (SnapToSurfaces, XZPlane) * are safe to include alongside the stateless ones. */ const _dragStrategyRegistry = new Map IDragPlaneStrategy>([ [DragMode.SnapToSurfaces, () => new SnapToSurfacesDragStrategy()], [DragMode.XZPlane, () => new XZPlaneDragStrategy()], [DragMode.HitNormal, () => new HitNormalDragStrategy()], [DragMode.Attached, () => new AttachedDragStrategy()], [DragMode.DynamicViewAngle, () => new DynamicViewAngleDragStrategy()], [DragMode.None, () => new NoDragStrategy()], ]); // #endregion // #region DragPointerHandler /** * Handles a single pointer on an object. * DragPointerHandlers manage determining if a drag operation has started, tracking pointer movement, * and controlling object translation based on the drag mode. */ class DragPointerHandler implements IDragHandler { /** * Returns the accumulated movement of the pointer in world units. * Used for determining if enough motion has occurred to start a drag. */ getTotalMovement(): Vector3 { return this._totalMovement; } /** * Returns the object that follows the pointer during dragging operations. */ get followObject(): GameObject { return this._followObject; } /** * Returns the point where the pointer initially hit the object in local space. */ get hitPointInLocalSpace(): Vector3 { return this._hitPointInLocalSpace; } /** The active drag strategy for the current drag. Exposed so {@link MultiTouchDragHandler} * can delegate constraint setup to the same strategy without duplicating mode logic. */ get currentStrategy(): IDragPlaneStrategy { return this._currentStrategy; } /** Local-space bounding box of the dragged object computed at drag start with scale = 1. * Exposed so {@link MultiTouchDragHandler} can compute bounds-bottom offsets for scale correction. */ get boundsAtScaleOne(): Box3 { return this._bounds; } private context: Context; private gameObject: Object3D | null; private settings: DragControls; private _lastRig: IGameObject | undefined = undefined; /** This object is placed at the pivot of the dragged object, and parented to the control space. */ private _followObject: GameObject; private _totalMovement: Vector3 = new Vector3(); /** Motion along the pointer ray. On screens this doesn't change. In XR it can be used to determine how much * effort someone is putting into moving an object closer or further away. */ private _totalMovementAlongRayDirection: number = 0; /** Distance between _followObject and its parent at grab start, in local space */ private _grabStartDistance: number = 0; private _deviceMode!: XRTargetRayMode | "transient-pointer"; private _followObjectStartPosition: Vector3 = new Vector3(); private _followObjectStartQuaternion: Quaternion = new Quaternion(); private _followObjectStartWorldQuaternion: Quaternion = new Quaternion(); private _lastDragPosRigSpace: Vector3 | undefined; private _tempVec: Vector3 = new Vector3(); private _tempMat: Matrix4 = new Matrix4(); private _hitPointInLocalSpace: Vector3 = new Vector3(); private _hitNormalInLocalSpace: Vector3 = new Vector3(); private _bottomCenter = new Vector3(); private _backCenter = new Vector3(); private _backBottomCenter = new Vector3(); private _bounds = new Box3(); private _dragPlane = new Plane(new Vector3(0, 1, 0)); /** Active strategy for the current drag. Replaced with a fresh instance each drag start. */ private _currentStrategy: IDragPlaneStrategy = _dragStrategyRegistry.get(DragMode.None)!(); /** Mutable context bag passed to _snapToSurfaces each frame. Initialized in constructor. */ private _snapContext!: IDragStrategyContext; /** Grid-snap constraint; resolution synced from settings before each constraint run. */ private _gridSnapConstraint!: GridSnapConstraint; /** Rotation-lock constraint; references the persistent _followObjectStartWorldQuaternion. */ private _keepRotationConstraint!: KeepRotationConstraint; /** Active constraint instances for the current drag. Rebuilt in onDragStart. */ private _activeConstraints: IDragConstraint[] = []; /** The GrabPointPlaneConstraint returned by the active strategy (if any). Cached here * so onDragUpdate can sync snapResolution without knowing the concrete strategy type. */ private _grabPointPlaneConstraint: GrabPointPlaneConstraint | null = null; /** Allows overriding which object is dragged while a drag is already ongoing. Used for example by Duplicatable */ setTargetObject(obj: Object3D | null) { this.gameObject = obj; } constructor(dragControls: DragControls, gameObject: Object3D) { this.settings = dragControls; this.context = dragControls.context; this.gameObject = gameObject; this._followObject = new Object3D() as GameObject; this._snapContext = { context: this.context, followObject: this._followObject, gameObject: this.gameObject, totalMovement: this._totalMovement, bounds: this._bounds, dragPlane: this._dragPlane, hitPointInLocalSpace: this._hitPointInLocalSpace, hitNormalInLocalSpace: this._hitNormalInLocalSpace, setPlaneViewAligned: this.setPlaneViewAligned.bind(this), }; this._gridSnapConstraint = new GridSnapConstraint(0); this._keepRotationConstraint = new KeepRotationConstraint(); } /** * Re-derives the grab point from the current pointer ray ∩ drag plane so that when a * two-pointer drag hands control back to this single pointer, the object keeps its current * offset to the pointer instead of snapping the original (now stale) grab point back under it. * Only needed for plane-intersection strategies — Attached-style modes re-base fully via * {@link recenter}. Call BEFORE {@link recenter} so the recomputed grab-start distance and * the constraint re-initialization use the rebased grab point. */ rebaseGrabPointToPointer() { if (!this._currentStrategy.requiresPlaneIntersection) return; if (!this.gameObject || !this._followObject.parent) return; const dragSource = this._followObject.parent as IGameObject; const ray = new Ray(dragSource.worldPosition, dragSource.worldForward); if (!ray.intersectPlane(this._dragPlane, this._tempVec)) return; // Keep the new grab point on the constraint plane (it differs from _dragPlane in the // XZPlane side-view fallback) so GrabPointPlaneConstraint doesn't add a one-time offset. this._grabPointPlaneConstraint?.plane.projectPoint(this._tempVec, this._tempVec); this._hitPointInLocalSpace.copy(this.gameObject.worldToLocal(this._tempVec)); } recenter() { if (!this._followObject.parent) { console.warn("Error: space follow object doesn't have parent but recenter() is called. This is likely a bug"); return; } if (!this.gameObject) { console.warn("Error: space follow object doesn't have a gameObject"); return; } const p = this._followObject.parent as GameObject; this.gameObject.add(this._followObject); this._followObject.matrixAutoUpdate = false; this._followObject.position.set(0, 0, 0); this._followObject.quaternion.set(0, 0, 0, 1); this._followObject.scale.set(1, 1, 1); this._followObject.updateMatrix(); this._followObject.updateMatrixWorld(true); p.attach(this._followObject); // p.attach sets position/quaternion/scale to preserve world transform, but since matrixAutoUpdate // is false, the local matrix is NOT rebuilt automatically. Without this updateMatrix() call, // worldQuaternion decomposes (parent.matrixWorld * stale-identity-matrix) = p.worldQuaternion // instead of gameObject.worldQuaternion — causing wild rotation when the parent is rotated. this._followObject.updateMatrix(); this._followObjectStartPosition.copy(this._followObject.position); this._followObjectStartQuaternion.copy(this._followObject.quaternion); this._followObjectStartWorldQuaternion.copy(this._followObject.worldQuaternion); this._followObject.updateMatrix(); this._followObject.updateMatrixWorld(true); const hitPointWP = this._hitPointInLocalSpace.clone(); this.gameObject.localToWorld(hitPointWP); this._grabStartDistance = hitPointWP.distanceTo(p.worldPosition); const rig = NeedleXRSession.active?.rig?.gameObject; const rigScale = rig?.worldScale.x || 1; this._grabStartDistance /= rigScale; this._totalMovementAlongRayDirection = 0; this._lastDragPosRigSpace = undefined; // Re-initialize all constraints to the current object state. // Ensures that after a multi-touch session (e.g. pinch-scale shifted the pivot), // all locked reference values (height, rotation, scale) reflect the actual // current state rather than the original drag-start snapshot. const reinitCtx: IDragConstraintContext = { hitPointInLocalSpace: this._hitPointInLocalSpace, hitNormalInLocalSpace: this._hitNormalInLocalSpace, gameObject: this.gameObject, boundsAtScaleOne: null, initialWorldScale: null, }; for (const c of this._activeConstraints) { c.init?.(reinitCtx); } if (debug) { Gizmos.DrawLine(hitPointWP, p.worldPosition, 0x00ff00, 0.5, false); Gizmos.DrawLabel(p.worldPosition.add(new Vector3(0, 0.1, 0)), this._grabStartDistance.toFixed(2), 0.03, 0.5); } } onDragStart(args: PointerEventData) { if (!this.gameObject) { console.warn("Error: space follow object doesn't have a gameObject"); return; } args.event.space.add(this._followObject); // prepare for drag, we will start dragging after an object has been dragged for a few centimeters this._lastDragPosRigSpace = undefined; if (args.point && args.normal) { this._hitPointInLocalSpace.copy(args.point); this.gameObject.worldToLocal(this._hitPointInLocalSpace); this._hitNormalInLocalSpace.copy(args.normal); } else if (args) { // can happen for e.g. close grabs; we can assume/guess a good hit point and normal based on the object's bounds or so // convert controller world position to local space instead and use that as hit point const controller = args.event.space as GameObject; const controllerWp = controller.worldPosition; this.gameObject.worldToLocal(controllerWp); this._hitPointInLocalSpace.copy(controllerWp); const controllerUp = controller.worldUp; this._tempMat.copy(this.gameObject.matrixWorld).invert(); controllerUp.transformDirection(this._tempMat); this._hitNormalInLocalSpace.copy(controllerUp); } this.recenter(); this._totalMovement.set(0, 0, 0); this._deviceMode = args.mode; const dragSource = this._followObject.parent as IGameObject; const rayDirection = dragSource.worldForward; const profile = isSpatialInput(this._deviceMode) ? this.settings.xrProfile : this.settings.screenProfile; const dragMode = profile.dragMode; // set up drag plane; we don't really know the normal yet but we can already set the point const hitWP = this._hitPointInLocalSpace.clone(); this.gameObject.localToWorld(hitWP); this._currentStrategy = (_dragStrategyRegistry.get(dragMode) ?? _dragStrategyRegistry.get(DragMode.None)!)(); this._currentStrategy.initialize(this._snapContext, hitWP, rayDirection); // Build the constraint pipeline for this drag. // Strategy-owned constraints come first (e.g. XZPlaneDragStrategy's GrabPointPlaneConstraint // which handles both plane-clamping and snap-on-plane, replacing the old _gridSnapConstraint.plane wiring). const keepRotation = profile.keepRotation; const constraintCx: IDragConstraintContext = { hitPointInLocalSpace: this._hitPointInLocalSpace, hitNormalInLocalSpace: this._hitNormalInLocalSpace, gameObject: this.gameObject, boundsAtScaleOne: null, initialWorldScale: null, }; const strategyConstraints = this._currentStrategy.getConstraints?.(constraintCx) ?? []; // Cache the GrabPointPlaneConstraint (if any) for snap-resolution sync in onDragUpdate. this._grabPointPlaneConstraint = strategyConstraints.find( c => c instanceof GrabPointPlaneConstraint ) as GrabPointPlaneConstraint ?? null; // Only add the world-space GridSnapConstraint for modes that don't own their own snap. const useWorldSnap = strategyConstraints.length === 0; this._activeConstraints = [ ...strategyConstraints, ...(useWorldSnap ? [this._gridSnapConstraint] : []), ]; if (keepRotation) { this._keepRotationConstraint.init(constraintCx); this._activeConstraints.push(this._keepRotationConstraint); } // calculate bounding box and snapping points. We want to either snap the "back" point or the "bottom" point. // const bbox = new Box3(); const p = this.gameObject.parent; const localP = this.gameObject.position.clone(); const localQ = this.gameObject.quaternion.clone(); const localS = this.gameObject.scale.clone(); // save the original matrix world (because if some other script is doing a raycast at the same moment the matrix will not be correct anymore....) const matrixWorld = this.gameObject.matrixWorld.clone(); if (p) p.remove(this.gameObject); this.gameObject.position.set(0, 0, 0); this.gameObject.quaternion.set(0, 0, 0, 1); this.gameObject.scale.set(1, 1, 1); const bbox = getBoundingBox([this.gameObject]); // we force the bbox to include our own point *because* the DragControls might be attached to an empty object (which isnt included in the bounding box call above) bbox.expandByPoint(this.gameObject.worldPosition); // console.log(this.gameObject.position.y - bbox.min.y) // bbox.min.y += (this.gameObject.position.y - bbox.min.y); // get front center point of the bbox. basically (0, 0, 1) in local space const bboxCenter = new Vector3(); bbox.getCenter(bboxCenter); const bboxSize = new Vector3(); bbox.getSize(bboxSize); // attachment points for dragging this._bottomCenter.copy(bboxCenter.clone().add(new Vector3(0, -bboxSize.y / 2, 0))); this._backCenter.copy(bboxCenter.clone().add(new Vector3(0, 0, bboxSize.z / 2))); this._backBottomCenter.copy(bboxCenter.clone().add(new Vector3(0, -bboxSize.y / 2, bboxSize.z / 2))); this._bounds.copy(bbox); // restore original transform if (p) p.add(this.gameObject); this.gameObject.position.copy(localP); this.gameObject.quaternion.copy(localQ); this.gameObject.scale.copy(localS); this.gameObject.matrixWorld.copy(matrixWorld); // surface snapping — reset is now handled inside each strategy's initialize() } collectMovementInfo() { // we're dragging - there is a controlling object if (!this._followObject.parent) return; const dragSource = this._followObject.parent as IGameObject; this._followObject.updateMatrix(); const dragPosRigSpace = dragSource.worldPosition; const rig = NeedleXRSession.active?.rig?.gameObject; if (rig) rig.worldToLocal(dragPosRigSpace); // sum up delta if (this._lastDragPosRigSpace === undefined || rig != this._lastRig) { this._lastDragPosRigSpace = dragPosRigSpace.clone(); this._lastRig = rig; } this._tempVec.copy(dragPosRigSpace).sub(this._lastDragPosRigSpace); const rayDirectionRigSpace = dragSource.worldForward; if (rig) { this._tempMat.copy(rig.matrixWorld).invert(); rayDirectionRigSpace.transformDirection(this._tempMat); } // sum up delta movement along ray this._totalMovementAlongRayDirection += rayDirectionRigSpace.dot(this._tempVec); this._tempVec.x = Math.abs(this._tempVec.x); this._tempVec.y = Math.abs(this._tempVec.y); this._tempVec.z = Math.abs(this._tempVec.z); // sum up absolute total movement this._totalMovement.add(this._tempVec); this._lastDragPosRigSpace.copy(dragPosRigSpace); if (debug) { let wp = dragPosRigSpace; // ray direction of the input source object if (rig) { wp = wp.clone(); wp.transformDirection(rig.matrixWorld); } Gizmos.DrawRay(wp, rayDirectionRigSpace, 0x0000ff); } } onDragUpdate(numberOfPointers: number) { // can only handle a single pointer // if there's more, we defer to multi-touch drag handlers if (numberOfPointers > 1) return; const draggedObject = this.gameObject as IGameObject | null; if (!draggedObject || !this._followObject) { console.warn("Warning: DragPointerHandler doesn't have a dragged object. This is likely a bug."); return; } // Safety: the object may have been deleted while dragging. if (!draggedObject.parent) return; const dragSource = this._followObject.parent as IGameObject | null; if (!dragSource) { console.warn("Warning: DragPointerHandler doesn't have a drag source. This is likely a bug."); return; } this._followObject.updateMatrix(); const dragSourceWP = dragSource.worldPosition; const rayDirection = dragSource.worldForward; // Actually move and rotate draggedObject const isTrackedPointerInput = this._deviceMode === "tracked-pointer" || this._deviceMode === "transient-pointer"; const profile = isSpatialInput(this._deviceMode) ? this.settings.xrProfile : this.settings.screenProfile; const keepRotation = profile.keepRotation; const dragMode = profile.dragMode; if (dragMode === DragMode.None) return; const lerpStrength = 10; if (keepRotation) this._followObject.worldQuaternion = this._followObjectStartWorldQuaternion; this._followObject.updateMatrix(); this._followObject.updateMatrixWorld(true); // Acceleration for moving the object - move followObject along the ray distance by _totalMovementAlongRayDirection let currentDist = 1.0; let lerpFactor = 2.0; if (isTrackedPointerInput && this._grabStartDistance > 0.5) // hands and controllers, but not touches { const factor = 1 + this._totalMovementAlongRayDirection * (2 * profile.distanceDragFactor); currentDist = Math.max(0.0, factor); currentDist = currentDist * currentDist * currentDist; } else if (this._grabStartDistance <= 0.5) { // TODO there's still a frame delay between dragged objects and the hand models lerpFactor = 3.0; } // reset _followObject to its original position and rotation this._followObject.position.copy(this._followObjectStartPosition); if (!keepRotation) this._followObject.quaternion.copy(this._followObjectStartQuaternion); // Distance dragging: move the grabbed HIT POINT along the ray, not the pivot. // When pulling a large object toward you, the grabbed point should reach the controller // but not go past it, preventing the object from "slapping" into your head. if (isTrackedPointerInput && this._grabStartDistance > 0.5) { // _hitPointInLocalSpace is in gameObject-local space. // _followObject.quaternion = space^-1 * gameObject_world_rotation, so applying it // transforms the hit point offset from gameObject-local → space-local coordinates. const hitPointOffset = this._hitPointInLocalSpace.clone(); hitPointOffset.applyQuaternion(this._followObject.quaternion); // The grab ray in space-local goes from the space origin through the initial hit point // position (pivot + offset). Normalizing this gives the correct ray direction, which // differs from normalize(pivot) whenever the hit point is not at the pivot. const rayDir = getTempVector(this._followObjectStartPosition).add(hitPointOffset).normalize(); // Scale the hit point along the ray to targetDistance, clamped to a minimum so // the hit point never passes through the controller (~10 cm). const targetDistance = Math.max(0.1, currentDist * this._grabStartDistance); this._followObject.position .copy(rayDir.multiplyScalar(targetDistance)) .sub(hitPointOffset); } else { // For close grabs or non-XR, use simple scaling of the pivot point. this._followObject.position.multiplyScalar(currentDist); } this._followObject.updateMatrix(); const ray = new Ray(dragSourceWP, rayDirection); // Per-mode drag plane update. No-op for most modes; SnapToSurfaces does the surface raycast here. this._snapContext.gameObject = this.gameObject; const stratResult = this._currentStrategy.update(this._snapContext, ray, dragSource, draggedObject); if (stratResult === null) return; // SnapToSurfaces: drag hasn't started enough yet // Raycast along the ray to the drag plane and move _followObject so that the grabbed point stays at the hit point // Drag on plane: if (this._currentStrategy.requiresPlaneIntersection && ray.intersectPlane(this._dragPlane, this._tempVec)) { this._followObject.worldPosition = this._tempVec; this._followObject.updateMatrix(); this._followObject.updateMatrixWorld(true); const newWP = getTempVector(this._hitPointInLocalSpace)//.clone(); this._followObject.localToWorld(newWP); if (debug) { Gizmos.DrawLine(newWP, this._tempVec, 0x00ffff, 0, false); } this._followObject.worldPosition = this._tempVec.multiplyScalar(2).sub(newWP); this._followObject.updateMatrix(); } this._gridSnapConstraint.snapGridResolution = this.settings.snapGridResolution; if (this._grabPointPlaneConstraint) this._grabPointPlaneConstraint.snapResolution = this.settings.snapGridResolution; applyFollowObjectConstraints(this._followObject, this._activeConstraints); // TODO should use unscaled time here // some test for lerp speed depending on distance const t = Mathf.clamp01(this.context.time.deltaTime * lerpStrength * lerpFactor);// / (currentDist - 1 + 0.01)); const t_rotation = Mathf.clamp01(this.context.time.deltaTime * lerpStrength * .5 * lerpFactor); const wp = draggedObject.worldPosition; wp.lerp(this._followObject.worldPosition, t); draggedObject.worldPosition = wp; // Rotation is only applied for spatial (XR controller / hand) input. // Screen and touch single-pointer drags are translation-only: the object must // never rotate from a single finger or mouse gesture regardless of keepRotation. if (isTrackedPointerInput) { const rot = draggedObject.worldQuaternion; rot.slerp(this._followObject.worldQuaternion, t_rotation); draggedObject.worldQuaternion = rot; } if (draggedObject.matrixAutoUpdate === false) { draggedObject.updateMatrix(); } if (debug) { const hitPointWP = this._hitPointInLocalSpace.clone(); draggedObject.localToWorld(hitPointWP); // draw grab attachment point and normal. They are in grabbed object space Gizmos.DrawSphere(hitPointWP, 0.02, 0xff0000); const hitNormalWP = this._hitNormalInLocalSpace.clone(); hitNormalWP.applyQuaternion(draggedObject.worldQuaternion); Gizmos.DrawRay(hitPointWP, hitNormalWP, 0xff0000); // debug info Gizmos.DrawLabel(wp.add(new Vector3(0, 0.25, 0)), `Distance: ${this._totalMovement.length().toFixed(2)}\n Along Ray: ${this._totalMovementAlongRayDirection.toFixed(2)}\n Session: ${!!NeedleXRSession.active}\n Device: ${this._deviceMode}\n `, 0.03 ); // draw bottom/back snap points const bottomCenter = this._bottomCenter.clone(); const backCenter = this._backCenter.clone(); const backBottomCenter = this._backBottomCenter.clone(); draggedObject.localToWorld(bottomCenter); draggedObject.localToWorld(backCenter); draggedObject.localToWorld(backBottomCenter); Gizmos.DrawSphere(bottomCenter, 0.01, 0x00ff00, 0, false); Gizmos.DrawSphere(backCenter, 0.01, 0x0000ff, 0, false); Gizmos.DrawSphere(backBottomCenter, 0.01, 0xff00ff, 0, false); Gizmos.DrawLine(bottomCenter, backBottomCenter, 0x00ffff, 0, false); Gizmos.DrawLine(backBottomCenter, backCenter, 0x00ffff, 0, false); } } onDragEnd(args: PointerEventData) { console.assert(this._followObject.parent === args.event.space, "Drag end: _followObject is not parented to the space object"); this._followObject.removeFromParent(); this._followObject.destroy(); this._lastDragPosRigSpace = undefined; } private setPlaneViewAligned(worldPoint: Vector3, useUpAngle: boolean) { if (!this._followObject.parent) { return false; } const viewDirection = (this._followObject.parent as IGameObject).worldForward;; const v0 = getTempVector(0, 1, 0); const v1 = viewDirection; const angle = v0.angleTo(v1); const angleThreshold = 0.5; if (useUpAngle && (angle > Math.PI / 2 + angleThreshold || angle < Math.PI / 2 - angleThreshold)) this._dragPlane.setFromNormalAndCoplanarPoint(v0, worldPoint); else this._dragPlane.setFromNormalAndCoplanarPoint(viewDirection, worldPoint); return true; } } // #endregion