Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7835205
test(events): behavioural tripwires for the pointer system
bigmistqke Jun 10, 2026
dedd4af
feat(events)!: r3f-faithful onPointerMissed; collapse *Missed to one …
bigmistqke Jun 11, 2026
f7d754f
docs(site): migrate pointer-event docs to onPointerMissed; drop canva…
bigmistqke Jun 11, 2026
6d63e71
docs(context): replace the *Missed glossary entry with the single onP…
bigmistqke Jun 11, 2026
a0cf692
feat(core): expose addFrameListener on the context
bigmistqke Jul 13, 2026
16f086b
test(hooks): make addFrameListener disposal assertion non-tautological
bigmistqke Jul 13, 2026
4288928
feat(plugins): engine statics — stable token, per-context install, ca…
bigmistqke Jul 13, 2026
7ce579e
feat(canvas): plugins prop — engines contribute canvas-level props
bigmistqke Jul 13, 2026
4eb5ed5
feat(create-t): withCanvas — a Canvas typed by the same plugins
bigmistqke Jul 13, 2026
9bee13f
refactor(events)!: extract the pointer engine to solid-three/events
bigmistqke Jul 13, 2026
a578d58
types: fix plugin prop inference for hoisted plugin arrays
bigmistqke Jul 13, 2026
847647e
fix(events): keep installEngine reachable only through initializePlugin
bigmistqke Jul 13, 2026
f2d3b83
refactor(core)!: drop eventRegistry; core's raycaster is a plain Rayc…
bigmistqke Jul 13, 2026
f04a916
fix(events): apply the canvas raycaster config to the engine's pickin…
bigmistqke Jul 13, 2026
8bf2331
feat(plugins): warn in dev when two plugins contribute the same prop
bigmistqke Jul 13, 2026
52e6b37
test(boundary): the seam's guarantees
bigmistqke Jul 13, 2026
de0e35a
test(boundary): make token-stability test falsifiable, tighten listen…
bigmistqke Jul 13, 2026
6162d33
build: add solid-three/events subpath export
bigmistqke Jul 13, 2026
ab55125
site(snippets): install the pointer-events engine in the pointer-even…
bigmistqke Jul 13, 2026
9ab83b5
build(types): emit declarations in one tsc pass so subpaths share one…
bigmistqke Jul 13, 2026
b5fe8c8
test(boundary): narrow the raycaster probe instead of asserting
bigmistqke Jul 13, 2026
efac9ad
docs: the event engine is a plugin
bigmistqke Jul 13, 2026
6d1ca17
build: emit one shared copy of core across the package entry points
bigmistqke Jul 13, 2026
77d0495
test(boundary): a foreign engine on the seam — @pmndrs/pointer-events
bigmistqke Jul 13, 2026
3442439
fix(types): make the plugins prop of withCanvas's Canvas real, and st…
bigmistqke Jul 13, 2026
83fff30
ci: run the published-package guard on every push
bigmistqke Jul 13, 2026
5b11523
fix: export the plugin contract, drop the unshipped engine from the p…
bigmistqke Jul 13, 2026
b9fbfb7
refactor(events)!: the engine owns the raycaster; core has none
bigmistqke Jul 13, 2026
ec8dbed
feat(events)!: the raycaster is a stack, resolved at cast time
bigmistqke Jul 13, 2026
e65ca4e
build: declare the @types/node dependency the build already relied on
bigmistqke Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/pkg-pr-new.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,13 @@ jobs:
- name: Test
run: pnpm test --run

# The packaging guard, and the only suite that can see a defect which exists solely
# in the BUILT bundles — e.g. a duplicated copy of core, which makes pointer events
# silently never fire for consumers while every other suite (all of which import
# `src/`, one module graph) stays green. `test:published` rebuilds first, so it can
# never run against a stale `dist/`.
- name: Test published package
run: pnpm test:published

- name: Publish preview
run: pnpx pkg-pr-new publish --compact --template ./demo
21 changes: 13 additions & 8 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ _Avoid_: sub-property, hyphen notation
### Runtime

**Canvas**:
The root component. Sets up the **renderer**, scene, camera, and raycaster, and provides the **Context** to descendants.
The root component. Sets up the **renderer**, scene, and camera, and provides the **Context** to descendants.

