Skip to content

feat(xr)!: XR as a plugin — solid-three/xr (xrEvents + controller capture + createXR/useXR)#65

Open
bigmistqke wants to merge 6 commits into
solidjs-community:nextfrom
bigmistqke:next-pluggable-xr
Open

feat(xr)!: XR as a plugin — solid-three/xr (xrEvents + controller capture + createXR/useXR)#65
bigmistqke wants to merge 6 commits into
solidjs-community:nextfrom
bigmistqke:next-pluggable-xr

Conversation

@bigmistqke

@bigmistqke bigmistqke commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Builds on the core event system — pointer capture + the object/currentObject API (#69) and the typed dispatch event (#72).

Summary

Externalizes XR into a solid-three/xr entry built on the plugin seam (#67) — XR is no longer baked into core:

  • xrEvents() plugin + controller source — XR controllers become pointer sources through the same eventRegistry, dispatching onXRSelectStart/End + onXRSqueezeStart/End to the ray-hit mesh, enriched with { controller, inputSource, handedness }.
  • Pointer capture for controllers — start events are capturable: a handler may 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 OS lostpointercapture, so the source releases itself. Captures register in the global captureRegistry, so reactive hasPointerCapture() works in XR too.
  • Typed XR payloadXRControllerExtra is declared once and feeds both dispatch<XRControllerExtra>(…) and XRThreeEvent = ThreeEvent<XRInputSourceEvent> & XRControllerExtra (on refactor(events): type the dispatched event (drop the any bag) #72's typed dispatch), so the supplied payload and the handler type can't drift.
  • EventRegistry — refcounted eventRegistry membership behind one module: register(object) → cleanup lists an object once however many handlers it bears, and an onVacated subscription 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/useXR move into solid-three/xr (new package export + tsup entry).
  • build: point ./testing types at the emitted dist/testing.d.ts.

Breaking change

createXR/useXR move from the core entry to solid-three/xr.

Test plan

  • pnpm lint + pnpm build green; ./xr entry emits (dist/xr.*).
  • Browser suite green — 223 passed, incl. XR dispatch to the ray-hit mesh, typed onXR* handlers, the capture test (off-ray select delivery + release), and the EventRegistry unit tests.

@pkg-pr-new

pkg-pr-new Bot commented Jun 4, 2026

Copy link
Copy Markdown

commit: 3b9d257

@bigmistqke

bigmistqke commented Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Hooking up the XR plugin

Pass xrEvents() to the element namespace, drive the session with createXR, and your meshes receive the typed onXR* handlers:

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

onXRSelectStart, onXRSelectEnd, onXRSqueezeStart, onXRSqueezeEnd — each (event: XRThreeEvent) => void. They bubble up the hit chain and honor event.stopPropagation(), just like the pointer events.

Grabbing with capture

Inside onXRSelectStart / onXRSqueezeStart, call event.setPointerCapture() to grab the hit object for the gesture — the paired …End then delivers to that object even if the controller ray has drifted off it (reprojected onto a drag plane), and releases automatically:

<T.Mesh
  onXRSelectStart={e => e.setPointerCapture()}
  onXRSelectEnd={() => console.log("released")}
/>

Captures register globally, so reactive hasPointerCapture(mesh) reflects an XR grab too — handy for highlight-while-held visuals.

XRThreeEvent payload

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).

@bigmistqke

Copy link
Copy Markdown
Contributor Author

Splitting this into three reviewable, independently-green PRs that land as three squash-merged commits on next-cleanup:

  1. Act 1 — source-agnostic pointer systemrefactor(events)!: source-agnostic pointer system (Pointer + EventRaycaster + DOMPointerManager) #66 (open)
  2. Act 2 — plugin system (follows once refactor(events)!: source-agnostic pointer system (Pointer + EventRaycaster + DOMPointerManager) #66 merges)
  3. Act 3 — XR as a pluginthis PR, rebased to show only the XR slice once Acts 1+2 land.

Drafting until then.

bigmistqke added a commit that referenced this pull request Jun 5, 2026
…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`)
@bigmistqke bigmistqke force-pushed the next-pluggable-xr branch from ac9cdfc to 0aa4791 Compare June 5, 2026 16:53
@bigmistqke bigmistqke marked this pull request as ready for review June 5, 2026 16:53
@bigmistqke bigmistqke changed the title feat(events)!: source-agnostic pointers + plugin system + XR as a plugin (solid-three/xr) feat(xr)!: XR as a plugin — solid-three/xr (xrEvents + createXR/useXR) Jun 5, 2026
bigmistqke added a commit that referenced this pull request Jun 7, 2026
…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.
@bigmistqke bigmistqke force-pushed the next-pluggable-xr branch from 0aa4791 to 38c9e7e Compare June 7, 2026 01:32
@bigmistqke bigmistqke changed the title feat(xr)!: XR as a plugin — solid-three/xr (xrEvents + createXR/useXR) feat(xr)!: XR as a plugin — solid-three/xr (xrEvents + controller capture + createXR/useXR) Jun 7, 2026
bigmistqke added a commit that referenced this pull request Jun 7, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant