import { AxesHelper, Box3, BufferGeometry, Camera, Color, Line, LineBasicMaterial, Matrix4, Mesh, MeshBasicMaterial, Object3D, Plane, Quaternion, Ray, Raycaster, SphereGeometry, Vector3 } from "three"; import { Gizmos } from "../engine/engine_gizmos.js"; import { InstancingUtil } from "../engine/engine_instancing.js"; import { Mathf } from "../engine/engine_math.js"; import { RaycastOptions } from "../engine/engine_physics.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 { Avatar_POI } from "./avatar/Avatar_Brain_LookAt.js"; import { Behaviour, GameObject } from "./Component.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[] = []; /** * 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, } /** * DragControls allows you to drag objects around in the scene. It can be used to move objects in 2D (screen space) or 3D (world space). * Debug mode can be enabled with the URL parameter `?debugdrag`, which shows visual helpers and logs drag operations. * * @category Interactivity * @group Components */ 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._active > 0; } private static _active: number = 0; /** * 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; /** * 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; /** * 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; /** * 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; // future: // constraints? /** The object to be dragged – we pass this to handlers when they are created */ private _targetObject: Object3D | null = null; private _dragHelper: LegacyDragVisualsHelper | null = null; private static lastHovered: Object3D; private _draggingRigidbodies: Rigidbody[] = []; private _potentialDragStartEvt: PointerEventData | null = null; private _dragHandlers: Map = new Map(); private _totalMovement: Vector3 = new Vector3(); /** A marker is attached to components that are currently interacted with, to e.g. prevent them from being deleted. */ private _marker: UsageMarker | null = null; private _isDragging: boolean = false; private _didDrag: boolean = false; /** @internal */ awake() { // initialize all data that may be cloned incorrectly otherwise this._potentialDragStartEvt = null; this._dragHandlers = new Map(); this._totalMovement = new Vector3(); this._marker = null; this._isDragging = false; this._didDrag = false; this._dragHelper = null; this._draggingRigidbodies = []; } /** @internal */ start() { if (!this.gameObject.getComponentInParent(ObjectRaycaster)) this.gameObject.addComponent(ObjectRaycaster); } /** @internal */ onEnable(): void { DragControls._instances.push(this); } /** @internal */ onDisable(): void { DragControls._instances = DragControls._instances.filter(i => i !== this); } /** * 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 isSpatialInput = evt.event.mode === "tracked-pointer" || evt.event.mode === "transient-pointer"; const dragMode = isSpatialInput ? 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'; } /** * 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 isSpatialInput = args.mode === "tracked-pointer" || args.mode === "transient-pointer"; const dragMode = isSpatialInput ? 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._active += 1; 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(); } } /** * 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) { if (DragControls._active > 0) DragControls._active -= 1; this.setTargetObject(null); if (handler.onDragEnd) handler.onDragEnd(args); this._dragHandlers.delete(args.event.space); if (this._dragHandlers.size === 0) { this.onLastDragEnd(args); } args.use(); } } /** * Updates the drag operation every frame. Processes pointer movement, accumulates drag distance * and triggers drag start once there's enough movement. * @internal */ update(): void { for (const handler of this._dragHandlers.values()) { if (handler.collectMovementInfo) handler.collectMovementInfo(); // TODO this doesn't make sense, we should instead just use the max here // or even better, each handler can decide on their own how to handle this if (handler.getTotalMovement) this._totalMovement.add(handler.getTotalMovement()); } // 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._dragHelper && this._dragHelper.hasSelected) 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; 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); } /** * Called each frame as long as any pointer is dragging this object. * Updates visuals and keeps rigidbodies awake during the drag. */ private onAnyDragUpdate() { if (!this._dragHelper) return; this._dragHelper.showGizmo = this.showGizmo; this._dragHelper.onUpdate(this.context); for (const rb of this._draggingRigidbodies) { rb.wakeUp(); rb.resetVelocities(); rb.resetForcesAndTorques(); } const object = this._targetObject || this.gameObject; InstancingUtil.markDirty(object); } /** * 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; for (const rb of this._draggingRigidbodies) { rb.setVelocity(rb.smoothedVelocity); } this._draggingRigidbodies.length = 0; this._targetObject = null; if (evt?.object) { const sync = GameObject.getComponentInChildren(evt.object, SyncedTransform); if (sync) { sync.fastMode = false; // sync?.requestOwnership(); } } if (this._marker) this._marker.destroy(); if (!this._dragHelper) return; const selected = this._dragHelper.selected; if (debug) console.log("DRAG END", selected, selected?.visible) this._dragHelper.setSelected(null, this.context); } } /** * 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; } /** * 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(); 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); this._initialScale.copy(gameObject.scale); 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); // 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); this._manipulatorScaleOffset.copy(this._followObject.scale); } 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(); private _manipulatorScaleOffset: Vector3 = new Vector3(); private _tempVec1: Vector3 = new Vector3(); private _tempVec2: Vector3 = new Vector3(); private _tempVec3: Vector3 = new Vector3(); private tempLookMatrix: Matrix4 = new Matrix4(); private _initialScale: Vector3 = new Vector3(); private _initialDistance: number = 0; 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); // - lookAt the second point on handlerB const camera = this.context.mainCamera; this.tempLookMatrix.lookAt(this._tempVec3, this._tempVec2, (camera as any as IGameObject).worldUp); this._manipulatorObject.quaternion.setFromRotationMatrix(this.tempLookMatrix); // - scale based on the distance between the two points const dist = this._tempVec1.distanceTo(this._tempVec2); this._manipulatorObject.scale.copy(this._initialScale).multiplyScalar(dist / this._initialDistance); 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); this._followObject.scale.copy(this._manipulatorScaleOffset); const draggedObject = this.gameObject; const targetObject = this._followObject; if (!draggedObject) { console.error("MultiTouchDragHandler has no dragged object. This is likely a bug."); return; } targetObject.updateMatrix(); targetObject.updateMatrixWorld(true); const isSpatialInput = this._deviceMode === "tracked-pointer" || this._deviceMode === "transient-pointer"; const keepRotation = isSpatialInput ? this.settings.xrKeepRotation : this.settings.keepRotation; // TODO refactor to a common place // apply constraints (position grid snap, rotation, ...) if (this.settings.snapGridResolution > 0) { const wp = this._followObject.worldPosition; const snap = this.settings.snapGridResolution; wp.x = Math.round(wp.x / snap) * snap; wp.y = Math.round(wp.y / snap) * snap; wp.z = Math.round(wp.z / snap) * snap; this._followObject.worldPosition = wp; this._followObject.updateMatrix(); } if (keepRotation) { this._followObject.worldQuaternion = this._followObjectStartWorldQuaternion; this._followObject.updateMatrix(); } // TODO refactor to a common place // 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; const scl = draggedObject.worldScale; scl.lerp(targetObject.worldScale, t); draggedObject.worldScale = scl; } setTargetObject(obj: Object3D | null): void { this.gameObject = obj as GameObject; } } /** * 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; } 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)); private _draggedOverObject: Object3D | null = null; private _draggedOverObjectLastSetUp: Object3D | null = null; private _draggedOverObjectLastNormal: Vector3 = new Vector3(); private _draggedOverObjectDuration: number = 0; /** 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; } 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); 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; 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 isSpatialInput = this._deviceMode === "tracked-pointer" || this._deviceMode === "transient-pointer"; const dragMode = isSpatialInput ? this.settings.xrDragMode : this.settings.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); switch (dragMode) { case DragMode.XZPlane: const up = new Vector3(0, 1, 0); if (this.gameObject.parent) { // TODO in this case _dragPlane should be in parent space, not world space, // otherwise dragging the parent and this object at the same time doesn't keep the plane constrained up.transformDirection(this.gameObject.parent.matrixWorld.clone().invert()); } this._dragPlane.setFromNormalAndCoplanarPoint(up, hitWP); break; case DragMode.HitNormal: const hitNormal = this._hitNormalInLocalSpace.clone(); hitNormal.transformDirection(this.gameObject.matrixWorld); this._dragPlane.setFromNormalAndCoplanarPoint(hitNormal, hitWP); break; case DragMode.Attached: this._dragPlane.setFromNormalAndCoplanarPoint(rayDirection, hitWP); break; case DragMode.DynamicViewAngle: // At start (when nothing is hit yet) the drag plane should be aligned to the view this.setPlaneViewAligned(hitWP, true); break; case DragMode.SnapToSurfaces: // At start (when nothing is hit yet) the drag plane should be aligned to the view this.setPlaneViewAligned(hitWP, false); break; case DragMode.None: break; } // 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 this._draggedOverObject = null; this._draggedOverObjectLastSetUp = null; this._draggedOverObjectLastNormal.set(0, 1, 0); this._draggedOverObjectDuration = 0; } collectMovementInfo() { // we're dragging - there is a controlling object if (!this._followObject.parent) return; // TODO This should all be handled properly per-pointer // and we want to have a chance to react to multiple pointers being on the same object. // some common stuff (calculating of movement offsets, etc) could be done by default // and then the main thing to override is the actual movement of the object based on N _followObjects const dragSource = this._followObject.parent as IGameObject; // modify _followObject with constraints, e.g. // - dragging on a plane, e.g. the floor (keeping the distance to the floor plane constant) /* TODO fix jump on drag start const p0 = this._followObject.parent as GameObject; const ray = new Ray(p0.worldPosition, p0.worldForward.multiplyScalar(-1)); const p = new Vector3(); const t0 = ray.intersectPlane(new Plane(new Vector3(0, 1, 0)), p); if (t0 !== null) this._followObject.worldPosition = t0; */ this._followObject.updateMatrix(); const dragPosRigSpace = dragSource.worldPosition; const rig = NeedleXRSession.active?.rig?.gameObject; if (rig) rig.worldToLocal(dragPosRigSpace); // sum up delta // TODO We need to do all/most of these calculations in Rig Space instead of world space // moving the rig while holding an object should not affect _rayDelta / _dragDelta 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; } 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 isSpatialInput = this._deviceMode === "tracked-pointer" || this._deviceMode === "transient-pointer"; const keepRotation = isSpatialInput ? this.settings.xrKeepRotation : this.settings.keepRotation; const dragMode = isSpatialInput ? this.settings.xrDragMode : this.settings.dragMode; if (dragMode === DragMode.None) return; const lerpStrength = 10; // - keeping rotation constant during dragging 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 (isSpatialInput && this._grabStartDistance > 0.5) // hands and controllers, but not touches { const factor = 1 + this._totalMovementAlongRayDirection * (2 * this.settings.xrDistanceDragFactor); 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); // TODO restore previous functionality: // When distance dragging, the HIT POINT should move along the ray until it reaches the controller; // NOT the pivot point of the dragged object. E.g. grabbing a large cube and pulling towards you should at most // move the grabbed point to your head and not slap the cube in your head. this._followObject.position.multiplyScalar(currentDist); this._followObject.updateMatrix(); const didHaveSurfaceHitPointLastFrame = this._hasLastSurfaceHitPoint; this._hasLastSurfaceHitPoint = false; const ray = new Ray(dragSourceWP, rayDirection); let didHit = false; // Surface snapping. // Feels quite weird in VR right now! if (dragMode == DragMode.SnapToSurfaces) { // 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 = this.context.physics.raycastFromRay(ray, { testObject: o => o !== this.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 += this.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 = this._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); // Adjust drag plane if we're dragging over a different object (for a certain amount of time) // or if the surface normal changed if ((dragTimeSatisfied || dragDistanceSatisfied) && (this._draggedOverObjectLastSetUp !== this._draggedOverObject || this._draggedOverObjectLastNormal.dot(worldNormal) < 0.999999 // if we're dragging on a flat surface with different levels (like the sandbox floor) || this.context.time.frame % 60 === 0 ) ) { this._draggedOverObjectLastSetUp = this._draggedOverObject; this._draggedOverObjectLastNormal.copy(hit.face.normal); const center = getTempVector(); const size = getTempVector(); this._bounds.getCenter(center); this._bounds.getSize(size); center.sub(size.multiplyScalar(0.5).multiply(worldNormal)); this._hitPointInLocalSpace.copy(center); this._hitNormalInLocalSpace.copy(hit.face.normal); // 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) this._bounds.getCenter(center); this._bounds.getSize(size); center.add(size.multiplyScalar(0.5).multiply(hit.face.normal)); const offset = getTempVector(this._hitPointInLocalSpace).add(center); this._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) this._dragPlane.setFromNormalAndCoplanarPoint(worldNormal, point); } // If the drag has just started and we're not yet really starting to update the object's position // we want to return here and wait until the drag has been going on for a bit // Otherwise the object will either immediately change it's position (when the user starts dragging) // Or interpolate to a wrong position for a short moment else if (!(dragTimeSatisfied || dragDistanceSatisfied)) { return; } } } else if (didHaveSurfaceHitPointLastFrame) { if (this.gameObject) this.setPlaneViewAligned(this.gameObject.worldPosition, false) } } // if(dragMode === DragMode.SnapToSurfaces){ // if(!didHit){ // return; // } // } // Objects could also serve as "slots" for dragging other objects into. In that case, we don't want to snap to the surface, // we want to snap to the pivot of that object. These dragged-over objects could also need to be invisible (a "slot") // 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 (dragMode !== DragMode.Attached && 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(); // TODO figure out nicer look rotation here // TODO rotating here will cause the object to intersect again with the surface // if (!keepRotation) { // const normal = this._dragPlane.normal; // // If the surface is perfectly aligned we jiggle the normal slightly in one direction // // Otherwise lookat will randomly choose a different rotation axis // const tinyNormalJiggle = 0.00001; // if (normal.x === 1) { // normal.add(getTempVector(0, tinyNormalJiggle, 0)); // } // else if (normal.y === 1) { // normal.add(getTempVector(tinyNormalJiggle, 0, 0)); // } // else if (normal.z === 1) { // normal.add(getTempVector(0, 0, tinyNormalJiggle)); // } // const lookPoint = getTempVector(normal).multiplyScalar(1000).add(this._tempVec); // if (lookPoint) { // this._followObject.lookAt(lookPoint); // this._followObject.rotateX(Math.PI / 2); // } // } this._followObject.updateMatrix(); } // TODO refactor to a common place // apply constraints (position grid snap, rotation, ...) if (this.settings.snapGridResolution > 0) { const wp = this._followObject.worldPosition; const snap = this.settings.snapGridResolution; wp.x = Math.round(wp.x / snap) * snap; wp.y = Math.round(wp.y / snap) * snap; wp.z = Math.round(wp.z / snap) * snap; this._followObject.worldPosition = wp; this._followObject.updateMatrix(); } if (keepRotation) { this._followObject.worldQuaternion = this._followObjectStartWorldQuaternion; this._followObject.updateMatrix(); } // TODO refactor to a common place // 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; const rot = draggedObject.worldQuaternion; rot.slerp(this._followObject.worldQuaternion, t_rotation); draggedObject.worldQuaternion = rot; 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(rot); 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 _hasLastSurfaceHitPoint: boolean = false; private readonly _lastSurfaceHitPoint: Vector3 = new Vector3(); 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; } } /** * Provides visual helper elements for DragControls. * Shows where objects will be placed and their relation to surfaces below them. */ class LegacyDragVisualsHelper { /** Controls whether visual helpers like lines and markers are displayed */ showGizmo: boolean = true; /** When true, drag plane alignment changes based on view angle */ useViewAngle: boolean = true; /** * Checks if there is a currently selected object being visualized */ public get hasSelected(): boolean { return this._selected !== null && this._selected !== undefined; } /** * Returns the currently selected object being visualized, if any */ public get selected(): Object3D | null { return this._selected; } private _selected: Object3D | null = null; private _context: Context | null = null; private _camera: Camera; private _cameraPlane: Plane = new Plane(); private _hasGroundPlane: boolean = false; private _groundPlane: Plane = new Plane(); private _groundOffset: Vector3 = new Vector3(); private _groundOffsetFactor: number = 0; private _groundDistance: number = 0; private _groundPlanePoint: Vector3 = new Vector3(); private _raycaster = new Raycaster(); private _cameraPlaneOffset = new Vector3(); private _intersection = new Vector3(); private _worldPosition = new Vector3(); private _inverseMatrix = new Matrix4(); private _rbs: Rigidbody[] = []; private _groundLine: Line; private _groundMarker: Object3D; private static geometry = new BufferGeometry().setFromPoints([new Vector3(0, 0, 0), new Vector3(0, -1, 0)]); constructor(camera: Camera) { this._camera = camera; const line = new Line(LegacyDragVisualsHelper.geometry); const mat = line.material as LineBasicMaterial; mat.color = new Color(.4, .4, .4); line.layers.set(2); line.name = 'line'; line.scale.y = 1; this._groundLine = line; const geometry = new SphereGeometry(.5, 22, 22); const material = new MeshBasicMaterial({ color: mat.color }); const sphere = new Mesh(geometry, material); sphere.visible = false; sphere.layers.set(2); this._groundMarker = sphere; } setSelected(newSelected: Object3D | null, context: Context) { if (this._selected && context) { for (const rb of this._rbs) { rb.wakeUp(); rb.setVelocity(0, 0, 0); } } if (this._selected) { // TODO move somewhere else Avatar_POI.Remove(context, this._selected); } this._selected = newSelected; this._context = context; this._rbs.length = 0; if (newSelected) { context.scene.add(this._groundLine); context.scene.add(this._groundMarker); } else { this._groundLine.removeFromParent(); this._groundMarker.removeFromParent(); } if (this._selected) { if (!context) { console.error("DragHelper: no context"); return; } // TODO move somewhere else Avatar_POI.Add(context, this._selected, null); this._groundOffsetFactor = 0; this._hasGroundPlane = true; this._groundOffset.set(0, 0, 0); this._requireUpdateGroundPlane = true; this.onUpdateScreenSpacePlane(); } } private _groundOffsetVector = new Vector3(0, 1, 0); private _requireUpdateGroundPlane = true; private _didDragOnGroundPlaneLastFrame: boolean = false; onUpdate(_context: Context) { if (!this._selected) return; // const wp = getWorldPosition(this._selected); // this.onUpdateWorldPosition(wp, this._groundPlanePoint, false); // this.onUpdateGroundPlane(); // this._didDragOnGroundPlaneLastFrame = true; // this._hasGroundPlane = true; /* if (!this._context) return; const mainKey: KeyCode = "Space"; const secondaryKey: KeyCode = "KeyD"; const scaleKey: KeyCode = "KeyS"; const isRotateKeyPressed = this._context?.input.isKeyPressed(mainKey) || this._context?.input.isKeyPressed(secondaryKey); const isRotating = this._context.input.getTouchesPressedCount() >= 2 || isRotateKeyPressed; if (isRotating) { const dt = this._context.input.getPointerPositionDelta(0); if (dt) { this._groundOffsetVector.set(0, 1, 0); this._selected?.rotateOnWorldAxis(this._groundOffsetVector, dt.x * this._context.time.deltaTime); } } // todo: allow this once synced transform sends world scale // const isScaling = this._context?.input.isKeyPressed(scaleKey); // if(isScaling){ // const dt = this._context.input.getPointerPositionDelta(0); // if(dt){ // this._selected?.scale.multiplyScalar(1 + (dt.x * this._context.time.deltaTime)); // return; // } // } const rc = this._context.input.getPointerPositionRC(0); if (!rc) return; this._raycaster.setFromCamera(rc, this._camera); if (this._selected) { if (debug) console.log("UPDATE DRAG", this._selected); this._groundOffsetVector.set(0, 1, 0); const lookDirection = getWorldPosition(this._camera).clone().sub(getWorldPosition(this._selected)).normalize(); const lookDot = Math.abs(lookDirection.dot(this._groundOffsetVector)); const switchModeKeyPressed = this._context?.input.isKeyPressed(mainKey) || this._context?.input.isKeyPressed(secondaryKey); let dragOnGroundPlane = !this.useViewAngle || lookDot > .2; if (isRotating || switchModeKeyPressed || this._context!.input.getPointerPressedCount() > 1) { dragOnGroundPlane = false; } const changed = this._didDragOnGroundPlaneLastFrame !== dragOnGroundPlane; this._didDragOnGroundPlaneLastFrame = dragOnGroundPlane; if (!this._hasGroundPlane) this._requireUpdateGroundPlane = true; if (this._requireUpdateGroundPlane || !dragOnGroundPlane || changed) this.onUpdateGroundPlane(); this._requireUpdateGroundPlane = false; if (this._hasGroundPlane) { // const wp = getWorldPosition(this._selected); // const ray = new Ray(wp, new Vector3(0, -1, 0)); if (this._raycaster.ray.intersectPlane(this._groundPlane, this._intersection)) { const y = this._intersection.y; this._groundPlanePoint.copy(this._intersection).sub(this._groundOffset); this._groundPlanePoint.y = y; if (dragOnGroundPlane) { this._groundOffsetVector.set(0, 1, 0); // console.log(this._groundOffset); const wp = this._intersection.sub(this._groundOffset).add(this._groundOffsetVector.multiplyScalar(this._groundOffsetFactor)); this.onUpdateWorldPosition(wp, this._groundPlanePoint, false); this.onDidUpdate(); return; } } // TODO: fix this else this._groundPlanePoint.set(0, 99999, 0); // else if (ray.intersectPlane(this._groundPlane, this._intersection)) { // const y = this._intersection.y; // this._groundPlanePoint.copy(this._intersection).sub(this._groundOffset); // this._groundPlanePoint.y = y; // } } if (changed) { this.onUpdateScreenSpacePlane(); } this._requireUpdateGroundPlane = true; if (this._raycaster.ray.intersectPlane(this._cameraPlane, this._intersection)) { this.onUpdateWorldPosition(this._intersection.sub(this._cameraPlaneOffset), this._groundPlanePoint, true); this.onDidUpdate(); } } */ } private onUpdateWorldPosition(wp: Vector3, pointOnPlane: Vector3 | null, heightOnly: boolean) { if (!this._selected) return; if (heightOnly) { const cur = getWorldPosition(this._selected); cur.y = wp.y; wp = cur; } setWorldPosition(this._selected, wp); setWorldPosition(this._groundLine, wp); if (this._hasGroundPlane) { this._groundLine.scale.y = this._groundDistance; } else this._groundLine.scale.y = 1000; this._groundLine.visible = this.showGizmo; this._groundMarker.visible = pointOnPlane !== null && this.showGizmo; if (pointOnPlane) { const s = getWorldPosition(this._camera).distanceTo(pointOnPlane) * .01; this._groundMarker.scale.set(s, s, s); setWorldPosition(this._groundMarker, pointOnPlane); } } private onUpdateScreenSpacePlane() { if (!this._selected || !this._context) return; const rc = this._context.input.getPointerPositionRC(0); if (!rc) return; this._raycaster.setFromCamera(rc, this._camera); this._cameraPlane.setFromNormalAndCoplanarPoint(this._camera.getWorldDirection(this._cameraPlane.normal), this._worldPosition.setFromMatrixPosition(this._selected.matrixWorld)); if (this._raycaster.ray.intersectPlane(this._cameraPlane, this._intersection) && this._selected.parent) { this._inverseMatrix.copy(this._selected.parent.matrixWorld).invert(); this._cameraPlaneOffset.copy(this._intersection).sub(this._worldPosition.setFromMatrixPosition(this._selected.matrixWorld)); } } private onUpdateGroundPlane() { if (!this._selected || !this._context) return; const wp = getWorldPosition(this._selected); const ray = new Ray(getTempVector(0, .1, 0).add(wp), getTempVector(0, -1, 0)); const opts = new RaycastOptions(); opts.testObject = o => o !== this._selected; const hits = this._context.physics.raycastFromRay(ray, opts); for (let i = 0; i < hits.length; i++) { const hit = hits[i]; if (!hit.face || this.contains(this._selected, hit.object)) { continue; } const normal = getTempVector(0, 1, 0); // hit.face.normal this._groundPlane.setFromNormalAndCoplanarPoint(normal, hit.point); break; } this._hasGroundPlane = true; this._groundPlane.setFromNormalAndCoplanarPoint(ray.direction.multiplyScalar(-1), ray.origin); this._raycaster.ray.intersectPlane(this._groundPlane, this._intersection); this._groundDistance = this._intersection.distanceTo(wp); this._groundOffset.copy(this._intersection).sub(wp); } private contains(obj: Object3D, toSearch: Object3D): boolean { if (obj === toSearch) return true; if (obj.children) { for (const child of obj.children) { if (this.contains(child, toSearch)) return true; } } return false; } }