﻿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 !== 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);

                    // See https://linear.app/needle/issue/NE-5004
                    // const offsetWP = this._followObject.worldPosition.sub(offset);
                    const point = hit.point;//.sub(offsetWP);

                    // Gizmos.DrawWireSphere(point, 2, 0xff0000, .3);
                    // Gizmos.DrawDirection(point, worldNormal, 0xffff00, 1);
                    // console.log(hit.normal)

                    cx.dragPlane.setFromNormalAndCoplanarPoint(worldNormal, point);
                }
            }
        }
        else if (didHaveSurfaceHitPointLastFrame) {
            if (cx.gameObject)
                cx.setPlaneViewAligned(cx.gameObject.worldPosition, false);
        }

        return didHit;
    }

    getConstraints(ctx: IDragConstraintContext): IDragConstraint[] {
        // Single-pointer (boundsAtScaleOne === null): the existing update() + plane-intersection
        // path handles surface snapping; no constraint needed here.
        // Multi-touch (boundsAtScaleOne !== null): inject a constraint that casts a downward
        // ray each frame and directly snaps the follow object to the detected surface.
        if (!ctx.boundsAtScaleOne || !this._context) return [];
        const c = new SnapToSurfaceConstraint(this._context);
        c.init(ctx);
        return [c];
    }
}

/** Drags the object along the parent's local XZ plane at the height where the pointer hit.
 *  Falls back to world XZ when the object has no parent.
 *
 *  Side-view fallback: when the view ray is nearly parallel to the XZ plane (angle < ~6°)
 *  the active drag plane is switched to a vertical plane whose normal is the horizontal
 *  component of the ray direction (Y stripped, re-normalised). The GrabPointPlaneConstraint
 *  then collapses the intersection back onto xzPlane, so the object can only slide along
 *  the one visible horizontal axis. Hysteresis prevents flickering at the threshold boundary.
 *
 *  The fallback coplanar point is captured once when entering fallback mode (projected from
 *  the object's world position onto xzPlane). Using a stable anchor avoids the feedback loop
 *  where a lagging lerp position would shift the plane each frame and amplify the lag.
 *
 *  Must be per-handler (not a shared singleton) because it holds per-drag state. */
class XZPlaneDragStrategy implements IDragPlaneStrategy {
    readonly requiresPlaneIntersection = true;
    /** The fixed XZ plane established at drag start. Used as the clamping target. */
    readonly xzPlane = new Plane();

    private _inFallback = false;
    /** Stable world-space point on xzPlane, captured once when entering fallback mode. */
    private readonly _fallbackAnchor = new Vector3();
    /** Reference to the most recently allocated PlaneHeightLockConstraint (from the last
     *  getConstraints call). Updated by onTwoPointerScaleUpdate to keep bounds grounded. */
    private _heightLock: PlaneHeightLockConstraint | null = null;

    initialize(cx: IDragStrategyContext, hitWP: Vector3, _rayDirection: Vector3): void {
        const up = getTempVector(0, 1, 0);
        if (cx.gameObject?.parent) {
            up.transformDirection(cx.gameObject.parent.matrixWorld);
        }
        cx.dragPlane.setFromNormalAndCoplanarPoint(up, hitWP);
        this.xzPlane.copy(cx.dragPlane);
        this._inFallback = false;
    }
    reset(): void { this._inFallback = false; }

    /**
     * Allocates fresh constraint instances and initializes each via {@link IDragConstraint.init}.
     * Each call produces independent objects so single-pointer and multi-touch
     * pipelines never share constraint state.
     */
    getConstraints(cx: IDragConstraintContext): IDragConstraint[] {
        const planeConstraint = new GrabPointPlaneConstraint(this.xzPlane);
        planeConstraint.init(cx);

        const heightLock = new PlaneHeightLockConstraint(this.xzPlane);
        heightLock.init(cx);
        this._heightLock = heightLock;

        // Restrict the object to rotate only around the plane normal (yaw on the surface).
        // AxisRotationConstraint uses swing-twist decomposition so it works correctly even
        // when the parent is tilted and the plane normal is not world Y.
        const rotLock = new AxisRotationConstraint(this.xzPlane.normal);
        rotLock.init(cx);

        return [planeConstraint, heightLock, rotLock];
    }

