From aa04b21ed70f9a675bb25097826252c5fcdaf154 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 20:19:04 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(xr):=20solid-three/xr=20=E2=80=94=20xr?= =?UTF-8?q?Events()=20plugin=20+=20controller=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship XR controller events as a composable plugin at the `solid-three/xr` sub-path. `xrEvents()` contributes onXRSelectStart/End + onXRSqueezeStart/End. On the first registration in a context it refcount-registers the handler-bearing mesh in `eventRegistry` and wires an `XRControllerSource` once per ctx (via `Context.initializePlugin`, rooted to the Canvas owner). The source owns the session lifecycle: on `sessionstart` each `getController(i)` gets a `Pointer` + `ControllerRaycaster`, and the four start/end events dispatch the matching handler — bubbling + canvas-level via `Pointer.dispatch` — enriched with `{ controller, inputSource, handedness, element, intersection }`. `sessionend` tears the controllers back down. Handlers are typed and surfaced through the plugin system as `(event: XRThreeEvent) => void`. Includes the tsup build entry + the `./xr` package export. --- package.json | 16 +++ src/xr.ts | 177 ++++++++++++++++++++++++++++++++++ tests/core/xr-events.test.tsx | 92 ++++++++++++++++++ tsup.config.ts | 1 + 4 files changed, 286 insertions(+) create mode 100644 src/xr.ts create mode 100644 tests/core/xr-events.test.tsx diff --git a/package.json b/package.json index fb6460f7..ac0b6d9c 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,22 @@ "types": "./dist/testing/index.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": { diff --git a/src/xr.ts b/src/xr.ts new file mode 100644 index 00000000..e4b026a3 --- /dev/null +++ b/src/xr.ts @@ -0,0 +1,177 @@ +import { onCleanup, runWithOwner } from "solid-js" +import type { Intersection, Object3D } from "three" +import { Pointer } from "./pointers.ts" +import { ControllerRaycaster } from "./raycasters.tsx" +import type { Context, Plugin, ThreeEvent } from "./types.ts" +import { getMeta } from "./utils.ts" + +/** The rich payload XR handlers receive. */ +export type XRThreeEvent = ThreeEvent & { + controller: Object3D + inputSource: XRInputSource | undefined + handedness: XRHandedness | undefined + element: Object3D | undefined + intersection: Intersection +} + +/** 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"], + ["selectend", "onXRSelectEnd"], + ["squeezestart", "onXRSqueezeStart"], + ["squeezeend", "onXRSqueezeEnd"], +] 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`. `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)) + const listeners = PAIRS.map(([native, handler]) => { + const listener = (event: ControllerEvent) => { + const inputSource = event?.data + pointer.dispatch(handler, new Event(native), { + controller, + inputSource, + handedness: inputSource?.handedness, + }) + } + 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") + +/** + * Reference count per registered element, so an element bearing several XR + * handlers (or remounting) is added to / removed from `eventRegistry` exactly once. + */ +const refcounts = new WeakMap() + +/** Add `element` to the context's `eventRegistry` (refcounted), removing on cleanup. */ +function registerInRegistry(context: Context, element: Object3D) { + const count = refcounts.get(element) ?? 0 + if (count === 0) context.eventRegistry.push(element) + refcounts.set(element, count + 1) + onCleanup(() => { + const current = (refcounts.get(element) ?? 1) - 1 + if (current <= 0) { + refcounts.delete(element) + const index = context.eventRegistry.indexOf(element) + if (index !== -1) context.eventRegistry.splice(index, 1) + } else { + refcounts.set(element, current) + } + }) +} + +/** 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 + registerInRegistry(ctx, 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/tests/core/xr-events.test.tsx b/tests/core/xr-events.test.tsx new file mode 100644 index 00000000..b737f5a3 --- /dev/null +++ b/tests/core/xr-events.test.tsx @@ -0,0 +1,92 @@ +import * as THREE from "three" +import { assertType, describe, expect, it, vi } from "vitest" +import { createT } from "../../src/create-t.tsx" +import { test as renderThree } from "../../src/testing/index.tsx" +import { XRControllerSource, type XRThreeEvent, xrEvents } from "../../src/xr.ts" +import { meta } from "../../src/utils.ts" + +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: [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!.element).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("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..30e5fb92 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.ts", name: "xr" }, ] return packageEntries.flatMap(({ entry, name }, i) => { From 77ab715d4a9013a74871517395054f6387cb9d07 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 23:05:26 +0200 Subject: [PATCH 2/6] refactor(xr)!: move createXR/useXR into solid-three/xr Consolidate all WebXR under the `solid-three/xr` sub-path so core's public surface is XR-free. `createXR`, `useXR`, and the `XRContext`/`XRState` types move out of the package entry; import them from `solid-three/xr` alongside `xrEvents()`. The sub-path is now a folder mirroring `testing/`: `src/xr/index.tsx` re-exports `src/xr/create-xr.tsx` (session entry) and `src/xr/events.ts` (the controller-events plugin). Core keeps only the renderer-duck-typed frame-loop XR awareness (`gl.xr.isPresenting`), which is loop correctness independent of who drives the session. Also fixes two latent lint issues the files surface now that they sit under the linted glob: drop an unnecessary optional chain on a non-null controller event, and type `XRRenderer.xr` optional to match its runtime guard (binding it to a local const through the session-start path). --- package.json | 3 +++ site/src/routes/api/hooks/create-xr.mdx | 3 ++- site/src/routes/api/hooks/use-xr.mdx | 3 ++- src/index.ts | 2 -- src/{ => xr}/create-xr.tsx | 15 +++++++++------ src/{xr.ts => xr/events.ts} | 10 +++++----- src/xr/index.tsx | 9 +++++++++ tests/core/create-xr.test.tsx | 6 +++--- tests/core/use-xr.test.tsx | 6 +++--- tests/core/xr-events.test.tsx | 2 +- tsup.config.ts | 2 +- 11 files changed, 38 insertions(+), 23 deletions(-) rename src/{ => xr}/create-xr.tsx (94%) rename src/{xr.ts => xr/events.ts} (96%) create mode 100644 src/xr/index.tsx diff --git a/package.json b/package.json index ac0b6d9c..70a224fc 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,9 @@ "*": { "testing": [ "./dist/testing/index.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/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/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.ts b/src/xr/events.ts similarity index 96% rename from src/xr.ts rename to src/xr/events.ts index e4b026a3..709afd6f 100644 --- a/src/xr.ts +++ b/src/xr/events.ts @@ -1,9 +1,9 @@ import { onCleanup, runWithOwner } from "solid-js" import type { Intersection, Object3D } from "three" -import { Pointer } from "./pointers.ts" -import { ControllerRaycaster } from "./raycasters.tsx" -import type { Context, Plugin, ThreeEvent } from "./types.ts" -import { getMeta } from "./utils.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 rich payload XR handlers receive. */ export type XRThreeEvent = ThreeEvent & { @@ -60,7 +60,7 @@ export class XRControllerSource { const pointer = new Pointer(this.context, new ControllerRaycaster(controller)) const listeners = PAIRS.map(([native, handler]) => { const listener = (event: ControllerEvent) => { - const inputSource = event?.data + const inputSource = event.data pointer.dispatch(handler, new Event(native), { controller, inputSource, 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/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 index b737f5a3..05f50e30 100644 --- a/tests/core/xr-events.test.tsx +++ b/tests/core/xr-events.test.tsx @@ -2,7 +2,7 @@ import * as THREE from "three" import { assertType, describe, expect, it, vi } from "vitest" import { createT } from "../../src/create-t.tsx" import { test as renderThree } from "../../src/testing/index.tsx" -import { XRControllerSource, type XRThreeEvent, xrEvents } from "../../src/xr.ts" +import { XRControllerSource, type XRThreeEvent, xrEvents } from "../../src/xr/events.ts" import { meta } from "../../src/utils.ts" function makeFakeXR(getController: (index: number) => THREE.Object3D) { diff --git a/tsup.config.ts b/tsup.config.ts index 30e5fb92..6846ea1d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,7 +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.ts", name: "xr" }, + { entry: "src/xr/index.tsx", name: "xr" }, ] return packageEntries.flatMap(({ entry, name }, i) => { From 4d00928ec2b6c835fa4e3cad0d3484564dd65754 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 23:07:24 +0200 Subject: [PATCH 3/6] fix(build): point ./testing types at the real emitted dist/testing.d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsup names declaration output by the entry key, so the `testing` entry emits flat as `dist/testing.d.ts` — but the `./testing` export's production `import.types` and the `typesVersions` entry pointed at `dist/testing/index.d.ts`, which is never produced. Exports-aware TypeScript (`moduleResolution: bundler`/`node16`) therefore failed to resolve types for `solid-three/testing`. Point both at the flat file (the `development` condition already did). --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 70a224fc..2a2a7abb 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ } }, "import": { - "types": "./dist/testing/index.d.ts", + "types": "./dist/testing.d.ts", "default": "./dist/testing.js" } }, @@ -87,7 +87,7 @@ "typesVersions": { "*": { "testing": [ - "./dist/testing/index.d.ts" + "./dist/testing.d.ts" ], "xr": [ "./dist/xr.d.ts" From 488753f23fbd653bfa5a98b67f1f15b387df2b3b Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 03:32:23 +0200 Subject: [PATCH 4/6] feat(xr): adapt to the #69 event API + pointer capture for select/squeeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto next-cleanup (post-#69): the superseded event.element/extra dispatch primitive is dropped (it's in core now as currentObject + extra), and the XR event field follows the rename (element → currentObject). Adds pointer capture to the controller source: start events (onXRSelectStart/onXRSqueezeStart) are capturable — a handler may call setPointerCapture() to grab the hit object — and the paired end event delivers to that captured object (the live ray reprojected onto the drag plane), then releases it, since XR has no OS lostpointercapture sink. Captures register in the global captureRegistry, so reactive hasPointerCapture() works for XR too. --- src/xr/events.ts | 41 ++++++++++++++++++++++------------ tests/core/xr-events.test.tsx | 42 ++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/xr/events.ts b/src/xr/events.ts index 709afd6f..e9487394 100644 --- a/src/xr/events.ts +++ b/src/xr/events.ts @@ -1,5 +1,6 @@ import { onCleanup, runWithOwner } from "solid-js" import type { Intersection, 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" @@ -10,7 +11,6 @@ export type XRThreeEvent = ThreeEvent & { controller: Object3D inputSource: XRInputSource | undefined handedness: XRHandedness | undefined - element: Object3D | undefined intersection: Intersection } @@ -29,10 +29,10 @@ type XRLike = { } const PAIRS = [ - ["selectstart", "onXRSelectStart"], - ["selectend", "onXRSelectEnd"], - ["squeezestart", "onXRSqueezeStart"], - ["squeezeend", "onXRSqueezeEnd"], + ["selectstart", "onXRSelectStart", "start"], + ["selectend", "onXRSelectEnd", "end"], + ["squeezestart", "onXRSqueezeStart", "start"], + ["squeezeend", "onXRSqueezeEnd", "end"], ] as const /** @@ -41,8 +41,11 @@ const PAIRS = [ * 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`. `sessionend` (or `connect`'s disconnect) tears the - * controllers down. Replaces the controller wiring previously baked into core. + * 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( @@ -57,15 +60,25 @@ export class XRControllerSource { 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)) - const listeners = PAIRS.map(([native, handler]) => { + 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 - pointer.dispatch(handler, new Event(native), { - controller, - inputSource, - handedness: inputSource?.handedness, - }) + // 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 }) diff --git a/tests/core/xr-events.test.tsx b/tests/core/xr-events.test.tsx index 05f50e30..a9a6d07c 100644 --- a/tests/core/xr-events.test.tsx +++ b/tests/core/xr-events.test.tsx @@ -1,6 +1,7 @@ import * as THREE from "three" import { assertType, describe, expect, it, vi } from "vitest" import { createT } from "../../src/create-t.tsx" +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" @@ -47,7 +48,7 @@ describe("XRControllerSource", () => { expect(start).toHaveBeenCalledTimes(1) expect(payload!.controller).toBe(controller) expect(payload!.handedness).toBe("left") - expect(payload!.element).toBe(mesh) + expect(payload!.currentObject).toBe(mesh) expect(payload!.intersection.object).toBe(mesh) controller.dispatchEvent({ type: "squeezeend", data: { handedness: "left" } } as any) @@ -60,6 +61,45 @@ describe("XRControllerSource", () => { 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: [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() From de6e190ef9ed581b8e5c9d34a1efd14829745147 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 09:16:43 +0200 Subject: [PATCH 5/6] =?UTF-8?q?refactor(events):=20extract=20EventRegistry?= =?UTF-8?q?=20=E2=80=94=20one=20refcounted=20membership=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create-events and the XR plugin each hand-rolled the same refcounted eventRegistry membership (push on 0→1, splice on 1→0, tolerate a same-tick reactive re-register) — and the XR copy lacked the race handling entirely. Extract one EventRegistry that owns the objects array + refcounts behind register(object) → cleanup, with an onVacated subscription for the only divergent bit: the DOM source releases a pointer capture on a genuine unmount (deferred past a same-tick re-register); XR doesn't subscribe. Context.eventRegistry is now this module and the pointer system reads registry.objects. The race logic lives in one place, unit-tested directly through the interface. --- src/create-events.ts | 33 +++-------------- src/create-three.tsx | 3 +- src/event-registry.ts | 56 +++++++++++++++++++++++++++++ src/pointers.ts | 6 ++-- src/types.ts | 5 +-- src/xr/events.ts | 25 +------------ tests/core/event-registry.test.tsx | 57 ++++++++++++++++++++++++++++++ tests/core/pointer.test.tsx | 12 +++++-- tests/core/xr-events.test.tsx | 11 ++++-- 9 files changed, 145 insertions(+), 63 deletions(-) create mode 100644 src/event-registry.ts create mode 100644 tests/core/event-registry.test.tsx 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/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/xr/events.ts b/src/xr/events.ts index e9487394..cfca11dc 100644 --- a/src/xr/events.ts +++ b/src/xr/events.ts @@ -118,29 +118,6 @@ export class XRControllerSource { /** Per-context dedup token for the controller source (one source per `ctx`). */ const XR_SOURCE = Symbol("xr-controller-source") -/** - * Reference count per registered element, so an element bearing several XR - * handlers (or remounting) is added to / removed from `eventRegistry` exactly once. - */ -const refcounts = new WeakMap() - -/** Add `element` to the context's `eventRegistry` (refcounted), removing on cleanup. */ -function registerInRegistry(context: Context, element: Object3D) { - const count = refcounts.get(element) ?? 0 - if (count === 0) context.eventRegistry.push(element) - refcounts.set(element, count + 1) - onCleanup(() => { - const current = (refcounts.get(element) ?? 1) - 1 - if (current <= 0) { - refcounts.delete(element) - const index = context.eventRegistry.indexOf(element) - if (index !== -1) context.eventRegistry.splice(index, 1) - } else { - refcounts.set(element, current) - } - }) -} - /** 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 @@ -175,7 +152,7 @@ export function xrEvents(): Plugin<(element: Object3D) => XRHandlers> { const register = () => { const ctx = getMeta(element)?.ctx if (!ctx) return - registerInRegistry(ctx, element) + onCleanup(ctx.eventRegistry.register(element)) const wire = () => ctx.initializePlugin(XR_SOURCE, () => wireSource(ctx)) if (ctx.owner) runWithOwner(ctx.owner, wire) else wire() 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/xr-events.test.tsx b/tests/core/xr-events.test.tsx index a9a6d07c..e7206374 100644 --- a/tests/core/xr-events.test.tsx +++ b/tests/core/xr-events.test.tsx @@ -1,11 +1,18 @@ 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 { @@ -39,7 +46,7 @@ describe("XRControllerSource", () => { 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: [mesh], props: {}, scene: new THREE.Scene() } as any + 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") @@ -77,7 +84,7 @@ describe("XRControllerSource", () => { const xr = makeFakeXR(index => (index === 0 ? controller : new THREE.Object3D())) const context = { gl: { xr }, - eventRegistry: [mesh], + eventRegistry: registryOf(mesh), props: {}, scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(), From 3b9d257297bf590c0c2f31ccca81b229845ca781 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 7 Jun 2026 10:56:33 +0200 Subject: [PATCH 6/6] refactor(xr): derive XRThreeEvent via dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that core dispatch is generic over the event's extra fields (#72), declare the controller's extra once as XRControllerExtra and reuse it for both the dispatch call (dispatch) and the handler type (XRThreeEvent = ThreeEvent & XRControllerExtra) — so the payload the source supplies and the type handlers receive can no longer drift, and the merge is type-checked instead of blind. --- src/xr/events.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/xr/events.ts b/src/xr/events.ts index cfca11dc..2687df62 100644 --- a/src/xr/events.ts +++ b/src/xr/events.ts @@ -1,19 +1,25 @@ import { onCleanup, runWithOwner } from "solid-js" -import type { Intersection, Object3D } from "three" +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 rich payload XR handlers receive. */ -export type XRThreeEvent = ThreeEvent & { +/** + * 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 - intersection: Intersection } +/** 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 } @@ -72,7 +78,7 @@ export class XRControllerSource { // 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( + pointer.dispatch( handler, new Event(native), { controller, inputSource, handedness: inputSource?.handedness },