**Context**:
The per-**Canvas** runtime state — the **renderer**, `scene`, `camera`, `raycaster`, `clock`, `eventRegistry`, `viewport`, `bounds`, and the render loop. Returned by `useThree`.
The per-**Canvas** runtime state — the **renderer**, `scene`, `camera`, `clock`, `viewport`, `bounds`, `initializePlugin`, `addFrameListener`, and the render loop. Returned by `useThree`.
_Avoid_: confusing it with the Solid context (`threeContext`) that distributes it — they're distinct.
_Note_: neither the **eventRegistry** nor the **raycaster** is here. Both belong to an **event engine**, one per Context — core holds no event state and does no picking.

**Renderer**:
The three.js renderer instance (`WebGLRenderer` or `WebGPURenderer`). Named `gl` in code — the `Context.gl` field and the `Canvas` `gl` prop (which also accepts renderer params or a factory). An R3F inheritance, and a misnomer once `WebGPURenderer` is in play.
Expand Down Expand Up @@ -94,14 +95,14 @@ _Avoid_: target / currentTarget for the 3D sense — those stay DOM-only, on `na
**Intersection** (intersections):
A raycast hit — three.js `Intersection` (`object`, `point`, `distance`, `face`, `uv`, `normal`). `event.intersections` is nearest-first; `event.intersection` is the nearest.

**Missed event**:
`onClickMissed` / `onDoubleClickMissed` / `onContextMenuMissed` — fires on a registered **object** when the interaction did _not_ hit it or its descendants.
**`onPointerMissed`**:
The miss handler. On an **object**, the per-object "not-me" — fires when a click lands somewhere other than that object or its descendants (a different object, or empty space). On the **Canvas**, narrower — fires only on a total miss (empty space), the void/deselect signal. Non-stoppable; the missed set is the registry minus the hit-closure (hits + their ancestors), so `stopPropagation` never widens it.

**raycast propagation**:
The first dispatch phase — the handler fires on each hit **object** nearest-first along the ray.

**tree propagation**:
The second dispatch phase — after an object's handler runs, the event _bubbles_ up its ancestors, finally to the **Canvas**. `stopPropagation()` halts both phases.
The second dispatch phase — after an object's handler runs, the event _bubbles_ up its ancestors. `stopPropagation()` halts both phases.

### Pointer system & raycasting

Expand All @@ -114,11 +115,14 @@ A **pointer source**'s implementation — owns the source's listeners and routes
**Pointer**:
The per-pointer state machine (one per `pointerId`, plus a primary) that raycasts the **eventRegistry** and dispatches/bubbles to handlers, tracking its own hover and capture state.

**event engine**:
A plugin that owns a pointer paradigm end to end — its own **eventRegistry**, **raycaster**, dispatch and propagation rules. Core ships none; `pointerEvents()` (`solid-three/events`) is the reference one. Engines are peers: each owns its own registry, so two engines on one **Canvas** partition by which namespace minted the **object**. Handler props are contributed by the engine, so without one installed, `onClick` is not a prop at all.

**eventRegistry**:
The set of **objects** carrying event handlers; what the pointer system raycasts.
The set of **objects** carrying event handlers; what an **event engine** raycasts. Belongs to the engine, one per **Context** — not to core.

**raycaster**:
Aims a ray and casts it against the **eventRegistry**. Variants differ by how they aim — a **screen raycaster** from a cursor in **NDC** (`CursorRaycaster` = mouse, `CenterRaycaster` = gaze), or a `ControllerRaycaster` from an XR controller's transform.
Aims a ray and casts it against the **eventRegistry**. Variants differ by how they aim — a **screen raycaster** from a cursor in **NDC** (`CursorRaycaster` = mouse, `CenterRaycaster` = gaze), or a `ControllerRaycaster` from an XR controller's transform. Belongs to the **event engine**, not to core — nothing in core casts a ray. It is stack-based, like the **camera**: the engine's own raycaster (configured at install with `pointerEvents({ raycaster })` — an instance, or a config object applied to the engine's `CursorRaycaster`) sits at the bottom, and `useRaycaster().setRaycaster(next)` pushes an override that pops on cleanup. The engine resolves the top of the stack at cast time, so a push genuinely changes what gets hit.

**NDC**:
Normalized device coordinates — the `[-1, 1]` cursor space a **screen raycaster** aims from.
Expand Down Expand Up @@ -163,7 +167,8 @@ WebXR (VR/AR) session management (`createXR` / `useXR`). _In flux_: being extern
- A **Plugin** matches **elements** via its **selector** and contributes **plugin props**; a plugin prop **overrides** the native prop of the same name
- A dispatch runs **raycast propagation** then **tree propagation**; `stopPropagation()` halts both. `event.object` is `event.intersections[0].object`
- A **pointer source** drives one or more **Pointers**; each **Pointer** raycasts the **eventRegistry** with a **raycaster**, then dispatches via **raycast propagation** and **tree propagation**
- The active **camera** and **raycaster** are stack-based: setting one (via `Canvas` props or `useThree`) pushes an override that pops on cleanup, restoring the previous
- The **raycaster** belongs to the **event engine**, not to core, and is stack-based: `useRaycaster()` returns `{ raycaster, setRaycaster }`, `raycaster()` is the object that actually picks (so mutating it always changes what gets picked), and `setRaycaster(next)` pushes an override that pops on cleanup
- The active **camera** is stack-based too: setting one (via the `Canvas` prop or `useThree().setCamera`) pushes an override that pops on cleanup, restoring the previous

## Example dialogue

Expand Down
120 changes: 54 additions & 66 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ The `Canvas` component initializes the `three.js` rendering context and acts as
- **fallback**: Element to render while the main content is loading asynchronously.
- **gl**: A flat object mixing `WebGLRenderer` constructor params (e.g. `antialias`, `alpha`, `powerPreference`) and instance-writable props (e.g. `toneMapping`) — solid-three splits the two internally. Instance props stay reactive (set them after construction, update them anytime). **Constructor params are applied once.** They're passed to `new WebGLRenderer({ ... })` at the first construction and never re-read. Updating one later does **not** rebuild the renderer or recreate the WebGL context, because `canvas.getContext("webgl2")` is idempotent: the browser returns the same context object on every subsequent call, with the original creation flags. solid-three logs a warning when it detects a reactive change to one of these keys. If you genuinely need to change them at runtime, unmount and remount the `<Canvas>` (e.g. gate it behind a `<Show>` keyed on the value you want to swap) — that gives you a fresh `<canvas>` element and therefore a fresh context. Also accepts a factory returning any renderer (`WebGLRenderer`, `WebGPURenderer`, `SVGRenderer`, `CSS2D/3DRenderer`, custom), or a pre-built renderer instance. See [Custom renderers](#custom-renderers) for narrowing the accepted renderer type project-wide.
- **scene**: Provides custom settings for the Scene instance or an existing Scene.
- **raycaster**: Configures the Raycaster for mouse and pointer events.
- **shadows**: Enables and configures shadows in the scene with various shadow mapping techniques.
- **orthographic**: Toggles between Orthographic and Perspective camera for the default camera.
- **linear**: Toggles linear interpolation for texture filtering.
Expand All @@ -133,7 +132,6 @@ interface CanvasProps {
| ((canvas: HTMLCanvasElement) => ResolvedRenderer)
| ResolvedRenderer
scene?: Partial<Scene> | Scene
raycaster?: Partial<Raycaster> | Raycaster
shadows?: boolean | "basic" | "percentage" | "soft" | "variance" | WebGLRenderer["shadowMap"]
orthographic?: boolean
linear?: boolean
Expand All @@ -155,7 +153,6 @@ interface CanvasProps {
fallback={<div>Loading...</div>}
gl={{ antialias: true, alpha: true }}
scene={{ fog: new Fog(0xffffff, 1, 100) }}
raycaster={{ params: { Line: { threshold: 0.1 } } }}
shadows="soft"
orthographic={false}
linear={false}
Expand Down Expand Up @@ -488,21 +485,21 @@ const camera = useThree(ctx => ctx.camera)
- **clock** (`Clock`): The `three.js` clock for timing.
- **dpr** (`number`): Device pixel ratio reported by the active renderer (falls back to `1` for renderers without `getPixelRatio`, e.g. `CSS3DRenderer` / `SVGRenderer`).
- **gl** (`Renderer`): The active renderer — `WebGLRenderer | WebGPURenderer | RendererLike` by default. Narrow to a concrete type project-wide via [Register augmentation](#narrowing-the-renderer-type-project-wide).
- **raycaster** (`Raycaster`): The current raycaster used for pointer events.
- **setRaycaster** (`(raycaster: Raycaster) => () => void`): A setter-function for setting the current raycaster.
- **render** (`(timestamp: number, frame?: XRFrame) => void`): Drives a single frame — runs the registered `useFrame` callbacks, then renders the scene. The optional `frame` is forwarded to those callbacks; an active WebXR session passes it on each immersive frame.
- **requestRender** (`() => void`): Function to request a render on the next frame.
- **scene** (`Scene`): The root scene.

**Camera and Raycaster Stack System:**
There is no raycaster here. Core does no picking, so it owns no raycaster: that belongs to the pointer-events engine, which hands it out through [`useRaycaster()`](#raycasters).

`solid-three` implements a stack-based system for managing its current camera and raycaster:
**Camera Stack System:**

- **Stack-based Management**: Both cameras and raycasters are managed as stacks internally
- **Default at Tail**: The `camera` and `raycaster` from Canvas props form the tail of their respective stacks
- **Current Active Camera at Head**: The camera/raycaster at the top of the stack is the currently active camera/raycaster
- **Push To The Stack To Become Active**: By calling `setCamera(camera)` and `setRaycaster(raycaster)`, the camera/raycaster is pushed to the stack. This causes it to become the currently active camera/raycaster
- **Pop From The Stack To Deactivate**: `setCamera(camera)` and `setRaycaster(raycaster)` return a cleanup-function to pop the camera/raycaster from the stack. If the camera/raycaster was on top of the stack, the previous camera/raycaster in the stack becomes active again
`solid-three` implements a stack-based system for managing its current camera:

- **Stack-based Management**: Cameras are managed as a stack internally
- **Default at Tail**: The `camera` from Canvas props forms the tail of the stack
- **Current Active Camera at Head**: The camera at the top of the stack is the currently active camera
- **Push To The Stack To Become Active**: By calling `setCamera(camera)`, the camera is pushed to the stack. This causes it to become the currently active camera
- **Pop From The Stack To Deactivate**: `setCamera(camera)` returns a cleanup-function to pop the camera from the stack. If the camera was on top of the stack, the previous camera in the stack becomes active again

**Usage:**

Expand Down Expand Up @@ -916,100 +913,91 @@ export function OrbitControls(props: S3.Props<typeof ThreeOrbitControls>) {

### Raycasters

`solid-three` provides custom raycaster implementations that handle pointer tracking internally. All raycasters extend THREE.Raycaster and implement the `EventRaycaster` interface.
The raycaster belongs to the pointer-events engine, not to core: core does no picking, so it owns no raycaster. There is exactly one per canvas — the one the engine casts with — and `solid-three/events` ships the strategies it can cast with. They all extend `THREE.Raycaster` and implement `EventRaycaster`:

The interface adds one method to the standard THREE.Raycaster:
- **cast(registry, context)**: aim the ray for the current pointer, then return its hits against the registry and its descendants, nearest-first.
- **aim(context)**: position the ray without intersecting anything — the aiming half of `cast`.

- **update**: Called automatically before intersection testing to update the raycaster's position based on the event
Screen-pointer raycasters also implement `ScreenRaycaster`, which adds **setCursor(ndc)**: the engine calls it with the cursor in normalized device coordinates before each `cast()`.

<details>
<summary>Typescript Interface</summary>

```tsx
interface EventRaycaster extends THREE.Raycaster {
update(event: PointerEvent | MouseEvent | WheelEvent, context: Context): void
cast(registry: Object3D[], context: Context): Intersection[]
aim(context: Context): void
}