    onTwoPointerScaleUpdate(ratio: number): void {
        if (this._heightLock) this._heightLock.currentScaleRatio = ratio;
    }

    update(cx: IDragStrategyContext, ray: Ray): false {
        // dot(rayDir, planeNormal) near 0 → ray nearly parallel → side-on view.
        const parallelness = Math.abs(ray.direction.dot(this.xzPlane.normal));
        // Hysteresis: enter side-view mode below 0.1 (~6°), exit above 0.2 (~12°).
        if (!this._inFallback && parallelness < 0.1) {
            this._inFallback = true;
            // Capture a stable anchor on xzPlane projected from the object's current position.
            // Using a one-time snapshot avoids a feedback loop with the lerping dragged object.
            this.xzPlane.projectPoint(
                cx.gameObject ? cx.gameObject.worldPosition : ray.origin,
                this._fallbackAnchor
            );
        }
        else if (this._inFallback && parallelness > 0.2) this._inFallback = false;

        if (this._inFallback) {
            // Use the horizontal component of the ray direction as the plane normal.
            // Stripping Y prevents vertical cursor movement from bleeding into depth.
            const hFwd = getTempVector(ray.direction.x, 0, ray.direction.z);
            if (hFwd.lengthSq() > 1e-6) hFwd.normalize();
            cx.dragPlane.setFromNormalAndCoplanarPoint(hFwd, this._fallbackAnchor);
        } else {
            cx.dragPlane.copy(this.xzPlane);
        }
        return false;
    }
}

/** Drags the object along the surface normal at the initial raycast hit point. */
class HitNormalDragStrategy implements IDragPlaneStrategy {
    readonly requiresPlaneIntersection = true;
    initialize(cx: IDragStrategyContext, hitWP: Vector3, _rayDirection: Vector3): void {
        if (!cx.gameObject) return;
        const hitNormal = cx.hitNormalInLocalSpace.clone();
        hitNormal.transformDirection(cx.gameObject.matrixWorld);
        cx.dragPlane.setFromNormalAndCoplanarPoint(hitNormal, hitWP);
    }
    reset(): void {}
    update(): false { return false; }
}

/** Drags the object attached to the pointer — screen plane in 2D, controller plane in XR. */
class AttachedDragStrategy implements IDragPlaneStrategy {
    readonly requiresPlaneIntersection = false;
    initialize(cx: IDragStrategyContext, hitWP: Vector3, rayDirection: Vector3): void {
        cx.dragPlane.setFromNormalAndCoplanarPoint(rayDirection, hitWP);
    }
    reset(): void {}
    update(): false { return false; }
}

/** Auto-selects XZ or screen plane based on the viewing angle. */
class DynamicViewAngleDragStrategy implements IDragPlaneStrategy {
    readonly requiresPlaneIntersection = true;
    initialize(cx: IDragStrategyContext, hitWP: Vector3, _rayDirection: Vector3): void {
        cx.setPlaneViewAligned(hitWP, true);
    }
    reset(): void {}
    update(): false { return false; }
}

/** No-op strategy for {@link DragMode.None}. */
class NoDragStrategy implements IDragPlaneStrategy {
    readonly requiresPlaneIntersection = false;
    initialize(): void {}
    reset(): void {}
    update(): false { return false; }
}

/**
 * Module-level registry of strategy factories keyed by {@link DragMode}.
 * Each call produces a fresh instance, so stateful strategies (SnapToSurfaces, XZPlane)
 * are safe to include alongside the stateless ones.
 */
const _dragStrategyRegistry = new Map<DragMode, () => IDragPlaneStrategy>([
    [DragMode.SnapToSurfaces,   () => new SnapToSurfacesDragStrategy()],
    [DragMode.XZPlane,          () => new XZPlaneDragStrategy()],
    [DragMode.HitNormal,        () => new HitNormalDragStrategy()],
    [DragMode.Attached,         () => new AttachedDragStrategy()],
    [DragMode.DynamicViewAngle, () => new DynamicViewAngleDragStrategy()],
    [DragMode.None,             () => new NoDragStrategy()],
]);
// #endregion


