feat(events)!: DOM-agnostic Pointer dispatch + XR controller pointers#64
feat(events)!: DOM-agnostic Pointer dispatch + XR controller pointers#64bigmistqke wants to merge 7 commits into
Conversation
…ointer* family) Ports the hover/missable/default dispatch from the per-kind registries into one per-pointer Pointer class with its own hover state, fed by a manager's raycaster. Adds context.eventRegistry (the single registry the Pointer raycasts; populated in a later task). Additive + unwired — existing suite unchanged (175/2 → +5).
…ster cast(registry, context) aims the ray and intersects (ported from the old raycast); ScreenRaycaster adds setCursor; ControllerRaycaster casts from a space's matrixWorld. Legacy update() kept (now optional) so the pre-Pointer engine still runs. Additive.
…d + primary) Owns the canvas listeners, a Pointer per native pointerId (multi-touch), and a primary Pointer for the family-agnostic click/dblclick/contextmenu/wheel gestures. Additive — not yet wired (the clean cut replaces createEvents next).
…op onMouse* createEvents now registers handler-bearing objects into the single context.eventRegistry and connects the DOMPointerManager (per-pointerId Pointers + a primary Pointer for click/wheel). Deletes the three per-kind registries, the old raycast, eventNameMap, createRegistry, and the duplicated createThreeEvent. BREAKING: onMouse* handlers removed (redundant with onPointer*; absent from r3f). Their test blocks are removed; every other test passes unchanged (185 → 172), confirming the new dispatch is behavior-identical for the mouse path.
The pre-Pointer engine was the only caller; cast() replaces it. Drops update() from EventRaycaster + Cursor/CenterRaycaster and the unused RayEvent type; migrates the two api-coverage tests that probed update() to the setCursor/cast API.
…ck (event-driven) Each XR controller (renderer.xr.getController(i)) becomes a Pointer with a ControllerRaycaster bound to its targetRay space; selectstart/selectend drive down/up and synthesize click. createXR wires it on sessionstart, tears down on sessionend/disconnect — guarded so the renderer must expose getController and the connected value be a full Context, leaving createXR's existing fakes unaffected. Sweep-hover (needs per-frame) is deferred.
commit: |
API implications: what this looks like for consumersThe headline is that there is no XR-specific event code to write. Once a session starts, each controller (up to 2) becomes its own
So a import { Canvas, createXR, createT, type S3 } from "solid-three"
import * as THREE from "three"
import { createSignal, Show } from "solid-js"
const T = createT(THREE)
function Button(props: { position: [number, number, number]; label: string }) {
const [active, setActive] = createSignal(false)
// The same handlers you'd write for the mouse. In a session, the XR
// controller ray drives them: point at this mesh, pull the trigger, and
// onPointerDown → onPointerUp → onClick fire — no XR-specific wiring.
return (
<T.Mesh
position={props.position}
scale={active() ? 1.2 : 1}
onPointerDown={(event: S3.ThreeEvent<Event>) => {
event.stopPropagation() // don't bubble to meshes behind this one
setActive(true)
}}
onPointerUp={() => setActive(false)}
onClick={(event) => {
// event.point / event.distance describe where the controller ray hit.
console.log(`${props.label} selected at`, event.point)
}}
>
<T.BoxGeometry args={[0.3, 0.15, 0.05]} />
<T.MeshStandardMaterial color={active() ? "orange" : "white"} />
</T.Mesh>
)
}
function Scene() {
return (
<>
<T.AmbientLight />
<T.DirectionalLight position={[2, 4, 3]} />
{/* A floating two-button panel a controller can point at and "click". */}
<Button position={[-0.4, 1.4, -1]} label="left" />
<Button position={[0.4, 1.4, -1]} label="right" />
</>
)
}
export function App() {
const xr = createXR()
return (
<>
{/* DOM button, outside the Canvas — its click is the user gesture that
starts the session. Don't await anything before enter(). */}
<button onClick={() => xr.enter("immersive-vr")}>Enter VR</button>
<Show when={xr.isPresenting()}>
<button onClick={() => xr.exit()}>Exit VR</button>
</Show>
{/* ref={xr.connect} hands createXR the renderer + per-frame render.
On sessionstart, XRPointerManager wires both controllers as pointers
automatically; on sessionend it tears them down. No pointers prop. */}
<Canvas ref={xr.connect} camera={{ position: [0, 1.6, 0] }}>
<Scene />
</Canvas>
</>
)
}Works now vs. deferred
|
|
Closing in favor of #65 — same work, rebuilt as one coherent history. #64 added the source-agnostic pointer system but also baked an Continued in #65. |
Decouples solid-three's pointer-event dispatch from the DOM behind a
Pointerabstraction, opens it to multiple simultaneous pointers viapointerId, and adds an API to wire XR controllers as pointers. Event-driven, and behavior-preserving for the mouse path.Why
The event system was DOM-triggered (each registry listened on
context.canvas) and single-pointer (one sharedhoveredSet, onecontext.raycaster). That blocked XR — controllers emitselectstart/selectend, never DOM events — and couldn't track multi-touch or multiple controllers. Dispatch now lives on a per-pointer object fed by pluggable sources, so a mouse, two fingers, and two controllers can all dispatch independently through the same code, while the mouse path stays byte-identical.What changed
Pointer(src/pointers.ts) — per-pointer, DOM-agnostic dispatch + hover/press state. The enter/leave ancestor diff, bubbling +stopPropagation, click +-Missed, and down/up/wheel logic are ported from the old per-kind registries; each pointer keeps its own hover set.EventRaycaster.cast+ScreenRaycaster+ControllerRaycaster(src/raycasters.tsx) — the ray strategy is a property of a pointer.cast(registry, context)aims the ray and intersects;ScreenRaycasteraddssetCursor(camera + NDC);ControllerRaycastercasts from anObject3D'smatrixWorld.DOMPointerManager(src/pointer-managers.ts) — the built-in screen source: owns the canvas listeners, aPointerper nativepointerId(multi-touch), and a primaryPointerfor click/dblclick/contextmenu/wheel (which areMouseEvents with nopointerId).XRPointerManager+createXRwiring — each controller (renderer.xr.getController(i)) becomes aPointerwith aControllerRaycaster;selectstart/selectenddriveonPointerDown/onPointerUpand synthesizeonClick. Created onsessionstart, torn down onsessionend/disconnect, and guarded so it only engages when the renderer exposesgetController.context.eventRegistry— one refcounted registry of handler-bearing objects that every pointer raycasts, replacing the three per-kind registries.Breaking
onMouse*removed (onMouseDown/onMouseUp/onMouseMove/onMouseEnter/onMouseLeave). Fully redundant withonPointer*(pointer events fire for mouse input and are a superset) and absent from react-three-fiber. Migration: use theonPointer*equivalents.EventRaycaster.update(event, context)removed in favour ofcast+setCursor(affects custom raycasters).click/dblclick/contextmenu; only XR controllers synthesize click from select-down/up. This is a deliberate hybrid — r3f-core is native-click-only,@react-three/xrsynthesizes uniformly; we keep the mouse byte-identical and synthesize only where there is no DOM click.Deferred (not in this PR)
Per-frame driving, and XR controller sweep-hover (enter/leave as a controller ray moves while held still) — controllers emit no move events, so that needs a per-frame tick. This PR is event-driven: point-and-click/select works now; controller hover-while-moving comes with the per-frame phase.
Test plan
pnpm test— 175 passed, 2 todo (16 files). Behavior-preserving: every pre-existing test passes unchanged except the removedonMouse*blocks (and twoapi-coveragetests migrated off the removed.update()). New tests coverPointerdispatch,EventRaycaster.cast, multi-touch (independent hover perpointerId), and XR controller select →onPointerDown/onPointerUp/onClick.pnpm lint:types— clean.pnpm lint:code— clean.