UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

1,085 lines (942 loc) 88.9 kB
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<Object3D> = 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<Object3D, IDragHandler> = 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(); // #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; 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; 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); // 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 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. Defaults to [0.01, 10000] relative to initial scale. */ private readonly _scaleLimitConstraint: ScaleLimitConstraint = new ScaleLimitConstraint(0.01, 10000, 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; 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: delta-frame approach instead of per-frame lookAt. // lookAt recomputes the full world orientation every frame using camera.worldUp as the // stabilising axis, which breaks when the A→B vector aligns with that up and causes // flips or jitter. Here we instead accumulate only the angular delta of A→B between // consecutive frames via setFromUnitVectors, so translation never contaminates rotation // and camera tilt has no effect after the initial seed. this._currABDir.subVectors(this._tempVec2, this._tempVec1); if (this._currABDir.lengthSq() > 1e-10) { this._currABDir.normalize(); if (this._isFirstAlignFrame) { // Seed the initial orientation once with lookAt so _manipulatorRotOffset // (captured right after this call in onDragStart) is consistent with the // dragged object's starting rotation. 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; } 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). 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); } } 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); 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; } /** * 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(); /** Original grab point (object local space) captured at drag start. Used to preserve the * surface-plane component of the grab offset when snapping to surfaces. */ private readonly _originalHitPointInLocalSpace: 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; this._originalHitPointInLocalSpace.set(0, 0, 0); } readonly requiresPlaneIntersection = true; initialize(cx: IDragStrategyContext, hitWP: Vector3, _rayDirection: Vector3): void { this._context = cx.context; cx.setPlaneViewAligned(hitWP, false); this.reset(); // Capture the exact point the user grabbed (in object local space). This is used // to preserve the surface-plane component of the grab offset while only adjusting // the normal-direction component to rest the object on the target surface. this._originalHitPointInLocalSpace.copy(cx.hitPointInLocalSpace); } 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 !==