feat(xr)!: XR as a plugin — solid-three/xr (xrEvents + controller capture + createXR/useXR)#65
feat(xr)!: XR as a plugin — solid-three/xr (xrEvents + controller capture + createXR/useXR)#65bigmistqke wants to merge 6 commits into
Conversation
commit: |
Hooking up the XR pluginPass import * as THREE from "three"
import { Show } from "solid-js"
import { Canvas, createT } from "solid-three"
import { createXR, xrEvents } from "solid-three/xr"
// Opt in once, at the namespace. T.* elements now accept the typed onXR* props.
const T = createT(THREE, [xrEvents()])
export default function App() {
const xr = createXR()
return (
<>
{/* DOM button, outside the Canvas — the click is the user gesture that enters the session. */}
<button onClick={() => xr.enter("immersive-vr")}>Enter VR</button>
<Show when={xr.isPresenting()}>
<button onClick={() => xr.exit()}>Exit VR</button>
</Show>
<Canvas ref={xr.connect}>
<T.Mesh
position={[0, 1.5, -2]}
onXRSelectStart={event => {
// trigger pressed while a controller's ray is on this mesh
console.log("select", event.handedness, event.intersection.point)
}}
onXRSelectEnd={() => console.log("select released")}
onXRSqueezeStart={event => {
event.intersection.object.scale.multiplyScalar(1.2) // grip squeezed
}}
>
<T.BoxGeometry />
<T.MeshStandardMaterial color="hotpink" />
</T.Mesh>
<T.AmbientLight intensity={0.5} />
</Canvas>
</>
)
}The handlers
Grabbing with captureInside <T.Mesh
onXRSelectStart={e => e.setPointerCapture()}
onXRSelectEnd={() => console.log("released")}
/>Captures register globally, so reactive
|
| field | type | what it is |
|---|---|---|
controller |
Object3D |
the controller's targetRay space (its matrixWorld aimed the ray) |
inputSource |
XRInputSource | undefined |
the source that fired the event |
handedness |
XRHandedness | undefined |
"left" / "right" / "none" |
object |
Object3D |
the closest hit (intersections[0].object); 3D analogue of a DOM target |
currentObject |
Object3D | undefined |
the node the handler is firing on as the event bubbles; 3D analogue of currentTarget |
intersection |
Intersection |
the ray hit — point, distance, face, … |
| …plus | the usual ThreeEvent fields: nativeEvent, stopPropagation(), intersections |
Per-element opt-in
If you'd rather not opt in the whole namespace, pass the plugin to a single <Entity>:
import { Entity } from "solid-three"
import { xrEvents } from "solid-three/xr"
<Entity object={myMesh} plugins={[xrEvents()]} onXRSqueezeStart={event => { /* … */ }} />Wiring is automatic and refcounted: the first onXR*-bearing element in a <Canvas> registers itself for raycasting and stands up the controller source once per context; everything tears down with the Canvas (and on sessionend).
|
Splitting this into three reviewable, independently-green PRs that land as three squash-merged commits on
Drafting until then. |
…caster + DOMPointerManager) (#66) **Act 1 of 3** splitting #65 into reviewable, independently-green slices that land as three squash-merged commits on `next-cleanup`. This one is self-contained; Act 2 (plugin system) and Act 3 (XR as a plugin) follow once this merges. ## Summary Replaces the DOM-coupled per-kind event registries with a layered, source-agnostic pointer system that any source can drive: - **`Pointer`** — per-pointer, DOM-agnostic dispatch over a single `onPointer*` family (the redundant `onMouse*` family is gone), tracking its own hover state so multiple pointers stay independent. - **`EventRaycaster.cast` + `ScreenRaycaster` + `ControllerRaycaster`** — the ray strategy, aimed from a 2D cursor (screen) or an `Object3D`'s world transform (controller). - **`DOMPointerManager`** — the built-in screen source: one `Pointer` per native `pointerId` (independent multi-touch) plus a `primary` `Pointer` for the family-agnostic click/dblclick/contextmenu/wheel gestures. ## Commits (6) - `feat(events)`: Pointer — per-pointer DOM-agnostic dispatch (single onPointer* family) - `feat(events)`: EventRaycaster.cast + ScreenRaycaster + ControllerRaycaster - `feat(events)`: DOMPointerManager — screen pointer source (per-pointerId + primary) - `refactor(events)!`: drive dispatch via DOMPointerManager + Pointer; drop onMouse* - `refactor(events)`: remove dead legacy raycaster.update() - `test(events)`: multi-touch — independent hover/leave per pointerId ## Breaking change Dispatch is driven by `DOMPointerManager` + `Pointer`; the `onMouse*` handler family and the legacy `raycaster.update()` are removed. ## Test plan - [x] full suite green at this commit (`ec8df05`)
ac9cdfc to
0aa4791
Compare
…currentObject event API (#69) ## Summary Brings **grab-and-hold pointer dragging** to the core event system, plus the event-object API it motivated and a few dispatch-consistency fixes surfaced along the way. Core-only; the XR `select`/`squeeze` capture glue rides with #65. ## Pointer capture - `event.setPointerCapture({ object?, normal? })` on `onPointerDown`/`Up`/`Move`: subsequent move/up deliver **exclusively** to the captured object's chain (still bubbling to canvas-level) — on-ray or off, and off-canvas for the DOM source, via an OS-capture sink. - No-arg captures the firing `currentObject` (sync); pass `object` to start a capture **later** (after an `await`/timer); pass `normal` to orient the **drag plane** (e.g. `{ normal: Vector3(0, 1, 0) }` for ground sliding) — default is the hit surface, else camera-facing. - While captured, hover is frozen and the intersection is **reprojected** onto the drag plane through the grab point, so `event.intersection.point` keeps tracking. - Auto-releases on `pointerup`/`pointercancel` and when the captured object unmounts mid-drag; idempotent; a failed OS capture rolls back. A captured drag that moved **suppresses its trailing click**. ## Reactive `hasPointerCapture(object)` - A context-free reactive predicate (top-level export) — whether an object currently holds a capture — for declarative drag visuals without a parallel `dragging` signal. Backed by a refcounted `ReactiveMap` (new dep `@solid-primitives/map`); the framework-agnostic `Pointer` stays Solid-free via an injected registry. Distinct from the event's imperative `event.hasPointerCapture()`. ## Event-object API (breaking) - Rename `event.element` → **`event.currentObject`** (the node a bubbled handler is firing on — the 3D analogue of DOM `currentTarget`, cleared after dispatch) and add **`event.object`** (the closest hit, `intersections[0].object` — the analogue of `target`, stable after dispatch). `target`/`currentTarget` stay DOM-only on `nativeEvent`. ## Dispatch fixes + refactor (behavior changes) - **bubble() helper** — the parent-chain walk deduped into one shared helper. - **fix:** a shared ancestor fires **once** for `onPointerDown`/`Up`/`Wheel` (matching `move`/`click`). - **fix:** `move` honors `stopPropagation` across hits (matching `click`/`dispatch`/R3F). Both had zero coverage; both now have regression tests. ## Raycaster - `aim()` factored out of `cast()` and `ray` exposed on `PointerRaycaster`, so capture reprojects the live ray without a full registry cast. ## Docs - Events API overview + README event section: drop the removed `onMouse*` handlers, document the full event object, `setPointerCapture({ object, normal })`, and reactive `hasPointerCapture`. - Tour chapter 04: a worked **drag demo**, drag state derived from `hasPointerCapture`. - CONTRIBUTING: the `create*`-factory vs class convention. ## Test plan - [x] `pnpm lint` + `pnpm build` green. - [x] Browser suite green — **214 passed**, incl. capture lifecycle, exclusive/bubbling delivery, reprojection, unmount release, drag-cancels-click, async + normal-plane capture, reactive `hasPointerCapture`, and the dispatch-fix regressions. - [x] Drag demo verified in-browser; site build green.
0aa4791 to
38c9e7e
Compare
An architecture-review deepening: the event `Pointer` dispatches to handlers had almost no type depth — `createThreeEvent` returned a bag, then `move`/`dispatch`/`click`/`propagate` re-typed it `any` (7 casts) and grew it by mutation (`currentObject`/`currentIntersection` per node, capture methods bolted on, plugin `extra` `Object.assign`'d). ## Change - Introduce `DispatchEvent<TExtra>` — the writable shape the `Pointer` assembles (optional event fields + `Partial<PointerCapture>` + typed `extra`). - `createThreeEvent` returns it and merges a **typed** `extra`; `dispatch<TExtra>` is generic. - The dispatch path is now fully typed — **no `any` on the event** — and a plugin source's extra fields (e.g. the XR controller payload) are type-checked instead of merged blind. Handlers still receive the precise public `ThreeEvent` via their prop types. ## Notes - **Behavior-preserving** — no runtime change; the existing suite passes untouched, plus one type-level test locking the typed-`extra` contract. - Sets up #65's `dispatch<XRControllerExtra>` so `XRThreeEvent` becomes *derived*, not hand-merged — that integration lands when #65 rebases on top of this. ## Test plan - [x] `pnpm lint` + `pnpm build` green. - [x] Browser suite green — **215 passed** (incl. the new `createThreeEvent` typing test).
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.
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).
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).
…re for select/squeeze Rebased onto next-cleanup (post-solidjs-community#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.
…odule 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.
Now that core dispatch is generic over the event's extra fields (solidjs-community#72), declare the controller's extra once as XRControllerExtra and reuse it for both the dispatch call (dispatch<XRControllerExtra>) and the handler type (XRThreeEvent = ThreeEvent<XRInputSourceEvent> & 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.
1224283 to
3b9d257
Compare
Builds on the core event system — pointer capture + the
object/currentObjectAPI (#69) and the typed dispatch event (#72).Summary
Externalizes XR into a
solid-three/xrentry built on the plugin seam (#67) — XR is no longer baked into core:xrEvents()plugin + controller source — XR controllers become pointer sources through the sameeventRegistry, dispatchingonXRSelectStart/End+onXRSqueezeStart/Endto the ray-hit mesh, enriched with{ controller, inputSource, handedness }.setPointerCapture()to grab the hit object, and the paired end event delivers to it (the controller ray reprojected onto the drag plane) then releases — XR has no OSlostpointercapture, so the source releases itself. Captures register in the globalcaptureRegistry, so reactivehasPointerCapture()works in XR too.XRControllerExtrais declared once and feeds bothdispatch<XRControllerExtra>(…)andXRThreeEvent = ThreeEvent<XRInputSourceEvent> & XRControllerExtra(on refactor(events): type the dispatched event (drop theanybag) #72's typed dispatch), so the supplied payload and the handler type can't drift.EventRegistry— refcountedeventRegistrymembership behind one module:register(object) → cleanuplists an object once however many handlers it bears, and anonVacatedsubscription fires on a genuine unmount (deferred past a same-tick reactive re-register). The DOM source and the XR plugin both register through it.createXR/useXRmove intosolid-three/xr(new package export +tsupentry)../testingtypes at the emitteddist/testing.d.ts.Breaking change
createXR/useXRmove from the core entry tosolid-three/xr.Test plan
pnpm lint+pnpm buildgreen;./xrentry emits (dist/xr.*).onXR*handlers, the capture test (off-ray select delivery + release), and theEventRegistryunit tests.