interface ScreenRaycaster extends EventRaycaster {
setCursor(ndc: Vector2): void
}
```

</details>

**When `update()` is called:**

The `update()` method is automatically called by `solid-three`'s event system whenever:

- **Mouse events**: `click`, `mousedown`, `mouseup`, `mousemove`, `contextmenu`, `dblclick`
- **Pointer events**: `pointerdown`, `pointerup`, `pointermove`
- **Wheel events**: `wheel`

This happens before intersection testing, ensuring the raycaster is properly positioned for accurate 3D object detection.

#### CursorRaycaster
#### Configuring the raycaster

The default raycaster that tracks the cursor position:
`pointerEvents({ raycaster })` takes either a config object of raycaster properties — applied to the engine's own `CursorRaycaster` — or a raycaster instance, used as the whole ray strategy:

```tsx
import { Canvas } from "solid-three"
import { CursorRaycaster } from "solid-three"
import { CenterRaycaster, pointerEvents } from "solid-three/events"

const App = () => {
const raycaster = new CursorRaycaster()
// Config: pick nothing further than 10 units away.
;<Canvas plugins={[pointerEvents({ raycaster: { far: 10, near: 1 } })]}>{/* Your scene */}</Canvas>

// CursorRaycaster is used by default, but you can explicitly set it:
return <Canvas raycaster={raycaster}>{/* Your scene */}</Canvas>
}
// Instance: cast from the screen centre instead of the cursor.
;<Canvas plugins={[pointerEvents({ raycaster: new CenterRaycaster() })]}>{/* Your scene */}</Canvas>
```

#### CenterRaycaster
The option is read once, when the engine installs.

A raycaster that always casts from the center of the screen:
#### useRaycaster

```tsx
import { Canvas } from "solid-three"
import { CenterRaycaster } from "solid-three"
`useRaycaster()` returns the raycaster this canvas picks with — the engine's own, and the only one there is. Mutate it and the next event picks differently. It throws when no engine is installed, rather than handing back a raycaster that nothing casts with.

