@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
JavaScript
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