@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.
986 lines • 81.8 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { AxesHelper, Box3, Matrix4, Object3D, Plane, Quaternion, Ray, Vector3 } from "three";
export { AxisRotationConstraint, GridSnapConstraint, GrabPointPlaneConstraint, KeepRotationConstraint, KeepScaleConstraint, ScaleLimitConstraint, RotationAxis, FixedRotationAxesConstraint, PlaneHeightLockConstraint, SnapToSurfaceConstraint, applyFollowObjectConstraints } from "./DragControlsConstraints.js";
import { AxisRotationConstraint, GridSnapConstraint, GrabPointPlaneConstraint, KeepRotationConstraint, ScaleLimitConstraint, 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 { getBoundingBox, getTempVector } from "../engine/engine_three_utils.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 { 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 = [];
/**
* 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) {
return mode === "tracked-pointer" || mode === "transient-pointer" || (NeedleXRSession.active?.isScreenBasedAR ?? false);
}
/**
* The DragMode determines how an object is dragged around in the scene.
*/
export var DragMode;
(function (DragMode) {
/** Object stays at the same horizontal plane as it started. Commonly used for objects on the floor */
DragMode[DragMode["XZPlane"] = 0] = "XZPlane";
/** 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. */
DragMode[DragMode["Attached"] = 1] = "Attached";
/** Object is dragged along the initial raycast hit normal. */
DragMode[DragMode["HitNormal"] = 2] = "HitNormal";
/** Combination of XZ and Screen based on the viewing angle. Low angles result in Screen dragging and higher angles in XZ dragging. */
DragMode[DragMode["DynamicViewAngle"] = 3] = "DynamicViewAngle";
/** The drag plane is snapped to surfaces in the scene while dragging. */
DragMode[DragMode["SnapToSurfaces"] = 4] = "SnapToSurfaces";
/** Don't allow dragging the object */
DragMode[DragMode["None"] = 5] = "None";
})(DragMode || (DragMode = {}));
/**
* 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 {
_dc;
_xr;
/** @internal — use {@link DragControls.screenProfile} or {@link DragControls.xrProfile} */
constructor(_dc, _xr) {
this._dc = _dc;
this._xr = _xr;
}
/** Active drag mode for this input type. */
get dragMode() { return this._xr ? this._dc.xrDragMode : this._dc.dragMode; }
/** Whether the dragged object's rotation is frozen during drag. */
get keepRotation() { return this._xr ? this._dc.xrKeepRotation : this._dc.keepRotation; }
/** Whether the dragged object's scale is frozen during two-pointer drag. */
get keepScale() { return this._xr ? this._dc.xrKeepScale : this._dc.keepScale; }
/** Multiplier for push/pull distance in XR; always 1 for screen input. */
get distanceDragFactor() { 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 {
/**
* Checks if any DragControls component is currently active with selected objects
* @returns True if any DragControls component is currently active
*/
static get HasAnySelected() { return this._activePointers.size > 0; }
/** Tracks individual pointer spaces that are currently dragging, preventing counter desync on missed pointer-up events. */
static _activePointers = new Set();
/**
* Retrieves a list of all DragControl components that are currently dragging objects.
* @returns Array of currently active DragControls components
*/
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 */
static _instances = [];
/**
* Determines how and where the object is dragged along. Different modes include
* dragging along a plane, attached to the pointer, or following surface normals.
*/
dragMode = DragMode.DynamicViewAngle;
/**
* Snaps dragged objects to a 3D grid with the specified resolution.
* Set to 0 to disable snapping.
*/
snapGridResolution = 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.
*/
keepRotation = 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.
*/
keepScale = 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.
*/
xrDragMode = 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.
*/
xrKeepRotation = 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.
*/
xrKeepScale = 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.
*/
xrDistanceDragFactor = 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.
*/
showGizmo = false;
/** Drag profile for screen / touch / mouse input. Reads live from the flat serialized fields. */
screenProfile = new DragProfile(this, false);
/** Drag profile for XR tracked-pointer and transient-pointer input. Reads live from the flat `xr*` serialized fields. */
xrProfile = new DragProfile(this, true);
/** Invoked once when a drag begins (after the minimum drag distance threshold is met). */
dragStarted = new EventList();
/** Invoked every frame while the object is being dragged. */
dragUpdated = new EventList();
/** Invoked once when the last pointer is released and the drag ends. */
dragEnded = 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) {
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;
}
}
}
_rigidbody = null;
/** The object to be dragged – we pass this to handlers when they are created */
_targetObject = null;
static lastHovered;
_draggingRigidbodies = [];
_potentialDragStartEvt = null;
_dragHandlers = new Map();
_totalMovement = new Vector3();
/** A marker is attached to components that are currently interacted with, to e.g. prevent them from being deleted. */
_marker = null;
_isDragging = false;
_didDrag = 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() {
DragControls._instances.push(this);
this.context.accessibility.updateElement(this, {
role: "button",
label: "Drag " + (this.gameObject.name || "object"),
hidden: false,
});
}
/** @internal */
onDisable() {
this.context.accessibility.updateElement(this, { hidden: true });
DragControls._instances = DragControls._instances.filter(i => i !== this);
}
onDestroy() {
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
*/
allowEdit(_obj = 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) {
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) {
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) {
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) {
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) {
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);
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() {
// 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
*/
onFirstDragStart(evt) {
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.
*/
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. */
_cancelDrag() {
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
*/
onLastDragEnd(evt) {
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();
}
}
__decorate([
serializable()
], DragControls.prototype, "dragMode", void 0);
__decorate([
serializable()
], DragControls.prototype, "snapGridResolution", void 0);
__decorate([
serializable()
], DragControls.prototype, "keepRotation", void 0);
__decorate([
serializable()
], DragControls.prototype, "keepScale", void 0);
__decorate([
serializable()
], DragControls.prototype, "xrDragMode", void 0);
__decorate([
serializable()
], DragControls.prototype, "xrKeepRotation", void 0);
__decorate([
serializable()
], DragControls.prototype, "xrKeepScale", void 0);
__decorate([
serializable()
], DragControls.prototype, "xrDistanceDragFactor", void 0);
__decorate([
serializable()
], DragControls.prototype, "showGizmo", void 0);
__decorate([
serializable(EventList)
], DragControls.prototype, "dragStarted", void 0);
__decorate([
serializable(EventList)
], DragControls.prototype, "dragUpdated", void 0);
__decorate([
serializable(EventList)
], DragControls.prototype, "dragEnded", void 0);
/** 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 {
handlerA;
handlerB;
context;
settings;
gameObject;
_handlerAAttachmentPoint = new Vector3();
_handlerBAttachmentPoint = new Vector3();
_followObject;
_manipulatorObject;
_deviceMode;
_followObjectStartWorldQuaternion = new Quaternion();
_gridSnapConstraint = new GridSnapConstraint(0);
_keepRotationConstraint = new KeepRotationConstraint();
/** GrabPointPlaneConstraint returned by the active strategy (if any); used to sync snapResolution. */
_planeConstraint = null;
_activeConstraints = [];
constructor(dragControls, gameObject, pointerA, pointerB) {
this.context = dragControls.context;
this.settings = dragControls;
this.gameObject = gameObject;
this.handlerA = pointerA;
this.handlerB = pointerB;
this._followObject = new Object3D();
this._manipulatorObject = new Object3D();
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) => `${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) {
// 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.worldScale);
this._currentScaleRatio = 1;
const constraintCx = {
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) ?? 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) {
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();
}
_manipulatorPosOffset = new Vector3();
_manipulatorRotOffset = new Quaternion();
/** Scale ratio between current two-hand distance and initial distance. Updated in alignManipulator(). */
_currentScaleRatio = 1;
/** World-scale of the dragged object captured at drag start. Used to apply scale directly. */
_initialWorldScale = 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. */
_scaleLimitConstraint = new ScaleLimitConstraint(0.01, 10000, true);
_tempVec1 = new Vector3();
_tempVec2 = new Vector3();
_tempVec3 = new Vector3();
tempLookMatrix = new Matrix4();
_initialDistance = 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. */
_prevABDirection = new Vector3();
/** Re-used scratch vector for the current-frame A→B direction inside `alignManipulator`. */
_currABDir = new Vector3();
/** True only for the very first `alignManipulator` call; triggers the one-time lookAt seed. */
_isFirstAlignFrame = true;
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.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);
}
if (draggedObject.matrixAutoUpdate === false) {
draggedObject.updateMatrix();
}
}
setTargetObject(obj) {
this.gameObject = obj;
}
}
/**
* Manages the per-frame surface-detection and drag-plane updates for {@link DragMode.SnapToSurfaces}.
*/
class SnapToSurfacesDragStrategy {
_draggedOverObject = null;
_draggedOverObjectDuration = 0;
_draggedOverObjectLastSetUp = null;
_draggedOverObjectLastNormal = new Vector3();
_lastSurfacePlaneRefreshTime = 0;
_hasLastSurfaceHitPoint = false;
_lastSurfaceHitPoint = 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. */
_originalHitPointInLocalSpace = new Vector3();
/** Context stored at initialize() time so getConstraints() can pass it to the multi-touch constraint. */
_context = null;
reset() {
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);
}
requiresPlaneIntersection = true;
initialize(cx, hitWP, _rayDirection) {
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, ray, dragSource, draggedObject) {
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) => 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);
const dragTimeThreshold = 0.15;
const dragTimeSatisfied = this._draggedOverObjectDuration >= dragTimeThreshold;
const dragDistance = 0.001;
const dragDistanceSatisfied = cx.totalMovement.length() >= dragDistance;
// 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);
// Detect whether the surface object or its normal has changed.
const needsAnchorUpdate = this._draggedOverObjectLastSetUp !== this._draggedOverObject
|| this._draggedOverObjectLastNormal.dot(worldNormal) < 0.999999;
// Periodically refresh the drag plane on flat same-normal surfaces (e.g. multi-level floors).
const needsPlaneRefresh = needsAnchorUpdate
|| (cx.context.time.time - this._lastSurfacePlaneRefreshTime) >= 1.0;
// Always update the grab anchor when the surface or normal changes — even BEFORE the
// movement threshold — so the first movement frame uses the correct anchor (no jump).
// The anchor preserves the original grab point's surface-plane components; only the
// normal-direction component is adjusted so the object rests flush on the surface.
if (needsAnchorUpdate) {
this._draggedOverObjectLastSetUp = this._draggedOverObject;
this._draggedOverObjectLastNormal.copy(hit.face.normal);
const center = getTempVector();
const size = getTempVector();
cx.bounds.getCenter(center);
cx.bounds.getSize(size);
// Surface-contact point: the face of the bounding box that touches the surface.
center.sub(size.multiplyScalar(0.5).multiply(worldNormal));
// Scalar projection of contact point onto the surface normal.
const contactAlongNormal = center.dot(worldNormal);
// Scalar projection of the original grab point onto the same normal.
const grabAlongNormal = this._originalHitPointInLocalSpace.dot(worldNormal);
// Build the new anchor: keep the grab point's surface-plane offset, shift only
// its normal component to match the contact surface (object rests on surface).
cx.hitPointInLocalSpace
.copy(this._originalHitPointInLocalSpace)
.addScaledVector(worldNormal, contactAlongNormal - grabAlongNormal);
cx.hitNormalInLocalSpace.copy(hit.face.normal);
}
// If the drag has just started and we're not yet really starting to update the
// object's position, wait until there's been enough movement or time.
// The anchor is already set correctly above so movement will begin without a jump.
if (!(dragTimeSatisfied || dragDistanceSatisfied)) {
return null; // abort frame update
}
// Update the drag plane when the surface/normal changes or periodically.
if (needsPlaneRefresh) {
this._lastSurfacePlaneRefreshTime = cx.context.time.time;
// ensure plane is far enough up that we don't drag into the surface
// Which offset we use here depends on the face normal direction we hit
// If we hit the bottom, we want to use the top, and vice versa
// To do this dynamically, we can find the intersection between our local bounds and the hit face normal (which is already in local space)
const center = getTempVector();
const size = getTempVector();
cx.bounds.getCenter(center);
cx.bounds.getSize(size);
center.add(size.multiplyScalar(0.5).multiply(hit.face.normal));
const offset = getTempVector(cx.hitPointInLocalSpace).add(center);
cx.followObject.localToWorld(offset);