// #region DragPointerHandler
/** 
 * Handles a single pointer on an object. 
 * DragPointerHandlers manage determining if a drag operation has started, tracking pointer movement,
 * and controlling object translation based on the drag mode.
 */
class DragPointerHandler implements IDragHandler {

    /** 
     * Returns the accumulated movement of the pointer in world units.
     * Used for determining if enough motion has occurred to start a drag.
     */
    getTotalMovement(): Vector3 { return this._totalMovement; }

    /** 
     * Returns the object that follows the pointer during dragging operations.
     */
    get followObject(): GameObject { return this._followObject; }

    /**
     * Returns the point where the pointer initially hit the object in local space.
     */
    get hitPointInLocalSpace(): Vector3 { return this._hitPointInLocalSpace; }

    /** The active drag strategy for the current drag. Exposed so {@link MultiTouchDragHandler}
     *  can delegate constraint setup to the same strategy without duplicating mode logic. */
    get currentStrategy(): IDragPlaneStrategy { return this._currentStrategy; }

    /** Local-space bounding box of the dragged object computed at drag start with scale = 1.
     *  Exposed so {@link MultiTouchDragHandler} can compute bounds-bottom offsets for scale correction. */
    get boundsAtScaleOne(): Box3 { return this._bounds; }

    private context: Context;
    private gameObject: Object3D | null;
    private settings: DragControls;
    private _lastRig: IGameObject | undefined = undefined;

    /** This object is placed at the pivot of the dragged object, and parented to the control space. */
    private _followObject: GameObject;
    private _totalMovement: Vector3 = new Vector3();
    /** Motion along the pointer ray. On screens this doesn't change. In XR it can be used to determine how much
     * effort someone is putting into moving an object closer or further away. */
    private _totalMovementAlongRayDirection: number = 0;
    /** Distance between _followObject and its parent at grab start, in local space */
    private _grabStartDistance: number = 0;
    private _deviceMode!: XRTargetRayMode | "transient-pointer";
    private _followObjectStartPosition: Vector3 = new Vector3();
    private _followObjectStartQuaternion: Quaternion = new Quaternion();
    private _followObjectStartWorldQuaternion: Quaternion = new Quaternion();
    private _lastDragPosRigSpace: Vector3 | undefined;
    private _tempVec: Vector3 = new Vector3();
    private _tempMat: Matrix4 = new Matrix4();

    private _hitPointInLocalSpace: Vector3 = new Vector3();
    private _hitNormalInLocalSpace: Vector3 = new Vector3();
    private _bottomCenter = new Vector3();
    private _backCenter = new Vector3();
    private _backBottomCenter = new Vector3();
    private _bounds = new Box3();
    private _dragPlane = new Plane(new Vector3(0, 1, 0));
    /** Active strategy for the current drag. Replaced with a fresh instance each drag start. */
    private _currentStrategy: IDragPlaneStrategy = _dragStrategyRegistry.get(DragMode.None)!();
    /** Mutable context bag passed to _snapToSurfaces each frame. Initialized in constructor. */
    private _snapContext!: IDragStrategyContext;
    /** Grid-snap constraint; resolution synced from settings before each constraint run. */
    private _gridSnapConstraint!: GridSnapConstraint;
    /** Rotation-lock constraint; references the persistent _followObjectStartWorldQuaternion. */
    private _keepRotationConstraint!: KeepRotationConstraint;
    /** Active constraint instances for the current drag. Rebuilt in onDragStart. */
    private _activeConstraints: IDragConstraint[] = [];
    /** The GrabPointPlaneConstraint returned by the active strategy (if any). Cached here
     *  so onDragUpdate can sync snapResolution without knowing the concrete strategy type. */
    private _grabPointPlaneConstraint: GrabPointPlaneConstraint | null = null;

