diff --git a/package.json b/package.json index fb6460f7..2a2a7abb 100644 --- a/package.json +++ b/package.json @@ -63,15 +63,34 @@ } }, "import": { - "types": "./dist/testing/index.d.ts", + "types": "./dist/testing.d.ts", "default": "./dist/testing.js" } + }, + "./xr": { + "solid": { + "development": "./dist/xr.dev.solid.jsx", + "import": "./dist/xr.dev.js" + }, + "development": { + "import": { + "types": "./dist/xr.d.ts", + "default": "./dist/xr.dev.js" + } + }, + "import": { + "types": "./dist/xr.d.ts", + "default": "./dist/xr.js" + } } }, "typesVersions": { "*": { "testing": [ - "./dist/testing/index.d.ts" + "./dist/testing.d.ts" + ], + "xr": [ + "./dist/xr.d.ts" ] } }, diff --git a/site/src/routes/api/hooks/create-xr.mdx b/site/src/routes/api/hooks/create-xr.mdx index 0a1d31c6..271e7821 100644 --- a/site/src/routes/api/hooks/create-xr.mdx +++ b/site/src/routes/api/hooks/create-xr.mdx @@ -30,7 +30,8 @@ const xr = createXR() A VR scene with a DOM "Enter VR" button: ```tsx -import { Canvas, createXR } from "solid-three" +import { Canvas } from "solid-three" +import { createXR } from "solid-three/xr" import { Show } from "solid-js" function App() { diff --git a/site/src/routes/api/hooks/use-xr.mdx b/site/src/routes/api/hooks/use-xr.mdx index 344bdc52..6df53cc7 100644 --- a/site/src/routes/api/hooks/use-xr.mdx +++ b/site/src/routes/api/hooks/use-xr.mdx @@ -9,7 +9,8 @@ Reads the XR state distributed by [`createXR().Provider`](/api/hooks/create-xr) Wrap the subtree with ``, then call `useXR()` in any descendant, including components inside ``. ```tsx -import { Canvas, createXR, useXR } from "solid-three" +import { Canvas } from "solid-three" +import { createXR, useXR } from "solid-three/xr" import { Show } from "solid-js" function ExitButton() { diff --git a/src/create-events.ts b/src/create-events.ts index de910d45..d040e664 100644 --- a/src/create-events.ts +++ b/src/create-events.ts @@ -45,33 +45,10 @@ export function createEvents(context: Context) { // Remove the canvas listeners when the Canvas owner disposes. onCleanup(manager.connect()) - // 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) - // Drop any active capture on a gone object so a drag stops dispatching to a - // detached node. But a *reactive* handler (e.g. `onPointerMove={dragging() ? - // a : b}`) re-registers in the same tick — cleanup (refcount → 0) then body - // (→ 1) — which must NOT tear down a live capture mid-drag. Defer, and - // release only if the object is still gone (a real unmount), not re-added. - queueMicrotask(() => { - if (!refCounts.has(object)) manager.releaseCaptured(object) - }) - } else { - refCounts.set(object, current - 1) - } - } - } + // Drop any active capture on an object that genuinely leaves the registry, so a + // drag stops dispatching to a detached node. The registry defers this past a + // same-tick reactive re-register, so it fires only on a real unmount. + onCleanup(context.eventRegistry.onVacated(object => manager.releaseCaptured(object))) return { /** @@ -82,7 +59,7 @@ export function createEvents(context: Context) { * registry is not keyed by type). */ addEventListener(object: Meta, _type: EventName) { - return addToRegistry(object) + return context.eventRegistry.register(object) }, } } diff --git a/src/create-three.tsx b/src/create-three.tsx index 8d8a6ad3..1451214c 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -32,6 +32,7 @@ import { import type { CanvasProps } from "./canvas.tsx" import { createEvents } from "./create-events.ts" import { Stack } from "./data-structure/stack.ts" +import { EventRegistry } from "./event-registry.ts" import { frameContext, threeContext } from "./hooks.ts" import { eventContext } from "./internal-context.ts" import { useProps, useSceneGraph } from "./props.ts" @@ -367,7 +368,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { }, canvas, clock, - eventRegistry: [], + eventRegistry: new 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/event-registry.ts b/src/event-registry.ts new file mode 100644 index 00000000..85dee1d2 --- /dev/null +++ b/src/event-registry.ts @@ -0,0 +1,56 @@ +import type { Object3D } from "three" + +/** + * The set of objects the pointer system raycasts, with refcounted membership: an + * object listening for several event types — or re-registering reactively — is + * listed in {@link objects} exactly once. + * + * Framework-agnostic: `register` returns a cleanup, so each pointer source wires it + * to its own lifecycle (the DOM source returns it up the chain; the XR plugin hands + * it to `onCleanup`). `onVacated` fires when an object genuinely leaves, after a + * deferred re-check that tolerates a same-tick reactive re-register — the DOM source + * uses it to release a pointer capture on a real unmount; XR doesn't subscribe. + */ +export class EventRegistry { + /** The objects to raycast. Read-only to callers; mutated only by {@link register}. */ + readonly objects: Object3D[] = [] + private counts = new WeakMap() + private vacatedListeners = new Set<(object: Object3D) => void>() + + /** Register `object` (refcounted). Returns a cleanup that decrements, removing it at zero. */ + register(object: Object3D): () => void { + const count = this.counts.get(object) ?? 0 + if (count === 0) this.objects.push(object) + this.counts.set(object, count + 1) + return () => { + const current = this.counts.get(object) + if (current === undefined) return + if (current > 1) { + this.counts.set(object, current - 1) + return + } + this.counts.delete(object) + const index = this.objects.indexOf(object) + if (index !== -1) this.objects.splice(index, 1) + // A reactive handler re-registers in the same tick (cleanup → body): defer the + // vacated notice and fire only if the object is still gone — a real unmount, not + // a re-register that would otherwise tear down a live capture mid-drag. + if (this.vacatedListeners.size) { + queueMicrotask(() => { + if (!this.counts.has(object)) { + for (const listener of this.vacatedListeners) listener(object) + } + }) + } + } + } + + /** + * Subscribe to genuine vacates — an object whose last registration was cleaned up + * (and not re-registered the same tick). Returns an unsubscribe. + */ + onVacated(listener: (object: Object3D) => void): () => void { + this.vacatedListeners.add(listener) + return () => this.vacatedListeners.delete(listener) + } +} diff --git a/src/index.ts b/src/index.ts index 9e53df58..934d28c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,6 @@ export { Canvas, type CanvasProps } from "./canvas.tsx" export { Entity, Portal, Resource } from "./components.tsx" export { $S3C } from "./constants.ts" export { createEntity, createT } from "./create-t.tsx" -export { createXR, useXR } from "./create-xr.tsx" -export type { XRContext, XRState } from "./create-xr.tsx" export { useFrame, useLoader, useThree } from "./hooks.ts" export { plugin } from "./plugin.ts" export { hasPointerCapture } from "./pointer-capture.ts" diff --git a/src/pointers.ts b/src/pointers.ts index a3656f06..503acdba 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -245,7 +245,7 @@ export class Pointer { // object — hover frozen, since `dispatch` never touches `this.hovered`). if (this.captured) return this.dispatch("onPointerMove", nativeEvent, undefined, true) - const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) + const intersections = this.raycaster.cast(this.context.eventRegistry.objects, this.context) const props = this.context.props as Record // Phase #1 — Enter (bubble up; fire onPointerEnter for newly-hovered objects). @@ -362,7 +362,7 @@ export class Pointer { return } - const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) + const intersections = this.raycaster.cast(this.context.eventRegistry.objects, this.context) const event = createThreeEvent(nativeEvent, { intersections }, extra) if (capturable) this.attachCapture(event) this.propagate( @@ -375,7 +375,7 @@ export class Pointer { /** 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 registry = this.context.eventRegistry.objects const props = this.context.props as Record if (registry.length === 0 && !props[kind] && !props[missedType]) return diff --git a/src/types.ts b/src/types.ts index 55a85456..c6d8ae4d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,7 @@ import type { import type { WebGPURenderer } from "three/webgpu" import type { CanvasProps } from "./canvas.tsx" import type { $S3C } from "./constants.ts" +import type { EventRegistry } from "./event-registry.ts" import type { EventRaycaster } from "./raycasters.tsx" import type { Measure } from "./utils/use-measure.ts" @@ -328,8 +329,8 @@ export interface Context { canvas: HTMLCanvasElement clock: Clock camera: CameraKind - /** Objects carrying any pointer handler; raycast by the pointer system. */ - eventRegistry: Object3D[] + /** The refcounted registry of objects carrying pointer handlers; raycast by the pointer system. */ + eventRegistry: EventRegistry raycaster: Raycaster | EventRaycaster dpr: number gl: Meta diff --git a/src/create-xr.tsx b/src/xr/create-xr.tsx similarity index 94% rename from src/create-xr.tsx rename to src/xr/create-xr.tsx index b9039a85..01a2750e 100644 --- a/src/create-xr.tsx +++ b/src/xr/create-xr.tsx @@ -7,7 +7,7 @@ import { onCleanup, useContext, } from "solid-js" -import type { Context } from "./types.ts" +import type { Context } from "../types.ts" /** * The in-scene XR state distributed by `createXR().Provider` and read by @@ -57,7 +57,9 @@ export type XRContext = Pick */ type XRRenderer = { setAnimationLoop(callback: ((time: number, frame?: XRFrame) => void) | null): void - xr: { + // Optional: a renderer cast to this slice may be a non-XR renderer at runtime + // (the guards below check before use), so the type must allow `xr` absent. + xr?: { enabled: boolean setSession(session: XRSession): Promise addEventListener(type: string, listener: () => void): void @@ -99,7 +101,7 @@ export function createXR() { setPresenting(false) setSession(undefined) gl.setAnimationLoop(null) // edge 2: stop three re-driving render post-exit - gl.xr.enabled = false + xr.enabled = false } xr.addEventListener("sessionstart", onStart) xr.addEventListener("sessionend", onEnd) @@ -127,7 +129,8 @@ export function createXR() { throw new Error("S3: createXR().enter() called before connected") } const gl = ctx.gl as unknown as XRRenderer - if (!gl.xr) { + const xr = gl.xr + if (!xr) { throw new Error("S3: the active renderer has no xr manager") } @@ -136,8 +139,8 @@ export function createXR() { // Edge 1: setAnimationLoop(render) BEFORE setSession (WebGPU snapshots here). gl.setAnimationLoop(ctx.render) - gl.xr.enabled = true - await gl.xr.setSession(xrSession) + xr.enabled = true + await xr.setSession(xrSession) setSession(xrSession) return xrSession } diff --git a/src/xr/events.ts b/src/xr/events.ts new file mode 100644 index 00000000..2687df62 --- /dev/null +++ b/src/xr/events.ts @@ -0,0 +1,173 @@ +import { onCleanup, runWithOwner } from "solid-js" +import type { Object3D } from "three" +import { captureRegistry } from "../pointer-capture.ts" +import { Pointer } from "../pointers.ts" +import { ControllerRaycaster } from "../raycasters.tsx" +import type { Context, Plugin, ThreeEvent } from "../types.ts" +import { getMeta } from "../utils.ts" + +/** + * The controller source's typed `extra` fields, merged onto the dispatched event. + * Declared once and reused for both the dispatch call (`dispatch`) + * and the handler type, so they can't drift. + */ +export interface XRControllerExtra { + controller: Object3D + inputSource: XRInputSource | undefined + handedness: XRHandedness | undefined +} + +/** The rich payload XR handlers receive — the public event plus {@link XRControllerExtra}. */ +export type XRThreeEvent = ThreeEvent & XRControllerExtra + +/** A controller's `selectstart`/`selectend`/`squeezestart`/`squeezeend` event. */ +type ControllerEvent = { type: string; data?: XRInputSource } + +/** + * The slice of `renderer.xr` this source drives: session-lifecycle events plus + * `getController(i)`. A controller (an `Object3D` extending `EventDispatcher`) + * structurally satisfies the listener half too. + */ +type XRLike = { + getController(index: number): Object3D + addEventListener(type: string, listener: () => void): void + removeEventListener(type: string, listener: () => void): void +} + +const PAIRS = [ + ["selectstart", "onXRSelectStart", "start"], + ["selectend", "onXRSelectEnd", "end"], + ["squeezestart", "onXRSqueezeStart", "start"], + ["squeezeend", "onXRSqueezeEnd", "end"], +] as const + +/** + * Wires XR controllers as pointer sources for the duration of a session. On + * `sessionstart`, each `getController(i)` (an `Object3D` whose `matrixWorld` aims + * the ray AND which dispatches select/squeeze events) gets a `Pointer` + a + * `ControllerRaycaster`; the four start/end events dispatch the matching handler, + * enriched with `{ controller, inputSource, handedness }`. Bubbling + canvas-level + * come from `Pointer.dispatch`. Start events are capturable — a handler may call + * `setPointerCapture()` to grab the hit object for the gesture; the paired end event + * then delivers to that captured object (reprojected) and releases it. `sessionend` + * (or `connect`'s disconnect) tears the controllers down. Replaces the controller + * wiring previously baked into core. + */ +export class XRControllerSource { + constructor( + private context: Context, + private xr: XRLike, + private count = 2, + ) {} + + connect(): () => void { + let controllerCleanups: Array<() => void> = [] + + const wire = () => { + for (let index = 0; index < this.count; index++) { + const controller = this.xr.getController(index) + const pointer = new Pointer( + this.context, + new ControllerRaycaster(controller), + undefined, + captureRegistry, + ) + const listeners = PAIRS.map(([native, handler, phase]) => { + const listener = (event: ControllerEvent) => { + const inputSource = event.data + // Start events are capturable (a handler may call `setPointerCapture()`), + // mirroring `onPointerDown`. XR has no OS `lostpointercapture`, so the + // source releases the capture on the paired end event. + pointer.dispatch( + handler, + new Event(native), + { controller, inputSource, handedness: inputSource?.handedness }, + phase === "start", + ) + if (phase === "end") pointer.release() + } + return [native, listener] as const + }) + const target = controller as unknown as XRLike + for (const [type, listener] of listeners) { + target.addEventListener(type, listener as () => void) + } + controllerCleanups.push(() => { + for (const [type, listener] of listeners) { + target.removeEventListener(type, listener as () => void) + } + }) + } + } + + const unwire = () => { + for (const cleanup of controllerCleanups) cleanup() + controllerCleanups = [] + } + + this.xr.addEventListener("sessionstart", wire) + this.xr.addEventListener("sessionend", unwire) + return () => { + this.xr.removeEventListener("sessionstart", wire) + this.xr.removeEventListener("sessionend", unwire) + unwire() + } + } +} + +/**********************************************************************************/ +/* */ +/* xrEvents plugin */ +/* */ +/**********************************************************************************/ + +/** Per-context dedup token for the controller source (one source per `ctx`). */ +const XR_SOURCE = Symbol("xr-controller-source") + +/** Attach the controller source to a context's renderer for the Canvas's lifetime. */ +function wireSource(context: Context) { + const xr = (context.gl as { xr?: XRLike }).xr + if (!xr || typeof xr.getController !== "function") return + const disconnect = new XRControllerSource(context, xr).connect() + onCleanup(disconnect) +} + +/** The four handlers `xrEvents()` contributes — each typed to receive an {@link XRThreeEvent}. */ +type XRHandler = (callback: (event: XRThreeEvent) => void) => void +type XRHandlers = { + onXRSelectStart: XRHandler + onXRSelectEnd: XRHandler + onXRSqueezeStart: XRHandler + onXRSqueezeEnd: XRHandler +} + +/** + * Plugin: composes XR controller events. `createT(THREE, [xrEvents()])` or + * ``. Contributes `onXRSelectStart/End` + + * `onXRSqueezeStart/End` — each a typed handler whose callback (the user's prop) + * is dispatched by the controller source to the ray-hit mesh. + * + * A contributed method's job is registration, not storage: the callback already + * rides on `getMeta(element).props.onXR…`, which `Pointer.dispatch` reads. The + * method registers the element (so the controller ray can hit it) and wires the + * source once per `ctx` via `initializePlugin` (rooted to the Canvas owner, so it + * tears down with the Canvas). + */ +export function xrEvents(): Plugin<(element: Object3D) => XRHandlers> { + return (element: Object3D) => { + const register = () => { + const ctx = getMeta(element)?.ctx + if (!ctx) return + onCleanup(ctx.eventRegistry.register(element)) + const wire = () => ctx.initializePlugin(XR_SOURCE, () => wireSource(ctx)) + if (ctx.owner) runWithOwner(ctx.owner, wire) + else wire() + } + return { + onXRSelectStart: () => register(), + onXRSelectEnd: () => register(), + onXRSqueezeStart: () => register(), + onXRSqueezeEnd: () => register(), + } + } +} diff --git a/src/xr/index.tsx b/src/xr/index.tsx new file mode 100644 index 00000000..1c600a97 --- /dev/null +++ b/src/xr/index.tsx @@ -0,0 +1,9 @@ +/** + * `solid-three/xr` — everything WebXR, kept out of core so the base package + * stays XR-free. Session entry/exit (`createXR` / `useXR`) plus the composable + * controller-events plugin (`xrEvents`). + */ +export { createXR, useXR } from "./create-xr.tsx" +export type { XRContext, XRState } from "./create-xr.tsx" +export { XRControllerSource, xrEvents } from "./events.ts" +export type { XRThreeEvent } from "./events.ts" diff --git a/tests/core/create-xr.test.tsx b/tests/core/create-xr.test.tsx index d56acbdc..534e23a0 100644 --- a/tests/core/create-xr.test.tsx +++ b/tests/core/create-xr.test.tsx @@ -1,7 +1,7 @@ import { createRoot, createSignal } from "solid-js" import { afterEach, describe, expect, it, vi } from "vitest" import type { CanvasProps } from "../../src/canvas.tsx" -import { createXR, type XRContext } from "../../src/create-xr.tsx" +import { createXR, type XRContext } from "../../src/xr/create-xr.tsx" // Compile-time only: narrowing connect's param to XRContext must keep it // assignable to `` (Context satisfies XRContext). @@ -264,8 +264,8 @@ describe("createXR — exit & isSupported", () => { }) describe("createXR — public export", () => { - it("is exported from the package entry", async () => { - const entry = await import("../../src/index.ts") + it("is exported from the solid-three/xr entry", async () => { + const entry = await import("../../src/xr/index.tsx") expect(typeof entry.createXR).toBe("function") }) }) diff --git a/tests/core/event-registry.test.tsx b/tests/core/event-registry.test.tsx new file mode 100644 index 00000000..2d3fc114 --- /dev/null +++ b/tests/core/event-registry.test.tsx @@ -0,0 +1,57 @@ +import { Object3D } from "three" +import { describe, expect, it, vi } from "vitest" +import { EventRegistry } from "../../src/event-registry.ts" + +describe("EventRegistry", () => { + it("refcounts membership — listed once, removed only at the last release", () => { + const registry = new EventRegistry() + const a = new Object3D() + + const off1 = registry.register(a) + const off2 = registry.register(a) // a second handler on the same object + expect(registry.objects).toEqual([a]) // listed once + + off1() + expect(registry.objects).toEqual([a]) // still referenced + off2() + expect(registry.objects).toEqual([]) // last reference gone + }) + + it("fires onVacated (deferred) on a genuine vacate", async () => { + const registry = new EventRegistry() + const a = new Object3D() + const vacated = vi.fn() + registry.onVacated(vacated) + + registry.register(a)() + expect(vacated).not.toHaveBeenCalled() // deferred past the current tick + await Promise.resolve() + expect(vacated).toHaveBeenCalledWith(a) + }) + + it("does not fire onVacated when re-registered in the same tick", async () => { + const registry = new EventRegistry() + const a = new Object3D() + const vacated = vi.fn() + registry.onVacated(vacated) + + const off = registry.register(a) + off() // refcount → 0, schedules the deferred vacate check + registry.register(a) // re-register same tick → refcount back to 1 + await Promise.resolve() + + expect(vacated).not.toHaveBeenCalled() // still referenced — not a real vacate + expect(registry.objects).toEqual([a]) + }) + + it("onVacated returns an unsubscribe", async () => { + const registry = new EventRegistry() + const a = new Object3D() + const vacated = vi.fn() + registry.onVacated(vacated)() + + registry.register(a)() + await Promise.resolve() + expect(vacated).not.toHaveBeenCalled() + }) +}) diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index 343bd077..392935f0 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -1,13 +1,19 @@ import { assertType, describe, expect, it, vi } from "vitest" import { type Intersection, Object3D, PerspectiveCamera, Ray, Vector3 } from "three" +import { EventRegistry } from "../../src/event-registry.ts" import { createThreeEvent, 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 +function registry(objects: Object3D[]) { + const eventRegistry = new EventRegistry() + for (const object of objects) eventRegistry.register(object) + return eventRegistry +} +function ctx(objects: Object3D[], props: Record = {}) { + return { eventRegistry: registry(objects), props } as any } // Mutable ray state a test tweaks between gestures. type RayState = { target?: Object3D; point?: Vector3; normal?: Vector3 } @@ -314,7 +320,7 @@ describe("Pointer capture lifecycle", () => { camera.updateMatrixWorld() // syntheticHit builds a camera-facing plane const sink = spySink() const pointer = new Pointer( - { eventRegistry: [mesh], props: {}, camera } as any, + { eventRegistry: registry([mesh]), props: {}, camera } as any, fakeRaycaster({ target: mesh }), sink, ) diff --git a/tests/core/use-xr.test.tsx b/tests/core/use-xr.test.tsx index e1f68745..4cb18781 100644 --- a/tests/core/use-xr.test.tsx +++ b/tests/core/use-xr.test.tsx @@ -2,7 +2,7 @@ import { createRoot } from "solid-js" import { render } from "solid-js/web" import { afterEach, describe, expect, it, vi } from "vitest" import { Canvas } from "../../src/canvas.tsx" -import { createXR, useXR, type XRState } from "../../src/create-xr.tsx" +import { createXR, useXR, type XRState } from "../../src/xr/create-xr.tsx" import { useThree } from "../../src/hooks.ts" describe("useXR", () => { @@ -102,8 +102,8 @@ describe("createXR().Provider + useXR", () => { }) describe("package entry", () => { - it("re-exports useXR from solid-three", async () => { - const mod = await import("../../src/index.ts") + it("re-exports useXR from solid-three/xr", async () => { + const mod = await import("../../src/xr/index.tsx") expect(typeof mod.useXR).toBe("function") }) }) diff --git a/tests/core/xr-events.test.tsx b/tests/core/xr-events.test.tsx new file mode 100644 index 00000000..e7206374 --- /dev/null +++ b/tests/core/xr-events.test.tsx @@ -0,0 +1,139 @@ +import * as THREE from "three" +import { assertType, describe, expect, it, vi } from "vitest" +import { createT } from "../../src/create-t.tsx" +import { EventRegistry } from "../../src/event-registry.ts" +import { hasPointerCapture } from "../../src/pointer-capture.ts" +import { test as renderThree } from "../../src/testing/index.tsx" +import { XRControllerSource, type XRThreeEvent, xrEvents } from "../../src/xr/events.ts" +import { meta } from "../../src/utils.ts" + +function registryOf(...objects: THREE.Object3D[]) { + const registry = new EventRegistry() + for (const object of objects) registry.register(object) + return registry +} + +function makeFakeXR(getController: (index: number) => THREE.Object3D) { + const listeners: Record void>> = {} + return { + 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), + } +} + +describe("XRControllerSource", () => { + it("dispatches onXRSqueezeStart/End to the ray-hit mesh with a rich payload", () => { + // Snapshot inside the handler: dispatch reuses one mutable event across the + // bubble + canvas phases, so a retained reference reads the final state. + let payload: Record | undefined + const start = vi.fn((event: any) => { + payload = { ...event } + }) + const end = vi.fn() + const mesh = meta(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.MeshBasicMaterial()), { + props: { onXRSqueezeStart: start, onXRSqueezeEnd: end }, + }) as unknown as THREE.Object3D + mesh.updateMatrixWorld() + + // Offset from dead-centre: a ray through the plane's exact centre pierces + // the diagonal shared by its two triangles (two intersections); off-centre + // hits a single triangle, so dispatch fires once. + const controller = new THREE.Object3D() + controller.position.set(0.5, 0.3, 5) + controller.updateMatrixWorld() + const xr = makeFakeXR(index => (index === 0 ? controller : new THREE.Object3D())) + const context = { gl: { xr }, eventRegistry: registryOf(mesh), props: {}, scene: new THREE.Scene() } as any + + const disconnect = new XRControllerSource(context, xr as any, 1).connect() + xr.dispatch("sessionstart") + + controller.dispatchEvent({ type: "squeezestart", data: { handedness: "left" } } as any) + expect(start).toHaveBeenCalledTimes(1) + expect(payload!.controller).toBe(controller) + expect(payload!.handedness).toBe("left") + expect(payload!.currentObject).toBe(mesh) + expect(payload!.intersection.object).toBe(mesh) + + controller.dispatchEvent({ type: "squeezeend", data: { handedness: "left" } } as any) + expect(end).toHaveBeenCalledTimes(1) + + xr.dispatch("sessionend") + controller.dispatchEvent({ type: "squeezestart", data: { handedness: "left" } } as any) + expect(start).toHaveBeenCalledTimes(1) // torn down + + disconnect() + }) + + it("a captured select delivers selectend to the grabbed mesh off-ray, then releases", () => { + const end = vi.fn() + const mesh = meta(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.MeshBasicMaterial()), { + props: { + onXRSelectStart: (event: any) => event.setPointerCapture(), // grab the hit + onXRSelectEnd: end, + }, + }) as unknown as THREE.Object3D + mesh.updateMatrixWorld() + + const controller = new THREE.Object3D() + controller.position.set(0.5, 0.3, 5) // aimed at the plane (−z) + controller.updateMatrixWorld() + const xr = makeFakeXR(index => (index === 0 ? controller : new THREE.Object3D())) + const context = { + gl: { xr }, + eventRegistry: registryOf(mesh), + props: {}, + scene: new THREE.Scene(), + camera: new THREE.PerspectiveCamera(), + } as any + + const disconnect = new XRControllerSource(context, xr as any, 1).connect() + xr.dispatch("sessionstart") + + controller.dispatchEvent({ type: "selectstart", data: { handedness: "right" } } as any) // captures + expect(hasPointerCapture(mesh)).toBe(true) + + // Aim the controller away from the mesh — the live ray no longer hits it. + controller.position.set(100, 100, 5) + controller.updateMatrixWorld() + + controller.dispatchEvent({ type: "selectend", data: { handedness: "right" } } as any) + expect(end).toHaveBeenCalledTimes(1) // still delivered to the captured mesh + expect(hasPointerCapture(mesh)).toBe(false) // released on end (no OS sink in XR) + + disconnect() + }) + + it("xrEvents() registers a handler-bearing mesh and wires the source once per ctx", () => { + const start = vi.fn() + const controller = new THREE.Object3D() + controller.position.set(0.5, 0.3, 5) + controller.updateMatrixWorld() + const xr = makeFakeXR(index => (index === 0 ? controller : new THREE.Object3D())) + + const T = createT({ Mesh: THREE.Mesh }, [xrEvents()]) + const three = renderThree( + () => ( + + ), + { gl: { xr } as any }, + ) + three.scene.children[0]!.updateMatrixWorld() + + xr.dispatch("sessionstart") + controller.dispatchEvent({ type: "squeezestart", data: { handedness: "right" } } as any) + expect(start).toHaveBeenCalledTimes(1) + three.unmount() + }) + + it("contributes typed onXR* handlers (XRThreeEvent) on the namespace", () => { + const T = createT({ Mesh: THREE.Mesh }, [xrEvents()]) + type MeshProps = Parameters[0] + // lint:types errors if the handler isn't surfaced / mistyped: + assertType<((event: XRThreeEvent) => void) | undefined>(({} as MeshProps).onXRSqueezeStart) + expect(true).toBe(true) + }) +}) diff --git a/tsup.config.ts b/tsup.config.ts index 581f330b..6846ea1d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig(config => { const packageEntries: Entry[] = [ { entry: "src/index.ts", name: "index" }, { entry: "src/testing/index.tsx", name: "testing" }, + { entry: "src/xr/index.tsx", name: "xr" }, ] return packageEntries.flatMap(({ entry, name }, i) => {