@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
text/typescript
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).
*
* 
*
* **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 !==