    /** Allows overriding which object is dragged while a drag is already ongoing. Used for example by Duplicatable */
    setTargetObject(obj: Object3D | null) {
        this.gameObject = obj;
    }

    constructor(dragControls: DragControls, gameObject: Object3D) {
        this.settings = dragControls;
        this.context = dragControls.context;
        this.gameObject = gameObject;
        this._followObject = new Object3D() as GameObject;
        this._snapContext = {
            context: this.context,
            followObject: this._followObject,
            gameObject: this.gameObject,
            totalMovement: this._totalMovement,
            bounds: this._bounds,
            dragPlane: this._dragPlane,
            hitPointInLocalSpace: this._hitPointInLocalSpace,
            hitNormalInLocalSpace: this._hitNormalInLocalSpace,
            setPlaneViewAligned: this.setPlaneViewAligned.bind(this),
        };
        this._gridSnapConstraint = new GridSnapConstraint(0);
        this._keepRotationConstraint = new KeepRotationConstraint();
    }

    recenter() {
        if (!this._followObject.parent) {
            console.warn("Error: space follow object doesn't have parent but recenter() is called. This is likely a bug");
            return;
        }
        if (!this.gameObject) {
            console.warn("Error: space follow object doesn't have a gameObject");
            return;
        }

        const p = this._followObject.parent as GameObject;

        this.gameObject.add(this._followObject);
        this._followObject.matrixAutoUpdate = false;

        this._followObject.position.set(0, 0, 0);
        this._followObject.quaternion.set(0, 0, 0, 1);
        this._followObject.scale.set(1, 1, 1);

        this._followObject.updateMatrix();
        this._followObject.updateMatrixWorld(true);

        p.attach(this._followObject);
        // p.attach sets position/quaternion/scale to preserve world transform, but since matrixAutoUpdate
        // is false, the local matrix is NOT rebuilt automatically. Without this updateMatrix() call,
        // worldQuaternion decomposes (parent.matrixWorld * stale-identity-matrix) = p.worldQuaternion
        // instead of gameObject.worldQuaternion — causing wild rotation when the parent is rotated.
        this._followObject.updateMatrix();

        this._followObjectStartPosition.copy(this._followObject.position);
        this._followObjectStartQuaternion.copy(this._followObject.quaternion);
        this._followObjectStartWorldQuaternion.copy(this._followObject.worldQuaternion);

        this._followObject.updateMatrix();
        this._followObject.updateMatrixWorld(true);

        const hitPointWP = this._hitPointInLocalSpace.clone();
        this.gameObject.localToWorld(hitPointWP);
        this._grabStartDistance = hitPointWP.distanceTo(p.worldPosition);
        const rig = NeedleXRSession.active?.rig?.gameObject;
        const rigScale = rig?.worldScale.x || 1;
        this._grabStartDistance /= rigScale;

        this._totalMovementAlongRayDirection = 0;
        this._lastDragPosRigSpace = undefined;

        // Re-initialize all constraints to the current object state.
        // Ensures that after a multi-touch session (e.g. pinch-scale shifted the pivot),
        // all locked reference values (height, rotation, scale) reflect the actual
        // current state rather than the original drag-start snapshot.
        const reinitCtx: IDragConstraintContext = {
            hitPointInLocalSpace: this._hitPointInLocalSpace,
            hitNormalInLocalSpace: this._hitNormalInLocalSpace,
            gameObject: this.gameObject,
            boundsAtScaleOne: null,
            initialWorldScale: null,
        };
        for (const c of this._activeConstraints) {
            c.init?.(reinitCtx);
        }

        if (debug) {
            Gizmos.DrawLine(hitPointWP, p.worldPosition, 0x00ff00, 0.5, false);
            Gizmos.DrawLabel(p.worldPosition.add(new Vector3(0, 0.1, 0)), this._grabStartDistance.toFixed(2), 0.03, 0.5);
        }
    }

