diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..c0be989a --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"9e1cb517-3972-4404-8528-7201f4a51b5e","pid":14500,"acquiredAt":1780076802787} \ No newline at end of file diff --git a/src/create-events.ts b/src/create-events.ts index d4851c14..57a46413 100644 --- a/src/create-events.ts +++ b/src/create-events.ts @@ -1,38 +1,7 @@ -import { Object3D, type Intersection } from "three" -import type { Context, EventName, Meta, Prettify, ThreeEvent } from "./types.ts" -import { getMeta } from "./utils.ts" - -const eventNameMap = { - onClick: "click", - onContextMenu: "contextmenu", - onDoubleClick: "dblclick", - onMouseDown: "mousedown", - onMouseMove: "mousemove", - onMouseUp: "mouseup", - onMouseLeave: "mouseleave", - onPointerUp: "pointerup", - onPointerDown: "pointerdown", - onPointerMove: "pointermove", - onPointerLeave: "pointerleave", - onWheel: "wheel", -} as const - -function createRegistry() { - const array: T[] = [] - - return { - array, - add(instance: T) { - array.push(instance) - return () => { - array.splice( - array.findIndex(_instance => _instance === instance), - 1, - ) - } - }, - } -} +import { Object3D } from "three" +import { DOMPointerManager } from "./pointer-managers.ts" +import { CursorRaycaster, type ScreenRaycaster } from "./raycasters.tsx" +import type { Context, EventName, Meta } from "./types.ts" /**********************************************************************************/ /* */ @@ -41,376 +10,13 @@ function createRegistry() { /**********************************************************************************/ /** - * Checks if a given string is a valid event type within the system. + * Checks if a given prop key is a pointer-event handler. * - * @param type - The type of the event to check. - * @returns `true` if the type is a recognized `EventType`, otherwise `false`. + * @param type - The prop key to check. + * @returns `true` if the key is a recognized pointer-event handler. */ export const isEventType = (type: string): type is EventName => - /^on(Pointer|Click|DoubleClick|ContextMenu|Wheel|Mouse)/.test(type) - -/**********************************************************************************/ -/* */ -/* Events */ -/* */ -/**********************************************************************************/ -// -/** Creates a `ThreeEvent` (intersection excluded) from the current `MouseEvent` | `WheelEvent`. */ -function createThreeEvent< - TEvent extends Event, - TConfig extends { stoppable?: boolean; intersections?: Array }, ->(nativeEvent: TEvent, { stoppable = true, intersections }: TConfig = {} as TConfig) { - const event: Record = stoppable - ? { - nativeEvent, - stopped: false, - stopPropagation() { - event.stopped = true - }, - } - : { nativeEvent } - - if (intersections) { - event.intersections = intersections - event.intersection = intersections[0] - } - - return event as Prettify< - Omit< - ThreeEvent< - TEvent, - { - stoppable: TConfig["stoppable"] extends false - ? TConfig["stoppable"] extends true - ? true - : false - : true - intersections: TConfig["intersections"] extends Intersection[] ? true : false - } - >, - "currentIntersection" - > - > -} - -/**********************************************************************************/ -/* */ -/* Raycast */ -/* */ -/**********************************************************************************/ - -/** - * Performs a raycast from the camera through the mouse position to find intersecting 3D objects. - */ -function raycast( - context: Context, - registry: Object3D[], - event: TNativeEvent, -): Intersection>[] { - if ("update" in context.raycaster) { - context.raycaster.update(event, context) - } - - const nodeSet = new Set() - const visitedSet = new Set() - const stack = [...registry] - - // Collect all unique descendants of registry - for (const object of stack) { - if (visitedSet.has(object)) continue - visitedSet.add(object) - - const meta = getMeta(object) - if (meta && meta.props.raycastable !== false) { - nodeSet.add(object) - } - - stack.push(...object.children) - } - - return context.raycaster.intersectObjects(Array.from(nodeSet), false) -} - -/**********************************************************************************/ -/* */ -/* Create Missable Event Registry */ -/* */ -/**********************************************************************************/ - -/** - * A registry for `MissableEvents`: - * - `onClick` / `onClickMissed` - * - `onContextMenu` / `onContextMenuMissed` - * - `onDoubleClick` / `onDoubleClickMissed` - */ -function createMissableEventRegistry( - type: "onClick" | "onDoubleClick" | "onContextMenu", - context: Context, -) { - const registry = createRegistry() - - context.canvas.addEventListener(eventNameMap[type], nativeEvent => { - const missedType = `${type}Missed` as const - if (registry.array.length === 0 && !context.props[type] && !context.props[missedType]) return - - // Track which objects have been visited during event processing - const missedObjects = new Set(registry.array) - const visitedObjects = new Set() - - // Phase #1 - Process normal click events - const intersections = raycast(context, registry.array, nativeEvent) - - const stoppableEvent = createThreeEvent(nativeEvent, { intersections }) - - for (const intersection of intersections) { - // Update currentIntersection - // @ts-expect-error TODO: fix type-error - stoppableEvent.currentIntersection = intersection - - // Bubble down - let node: Object3D | null = intersection.object - while (node && !stoppableEvent.stopped && !visitedObjects.has(node)) { - missedObjects.delete(node) - visitedObjects.add(node) - getMeta(node)?.props[type]?.( - // @ts-expect-error TODO: fix type-error - stoppableEvent, - ) - node = node.parent - } - } - - // Call the respective canvas event-handler - // if event propagated all the way down - if (!stoppableEvent.stopped) { - // Remove currentIntersection - // @ts-expect-error TODO: fix type-error - delete stoppableEvent.currentIntersection - context.props[type]?.(stoppableEvent) - } - - // Phase #2 - Raycast remaining missed objects - for (const remainingObject of missedObjects) { - const intersections = context.raycaster.intersectObject(remainingObject, true) - - // Bubble down intersections - // if they haven't been visited before: - // - add object to visitedObjects - // - remove from remainingObjects, - for (const { object } of intersections) { - let node: Object3D | null = object - while (node && !visitedObjects.has(node)) { - missedObjects.delete(node) - visitedObjects.add(node) - node = node.parent - } - } - } - - // Phase #3 - Fire missed event-handler on missed objects - const missedEvent = createThreeEvent(nativeEvent, { stoppable: false }) - - for (const object of missedObjects) { - getMeta(object)?.props[missedType]?.(missedEvent) - } - - if (intersections.length === 0) { - context.props[`${type}Missed`]?.(missedEvent) - } - }) - - return registry -} - -/**********************************************************************************/ -/* */ -/* Create Hover Event Registry */ -/* */ -/**********************************************************************************/ - -/** - * A registry for `HoverEvents`: - * - Mouse - * - `onMouseEnter` - * - `onMouseMove` - * - `onMouseLeave` - * - Pointer - * - `onPointerEnter` - * - `onPointerMove` - * - `onPointerLeave` - */ -function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { - const registry = createRegistry() - let hoveredSet = new Set() - let intersections: Intersection>[] = [] - let hoveredCanvas = false - - context.canvas.addEventListener(eventNameMap[`on${type}Move`], nativeEvent => { - intersections = raycast(context, registry.array, nativeEvent) - - // Phase #1 - Enter - const enterEvent = createThreeEvent(nativeEvent, { stoppable: false, intersections }) - const enterSet = new Set() - - for (const intersection of intersections) { - // Update currentIntersection - // @ts-expect-error TODO: fix type-error - enterEvent.currentIntersection = intersection - - // Bubble up - let current: Object3D | null = intersection.object - while (current && !enterSet.has(current)) { - enterSet.add(current) - if (!hoveredSet.has(current)) { - getMeta(current)?.props[`on${type}Enter`]?.( - // @ts-expect-error TODO: fix type-error - enterEvent, - ) - } - - // We bubble a layer down. - current = current.parent - } - } - - if (hoveredCanvas === false) { - context.props[`on${type}Enter`]?.( - // @ts-expect-error TODO: fix type-error - enterEvent, - ) - hoveredCanvas = true - } - - // Phase #2 - Move - const moveEvent = createThreeEvent(nativeEvent, { intersections }) - const moveSet = new Set() - - for (const intersection of intersections) { - // Update currentIntersection - // @ts-expect-error TODO: fix type-error - moveEvent.currentIntersection = intersection - - // Bubble up - let current: Object3D | null = intersection.object - - while (current && !moveSet.has(current)) { - moveSet.add(current) - const meta = getMeta(current) - if (meta) { - meta.props[`on${type}Move`]?.( - // @ts-expect-error TODO: fix type-error - moveEvent, - ) - // Break if event was - if (moveEvent.stopped) { - break - } - } - // We bubble a layer down. - current = current.parent - } - } - - if (!moveEvent.stopped) { - // Remove currentIntersection - // @ts-expect-error TODO: fix type-error - delete moveEvent.currentIntersection - context.props[`on${type}Move`]?.( - // @ts-expect-error TODO: fix type-error - moveEvent, - ) - } - - // Handle leave-event - const leaveEvent = createThreeEvent(nativeEvent, { intersections, stoppable: false }) - const prevHoveredSet = hoveredSet - hoveredSet = enterSet - - for (const object of prevHoveredSet) { - if (enterSet.has(object)) continue - getMeta(object)?.props[`on${type}Leave`]?.( - // @ts-expect-error TODO: fix type-error - leaveEvent, - ) - } - }) - - context.canvas.addEventListener(eventNameMap[`on${type}Leave`], nativeEvent => { - const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false }) - // @ts-expect-error TODO: fix type-error - context.props[`on${type}Leave`]?.(leaveEvent) - hoveredCanvas = false - - for (const object of hoveredSet) { - getMeta(object)?.props[`on${type}Leave`]?.( - // @ts-expect-error TODO: fix type-error - leaveEvent, - ) - } - hoveredSet.clear() - }) - - return registry -} - -/**********************************************************************************/ -/* */ -/* Create Default Event Registry */ -/* */ -/**********************************************************************************/ - -/** - * A registry for `DefaultEvents`: - * - `onMouseDown` - * - `onMouseUp` - * - `onPointerDown` - * - `onPointerUp` - * - `onWheel` - */ -function createDefaultEventRegistry( - type: "onMouseDown" | "onMouseUp" | "onPointerDown" | "onPointerUp" | "onWheel", - context: Context, - options?: AddEventListenerOptions, -) { - const registry = createRegistry() - - context.canvas.addEventListener( - eventNameMap[type], - nativeEvent => { - const intersections = raycast(context, registry.array, nativeEvent) - const event = createThreeEvent(nativeEvent, { intersections }) - - for (const intersection of intersections) { - // Update currentIntersection - // @ts-expect-error TODO: fix type-error - event.currentIntersection = intersection - - // Bubble up - let node: Object3D | null = intersection.object - - while (node && !event.stopped) { - getMeta(node)?.props[type]?.( - // @ts-expect-error TODO: fix type-error - event, - ) - node = node.parent - } - } - - if (!event.stopped) { - // Remove currentIntersection - // @ts-expect-error TODO: fix type-error - delete event.currentIntersection - - // @ts-expect-error TODO: fix type-error - context.props[type]?.(event) - } - }, - options, - ) - - return registry -} + /^on(Pointer|Click|DoubleClick|ContextMenu|Wheel)/.test(type) /**********************************************************************************/ /* */ @@ -419,72 +25,52 @@ function createDefaultEventRegistry( /**********************************************************************************/ /** - * Initializes and manages event handling for all `Instance`. + * Wires the pointer-event system. Registers handler-bearing objects into the + * single `context.eventRegistry`, and connects the built-in screen pointer + * source (`DOMPointerManager`), which raycasts that registry and dispatches via + * per-`pointerId` `Pointer`s. XR controllers are added as further pointers by + * the XR layer through the same registry. */ export function createEvents(context: Context) { - // onMouseMove/onMouseEnter/onMouseLeave - const hoverMouseRegistry = createHoverEventRegistry("Mouse", context) - // onPointerMove/onPointerEnter/onPointerLeave - const hoverPointerRegistry = createHoverEventRegistry("Pointer", context) - - // onClick/onClickMissed - const missableClickRegistry = createMissableEventRegistry("onClick", context) - // onContextMenu/onContextMenuMissed - const missableContextMenuRegistry = createMissableEventRegistry("onContextMenu", context) - // onDoubleClick/onDoubleClickMissed - const missableDoubleClickRegistry = createMissableEventRegistry("onDoubleClick", context) + // The single registry the pointer system raycasts; refcounted so an object + // listening for several event types is listed exactly once. + const refCounts = new Map() + function addToRegistry(object: Object3D) { + const count = refCounts.get(object) ?? 0 + if (count === 0) context.eventRegistry.push(object) + refCounts.set(object, count + 1) + return () => { + const current = refCounts.get(object) + if (current === undefined) return + if (current <= 1) { + refCounts.delete(object) + const index = context.eventRegistry.indexOf(object) + if (index !== -1) context.eventRegistry.splice(index, 1) + } else { + refCounts.set(object, current - 1) + } + } + } - // Default mouse-events - const mouseDownRegistry = createDefaultEventRegistry("onMouseDown", context) - const mouseUpRegistry = createDefaultEventRegistry("onMouseUp", context) - // Default pointer-events - const pointerDownRegistry = createDefaultEventRegistry("onPointerDown", context) - const pointerUpRegistry = createDefaultEventRegistry("onPointerUp", context) - // Default wheel-event - const wheelRegistry = createDefaultEventRegistry("onWheel", context, { passive: true }) + // The screen pointer's ray strategy: the configured `raycaster` (default + // `CursorRaycaster`) when it's a screen raycaster, else a fresh one. + const candidate = context.raycaster + const screenRaycaster: ScreenRaycaster = + "setCursor" in candidate && "cast" in candidate + ? (candidate as ScreenRaycaster) + : new CursorRaycaster() + new DOMPointerManager(context, screenRaycaster).connect() return { /** - * Registers an `AugmentedElement` with the event handling system. + * Registers an `AugmentedElement` with the pointer-event system. * * @param object - The 3D object to register. - * @param type - The type of event the object should listen for. + * @param _type - The handler type (accepted for API compatibility; the single + * registry is not keyed by type). */ - addEventListener(object: Meta, type: EventName) { - switch (type) { - // Missable Events - case "onClick": - case "onClickMissed": - return missableClickRegistry.add(object) - case "onContextMenu": - case "onContextMenuMissed": - return missableContextMenuRegistry.add(object) - case "onDoubleClick": - case "onDoubleClickMissed": - return missableDoubleClickRegistry.add(object) - - // Hover Events - case "onMouseEnter": - case "onMouseLeave": - case "onMouseMove": - return hoverMouseRegistry.add(object) - case "onPointerEnter": - case "onPointerLeave": - case "onPointerMove": - return hoverPointerRegistry.add(object) - - // Default Events - case "onMouseDown": - return mouseDownRegistry.add(object) - case "onMouseUp": - return mouseUpRegistry.add(object) - case "onPointerDown": - return pointerDownRegistry.add(object) - case "onPointerUp": - return pointerUpRegistry.add(object) - case "onWheel": - return wheelRegistry.add(object) - } + addEventListener(object: Meta, _type: EventName) { + return addToRegistry(object) }, } } diff --git a/src/create-three.tsx b/src/create-three.tsx index 7f9811b3..f7ea7b83 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -357,6 +357,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { }, canvas, clock, + eventRegistry: [], get dpr() { // Renderers without a pixel-ratio API (CSS2D/3D, SVG) didn't scale // anything — reporting `1` is honest. Users who need the device's diff --git a/src/create-xr.tsx b/src/create-xr.tsx index b9039a85..aff4b99a 100644 --- a/src/create-xr.tsx +++ b/src/create-xr.tsx @@ -7,6 +7,8 @@ import { onCleanup, useContext, } from "solid-js" +import type { Object3D } from "three" +import { XRPointerManager } from "./pointer-managers.ts" import type { Context } from "./types.ts" /** @@ -89,23 +91,44 @@ export function createXR() { // `context.gl` is a reactive getter, so this re-runs on renderer swap; // onCleanup detaches the previous manager's listeners. Clearing context() // (via connect's disconnect) cascades through here to tear everything down. + // Connected XR-controller pointer wiring, torn down on sessionend / disconnect. + let disconnectPointers: (() => void) | undefined + createRenderEffect(() => { const ctx = context() const gl = ctx ? (ctx.gl as unknown as XRRenderer) : undefined const xr = gl?.xr if (!gl || !xr || typeof xr.addEventListener !== "function") return - const onStart = () => setPresenting(true) + const onStart = () => { + setPresenting(true) + // Wire XR controllers as pointers (event-driven select → down/up/click). + // Guarded: only when the renderer exposes `getController` and the connected + // value is a full Context (the `` passes one); a bare + // `{ gl, render }` fake or a getController-less renderer skips this. + const fullContext = ctx as unknown as Context + const xrManager = xr as { getController?(index: number): Object3D } + if (typeof xrManager.getController === "function" && Array.isArray(fullContext.eventRegistry)) { + disconnectPointers = new XRPointerManager( + fullContext, + xrManager as { getController(index: number): Object3D }, + ).connect() + } + } const onEnd = () => { setPresenting(false) setSession(undefined) gl.setAnimationLoop(null) // edge 2: stop three re-driving render post-exit gl.xr.enabled = false + disconnectPointers?.() + disconnectPointers = undefined } xr.addEventListener("sessionstart", onStart) xr.addEventListener("sessionend", onEnd) onCleanup(() => { xr.removeEventListener("sessionstart", onStart) xr.removeEventListener("sessionend", onEnd) + disconnectPointers?.() + disconnectPointers = undefined }) }) diff --git a/src/pointer-managers.ts b/src/pointer-managers.ts new file mode 100644 index 00000000..b543e2d3 --- /dev/null +++ b/src/pointer-managers.ts @@ -0,0 +1,161 @@ +import { Vector2, type Object3D } from "three" +import { Pointer } from "./pointers.ts" +import { ControllerRaycaster, type ScreenRaycaster } from "./raycasters.tsx" +import type { Context } from "./types.ts" + +type RayEvent = PointerEvent | MouseEvent | WheelEvent + +/** + * The built-in screen pointer source. Owns the canvas's pointer/click/wheel + * listeners, one `Pointer` per native `pointerId` (so multi-touch tracks + * independently), and a `primary` `Pointer` for the family-agnostic + * click/dblclick/contextmenu/wheel gestures — those are `MouseEvent`s with no + * `pointerId`, so they don't belong to a specific touch. + * + * It aims the (single, shared) screen raycaster from each event before calling + * the pointer's gesture method; that's safe because `setCursor` → `cast` runs + * synchronously within one event, so concurrent pointers never collide. + */ +export class DOMPointerManager { + private pointers = new Map() + private primary: Pointer + + constructor( + private context: Context, + private raycaster: ScreenRaycaster, + ) { + this.primary = new Pointer(context, raycaster) + } + + private forId(id: number): Pointer { + let pointer = this.pointers.get(id) + if (!pointer) { + pointer = new Pointer(this.context, this.raycaster) + this.pointers.set(id, pointer) + } + return pointer + } + + private ndc(event: RayEvent): Vector2 { + const { width, height } = this.context.bounds + return new Vector2((event.offsetX / width) * 2 - 1, -(event.offsetY / height) * 2 + 1) + } + + /** Attach all canvas listeners; returns a disconnect that removes them. */ + connect(): () => void { + const canvas = this.context.canvas + const aim = (event: RayEvent) => this.raycaster.setCursor(this.ndc(event)) + + const onMove = (event: PointerEvent) => { + aim(event) + this.forId(event.pointerId).move(event) + } + const onDown = (event: PointerEvent) => { + aim(event) + this.forId(event.pointerId).down(event) + } + const onUp = (event: PointerEvent) => { + aim(event) + this.forId(event.pointerId).up(event) + // A lifted touch no longer exists — leave + drop it so it keeps no state. + if (event.pointerType === "touch") { + this.pointers.get(event.pointerId)?.leave(event) + this.pointers.delete(event.pointerId) + } + } + const onLeaveOrCancel = (event: PointerEvent) => { + // Always fire the canvas-level leave (a fresh pointer's leave does that even + // with nothing hovered), matching the old per-session leave behavior. + this.forId(event.pointerId).leave(event) + this.pointers.delete(event.pointerId) + } + const onClick = (event: MouseEvent) => { + aim(event) + this.primary.click("onClick", event) + } + const onDoubleClick = (event: MouseEvent) => { + aim(event) + this.primary.click("onDoubleClick", event) + } + const onContextMenu = (event: MouseEvent) => { + aim(event) + this.primary.click("onContextMenu", event) + } + const onWheel = (event: WheelEvent) => { + aim(event) + this.primary.wheel(event) + } + + canvas.addEventListener("pointermove", onMove) + canvas.addEventListener("pointerdown", onDown) + canvas.addEventListener("pointerup", onUp) + canvas.addEventListener("pointerleave", onLeaveOrCancel) + canvas.addEventListener("pointercancel", onLeaveOrCancel) + canvas.addEventListener("click", onClick) + canvas.addEventListener("dblclick", onDoubleClick) + canvas.addEventListener("contextmenu", onContextMenu) + canvas.addEventListener("wheel", onWheel, { passive: true }) + + return () => { + canvas.removeEventListener("pointermove", onMove) + canvas.removeEventListener("pointerdown", onDown) + canvas.removeEventListener("pointerup", onUp) + canvas.removeEventListener("pointerleave", onLeaveOrCancel) + canvas.removeEventListener("pointercancel", onLeaveOrCancel) + canvas.removeEventListener("click", onClick) + canvas.removeEventListener("dblclick", onDoubleClick) + canvas.removeEventListener("contextmenu", onContextMenu) + canvas.removeEventListener("wheel", onWheel) + } + } +} + +/** A controller's targetRay space that also dispatches XR select events. */ +type SelectTarget = { + addEventListener(type: string, listener: () => void): void + removeEventListener(type: string, listener: () => void): void +} + +/** + * Wires XR controllers as pointers. three's `renderer.xr.getController(i)` returns + * an `Object3D` that is both the controller's targetRay space (its `matrixWorld` + * aims the ray) and the dispatcher of `selectstart`/`selectend`. Each controller + * gets its own `Pointer` (with a `ControllerRaycaster`); a select press/release + * drives `onPointerDown`/`onPointerUp`, and a release synthesizes `onClick` + * (controllers have no DOM click). Event-driven only — continuous sweep-hover + * needs a per-frame tick and is out of scope here. + */ +export class XRPointerManager { + private cleanups: Array<() => void> = [] + + constructor( + private context: Context, + private xr: { getController(index: number): Object3D }, + private count = 2, + ) {} + + connect(): () => void { + for (let index = 0; index < this.count; index++) { + const controller = this.xr.getController(index) + const pointer = new Pointer(this.context, new ControllerRaycaster(controller)) + const target = controller as unknown as SelectTarget + + const onSelectStart = () => pointer.down(new Event("selectstart")) + const onSelectEnd = () => { + pointer.up(new Event("selectend")) + pointer.click("onClick", new Event("click")) + } + + target.addEventListener("selectstart", onSelectStart) + target.addEventListener("selectend", onSelectEnd) + this.cleanups.push(() => { + target.removeEventListener("selectstart", onSelectStart) + target.removeEventListener("selectend", onSelectEnd) + }) + } + return () => { + for (const cleanup of this.cleanups) cleanup() + this.cleanups = [] + } + } +} diff --git a/src/pointers.ts b/src/pointers.ts new file mode 100644 index 00000000..2d514775 --- /dev/null +++ b/src/pointers.ts @@ -0,0 +1,210 @@ +import type { Intersection, Object3D } from "three" +import type { Context, Meta, Prettify, ThreeEvent } from "./types.ts" +import { getMeta } from "./utils.ts" + +/** + * The slice of an `EventRaycaster` a `Pointer` needs: cast its current ray against + * a registry, and (for the click-missed phase) re-cast a single object. The real + * `EventRaycaster` (which extends three's `Raycaster`) satisfies this structurally. + */ +export type PointerRaycaster = { + cast(registry: Object3D[], context: Context): Intersection>[] + intersectObject(object: Object3D, recursive?: boolean): Intersection[] +} + +/** Creates a `ThreeEvent` (intersection excluded) from a native `MouseEvent` | `PointerEvent` | `WheelEvent`. */ +export function createThreeEvent< + TEvent extends Event, + TConfig extends { stoppable?: boolean; intersections?: Array }, +>(nativeEvent: TEvent, { stoppable = true, intersections }: TConfig = {} as TConfig) { + const event: Record = stoppable + ? { + nativeEvent, + stopped: false, + stopPropagation() { + event.stopped = true + }, + } + : { nativeEvent } + + if (intersections) { + event.intersections = intersections + event.intersection = intersections[0] + } + + return event as Prettify< + Omit< + ThreeEvent< + TEvent, + { + stoppable: TConfig["stoppable"] extends false + ? TConfig["stoppable"] extends true + ? true + : false + : true + intersections: TConfig["intersections"] extends Intersection[] ? true : false + } + >, + "currentIntersection" + > + > +} + +/** + * One pointer's dispatch + per-pointer state, decoupled from the DOM. A + * `*PointerManager` owns the source (canvas / XR controller) and the raycaster, + * and calls these gesture methods; the `Pointer` raycasts the context's single + * `eventRegistry` and bubbles to the `onPointer*` / `onClick` / … handlers, + * tracking its own hover state so multiple pointers stay independent. + * + * Dispatch logic is ported verbatim from the previous per-kind registries + * (`createHoverEventRegistry` / `createMissableEventRegistry` / + * `createDefaultEventRegistry`); the only changes are per-pointer instance state + * and the single `onPointer*` family (the redundant `onMouse*` family is gone). + */ +export class Pointer { + private hovered = new Set() + private hoveredCanvas = false + + constructor( + private context: Context, + private raycaster: PointerRaycaster, + ) {} + + /** Hover: enter/leave diff + bubbled `onPointerMove`, plus canvas-level. */ + move(nativeEvent: Event) { + const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) + const props = this.context.props as Record + + // Phase #1 — Enter (bubble up; fire onPointerEnter for newly-hovered objects). + const enterEvent: any = createThreeEvent(nativeEvent, { stoppable: false, intersections }) + const entered = new Set() + for (const intersection of intersections) { + enterEvent.currentIntersection = intersection + let current: Object3D | null = intersection.object + while (current && !entered.has(current)) { + entered.add(current) + if (!this.hovered.has(current)) (getMeta(current)?.props as any)?.onPointerEnter?.(enterEvent) + current = current.parent + } + } + if (!this.hoveredCanvas) { + this.hoveredCanvas = true + props.onPointerEnter?.(enterEvent) + } + + // Phase #2 — Move (bubble up, stoppable). + const moveEvent: any = createThreeEvent(nativeEvent, { intersections }) + const moved = new Set() + for (const intersection of intersections) { + moveEvent.currentIntersection = intersection + let current: Object3D | null = intersection.object + while (current && !moved.has(current)) { + moved.add(current) + const meta = getMeta(current) + if (meta) { + ;(meta.props as any).onPointerMove?.(moveEvent) + if (moveEvent.stopped) break + } + current = current.parent + } + } + if (!moveEvent.stopped) { + delete moveEvent.currentIntersection + props.onPointerMove?.(moveEvent) + } + + // Phase #3 — Leave (objects hovered last time but not now). + const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false, intersections }) + const previous = this.hovered + this.hovered = entered + for (const object of previous) { + if (entered.has(object)) continue + ;(getMeta(object)?.props as any)?.onPointerLeave?.(leaveEvent) + } + } + + /** The pointer left the canvas/source: leave everything currently hovered. */ + leave(nativeEvent: Event) { + const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false }) + ;(this.context.props as Record).onPointerLeave?.(leaveEvent) + this.hoveredCanvas = false + for (const object of this.hovered) (getMeta(object)?.props as any)?.onPointerLeave?.(leaveEvent) + this.hovered.clear() + } + + down(nativeEvent: Event) { + this.dispatchBubbled("onPointerDown", nativeEvent) + } + up(nativeEvent: Event) { + this.dispatchBubbled("onPointerUp", nativeEvent) + } + wheel(nativeEvent: Event) { + this.dispatchBubbled("onWheel", nativeEvent) + } + + /** Shared body for the down/up/wheel "default" gestures (bubble + canvas-level). */ + private dispatchBubbled(handler: "onPointerDown" | "onPointerUp" | "onWheel", nativeEvent: Event) { + const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) + const event: any = createThreeEvent(nativeEvent, { intersections }) + for (const intersection of intersections) { + event.currentIntersection = intersection + let node: Object3D | null = intersection.object + while (node && !event.stopped) { + ;(getMeta(node)?.props as any)?.[handler]?.(event) + node = node.parent + } + } + if (!event.stopped) { + delete event.currentIntersection + ;(this.context.props as Record)[handler]?.(event) + } + } + + /** Missable gesture: bubbled `onClick`/`onDoubleClick`/`onContextMenu` + `-Missed`. */ + click(kind: "onClick" | "onDoubleClick" | "onContextMenu", nativeEvent: Event) { + const missedType = `${kind}Missed` as const + const registry = this.context.eventRegistry + const props = this.context.props as Record + if (registry.length === 0 && !props[kind] && !props[missedType]) return + + const missed = new Set(registry) + const visited = new Set() + const intersections = this.raycaster.cast(registry, this.context) + const event: any = createThreeEvent(nativeEvent, { intersections }) + + // Phase #1 — fire the handler, bubbling down the hit chain. + for (const intersection of intersections) { + event.currentIntersection = intersection + let node: Object3D | null = intersection.object + while (node && !event.stopped && !visited.has(node)) { + missed.delete(node) + visited.add(node) + ;(getMeta(node)?.props as any)?.[kind]?.(event) + node = node.parent + } + } + if (!event.stopped) { + delete event.currentIntersection + props[kind]?.(event) + } + + // Phase #2 — re-raycast remaining objects to mark any genuinely under the ray as hit. + for (const remaining of missed) { + const hits = this.raycaster.intersectObject(remaining, true) + for (const { object } of hits) { + let node: Object3D | null = object + while (node && !visited.has(node)) { + missed.delete(node) + visited.add(node) + node = node.parent + } + } + } + + // Phase #3 — fire `-Missed` on the truly-missed objects, and canvas-level on a total miss. + const missedEvent = createThreeEvent(nativeEvent, { stoppable: false }) + for (const object of missed) (getMeta(object)?.props as any)?.[missedType]?.(missedEvent) + if (intersections.length === 0) props[missedType]?.(missedEvent) + } +} diff --git a/src/raycasters.tsx b/src/raycasters.tsx index b26db907..0e6333c1 100644 --- a/src/raycasters.tsx +++ b/src/raycasters.tsx @@ -1,30 +1,86 @@ -import { Raycaster, Vector2 } from "three" -import type { Context } from "./types" +import { Quaternion, Raycaster, Vector2, Vector3, type Intersection, type Object3D } from "three" +import type { Context, Meta } from "./types.ts" +import { getMeta } from "./utils.ts" -type RayEvent = PointerEvent | MouseEvent | WheelEvent +const CENTER = new Vector2(0, 0) export interface EventRaycaster extends Raycaster { - update(event: RayEvent, context: Context): void + /** + * Aim this raycaster's ray for the current pointer, then intersect `registry` + * (and its descendants, honoring `raycastable !== false`). The pointer system + * calls this; how the ray is aimed is the subclass's business (camera + NDC + * for screen pointers, `matrixWorld` for an XR controller). + */ + cast(registry: Object3D[], context: Context): Intersection>[] } -export class CursorRaycaster extends Raycaster implements EventRaycaster { - pointer = new Vector2() - update(event: RayEvent, context: Context) { - this.pointer.x = (event.offsetX / context.bounds.width) * 2 - 1 - this.pointer.y = -(event.offsetY / context.bounds.height) * 2 + 1 - this.setFromCamera(this.pointer, context.camera) +/** Screen-ray family: aimed from a 2D cursor position in NDC. */ +export interface ScreenRaycaster extends EventRaycaster { + setCursor(ndc: Vector2): void +} + +/** + * Collect `registry` + all descendants that opt in to raycasting + * (`raycastable !== false`), then intersect. Ported verbatim from the previous + * `raycast` helper (minus the aim step, which each `cast` does itself). + */ +function castRegistry(raycaster: Raycaster, registry: Object3D[]): Intersection>[] { + const nodeSet = new Set() + const visitedSet = new Set() + const stack = [...registry] + + for (const object of stack) { + if (visitedSet.has(object)) continue + visitedSet.add(object) + + const meta = getMeta(object) + if (meta && meta.props.raycastable !== false) { + nodeSet.add(object) + } + + stack.push(...object.children) } + + return raycaster.intersectObjects(Array.from(nodeSet), false) as Intersection>[] } -export class CenterRaycaster extends Raycaster implements EventRaycaster { +export class CursorRaycaster extends Raycaster implements ScreenRaycaster { pointer = new Vector2() - update(event: RayEvent, context: Context) { - const offsetX = context.bounds.width / 2 - const offsetY = context.bounds.height / 2 - this.pointer.set( - (offsetX / context.bounds.width) * 2 - 1, - -(offsetY / context.bounds.height) * 2 + 1, - ) + setCursor(ndc: Vector2) { + this.pointer.copy(ndc) + } + cast(registry: Object3D[], context: Context) { this.setFromCamera(this.pointer, context.camera) + return castRegistry(this, registry) + } +} + +export class CenterRaycaster extends Raycaster implements ScreenRaycaster { + pointer = new Vector2(0, 0) + setCursor(_ndc: Vector2) { + /* centre is fixed — ignore the cursor */ + } + cast(registry: Object3D[], context: Context) { + this.setFromCamera(CENTER, context.camera) + return castRegistry(this, registry) + } +} + +/** + * Casts from an `Object3D`'s world transform (origin = world position, direction + * = its local -Z in world space) — the ray strategy for an XR controller. + */ +export class ControllerRaycaster extends Raycaster implements EventRaycaster { + constructor(public space: Object3D) { + super() + } + cast(registry: Object3D[], _context: Context) { + this.space.updateMatrixWorld() + const origin = new Vector3().setFromMatrixPosition(this.space.matrixWorld) + const direction = new Vector3(0, 0, -1) + .applyQuaternion(new Quaternion().setFromRotationMatrix(this.space.matrixWorld)) + .normalize() + this.ray.set(origin, direction) + return castRegistry(this, registry) } } diff --git a/src/types.ts b/src/types.ts index f38d248a..08781038 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ import type { ColorRepresentation, Intersection, Loader, + Object3D, OrthographicCamera, PerspectiveCamera, Raycaster, @@ -234,6 +235,8 @@ export interface Context { canvas: HTMLCanvasElement clock: Clock camera: CameraKind + /** Objects carrying any pointer handler; raycast by the pointer system. */ + eventRegistry: Object3D[] raycaster: Raycaster | EventRaycaster dpr: number gl: Meta @@ -308,11 +311,6 @@ type EventHandlersMap = { onDoubleClickMissed: Prettify> onContextMenu: Prettify> onContextMenuMissed: Prettify> - onMouseDown: Prettify> - onMouseEnter: Prettify> - onMouseLeave: Prettify> - onMouseMove: Prettify> - onMouseUp: Prettify> onPointerUp: Prettify> onPointerDown: Prettify> onPointerMove: Prettify> diff --git a/tests/core/api-coverage.test.tsx b/tests/core/api-coverage.test.tsx index 6f6ba530..a52cef63 100644 --- a/tests/core/api-coverage.test.tsx +++ b/tests/core/api-coverage.test.tsx @@ -154,24 +154,30 @@ describe("CursorRaycaster / CenterRaycaster", () => { } as unknown as Context } - it("CursorRaycaster maps pointer coords to NDC and seeds the ray from the camera", () => { + it("CursorRaycaster seeds the ray from the camera at the set cursor", () => { const raycaster = new CursorRaycaster() const context = makeContext(100, 50) const setFromCamera = vi.spyOn(raycaster, "setFromCamera") - raycaster.update({ offsetX: 75, offsetY: 25 } as PointerEvent, context) + raycaster.setCursor(new THREE.Vector2(0.5, 0)) + raycaster.cast([], context) - // 75 / 100 * 2 - 1 = 0.5; -(25 / 50 * 2 - 1) = 0 expect(raycaster.pointer.x).toBeCloseTo(0.5) expect(raycaster.pointer.y).toBeCloseTo(0) expect(setFromCamera).toHaveBeenCalledWith(raycaster.pointer, context.camera) }) - it("CenterRaycaster always points to (0, 0) regardless of the event", () => { + it("CenterRaycaster always casts from (0, 0), ignoring setCursor", () => { const raycaster = new CenterRaycaster() const context = makeContext(200, 100) - raycaster.update({ offsetX: 999, offsetY: -42 } as PointerEvent, context) - expect(raycaster.pointer.x).toBeCloseTo(0) - expect(raycaster.pointer.y).toBeCloseTo(0) + const setFromCamera = vi.spyOn(raycaster, "setFromCamera") + + raycaster.setCursor(new THREE.Vector2(0.99, 0.99)) // ignored — centre is fixed + raycaster.cast([], context) + + expect(setFromCamera).toHaveBeenCalledWith( + expect.objectContaining({ x: 0, y: 0 }), + context.camera, + ) }) }) diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index e52a760a..9d8d016d 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -352,82 +352,6 @@ describe("canvas missable events", () => { /**********************************************************************************/ describe("canvas default events", () => { - // - // onMouseDown - // - describe("onMouseDown", () => { - it("fires when mousedown occurs with no meshes in the scene", () => { - const handleMouseDown = vi.fn() - const { canvas } = test(() => null, { onMouseDown: handleMouseDown }) - - fireEvent(canvas, hitEvent("mousedown")) - - expect(handleMouseDown).toHaveBeenCalledTimes(1) - }) - - it("fires when mousedown propagates through a mesh that does not stop it", () => { - const handleMouseDown = vi.fn() - const { canvas } = test( - () => , - { onMouseDown: handleMouseDown }, - ) - - fireEvent(canvas, hitEvent("mousedown")) - - expect(handleMouseDown).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handleMouseDown = vi.fn() - const { canvas } = test( - () => , - { onMouseDown: handleMouseDown }, - ) - - fireEvent(canvas, hitEvent("mousedown")) - - expect(handleMouseDown).not.toHaveBeenCalled() - }) - }) - - // - // onMouseUp - // - describe("onMouseUp", () => { - it("fires when mouseup occurs with no meshes in the scene", () => { - const handleMouseUp = vi.fn() - const { canvas } = test(() => null, { onMouseUp: handleMouseUp }) - - fireEvent(canvas, hitEvent("mouseup")) - - expect(handleMouseUp).toHaveBeenCalledTimes(1) - }) - - it("fires when mouseup propagates through a mesh that does not stop it", () => { - const handleMouseUp = vi.fn() - const { canvas } = test( - () => , - { onMouseUp: handleMouseUp }, - ) - - fireEvent(canvas, hitEvent("mouseup")) - - expect(handleMouseUp).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handleMouseUp = vi.fn() - const { canvas } = test( - () => , - { onMouseUp: handleMouseUp }, - ) - - fireEvent(canvas, hitEvent("mouseup")) - - expect(handleMouseUp).not.toHaveBeenCalled() - }) - }) - // // onPointerDown // @@ -642,86 +566,4 @@ describe("canvas hover events", () => { }) }) - // - // onMouseEnter / onMouseLeave / onMouseMove - // - describe("onMouseEnter", () => { - it("fires when the mouse first moves over the canvas", () => { - const handleMouseEnter = vi.fn() - const { canvas } = test(() => null, { onMouseEnter: handleMouseEnter }) - - fireEvent(canvas, hitEvent("mousemove")) - - expect(handleMouseEnter).toHaveBeenCalledTimes(1) - }) - - it("fires only once per canvas hover session", () => { - const handleMouseEnter = vi.fn() - const { canvas } = test(() => null, { onMouseEnter: handleMouseEnter }) - - fireEvent(canvas, hitEvent("mousemove")) - fireEvent(canvas, hitEvent("mousemove")) - fireEvent(canvas, hitEvent("mousemove")) - - expect(handleMouseEnter).toHaveBeenCalledTimes(1) - }) - - it("fires again after the mouse has left and re-entered", () => { - const handleMouseEnter = vi.fn() - const { canvas } = test(() => null, { onMouseEnter: handleMouseEnter }) - - fireEvent(canvas, hitEvent("mousemove")) - fireEvent(canvas, makeEvent("mouseleave", HIT_X, HIT_Y)) - fireEvent(canvas, hitEvent("mousemove")) - - expect(handleMouseEnter).toHaveBeenCalledTimes(2) - }) - }) - - describe("onMouseLeave", () => { - it("fires when the mouse leaves the canvas", () => { - const handleMouseLeave = vi.fn() - const { canvas } = test(() => null, { onMouseLeave: handleMouseLeave }) - - fireEvent(canvas, hitEvent("mousemove")) - fireEvent(canvas, makeEvent("mouseleave", HIT_X, HIT_Y)) - - expect(handleMouseLeave).toHaveBeenCalledTimes(1) - }) - }) - - describe("onMouseMove", () => { - it("fires when the mouse moves over the canvas with no meshes", () => { - const handleMouseMove = vi.fn() - const { canvas } = test(() => null, { onMouseMove: handleMouseMove }) - - fireEvent(canvas, hitEvent("mousemove")) - - expect(handleMouseMove).toHaveBeenCalledTimes(1) - }) - - it("fires when mouse move propagates through a mesh that does not stop it", () => { - const handleMouseMove = vi.fn() - const { canvas } = test( - () => , - { onMouseMove: handleMouseMove }, - ) - - fireEvent(canvas, hitEvent("mousemove")) - - expect(handleMouseMove).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handleMouseMove = vi.fn() - const { canvas } = test( - () => , - { onMouseMove: handleMouseMove }, - ) - - fireEvent(canvas, hitEvent("mousemove")) - - expect(handleMouseMove).not.toHaveBeenCalled() - }) - }) }) diff --git a/tests/core/events.test.tsx b/tests/core/events.test.tsx index 994817a9..6a987114 100644 --- a/tests/core/events.test.tsx +++ b/tests/core/events.test.tsx @@ -11,14 +11,14 @@ describe("events", () => { it("can handle onPointerDown", async () => { const handlePointerDown = vi.fn() - const { canvas, waitTillNextFrame } = test(() => ( - + const { canvas } = test(() => ( + )) - fireEvent(canvas, new MouseEvent("mousedown", { clientX: 640, clientY: 400, bubbles: true })) + fireEvent(canvas, new PointerEvent("pointerdown", { clientX: 640, clientY: 400, pointerId: 1, bubbles: true })) expect(handlePointerDown).toHaveBeenCalled() }) diff --git a/tests/core/multi-pointer.test.tsx b/tests/core/multi-pointer.test.tsx new file mode 100644 index 00000000..e7022771 --- /dev/null +++ b/tests/core/multi-pointer.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent } from "@solidjs/testing-library" +import * as THREE from "three" +import { afterEach, describe, expect, it, vi } from "vitest" +import { createT } from "../../src/index.ts" +import { cleanup, test } from "../../src/testing/index.tsx" + +const T = createT(THREE) + +afterEach(cleanup) + +// Centre of the test canvas (matches the canvas-events suite's HIT coords). +const HIT_X = 640 +const HIT_Y = 400 +const moveAt = (pointerId: number) => + new PointerEvent("pointermove", { clientX: HIT_X, clientY: HIT_Y, pointerId, bubbles: true }) + +describe("multi-pointer", () => { + it("tracks hover independently per pointerId", () => { + const enter = vi.fn() + const { canvas } = test(() => ( + + + + + )) + + // Two distinct pointers move onto the same mesh → enter fires once per pointer + // (a single-pointer engine would mark the mesh hovered on the first and + // suppress the second). + fireEvent(canvas, moveAt(1)) + fireEvent(canvas, moveAt(2)) + expect(enter).toHaveBeenCalledTimes(2) + + // Pointer 1 stays on the mesh → no re-enter for pointer 1 (its own hover state). + fireEvent(canvas, moveAt(1)) + expect(enter).toHaveBeenCalledTimes(2) + }) + + it("leaves one pointer without disturbing another's hover", () => { + const enter = vi.fn() + const leave = vi.fn() + const { canvas } = test(() => ( + + + + + )) + + fireEvent(canvas, moveAt(1)) + fireEvent(canvas, moveAt(2)) + expect(enter).toHaveBeenCalledTimes(2) + + // Pointer 1 leaves the canvas → exactly one leave (pointer 1's); pointer 2 unaffected. + fireEvent(canvas, new PointerEvent("pointerleave", { clientX: HIT_X, clientY: HIT_Y, pointerId: 1, bubbles: true })) + expect(leave).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx new file mode 100644 index 00000000..aa18e0db --- /dev/null +++ b/tests/core/pointer.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest" +import { Object3D } from "three" +import { Pointer, type PointerRaycaster } from "../../src/pointers.ts" +import { meta } from "../../src/utils.ts" + +function eventful(handlers: Record) { + return meta(new Object3D(), { props: handlers }) as any as Object3D +} +function ctx(eventRegistry: Object3D[], props: Record = {}) { + return { eventRegistry, props } as any +} +// Fake raycaster: `cast` hits whatever `state.target` is; phase-2 re-cast finds nothing. +function fakeRaycaster(state: { target?: Object3D }): PointerRaycaster { + return { + cast: () => (state.target ? [{ object: state.target, distance: 1 } as any] : []), + intersectObject: () => [], + } +} + +describe("Pointer dispatch", () => { + it("fires onPointerEnter on first hover and onPointerLeave when it moves off", () => { + const enter = vi.fn() + const leave = vi.fn() + const mesh = eventful({ onPointerEnter: enter, onPointerLeave: leave }) + const state: { target?: Object3D } = { target: mesh } + const pointer = new Pointer(ctx([mesh]), fakeRaycaster(state)) + + pointer.move(new Event("pointermove")) + expect(enter).toHaveBeenCalledTimes(1) + + state.target = undefined + pointer.move(new Event("pointermove")) + expect(leave).toHaveBeenCalledTimes(1) + }) + + it("does not re-fire enter while staying on the object", () => { + const enter = vi.fn() + const mesh = eventful({ onPointerEnter: enter }) + const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh })) + + pointer.move(new Event("pointermove")) + pointer.move(new Event("pointermove")) + expect(enter).toHaveBeenCalledTimes(1) + }) + + it("bubbles onPointerMove up the ancestor chain and honors stopPropagation", () => { + const parentMove = vi.fn() + const childMove = vi.fn((event: any) => event.stopPropagation()) + const parent = eventful({ onPointerMove: parentMove }) + const child = eventful({ onPointerMove: childMove }) + ;(child as any).parent = parent + const pointer = new Pointer(ctx([child]), fakeRaycaster({ target: child })) + + pointer.move(new Event("pointermove")) + expect(childMove).toHaveBeenCalledTimes(1) + expect(parentMove).not.toHaveBeenCalled() + }) + + it("fires onClick on the hit object", () => { + const click = vi.fn() + const mesh = eventful({ onClick: click }) + const pointer = new Pointer(ctx([mesh]), fakeRaycaster({ target: mesh })) + + pointer.click("onClick", new MouseEvent("click")) + expect(click).toHaveBeenCalledTimes(1) + }) + + it("fires onClickMissed (mesh-level + canvas-level) when the click hits nothing", () => { + const meshMissed = vi.fn() + const canvasMissed = vi.fn() + const mesh = eventful({ onClickMissed: meshMissed }) + const pointer = new Pointer(ctx([mesh], { onClickMissed: canvasMissed }), fakeRaycaster({})) + + pointer.click("onClick", new MouseEvent("click")) + expect(meshMissed).toHaveBeenCalledTimes(1) + expect(canvasMissed).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/core/raycaster-cast.test.tsx b/tests/core/raycaster-cast.test.tsx new file mode 100644 index 00000000..62b3dd86 --- /dev/null +++ b/tests/core/raycaster-cast.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest" +import { + BoxGeometry, + Mesh, + MeshBasicMaterial, + Object3D, + PerspectiveCamera, + Vector2, +} from "three" +import { CenterRaycaster, ControllerRaycaster, CursorRaycaster } from "../../src/raycasters.tsx" +import { meta } from "../../src/utils.ts" + +function ctx(camera: PerspectiveCamera) { + return { camera, bounds: { width: 200, height: 100 } } as any +} +// A box carrying solid-three meta (only meta'd objects are raycast, like the real engine). +function box(props: Record = {}) { + const mesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial()) + mesh.updateMatrixWorld() + return meta(mesh, { props }) as any as Mesh +} +function camera() { + const c = new PerspectiveCamera(75, 2, 0.1, 1000) + c.position.set(0, 0, 5) + c.updateMatrixWorld() + return c +} + +describe("EventRaycaster.cast", () => { + it("CursorRaycaster casts from the set cursor and hits", () => { + const mesh = box() + const rc = new CursorRaycaster() + rc.setCursor(new Vector2(0, 0)) + expect(rc.cast([mesh], ctx(camera()))[0]?.object).toBe(mesh) + }) + + it("CursorRaycaster misses when the cursor is off-axis", () => { + const mesh = box() + const rc = new CursorRaycaster() + rc.setCursor(new Vector2(0.99, 0.99)) + expect(rc.cast([mesh], ctx(camera()))).toHaveLength(0) + }) + + it("CenterRaycaster ignores setCursor and casts from centre", () => { + const mesh = box() + const rc = new CenterRaycaster() + rc.setCursor(new Vector2(0.99, 0.99)) // ignored — centre is fixed + expect(rc.cast([mesh], ctx(camera()))[0]?.object).toBe(mesh) + }) + + it("ControllerRaycaster casts from its space's matrixWorld", () => { + const mesh = box() + const space = new Object3D() + space.position.set(0, 0, 1) // at +z, default orientation looks down -z toward origin + space.updateMatrixWorld() + const rc = new ControllerRaycaster(space) + expect(rc.cast([mesh], ctx(camera()))[0]?.object).toBe(mesh) + }) + + it("honors raycastable === false (object opts out of hit-testing)", () => { + const mesh = box({ raycastable: false }) + const rc = new CursorRaycaster() + rc.setCursor(new Vector2(0, 0)) + expect(rc.cast([mesh], ctx(camera()))).toHaveLength(0) + }) +}) diff --git a/tests/core/xr-pointer.test.tsx b/tests/core/xr-pointer.test.tsx new file mode 100644 index 00000000..39f71338 --- /dev/null +++ b/tests/core/xr-pointer.test.tsx @@ -0,0 +1,80 @@ +import { createRoot } from "solid-js" +import * as THREE from "three" +import { describe, expect, it, vi } from "vitest" +import { createXR } from "../../src/create-xr.tsx" +import { meta } from "../../src/utils.ts" + +// three's WebXRManager is an EventDispatcher ({ type } events). Mirror the slice +// createXR + XRPointerManager use, plus getController returning a real Object3D +// (which is itself an EventDispatcher and carries a matrixWorld for the ray). +function makeFakeXR(getController: (index: number) => THREE.Object3D) { + const listeners: Record void>> = {} + return { + enabled: false, + setSession: vi.fn(async () => {}), + addEventListener: (type: string, listener: (event: { type: string }) => void) => { + ;(listeners[type] ??= new Set()).add(listener) + }, + removeEventListener: (type: string, listener: (event: { type: string }) => void) => { + listeners[type]?.delete(listener) + }, + dispatch: (type: string) => listeners[type]?.forEach(listener => listener({ type })), + getController: vi.fn(getController), + } +} + +function renderXR() { + let xr!: ReturnType + const dispose = createRoot(d => { + xr = createXR() + return d + }) + return { xr, dispose } +} + +describe("XR controller pointers", () => { + it("select drives onPointerDown/onPointerUp/onClick on the targeted mesh, and tears down on sessionend", () => { + const down = vi.fn() + const up = vi.fn() + const click = vi.fn() + // A plane (single front-facing intersection) keeps the per-intersection + // down/up dispatch deterministic — a box yields two hits (front+back). + const mesh = meta(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.MeshBasicMaterial()), { + props: { onPointerDown: down, onPointerUp: up, onClick: click }, + }) as unknown as THREE.Object3D + mesh.updateMatrixWorld() + + // Controller at +z, default orientation looks down -z toward the mesh at origin. + const controller = new THREE.Object3D() + controller.position.set(0, 0, 5) + controller.updateMatrixWorld() + const empty = new THREE.Object3D() + + const xrManager = makeFakeXR(index => (index === 0 ? controller : empty)) + const context = { + gl: { xr: xrManager, setAnimationLoop: vi.fn() }, + render: vi.fn(), + eventRegistry: [mesh], + props: {}, + } as any + + const { xr, dispose } = renderXR() + xr.connect(context) + xrManager.dispatch("sessionstart") // creates the XRPointerManager + + controller.dispatchEvent({ type: "selectstart" } as any) + expect(down).toHaveBeenCalled() // bubbled per intersection (count is a raycast detail) + + controller.dispatchEvent({ type: "selectend" } as any) + expect(up).toHaveBeenCalled() + expect(click).toHaveBeenCalledTimes(1) // synthesized from select-down/up; deduped via visited + + // sessionend disconnects the controller pointers — further select is inert. + const downCallsBefore = down.mock.calls.length + xrManager.dispatch("sessionend") + controller.dispatchEvent({ type: "selectstart" } as any) + expect(down.mock.calls.length).toBe(downCallsBefore) + + dispose() + }) +})