UNPKG

@needle-tools/engine

Version:

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

1,178 lines • 56.9 kB
import { Matrix4, Object3D, Ray, Vector2, Vector3 } from 'three'; import { showBalloonMessage, showBalloonWarning } from './debug/debug.js'; import { Context } from './engine_setup.js'; import { getTempVector, getWorldQuaternion } from './engine_three_utils.js'; import { DeviceUtilities, getParam } from './engine_utils.js'; const debug = getParam("debuginput"); /** * Types of pointer input devices supported by Needle Engine. */ export var PointerType; (function (PointerType) { /** Mouse or trackpad input */ PointerType["Mouse"] = "mouse"; /** Touch screen input */ PointerType["Touch"] = "touch"; /** XR controller input (e.g., VR controllers) */ PointerType["Controller"] = "controller"; /** XR hand tracking input */ PointerType["Hand"] = "hand"; })(PointerType || (PointerType = {})); var PointerEnumType; (function (PointerEnumType) { PointerEnumType["PointerDown"] = "pointerdown"; PointerEnumType["PointerUp"] = "pointerup"; PointerEnumType["PointerMove"] = "pointermove"; })(PointerEnumType || (PointerEnumType = {})); var KeyboardEnumType; (function (KeyboardEnumType) { KeyboardEnumType["KeyDown"] = "keydown"; KeyboardEnumType["KeyUp"] = "keyup"; KeyboardEnumType["KeyPressed"] = "keypress"; })(KeyboardEnumType || (KeyboardEnumType = {})); /** * Event types that can be listened to via {@link Input.addEventListener}. * @see {@link NEPointerEvent} for pointer event data * @see {@link NEKeyboardEvent} for keyboard event data */ export var InputEvents; (function (InputEvents) { /** Fired when a pointer button is pressed */ InputEvents["PointerDown"] = "pointerdown"; /** Fired when a pointer button is released */ InputEvents["PointerUp"] = "pointerup"; /** Fired when a pointer moves */ InputEvents["PointerMove"] = "pointermove"; /** Fired when a key is pressed down */ InputEvents["KeyDown"] = "keydown"; /** Fired when a key is released */ InputEvents["KeyUp"] = "keyup"; /** Fired when a key produces a character value */ InputEvents["KeyPressed"] = "keypress"; })(InputEvents || (InputEvents = {})); /** * Extended PointerEvent with Needle Engine-specific data. * Contains information about the input device, spatial data for XR, and world-space ray. * * @example Accessing event data in a component * ```ts * onPointerDown(args: PointerEventData) { * const evt = args.event; * console.log(`Pointer ${evt.pointerId} (${evt.pointerType})`); * if (evt.isSpatial) { * console.log("XR input, ray:", evt.ray); * } * } * ``` * * @see {@link Input} for the input management system * @see {@link PointerType} for available pointer types */ export class NEPointerEvent extends PointerEvent { /** * Spatial input data */ clientZ; /** the device index: mouse and touch are always 0, otherwise e.g. index of the connected Gamepad or XRController */ deviceIndex; /** The origin of the event contains a reference to the creator of this event. * This can be the Needle Engine input system or e.g. a XR controller. * Implement `onPointerHits` to receive the intersections of this event. */ origin; /** the browser event that triggered this event (if any) */ source; /** Is the pointer event created via a touch on screen or a spatial device like a XR controller or hand tracking? */ mode; /** Returns true if the input was emitted in 3D space (and not by e.g. clicking on a 2D screen). You can use {@link mode} if you need more information about the input source */ get isSpatial() { return this.mode != "screen"; } /** A ray in worldspace for the event. * If the ray is undefined you can also use `space.worldForward` and `space.worldPosition` */ get ray() { if (!this._ray) { this._ray = new Ray(this.space.worldPosition.clone(), this.space.worldForward.clone()); } return this._ray; } set ray(value) { this._ray = value; } /**@returns true if this event has a ray. If you access the ray property a ray will automatically created */ get hasRay() { return this._ray !== undefined; } _ray; /** The device space (this object is not necessarily rendered in the scene but you can access or copy the matrix) * E.g. you can access the input world space source position with `space.worldPosition` or world direction with `space.worldForward` */ space; /** true if this event is a click */ isClick = false; /** true if this event is a double click */ isDoubleClick = false; /** @returns `true` if the event is marked to be used (when `use()` has been called). Default: `false` */ get used() { return this._used; } _used = false; /** Call to mark an event to be used */ use() { this._used = true; } /** Identifier for this pointer event. * For mouse and touch this is always 0. * For XR input: a combination of the deviceIndex + button to uniquely identify the exact input (e.g. LeftController:Button0 = 0, RightController:Button1 = 11) */ get pointerId() { return this._pointerid; } _pointerid; // this is set via the init arguments (we override it here for intellisense to show the string options) /** What type of input created this event: touch, mouse, xr controller, xr hand tracking... */ get pointerType() { return this._pointerType; } _pointerType; /** * The button name that raised this event (e.g. for mouse events "left", "right", "middle" or for XRTrigger "xr-standard-trigger" or "xr-standard-thumbstick") * Use {@link button} to get the numeric button index (e.g. 0, 1, 2...) on the controller or mouse. */ buttonName = undefined; // this is set via the init arguments (we override it here for intellisense to show the string options) /** The input that raised this event like `pointerdown` */ get type() { return this._type; } _type; /** metadata can be used to associate additional information with the event */ metadata = {}; /** intersections that were generated from this event (or are associated with this event in any way) */ intersections = new Array(); constructor(type, source, init) { super(type, init); this.clientZ = init.clientZ; // apply the init arguments. Otherwise the arguments will be undefined in the bundled / published version of needle engine // so we have to be careful if we override properties - we then also need to set them in the constructor this._pointerid = init.pointerId; this._pointerType = init.pointerType; this._type = type; this.deviceIndex = init.deviceIndex; this.origin = init.origin; this.source = source; this.mode = init.mode; this._ray = init.ray; this.space = init.device; this.buttonName = init.buttonName; } _immediatePropagationStopped = false; get immediatePropagationStopped() { return this._immediatePropagationStopped; } _propagationStopped = false; get propagationStopped() { return this._immediatePropagationStopped || this._propagationStopped; } stopImmediatePropagation() { this._immediatePropagationStopped = true; super.stopImmediatePropagation(); this.source?.stopImmediatePropagation(); } stopPropagation() { this._propagationStopped = true; super.stopPropagation(); this.source?.stopPropagation(); if (debug) console.warn("Stop propagation...", this.pointerId, this.pointerType); } } export class NEKeyboardEvent extends KeyboardEvent { source; constructor(type, source, init) { super(type, init); this.source = source; } stopImmediatePropagation() { super.stopImmediatePropagation(); this.source?.stopImmediatePropagation(); } } export class KeyEventArgs { key; keyType; source; constructor(evt) { this.key = evt.key; this.keyType = evt.type; this.source = evt; } } export var InputEventQueue; (function (InputEventQueue) { InputEventQueue[InputEventQueue["Early"] = -100] = "Early"; InputEventQueue[InputEventQueue["Default"] = 0] = "Default"; InputEventQueue[InputEventQueue["Late"] = 100] = "Late"; })(InputEventQueue || (InputEventQueue = {})); /** * Handles all input events including mouse, touch, keyboard, and XR controllers. * Access via `this.context.input` from any component. * * @example Checking mouse/pointer state * ```ts * update() { * if (this.context.input.mouseDown) { * console.log("Mouse button pressed"); * } * if (this.context.input.mouseClick) { * console.log("Click detected"); * } * const pos = this.context.input.mousePosition; * console.log(`Mouse at: ${pos.x}, ${pos.y}`); * } * ``` * @example Keyboard input * ```ts * update() { * if (this.context.input.isKeyDown("Space")) { * console.log("Space pressed this frame"); * } * if (this.context.input.isKeyPressed("w")) { * console.log("W key is held down"); * } * } * ``` * @example Event-based input * ```ts * onEnable() { * this.context.input.addEventListener("pointerdown", this.onPointerDown); * } * onDisable() { * this.context.input.removeEventListener("pointerdown", this.onPointerDown); * } * onPointerDown = (evt: NEPointerEvent) => { * console.log("Pointer down:", evt.pointerId); * } * ``` * * @see {@link NEPointerEvent} for pointer event data * @see {@link InputEvents} for available event types * @see {@link PointerType} for pointer device types * @link https://engine.needle.tools/docs/scripting.html */ export class Input { /** This is a list of event listeners per event type (e.g. pointerdown, pointerup, keydown...). Each entry contains a priority and list of listeners. * That way users can control if they want to receive events before or after other listeners (e.g subscribe to pointer events before the EventSystem receives them) - this allows certain listeners to be always invoked first (or last) and stop propagation * Listeners per event are sorted */ _eventListeners = {}; addEventListener(type, callback, options) { if (!this._eventListeners[type]) this._eventListeners[type] = []; if (!callback || typeof callback !== "function") { console.error("Invalid call to addEventListener: callback is required and must be a function!"); return; } if (!options) options = {}; // create a copy of the options object to avoid the original object being modified else options = { ...options }; let queue = 0; if (options?.queue != undefined) queue = options.queue; const listeners = this._eventListeners[type]; const queueListeners = listeners.find(l => l.priority === queue); if (!queueListeners) { listeners.push({ priority: queue, listeners: [{ callback, options }] }); // ensure we sort the listeners by priority listeners.sort((a, b) => a.priority - b.priority); } else { queueListeners.listeners.push({ callback, options }); } } removeEventListener(type, callback, options) { if (!this._eventListeners[type]) return; if (!callback) return; const listeners = this._eventListeners[type]; // if a specific queue is requested the callback should only be removed from that queue if (options?.queue != undefined) { const queueListeners = listeners.find(l => l.priority === options.queue); if (!queueListeners) return; const index = queueListeners.listeners.findIndex(l => l.callback === callback); if (index >= 0) queueListeners.listeners.splice(index, 1); } // if no queue is requested the callback will be removed from all queues else { for (const l of listeners) { const index = l.listeners.findIndex(l => l.callback === callback); if (index >= 0) l.listeners.splice(index, 1); } } } dispatchEvent(evt) { /** True when the next event queue should not be invoked */ let preventNextEventQueue = false; // Handle keyboard event if (evt instanceof NEKeyboardEvent) { const listeners = this._eventListeners[evt.type]; if (listeners) { for (const queue of listeners) { for (let i = 0; i < queue.listeners.length; i++) { const entry = queue.listeners[i]; // if the abort signal is aborted we remove the listener and will not invoke it if (entry.options?.signal?.aborted) { queue.listeners.splice(i, 1); i--; continue; } // if the event should only be invoked once then we remove the listener before invoking it if (entry.options.once) { queue.listeners.splice(i, 1); i--; } entry.callback(evt); } } } } // Hnadle pointer event if (evt instanceof NEPointerEvent) { const listeners = this._eventListeners[evt.type]; if (listeners) { for (const queue of listeners) { if (preventNextEventQueue) break; for (let i = 0; i < queue.listeners.length; i++) { const entry = queue.listeners[i]; // if the abort signal is aborted we remove the listener and will not invoke it if (entry.options?.signal?.aborted) { queue.listeners.splice(i, 1); i--; continue; } // if immediatePropagationStopped is true we stop propagation altogether if (evt.immediatePropagationStopped) { preventNextEventQueue = true; if (debug) console.log("immediatePropagationStopped", evt.type); break; } // if propagationStopped is true we continue invoking the current queue but then not invoke the next queue else if (evt.propagationStopped) { preventNextEventQueue = true; if (debug) console.log("propagationStopped", evt.type); // we do not break here but continue invoking the listeners in the queue } // if the event should only be invoked once then we remove the listener before invoking it if (entry.options.once) { queue.listeners.splice(i, 1); i--; } entry.callback(evt); } } } } } _doubleClickTimeThreshold = .2; _longPressTimeThreshold = 1; get mousePosition() { return this._pointerPositions[0]; } ; get mousePositionRC() { return this._pointerPositionsRC[0]; } get mouseDown() { return this._pointerDown[0]; } get mouseUp() { return this._pointerUp[0]; } /** Is the primary pointer clicked (usually the left button). This is equivalent to `input.click` */ get mouseClick() { return this._pointerClick[0]; } /** Was a double click detected for the primary pointer? This is equivalent to `input.doubleClick` */ get mouseDoubleClick() { return this._pointerDoubleClick[0]; } get mousePressed() { return this._pointerPressed[0]; } get mouseWheelChanged() { return this.getMouseWheelChanged(0); } /** Is the primary pointer double clicked (usually the left button). This is equivalent to `input.mouseDoubleClick` */ get click() { return this._pointerClick[0]; } /** Was a double click detected for the primary pointer? */ get doubleClick() { return this._pointerDoubleClick[0]; } /** * Get a connected Gamepad * Note: For a gamepad to be available to the browser it must have received input before while the page was focused. * @link https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API * @returns The gamepad or null if no gamepad is connected */ getGamepad(index = 0) { if (typeof navigator !== "undefined" && "getGamepads" in navigator) { return navigator.getGamepads()[index] || null; } return null; } _setCursorTypes = []; /** @deprecated use setCursor("pointer") */ setCursorPointer() { this.setCursor("pointer"); } /** @deprecated use unsetCursor() */ setCursorNormal() { this.unsetCursor("pointer"); } /** * Set a custom cursor. This will set the cursor type until unsetCursor is called */ setCursor(type) { this._setCursorTypes.push(type); if (this._setCursorTypes.length > 10) { this._setCursorTypes.shift(); } this.updateCursor(); } /** * Unset a custom cursor. This will set the cursor type to the previous type or default */ unsetCursor(type) { for (let i = this._setCursorTypes.length - 1; i >= 0; i--) { if (this._setCursorTypes[i] === type) { this._setCursorTypes.splice(i, 1); this.updateCursor(); break; } } } updateCursor() { if (this._setCursorTypes?.length == 0) this.context.domElement.style.cursor = "default"; else this.context.domElement.style.cursor = this._setCursorTypes[this._setCursorTypes.length - 1]; } /** * Check if a pointer id is currently used. */ getIsPointerIdInUse(pointerId) { for (const evt of this._pointerEventsPressed) { if (evt.pointerId === pointerId) { if (evt.used) return true; } } return false; } /** how many pointers are currently pressed */ getPointerPressedCount() { let count = 0; for (let i = 0; i < this._pointerPressed.length; i++) { if (this._pointerPressed[i]) { count++; } } return count; } /** * Gets the position of the given pointer index in pixel * @param i The pointer index * @returns The position of the pointer in pixel */ getPointerPosition(i) { if (i >= this._pointerPositions.length) return null; return this._pointerPositions[i]; } getPointerPositionLastFrame(i) { if (i >= this._pointerPositionsLastFrame.length) return null; return this._pointerPositionsLastFrame[i]; } getPointerPositionDelta(i) { if (i >= this._pointerPositionsDelta.length) return null; return this._pointerPositionsDelta[i]; } /** * The pointer position in screenspace coordinates (-1 to 1) where 0 is the center of the screen. * This can be useful for e.g. raycasting (see https://threejs.org/docs/#api/en/core/Raycaster.setFromCamera) */ getPointerPositionRC(i) { if (i >= this._pointerPositionsRC.length) return null; return this._pointerPositionsRC[i]; } getPointerDown(i) { if (i >= this._pointerDown.length) return false; return this._pointerDown[i]; } getPointerUp(i) { if (i >= this._pointerUp.length) return false; return this._pointerUp[i]; } getPointerPressed(i) { if (i >= this._pointerPressed.length) return false; const res = this._pointerPressed[i]; // if (i === 0) console.log(...this._pointerIds); return res; } getPointerClicked(i) { if (i >= this._pointerClick.length) return false; return this._pointerClick[i]; } getPointerDoubleClicked(i) { if (i >= this._pointerDoubleClick.length) return false; return this._pointerDoubleClick[i]; } getPointerDownTime(i) { if (i >= this._pointerDownTime.length) return -1; return this._pointerDownTime[i]; } getPointerUpTime(i) { if (i >= this._pointerUpTime.length) return -1; return this._pointerUpTime[i]; } getPointerLongPress(i) { if (i >= this._pointerDownTime.length) return false; return this.getPointerPressed(i) && this.context.time.time - this._pointerDownTime[i] > this._longPressTimeThreshold; } getIsMouse(i) { if (i < 0 || i >= this._pointerTypes.length) return false; return this._pointerTypes[i] === PointerType.Mouse; } getIsTouch(i) { if (i < 0 || i >= this._pointerTypes.length) return false; return this._pointerTypes[i] === PointerType.Touch; } getTouchesPressedCount() { let count = 0; for (let i = 0; i < this._pointerPressed.length; i++) { if (this._pointerPressed[i] && this.getIsTouch(i)) { count++; } } return count; } getMouseWheelChanged(i = 0) { if (i >= this._mouseWheelChanged.length) return false; return this._mouseWheelChanged[i]; } getMouseWheelDeltaY(i = 0) { if (i >= this._mouseWheelDeltaY.length) return 0; return this._mouseWheelDeltaY[i]; } getPointerEvent(i) { if (i >= this._pointerEvent.length) return undefined; return this._pointerEvent[i] ?? undefined; } *foreachPointerId(pointerType) { for (let i = 0; i < this._pointerTypes.length; i++) { // check if the pointer is active if (this._pointerIsActive(i)) { // if specific pointer types are requested if (pointerType !== undefined) { const type = this._pointerTypes[i]; if (Array.isArray(pointerType)) { let isInArray = false; for (const t of pointerType) { if (type === t) { isInArray = true; break; } } if (!isInArray) continue; } else { if (pointerType !== type) continue; } } yield i; } } } *foreachTouchId() { for (let i = 0; i < this._pointerTypes.length; i++) { const type = this._pointerTypes[i]; if (type !== PointerType.Touch) continue; if (this._pointerIsActive[i]) yield i; } } _pointerIsActive(index) { if (index < 0) return false; return this._pointerPressed[index] || this._pointerDown[index] || this._pointerUp[index]; } context; _pointerDown = [false]; _pointerUp = [false]; _pointerClick = [false]; _pointerDoubleClick = [false]; _pointerPressed = [false]; _pointerPositions = [new Vector2()]; _pointerPositionsLastFrame = [new Vector2()]; _pointerPositionsDelta = [new Vector2()]; _pointerPositionsRC = [new Vector2()]; _pointerPositionDown = [new Vector3()]; _pointerDownTime = []; _pointerUpTime = []; _pointerUpTimestamp = []; _pointerIds = []; _pointerTypes = [""]; _mouseWheelChanged = [false]; _mouseWheelDeltaY = [0]; _pointerEvent = []; /** current pressed pointer events. Used to check if any of those events was used */ _pointerEventsPressed = []; /** This is added/updated for pointers. screenspace pointers set this to the camera near plane */ _pointerSpace = []; _pressedStack = new Map(); onDownButton(pointerId, button) { let stack = this._pressedStack.get(pointerId); if (!stack) { stack = []; this._pressedStack.set(pointerId, stack); } stack.push(button); } onReleaseButton(pointerId, button) { const stack = this._pressedStack.get(pointerId); if (!stack) return; const index = stack.indexOf(button); if (index >= 0) stack.splice(index, 1); } /** the first button that was down and is currently pressed */ getFirstPressedButtonForPointer(pointerId) { const stack = this._pressedStack.get(pointerId); if (!stack) return undefined; return stack[0]; } /** the last (most recent) button that was down and is currently pressed */ getLatestPressedButtonForPointer(pointerId) { const stack = this._pressedStack.get(pointerId); if (!stack) return undefined; return stack[stack.length - 1]; } getKeyDown(key) { // If a key is provided check if it was pressed this frame if (key !== undefined) { return this.isKeyDown(key); } // If no key was provided get the first key that was pressed this frame for (const key in this.keysPressed) { const k = this.keysPressed[key]; if (k.startFrame === this.context.time.frameCount) return k.key; } return null; } getKeyPressed(key) { if (key !== undefined) { return this.isKeyPressed(key); } for (const key in this.keysPressed) { const k = this.keysPressed[key]; if (k.pressed) return k.key; } return null; } getKeyUp(key) { if (key !== undefined) { return this.isKeyUp(key); } for (const key in this.keysPressed) { const k = this.keysPressed[key]; if (k.pressed === false && k.frame === this.context.time.frameCount) return true; return false; } return null; } isKeyDown(keyCode) { if (!this.context.application.isVisible || !this.context.application.hasFocus) return false; const codes = this.getCodeForCommonKeyName(keyCode); if (codes !== null) { for (const code of codes) if (this.isKeyDown(code)) return true; return false; } const k = this.keysPressed[keyCode]; if (!k) return false; return k.startFrame === this.context.time.frameCount && k.pressed; } isKeyUp(keyCode) { if (!this.context.application.isVisible || !this.context.application.hasFocus) return false; const codes = this.getCodeForCommonKeyName(keyCode); if (codes !== null) { for (const code of codes) if (this.isKeyUp(code)) return true; return false; } const k = this.keysPressed[keyCode]; if (!k) return false; return k.frame === this.context.time.frameCount && k.pressed === false; } isKeyPressed(keyCode) { if (!this.context.application.isVisible || !this.context.application.hasFocus) return false; const codes = this.getCodeForCommonKeyName(keyCode); if (codes !== null) { for (const code of codes) if (this.isKeyPressed(code)) return true; return false; } const k = this.keysPressed[keyCode]; if (!k) return false; return k.pressed || false; } // utility helper for mapping common names to actual codes; e.g. "Shift" -> "ShiftLeft" and "ShiftRight" or "a" -> "KeyA" getCodeForCommonKeyName(keyName) { if (keyName.length === 1) { // check if this is a digit if (keyName >= "0" && keyName <= "9") return ["Digit" + keyName]; // check if this is a letter if (keyName >= "a" && keyName <= "z") return ["Key" + keyName.toUpperCase()]; if (keyName == " ") return ["Space"]; } switch (keyName) { case "shift": case "Shift": return ["ShiftLeft", "ShiftRight"]; case "control": case "Control": return ["ControlLeft", "ControlRight"]; case "alt": case "Alt": return ["AltLeft", "AltRight"]; } return null; } createInputEvent(args) { // TODO: technically we would need to check for circular invocations here! switch (args.type) { case InputEvents.PointerDown: if (debug) showBalloonMessage("Create Pointer down"); this.onDownButton(args.deviceIndex, args.button); this.onDown(args); break; case InputEvents.PointerMove: if (debug) showBalloonMessage("Create Pointer move"); this.onMove(args); break; case InputEvents.PointerUp: if (debug) showBalloonMessage("Create Pointer up"); this.onUp(args); this.onReleaseButton(args.deviceIndex, args.button); break; } } convertScreenspaceToRaycastSpace(vec2) { vec2.x = (vec2.x - this.context.domX) / this.context.domWidth * 2 - 1; vec2.y = -((vec2.y - this.context.domY) / this.context.domHeight) * 2 + 1; return vec2; } /** @internal */ constructor(context) { this.context = context; this.context.post_render_callbacks.push(this.onEndOfFrame); } /** this is the html element we subscribed to for events */ _htmlEventSource; bindEvents() { this.unbindEvents(); // we subscribe to the canvas element because we don't want to receive events when the user is interacting with the UI // e.g. if we have slotted HTML elements in the needle engine DOM elements we don't want to receive input events for those this._htmlEventSource = this.context.renderer.domElement; window.addEventListener('contextmenu', this.onContextMenu); this._htmlEventSource.addEventListener('pointerdown', this.onPointerDown, { passive: true }); window.addEventListener('pointermove', this.onPointerMove, { passive: true, capture: true, }); window.addEventListener('pointerup', this.onPointerUp, { passive: true }); window.addEventListener('pointercancel', this.onPointerCancel, { passive: true }); window.addEventListener("touchstart", this.onTouchStart, { passive: true }); window.addEventListener("touchmove", this.onTouchMove, { passive: true }); window.addEventListener("touchend", this.onTouchEnd, { passive: true }); this._htmlEventSource.addEventListener('wheel', this.onMouseWheel, { passive: true }); window.addEventListener("wheel", this.onWheelWindow, { passive: true }); window.addEventListener("keydown", this.onKeyDown, false); window.addEventListener("keypress", this.onKeyPressed, false); window.addEventListener("keyup", this.onKeyUp, false); // e.g. when using sharex to capture we loose focus thus dont get e.g. key up events window.addEventListener('blur', this.onLostFocus); } unbindEvents() { for (const key in this._eventListeners) { this._eventListeners[key].length = 0; // clear all listeners for this event } window.removeEventListener('contextmenu', this.onContextMenu); this._htmlEventSource?.removeEventListener('pointerdown', this.onPointerDown); window.removeEventListener('pointermove', this.onPointerMove); window.removeEventListener('pointerup', this.onPointerUp); window.removeEventListener('pointercancel', this.onPointerCancel); window.removeEventListener("touchstart", this.onTouchStart); window.removeEventListener("touchmove", this.onTouchMove); window.removeEventListener("touchend", this.onTouchEnd); this._htmlEventSource?.removeEventListener('wheel', this.onMouseWheel, false); window.removeEventListener("wheel", this.onWheelWindow, false); window.removeEventListener("keydown", this.onKeyDown, false); window.removeEventListener("keypress", this.onKeyPressed, false); window.removeEventListener("keyup", this.onKeyUp, false); window.removeEventListener('blur', this.onLostFocus); } dispose() { const index = this.context.post_render_callbacks.indexOf(this.onEndOfFrame); if (index >= 0) this.context.post_render_callbacks.splice(index, 1); this.unbindEvents(); } onLostFocus = () => { for (const kp in this.keysPressed) { this.keysPressed[kp].pressed = false; } }; _receivedPointerMoveEventsThisFrame = new Array; onEndOfFrame = () => { this._receivedPointerMoveEventsThisFrame.length = 0; for (let i = 0; i < this._pointerUp.length; i++) this._pointerUp[i] = false; for (let i = 0; i < this._pointerDown.length; i++) this._pointerDown[i] = false; for (let i = 0; i < this._pointerClick.length; i++) this._pointerClick[i] = false; for (let i = 0; i < this._pointerDoubleClick.length; i++) this._pointerDoubleClick[i] = false; for (const pt of this._pointerPositionsDelta) pt.set(0, 0); for (let i = 0; i < this._mouseWheelChanged.length; i++) this._mouseWheelChanged[i] = false; for (let i = 0; i < this._mouseWheelDeltaY.length; i++) this._mouseWheelDeltaY[i] = 0; }; canReceiveInput(evt) { // If the user has HTML objects ontop of the canvas // if(evt.target === this.context.renderer.domElement) return true; // const css = window.getComputedStyle(evt.target as HTMLElement); // if(css.pointerEvents === "all") return false; // We only check the target elements here since the canvas may be overlapped by other elements // in which case we do not want to use the input (e.g. if a HTML element is being triggered) if (evt.target === this.context.renderer?.domElement) return true; if (evt.target === this.context.domElement) return true; // if we are in AR we always want to receive touches because the canvas is the whole screen. // See https://linear.app/needle/issue/NE-4345 if (this.context.isInAR) { return true; } // looks like in Mozilla WebXR viewer the target element is the body if (this.context.isInAR && evt.target === document.body && DeviceUtilities.isMozillaXR()) return true; if (debug) console.warn("CanReceiveInput:False for", evt.target); return false; } onContextMenu = (evt) => { if (this.canReceiveInput(evt) === false) return; // if (evt instanceof PointerEvent) { // // for longpress on touch there might open a context menu // // in which case we set the pointer pressed back to false (resetting the pressed pointer) // // we need to emit a pointer up event here as well // if (evt.pointerType === "touch") { // // for (const index in this._pointerPressed) { // // if (this._pointerTypes[index] === PointerType.Touch) { // // // this._pointerPressed[index] = false; // // // this throws orbit controls? // // // const ne = this.createPointerEventFromTouch("pointerup", parseInt(index), this._pointerPositions[index].x, this._pointerPositions[index].y, 0, evt); // // // this.onUp(ne); // // } // // } // } // } }; keysPressed = {}; onKeyDown = (evt) => { if (debug) console.log(`key down ${evt.code}, ${this.context.application.hasFocus}`, evt); if (!this.context.application.hasFocus) return; const ex = this.keysPressed[evt.code]; if (ex && ex.pressed) return; this.keysPressed[evt.code] = { pressed: true, frame: this.context.time.frameCount + 1, startFrame: this.context.time.frameCount + 1, key: evt.key, code: evt.code }; const ne = new NEKeyboardEvent(InputEvents.KeyDown, evt, evt); this.onDispatchEvent(ne); }; onKeyPressed = (evt) => { if (!this.context.application.hasFocus) return; const p = this.keysPressed[evt.code]; if (!p) return; p.pressed = true; p.frame = this.context.time.frameCount + 1; const ne = new NEKeyboardEvent(InputEvents.KeyPressed, evt, evt); this.onDispatchEvent(ne); }; onKeyUp = (evt) => { if (!this.context.application.hasFocus) return; const p = this.keysPressed[evt.code]; if (!p) return; p.pressed = false; p.frame = this.context.time.frameCount + 1; const ne = new NEKeyboardEvent(InputEvents.KeyUp, evt, evt); this.onDispatchEvent(ne); }; onWheelWindow = (evt) => { // check if we are in pointer lock mode if (document.pointerLockElement) { // only if yes we want to use the mouse wheel as a pointer event this.onMouseWheel(evt); } }; onMouseWheel = (evt) => { if (this.canReceiveInput(evt) === false) return; if (this._mouseWheelDeltaY.length <= 0) this._mouseWheelDeltaY.push(0); if (this._mouseWheelChanged.length <= 0) this._mouseWheelChanged.push(false); this._mouseWheelChanged[0] = true; const current = this._mouseWheelDeltaY[0]; this._mouseWheelDeltaY[0] = current + evt.deltaY; }; onPointerDown = (evt) => { if (this.context.isInAR) return; if (this.canReceiveInput(evt) === false) return; if (evt.target instanceof HTMLElement) { evt.target.setPointerCapture(evt.pointerId); } const id = this.getPointerId(evt); if (debug) showBalloonMessage(`pointer down #${id}, identifier:${evt.pointerId}`); const space = this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY); const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: evt.button, clientX: evt.clientX, clientY: evt.clientY, pointerType: evt.pointerType, buttonName: this.getButtonName(evt), device: space, pressure: evt.pressure }); this.onDown(ne); }; onPointerMove = (evt) => { if (this.context.isInAR) return; // Prevent multiple pointerMove events per frame if (this._receivedPointerMoveEventsThisFrame.includes(evt.pointerId)) return; this._receivedPointerMoveEventsThisFrame.push(evt.pointerId); // We want to keep receiving move events until pointerUp and not stop handling events just because we're hovering over *some* HTML element // if (this.canReceiveInput(evt) === false) return; let button = evt.button; if (evt.pointerType === "mouse") { const pressedButton = this.getFirstPressedButtonForPointer(0); button = pressedButton ?? 0; } const id = this.getPointerId(evt, button); if (button === -1) { button = id; } const space = this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY); const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: button, clientX: evt.clientX, clientY: evt.clientY, pointerType: evt.pointerType, buttonName: this.getButtonName(evt), device: space, pressure: evt.pressure }); this.onMove(ne); }; onPointerCancel = (evt) => { if (this.context.isInAR) return; if (debug) console.log("Pointer cancel", evt); // we treat this as an up event for now to make sure we don't have any pointers stuck in a pressed state etc. Technically we dont want to invoke a up event for cancels... this.onPointerUp(evt); }; onPointerUp = (evt) => { if (this.context.isInAR) return; if (evt.target instanceof HTMLElement) { evt.target.releasePointerCapture(evt.pointerId); } // the pointer up event should always be handled // if (this.canReceiveInput(evt) === false) return; const id = this.getPointerId(evt); // if (!this.isNewEvent(evt.timeStamp, id, this._pointerUpTimestamp)) return; const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: evt.button, clientX: evt.clientX, clientY: evt.clientY, pointerType: evt.pointerType, buttonName: this.getButtonName(evt), device: this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY), pressure: evt.pressure }); this.onUp(ne); this._pointerIds[id] = -1; if (debug) console.log("ID=" + id, "PointerId=" + evt.pointerId, "ALL:", [...this._pointerIds]); }; getPointerId(evt, button) { if (evt.pointerType === "mouse") return 0 + (button ?? evt.button); return this.getPointerIndex(evt.pointerId); } getButtonName(evt) { const button = evt.button; if (evt.pointerType === "mouse") { switch (button) { case 0: return "left"; case 1: return "middle"; case 2: return "right"; } } return "unknown"; } // the touch events are currently only used for AR support on android onTouchStart = (evt) => { if (!this.context.isInAR) return; for (let i = 0; i < evt.changedTouches.length; i++) { const touch = evt.changedTouches[i]; const id = this.getPointerIndex(touch.identifier); const space = this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY); const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: "touch", buttonName: "unknown", device: space, pressure: touch.force }); this.onDown(ne); } ; }; onTouchMove = (evt) => { if (!this.context.isInAR) return; for (let i = 0; i < evt.changedTouches.length; i++) { const touch = evt.changedTouches[i]; const id = this.getPointerIndex(touch.identifier); const space = this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY); const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: "touch", buttonName: "unknown", device: space, pressure: touch.force }); this.onMove(ne); } ; }; onTouchEnd = (evt) => { if (!this.context.isInAR) return; for (let i = 0; i < evt.changedTouches.length; i++) { const touch = evt.changedTouches[i]; const id = this.getPointerIndex(touch.identifier); const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: "touch", buttonName: "unknown", device: this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY), pressure: touch.force }); this.onUp(ne); this._pointerIds[id] = -1; } ; }; tempNearPlaneVector = new Vector3(); tempFarPlaneVector = new Vector3(); tempLookMatrix = new Matrix4(); getAndUpdateSpatialObjectForScreenPosition(id, screenX, screenY) { let space = this._pointerSpace[id]; if (!space) { space = new Object3D(); this._pointerSpace[id] = space; } this._pointerSpace[id] = space; const camera = this.context.mainCamera; if (camera) { const pointOnNearPlane = this.tempNearPlaneVector.set(screenX, screenY, -1); this.convertScreenspaceToRaycastSpace(pointOnNearPlane); const pointOnFarPlane = this.tempFarPlaneVector.set(pointOnNearPlane.x, pointOnNearPlane.y, 1); pointOnNearPlane.unproject(camera); pointOnFarPlane.unproject(camera); const worldUp = camera.worldUp || getTempVector(0, 1, 0).applyQuaternion(getWorldQuaternion(camera)); this.tempLookMatrix.lookAt(pointOnFarPlane, pointOnNearPlane, worldUp); space.position.set(pointOnNearPlane.x, pointOnNearPlane.y, pointOnNearPlane.z); space.quaternion.setFromRotationMatrix(this.tempLookMatrix); } return space; } // Prevent the same event being handled twice (e.g. on touch we get a mouseUp and touchUp evt with the same timestamp) // private isNewEvent(timestamp: number, index: number, arr: number[]): boolean { // while (arr.length <= index) arr.push(-1); // if (timestamp === arr[index]) return false; // arr[index] = timestamp; // return true; // } isInRect(e) { if (this.context.isInXR) return true; const rect = this.context.domElement.getBoundingClientRect(); const px = e.clientX; const py = e.clientY; const isInRect = px >= rect.x && px <= rect.right && py >= rect.y && py <= rect.bottom; if (debug && !isInRect) console.log("Not in rect", rect, px, py); return isInRect; } onDown(evt) { const index = evt.pointerId; if (this.getPointerPressed(index)) { // see https://linear.app/needle/issue/NE-6855#comment-3b0e3365 if (debug) console.warn(`Received pointerDown event for pointerId that is already pressed: ${index}/${evt.button}`, debug ? evt : ''); return; } if (debug) console.log(evt.pointerType, "DOWN", index, evt.button); if (!this.isInRect(evt)) return; // if (this.isMouseEventFromTouch(evt)) return; this.setPointerState(index, this._pointerPressed, true); this.setPointerState(index, this._pointerDown, true); this.setPointerStateT(index, this._pointerEvent, evt.source); while (index >= this._pointerTypes.length) this._pointerTypes.push(evt.pointerType); this._pointerTypes[index] = evt.pointerType; while (index >= this._pointerPositionDown.length) this._pointerPositionDown.push(new Vector3()); this._pointerPositionDown[index].set(evt.clientX, evt.clientY, evt.clientZ ?? 0); while (index >= this._pointerPositions.length) this._pointerPositions.push(new Vector2()); this._pointerPositions[index].set(evt.clientX, evt.clientY); if (index >= this._pointerDownTime.length) this._pointerDownTime.push(0); this._pointerDownTime[index] = this.context.time.realtimeSinceStartup; this.updatePointerPosition(evt); this._pointerEventsPressed.push(evt); this.onDispatchEvent(evt); } // moveEvent?: Event; onMove(evt) { const index = evt.pointerId; const isDown = this.getPointerPressed(index); if (isDown === false && !this.isInRect(evt)) return; if (evt.pointerType === PointerType.Touch && !isDown) return; // if (this.isMouseEventFromTouch(evt)) return; // if (debug) console.log(evt.pointerType, "MOVE", index, "hasSpace=" + evt.space != null); this.updatePointerPosition(evt); this.setPointerStateT(index, this._pointerEvent, evt.source); this.onDispatchEvent(evt); } onUp(evt) { const index = evt.pointerId; const wasDown = this.getPointerPressed(index); if (!wasDown) { if (debug) console.warn(`Received pointerUp for pointerId that is not pressed: ${index}/${evt.button}`, debug ? evt : ''); return; } // if (this.isMouseEventFromTouch(evt)) return; if (debug) console.log(evt.pointerType, "UP", index); this.setPointerState(index, this._pointerPressed, false