    onDragStart(args: PointerEventData) {
        if (!this.gameObject) {
            console.warn("Error: space follow object doesn't have a gameObject");
            return;
        }

        args.event.space.add(this._followObject);

        // prepare for drag, we will start dragging after an object has been dragged for a few centimeters
        this._lastDragPosRigSpace = undefined;

        if (args.point && args.normal) {
            this._hitPointInLocalSpace.copy(args.point);
            this.gameObject.worldToLocal(this._hitPointInLocalSpace);
            this._hitNormalInLocalSpace.copy(args.normal);
        }
        else if (args) {
            // can happen for e.g. close grabs; we can assume/guess a good hit point and normal based on the object's bounds or so
            // convert controller world position to local space instead and use that as hit point
            const controller = args.event.space as GameObject;
            const controllerWp = controller.worldPosition;
            this.gameObject.worldToLocal(controllerWp);
            this._hitPointInLocalSpace.copy(controllerWp);

            const controllerUp = controller.worldUp;
            this._tempMat.copy(this.gameObject.matrixWorld).invert();
            controllerUp.transformDirection(this._tempMat);
            this._hitNormalInLocalSpace.copy(controllerUp);
        }

        this.recenter();

        this._totalMovement.set(0, 0, 0);
        this._deviceMode = args.mode;


        const dragSource = this._followObject.parent as IGameObject;
        const rayDirection = dragSource.worldForward;

        const profile = isSpatialInput(this._deviceMode) ? this.settings.xrProfile : this.settings.screenProfile;
        const dragMode = profile.dragMode;
        // set up drag plane; we don't really know the normal yet but we can already set the point
        const hitWP = this._hitPointInLocalSpace.clone();
        this.gameObject.localToWorld(hitWP);

        this._currentStrategy = (_dragStrategyRegistry.get(dragMode) ?? _dragStrategyRegistry.get(DragMode.None)!)();
        this._currentStrategy.initialize(this._snapContext, hitWP, rayDirection);

        // Build the constraint pipeline for this drag.
        // Strategy-owned constraints come first (e.g. XZPlaneDragStrategy's GrabPointPlaneConstraint
        // which handles both plane-clamping and snap-on-plane, replacing the old _gridSnapConstraint.plane wiring).
        const keepRotation = profile.keepRotation;
        const constraintCx: IDragConstraintContext = {
            hitPointInLocalSpace: this._hitPointInLocalSpace,
            hitNormalInLocalSpace: this._hitNormalInLocalSpace,
            gameObject: this.gameObject,
            boundsAtScaleOne: null,
            initialWorldScale: null,
        };
        const strategyConstraints = this._currentStrategy.getConstraints?.(constraintCx) ?? [];
        // Cache the GrabPointPlaneConstraint (if any) for snap-resolution sync in onDragUpdate.
        this._grabPointPlaneConstraint = strategyConstraints.find(
            c => c instanceof GrabPointPlaneConstraint
        ) as GrabPointPlaneConstraint ?? null;
        // Only add the world-space GridSnapConstraint for modes that don't own their own snap.
        const useWorldSnap = strategyConstraints.length === 0;
        this._activeConstraints = [
            ...strategyConstraints,
            ...(useWorldSnap ? [this._gridSnapConstraint] : []),
        ];
        if (keepRotation) {
            this._keepRotationConstraint.init(constraintCx);
            this._activeConstraints.push(this._keepRotationConstraint);
        }

        // calculate bounding box and snapping points. We want to either snap the "back" point or the "bottom" point.
        // const bbox = new Box3();
        const p = this.gameObject.parent;
        const localP = this.gameObject.position.clone();
        const localQ = this.gameObject.quaternion.clone();
        const localS = this.gameObject.scale.clone();
        // save the original matrix world (because if some other script is doing a raycast at the same moment the matrix will not be correct anymore....)
        const matrixWorld = this.gameObject.matrixWorld.clone();

        if (p) p.remove(this.gameObject);
        this.gameObject.position.set(0, 0, 0);
        this.gameObject.quaternion.set(0, 0, 0, 1);
        this.gameObject.scale.set(1, 1, 1);
        const bbox = getBoundingBox([this.gameObject]);
        // we force the bbox to include our own point *because* the DragControls might be attached to an empty object (which isnt included in the bounding box call above)
        bbox.expandByPoint(this.gameObject.worldPosition);

        // console.log(this.gameObject.position.y - bbox.min.y)
        // bbox.min.y += (this.gameObject.position.y - bbox.min.y);

        // get front center point of the bbox. basically (0, 0, 1) in local space
        const bboxCenter = new Vector3();
        bbox.getCenter(bboxCenter);
        const bboxSize = new Vector3();
        bbox.getSize(bboxSize);

        // attachment points for dragging
        this._bottomCenter.copy(bboxCenter.clone().add(new Vector3(0, -bboxSize.y / 2, 0)));
        this._backCenter.copy(bboxCenter.clone().add(new Vector3(0, 0, bboxSize.z / 2)));
        this._backBottomCenter.copy(bboxCenter.clone().add(new Vector3(0, -bboxSize.y / 2, bboxSize.z / 2)));

        this._bounds.copy(bbox);

        // restore original transform
        if (p) p.add(this.gameObject);
        this.gameObject.position.copy(localP);
        this.gameObject.quaternion.copy(localQ);
        this.gameObject.scale.copy(localS);
        this.gameObject.matrixWorld.copy(matrixWorld);

        // surface snapping — reset is now handled inside each strategy's initialize()
    }

    collectMovementInfo() {
        // we're dragging - there is a controlling object
        if (!this._followObject.parent) return;

        const dragSource = this._followObject.parent as IGameObject;

        this._followObject.updateMatrix();
        const dragPosRigSpace = dragSource.worldPosition;
        const rig = NeedleXRSession.active?.rig?.gameObject;
        if (rig)
            rig.worldToLocal(dragPosRigSpace);

        // sum up delta
        if (this._lastDragPosRigSpace === undefined || rig != this._lastRig) {
            this._lastDragPosRigSpace = dragPosRigSpace.clone();
            this._lastRig = rig;
        }
        this._tempVec.copy(dragPosRigSpace).sub(this._lastDragPosRigSpace);

        const rayDirectionRigSpace = dragSource.worldForward;
        if (rig) {
            this._tempMat.copy(rig.matrixWorld).invert();
            rayDirectionRigSpace.transformDirection(this._tempMat);
        }
        // sum up delta movement along ray
        this._totalMovementAlongRayDirection += rayDirectionRigSpace.dot(this._tempVec);
        this._tempVec.x = Math.abs(this._tempVec.x);
        this._tempVec.y = Math.abs(this._tempVec.y);
        this._tempVec.z = Math.abs(this._tempVec.z);

        // sum up absolute total movement
        this._totalMovement.add(this._tempVec);
        this._lastDragPosRigSpace.copy(dragPosRigSpace);

        if (debug) {
            let wp = dragPosRigSpace;
            // ray direction of the input source object
            if (rig) {
                wp = wp.clone();
                wp.transformDirection(rig.matrixWorld);
            }
            Gizmos.DrawRay(wp, rayDirectionRigSpace, 0x0000ff);
        }
    }