const App = () => {
const raycaster = new CenterRaycaster()
```tsx
import { useRaycaster } from "solid-three/events"

return <Canvas raycaster={raycaster}>>{/* Your scene */}</Canvas>
const Reach = (props: { far: number }) => {
const raycaster = useRaycaster()
createEffect(() => (raycaster.far = props.far))
return null
}
```

#### Creating Your Own Raycaster
#### CursorRaycaster

You can create custom raycasters by extending THREE.Raycaster and (optionally) implementing the `EventRaycaster` interface:
The default the engine falls back to when none is configured. Tracks the cursor and casts from the active camera.

```tsx
import { Raycaster, Vector2 } from "three"
import type { EventRaycaster, Context } from "solid-three"
#### CenterRaycaster

class CustomRaycaster extends Raycaster implements EventRaycaster {
constructor() {
super()
// Initialize your custom raycaster
}
Ignores the cursor and always casts from the center of the screen — useful for gaze or crosshair interaction.

update(event: PointerEvent | MouseEvent | WheelEvent, context: Context) {
const pointer = new Vector2()
#### Creating Your Own Raycaster

// Calculate normalized device coordinates based on your custom logic
For a screen pointer, the simplest path is to subclass `CursorRaycaster` and reshape the cursor — `cast()` is inherited:

// Example: Apply custom transformation to pointer coordinates
pointer.x = ((event.offsetX / context.bounds.width) * 2 - 1) * 0.5 // Scale down horizontal movement
pointer.y = (-(event.offsetY / context.bounds.height) * 2 + 1) * 0.5 // Scale down vertical movement
```tsx
import { Vector2 } from "three"
import { Canvas } from "solid-three"
import { CursorRaycaster, pointerEvents } from "solid-three/events"

// Update the raycaster with the transformed coordinates
this.setFromCamera(pointer, context.camera)
// Damp pointer movement to half speed.
class DampedRaycaster extends CursorRaycaster {
setCursor(ndc: Vector2) {
super.setCursor(new Vector2(ndc.x * 0.5, ndc.y * 0.5))
}
}

// Usage
const App = () => {
const raycaster = new CustomRaycaster()

return <Canvas raycaster={raycaster}>{/* Your scene */}</Canvas>
}
const App = () => (
<Canvas plugins={[pointerEvents({ raycaster: new DampedRaycaster() })]}>{/* Your scene */}</Canvas>
)
```

For a non-screen ray — a custom origin and direction — implement `cast()` directly: aim `this.ray` from whatever transform you like, then return `this.intersectObjects(registry, true)`. `ControllerRaycaster` is the reference implementation.

### LoaderCache

`LoaderCache` is the default cache-manager for `useLoader`. It implements the `LoaderRegistry` interface and adds additional methods to simplify managing the cached resources.
Expand Down
Loading
Loading