    onDragUpdate(numberOfPointers: number) {

        // can only handle a single pointer
        // if there's more, we defer to multi-touch drag handlers
        if (numberOfPointers > 1) return;
        const draggedObject = this.gameObject as IGameObject | null;
        if (!draggedObject || !this._followObject) {
            console.warn("Warning: DragPointerHandler doesn't have a dragged object. This is likely a bug.");
            return;
        }
        // Safety: the object may have been deleted while dragging.
        if (!draggedObject.parent) return;
        const dragSource = this._followObject.parent as IGameObject | null;
        if (!dragSource) {
            console.warn("Warning: DragPointerHandler doesn't have a drag source. This is likely a bug.");
            return;
        }
        this._followObject.updateMatrix();
        const dragSourceWP = dragSource.worldPosition;
        const rayDirection = dragSource.worldForward;


        // Actually move and rotate draggedObject
        const isTrackedPointerInput = this._deviceMode === "tracked-pointer" || this._deviceMode === "transient-pointer";
        const profile = isSpatialInput(this._deviceMode) ? this.settings.xrProfile : this.settings.screenProfile;
        const keepRotation = profile.keepRotation;
        const dragMode = profile.dragMode;

        if (dragMode === DragMode.None) return;

        const lerpStrength = 10;
        if (keepRotation) this._followObject.worldQuaternion = this._followObjectStartWorldQuaternion;
        
        this._followObject.updateMatrix();
        this._followObject.updateMatrixWorld(true);

        // Acceleration for moving the object - move followObject along the ray distance by _totalMovementAlongRayDirection
        let currentDist = 1.0;
        let lerpFactor = 2.0;
        if (isTrackedPointerInput && this._grabStartDistance > 0.5) // hands and controllers, but not touches
        {
            const factor = 1 + this._totalMovementAlongRayDirection * (2 * profile.distanceDragFactor);
            currentDist = Math.max(0.0, factor);
            currentDist = currentDist * currentDist * currentDist;
        }
        else if (this._grabStartDistance <= 0.5) {
            // TODO there's still a frame delay between dragged objects and the hand models
            lerpFactor = 3.0;
        }

        // reset _followObject to its original position and rotation
        this._followObject.position.copy(this._followObjectStartPosition);
        if (!keepRotation)
            this._followObject.quaternion.copy(this._followObjectStartQuaternion);

        // Distance dragging: move the grabbed HIT POINT along the ray, not the pivot.
        // When pulling a large object toward you, the grabbed point should reach the controller
        // but not go past it, preventing the object from "slapping" into your head.
        if (isTrackedPointerInput && this._grabStartDistance > 0.5) {
            // _hitPointInLocalSpace is in gameObject-local space.
            // _followObject.quaternion = space^-1 * gameObject_world_rotation, so applying it
            // transforms the hit point offset from gameObject-local → space-local coordinates.
            const hitPointOffset = this._hitPointInLocalSpace.clone();
            hitPointOffset.applyQuaternion(this._followObject.quaternion);

            // The grab ray in space-local goes from the space origin through the initial hit point
            // position (pivot + offset). Normalizing this gives the correct ray direction, which
            // differs from normalize(pivot) whenever the hit point is not at the pivot.
            const rayDir = getTempVector(this._followObjectStartPosition).add(hitPointOffset).normalize();

            // Scale the hit point along the ray to targetDistance, clamped to a minimum so
            // the hit point never passes through the controller (~10 cm).
            const targetDistance = Math.max(0.1, currentDist * this._grabStartDistance);
            this._followObject.position
                .copy(rayDir.multiplyScalar(targetDistance))
                .sub(hitPointOffset);
        } else {
            // For close grabs or non-XR, use simple scaling of the pivot point.
            this._followObject.position.multiplyScalar(currentDist);
        }
        this._followObject.updateMatrix();

        const ray = new Ray(dragSourceWP, rayDirection);

        // Per-mode drag plane update. No-op for most modes; SnapToSurfaces does the surface raycast here.
        this._snapContext.gameObject = this.gameObject;
        const stratResult = this._currentStrategy.update(this._snapContext, ray, dragSource, draggedObject);
        if (stratResult === null) return; // SnapToSurfaces: drag hasn't started enough yet

        // Raycast along the ray to the drag plane and move _followObject so that the grabbed point stays at the hit point
        // Drag on plane:
        if (this._currentStrategy.requiresPlaneIntersection && ray.intersectPlane(this._dragPlane, this._tempVec)) {

            this._followObject.worldPosition = this._tempVec;
            this._followObject.updateMatrix();
            this._followObject.updateMatrixWorld(true);

            const newWP = getTempVector(this._hitPointInLocalSpace)//.clone();
            this._followObject.localToWorld(newWP);

            if (debug) {
                Gizmos.DrawLine(newWP, this._tempVec, 0x00ffff, 0, false);
            }

            this._followObject.worldPosition = this._tempVec.multiplyScalar(2).sub(newWP);
            this._followObject.updateMatrix();
        }

        this._gridSnapConstraint.snapGridResolution = this.settings.snapGridResolution;
        if (this._grabPointPlaneConstraint) this._grabPointPlaneConstraint.snapResolution = this.settings.snapGridResolution;
        applyFollowObjectConstraints(this._followObject, this._activeConstraints);

        // TODO should use unscaled time here // some test for lerp speed depending on distance
        const t = Mathf.clamp01(this.context.time.deltaTime * lerpStrength * lerpFactor);// / (currentDist - 1 + 0.01));
        const t_rotation = Mathf.clamp01(this.context.time.deltaTime * lerpStrength * .5 * lerpFactor);

        const wp = draggedObject.worldPosition;
        wp.lerp(this._followObject.worldPosition, t);
        draggedObject.worldPosition = wp;

        // Rotation is only applied for spatial (XR controller / hand) input.
        // Screen and touch single-pointer drags are translation-only: the object must
        // never rotate from a single finger or mouse gesture regardless of keepRotation.
        if (isTrackedPointerInput) {
            const rot = draggedObject.worldQuaternion;
            rot.slerp(this._followObject.worldQuaternion, t_rotation);
            draggedObject.worldQuaternion = rot;
        }

        if (draggedObject.matrixAutoUpdate === false) {
            draggedObject.updateMatrix();
        }


        if (debug) {
            const hitPointWP = this._hitPointInLocalSpace.clone();
            draggedObject.localToWorld(hitPointWP);
            // draw grab attachment point and normal. They are in grabbed object space
            Gizmos.DrawSphere(hitPointWP, 0.02, 0xff0000);
            const hitNormalWP = this._hitNormalInLocalSpace.clone();
            hitNormalWP.applyQuaternion(draggedObject.worldQuaternion);
            Gizmos.DrawRay(hitPointWP, hitNormalWP, 0xff0000);

            // debug info
            Gizmos.DrawLabel(wp.add(new Vector3(0, 0.25, 0)),
                `Distance: ${this._totalMovement.length().toFixed(2)}\n
                Along Ray: ${this._totalMovementAlongRayDirection.toFixed(2)}\n
                Session: ${!!NeedleXRSession.active}\n
                Device: ${this._deviceMode}\n
                `,
                0.03
            );

            // draw bottom/back snap points
            const bottomCenter = this._bottomCenter.clone();
            const backCenter = this._backCenter.clone();
            const backBottomCenter = this._backBottomCenter.clone();
            draggedObject.localToWorld(bottomCenter);
            draggedObject.localToWorld(backCenter);
            draggedObject.localToWorld(backBottomCenter);
            Gizmos.DrawSphere(bottomCenter, 0.01, 0x00ff00, 0, false);
            Gizmos.DrawSphere(backCenter, 0.01, 0x0000ff, 0, false);
            Gizmos.DrawSphere(backBottomCenter, 0.01, 0xff00ff, 0, false);
            Gizmos.DrawLine(bottomCenter, backBottomCenter, 0x00ffff, 0, false);
            Gizmos.DrawLine(backBottomCenter, backCenter, 0x00ffff, 0, false);
        }
    }

    onDragEnd(args: PointerEventData) {
        console.assert(this._followObject.parent === args.event.space, "Drag end: _followObject is not parented to the space object");
        this._followObject.removeFromParent();
        this._followObject.destroy();
        this._lastDragPosRigSpace = undefined;
    }


    private setPlaneViewAligned(worldPoint: Vector3, useUpAngle: boolean) {
        if (!this._followObject.parent) {
            return false;
        }
        const viewDirection = (this._followObject.parent as IGameObject).worldForward;;
        const v0 = getTempVector(0, 1, 0);
        const v1 = viewDirection;
        const angle = v0.angleTo(v1);
        const angleThreshold = 0.5;
        if (useUpAngle && (angle > Math.PI / 2 + angleThreshold || angle < Math.PI / 2 - angleThreshold))
            this._dragPlane.setFromNormalAndCoplanarPoint(v0, worldPoint);
        else
            this._dragPlane.setFromNormalAndCoplanarPoint(viewDirection, worldPoint);
        return true;
    }
}
// #endregion
