Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ A `Context` method that runs a plugin's one-time, per-`Context` setup once (e.g.
### Events

**`event.object`**:
The closest hit — `event.intersections[0].object`. Stable for the whole dispatch; the 3D analogue of a DOM event's `target`.
The closest hit — `event.intersections[0]?.object`, or `undefined` on a **void**. Stable for the whole dispatch; the 3D analogue of a DOM event's `target`.

**`event.currentObject`**:
The **object** whose handler is firing as the event bubbles the hit chain; cleared after dispatch. The 3D analogue of `currentTarget`.
Expand All @@ -94,8 +94,9 @@ _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.
**Void**:
A gesture that hit no **object** — the ray missed everything. There's no dedicated handler: every gesture's canvas-level handler fires (unless `stopPropagation` halted it), with `event.object` `undefined` on a void and the hit **object** otherwise. The 3D counterpart to a DOM click landing on the page background. Read it as `if (!event.object)`.
_Avoid_: missed / `*Missed` (the removed per-object inversion).

**raycast propagation**:
The first dispatch phase — the handler fires on each hit **object** nearest-first along the ray.
Expand Down Expand Up @@ -161,7 +162,7 @@ WebXR (VR/AR) session management (`createXR` / `useXR`). _In flux_: being extern
- An **Object** joins its parent's scene graph (`.add()`); a non-object **Element** binds via **attach**
- A **Canvas** owns one **Context**, which owns one **renderer**
- 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 dispatch runs **raycast propagation** then **tree propagation**, then the canvas-level handler; `stopPropagation()` halts all of it. `event.object` is `event.intersections[0]?.object` — `undefined` on a **void**
- 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

Expand Down
6 changes: 4 additions & 2 deletions site/src/routes/api/components/canvas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ title: Canvas
| `style` | `JSX.CSSProperties` | — | CSS for the canvas container. |
| `class` | `string` | — | CSS class for the canvas container. |
| `ref` | `RefWithCleanup<Context>` | — | Receives the [`Context`](/api/hooks/use-three) once the renderer is created, so code outside `<Canvas>` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts; [`createXR`](/api/hooks/create-xr) uses this. |
| event handlers | `Partial<CanvasEventHandlers>` | — | Any [event handler](/api/events/overview), firing after the event bubbles through the whole scene (e.g. `onClick`, `onClickMissed`). |
| event handlers | `Partial<CanvasEventHandlers>` | — | Any [event handler](/api/events/overview), firing after the event bubbles through the whole scene (e.g. `onClick`, `onPointerDown`); check `event.object` to tell a hit from empty space. |

<details>
<summary>Exact type</summary>
Expand Down Expand Up @@ -65,7 +65,9 @@ interface CanvasProps extends ParentProps<Partial<CanvasEventHandlers>> {
camera={{ position: [0, 0, 5], fov: 75 }}
shadows="soft"
gl={{ antialias: true }}
onClickMissed={() => console.log("Clicked empty space")}
onClick={event => {
if (!event.object) console.log("Clicked empty space")
}}
>
{/* Your 3D scene */}
</Canvas>
Expand Down
64 changes: 20 additions & 44 deletions site/src/routes/api/events/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ title: Events overview

`solid-three` has its own pointer-event system, inspired by [`react-three-fiber`](https://github.com/pmndrs/react-three-fiber). Events are dispatched by raycasting against the scene, then propagate twice: along the ray (through every object the ray hit) and up the scene graph (from the hit object to its ancestors).

You attach handlers as props on any [`<T.*>`](/api/components/t) or [`<Entity/>`](/api/components/entity) component. The same handler names also work on [`<Canvas/>`](/api/components/canvas), where they fire after tree propagation finishes — handy for catching clicks that land on empty space (see [Missed events](#missed-events)).
You attach handlers as props on any [`<T.*>`](/api/components/t) or [`<Entity/>`](/api/components/entity) component. The same handler names also work on [`<Canvas/>`](/api/components/canvas), where they fire after tree propagation finishes — handy for catching clicks that land on empty space (see [Clicking empty space](#clicking-empty-space)).

## Supported events

### Click events (missable)
### Click events

These fire on the object that was hit, or — when nothing handles them — as a paired `*Missed` event:
These fire on the object that was hit, then on the [`<Canvas/>`](/api/components/canvas) — where `event.object` is `undefined` if the click hit nothing (see [Clicking empty space](#clicking-empty-space)):

- `onClick` / `onClickMissed`
- `onContextMenu` / `onContextMenuMissed` — right-click
- `onDoubleClick` / `onDoubleClickMissed`
- `onClick`
- `onContextMenu` — right-click
- `onDoubleClick`

### Hover events

Expand All @@ -38,7 +38,7 @@ Every handler receives one event argument that combines the original DOM event w
| `nativeEvent` | `MouseEvent \| PointerEvent \| WheelEvent` | always | The original DOM event that triggered the handler. |
| `intersections` | `Intersection[]` | events that raycast | All hit intersections, sorted nearest-first. |
| `intersection` | `Intersection` | events that raycast | Shorthand for `intersections[0]` — the closest hit overall. |
| `object` | `Object3D` | events that raycast | The closest hit object (`intersections[0].object`) — stable after dispatch, the 3D analogue of a DOM event's `target`. |
| `object` | `Object3D \| undefined` | events that raycast | The closest hit object (`intersections[0]?.object`), or `undefined` when the ray hit nothing — stable after dispatch, the 3D analogue of a DOM event's `target`. |
| `currentIntersection` | `Intersection` | inside an object handler | The intersection for the current handler's object. Absent on canvas-level dispatch. |
| `currentObject` | `Object3D` | inside an object handler | The object this handler is firing on; as the event bubbles it walks up the ancestor chain (while `currentIntersection` stays on the hit) and is cleared after dispatch — the 3D analogue of a DOM event's `currentTarget`. |
| `stopped` | `boolean` | stoppable events only | Whether `stopPropagation()` has been called. |
Expand All @@ -50,7 +50,6 @@ Not every handler receives every field — what you get depends on the event:
| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `onClick`, `onContextMenu`, `onDoubleClick`, the `*Move` / `*Down` / `*Up` handlers, `onWheel` | `nativeEvent`, the intersections, and `stopPropagation` |
| `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped |
| `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` | only `nativeEvent` — missed events don't raycast |

<details>
<summary>Exact type</summary>
Expand Down Expand Up @@ -154,54 +153,31 @@ Not every event accepts `stopPropagation()`. The split mirrors the DOM:

**Non-stoppable** — these always fire for every registered handler, regardless of order:

- `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` — a [missed event](#missed-events) is by definition the "nothing else handled it" signal.
- `onPointerEnter` — enter has to reach the newly-hovered subtree.
- `onPointerLeave` — leave has to reach the previously-hovered subtree.

## Missed events
## Clicking empty space

A `*Missed` variant fires on a handler-bearing object when the click, double-click, or context-menu did **not** reach it. Two cases:

1. **Clicked outside** — the ray missed the object and all its descendants.
2. **Blocked by `stopPropagation()`** — another object handled the event first and stopped it.

```tsx
<T.Mesh onClickMissed={() => console.log("Missed — clicked outside this mesh")}>
<T.BoxGeometry args={[1, 1, 1]} />
<T.MeshBasicMaterial color="blue" />
</T.Mesh>
```
Every gesture handler also works on the [`<Canvas/>`](/api/components/canvas) itself, and there `event.object` is the object the ray hit — or `undefined` when it hit nothing. One canvas handler covers both: read `event.object` to tell a real hit from a click on empty space. There's no separate "missed" handler.

```tsx
<T.Group onClickMissed={() => console.log("Group missed — child stopped propagation")}>
<T.Mesh
onClick={event => {
event.stopPropagation()
console.log("Child clicked")
}}
>
<T.BoxGeometry args={[1, 1, 1]} />
<T.MeshBasicMaterial color="red" />
</T.Mesh>
</T.Group>
```

Common uses: deselecting on a background click, blocking interaction with objects behind a UI layer, or telling a parent container that a child intercepted the event.

### Missed events on `<Canvas>`

The same handlers work on [`<Canvas/>`](/api/components/canvas), where they fire when **no object handler at all** consumed the ray:

```tsx
<Canvas onClickMissed={() => deselect()}>
<Canvas
onClick={event => {
if (!event.object) deselect()
}}
>
<T.Mesh onClick={() => select()}>
<T.BoxGeometry />
<T.MeshBasicMaterial />
</T.Mesh>
</Canvas>
```

Use this for canvas-wide "click on empty space" logic without attaching a sentinel mesh.
The mesh's `onClick` fires as the click bubbles; the canvas's runs last and deselects only when `event.object` is empty — the canvas-wide "click on empty space" pattern, with no sentinel mesh.

Every discrete gesture behaves this way: `onPointerDown`, `onPointerUp`, `onPointerMove`, `onWheel`, and the click family all reach the canvas with `event.object` either set or `undefined`. `onPointerEnter` and `onPointerLeave` are the exception — they track the canvas boundary, not a per-event hit, so there's no hit-or-empty distinction to read there.

A handler that calls `stopPropagation()` ends the dispatch before it reaches the canvas, so a consumed event arrives as neither a hit nor an empty click.

## Hover events

Expand Down Expand Up @@ -289,4 +265,4 @@ To keep an object from being hit while it still receives events that bubble up f

- [Pointer events](/tour/04-pointer-events) — the tutorial chapter, with a worked walk-through.
- [`raycastable`](/api/events/raycastable) — opt an object out of hit-testing while keeping bubbled events.
- [`<Canvas/>`](/api/components/canvas) — where canvas-level handlers and `onClickMissed` fire.
- [`<Canvas/>`](/api/components/canvas) — where canvas-level handlers fire, with `event.object` empty on a void.
14 changes: 8 additions & 6 deletions site/src/routes/tour/04-pointer-events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import clickSnippet from "../../snippets/04-click.tsx?raw"
import clickUrl from "../../snippets/04-click.tsx?importChunkUrl"
import hoverSnippet from "../../snippets/04-hover.tsx?raw"
import hoverUrl from "../../snippets/04-hover.tsx?importChunkUrl"
import clickMissedSnippet from "../../snippets/04-click-missed.tsx?raw"
import clickMissedUrl from "../../snippets/04-click-missed.tsx?importChunkUrl"
import voidClickSnippet from "../../snippets/04-void-click.tsx?raw"
import voidClickUrl from "../../snippets/04-void-click.tsx?importChunkUrl"
import stopPropagationSnippet from "../../snippets/04-stop-propagation.tsx?raw"
import stopPropagationUrl from "../../snippets/04-stop-propagation.tsx?importChunkUrl"
import dragSnippet from "../../snippets/04-drag.tsx?raw"
Expand Down Expand Up @@ -45,13 +45,15 @@ flips; `scale` reads it; one property assignment runs.

Sometimes the interesting event is when the user clicks but _doesn't_ hit any
mesh — to deselect the current selection, dismiss a tooltip, that sort of
thing. [`<Canvas>`](/api/components/canvas) exposes `onClickMissed` for exactly that case:
thing. Put an `onClick` on the [`<Canvas>`](/api/components/canvas) itself: it
hears every click once the scene has had its turn, and `event.object` is the
mesh that was hit — or `undefined` when the click landed on empty space.

<Demo code={clickMissedSnippet} url={clickMissedUrl} />
<Demo code={voidClickSnippet} url={voidClickUrl} />

Click the cube to select it; click anywhere else in the canvas to deselect.
The two handlers cover the full "did you hit something?" question between
them.
The cube's own `onClick` selects; the canvas `onClick` checks `event.object`
and deselects only when it's empty.

That covers the everyday cases. There's a
[longer list of pointer events](/api/events/overview)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ export default () => {
const [selected, setSelected] = createSignal(false)

return (
<Canvas camera={{ position: [0, 0, 3] }} onClickMissed={() => setSelected(false)}>
<Canvas
camera={{ position: [0, 0, 3] }}
onClick={event => {
if (!event.object) setSelected(false)
}}
>
<T.Mesh onClick={() => setSelected(true)}>
<T.BoxGeometry />
<T.MeshStandardMaterial color={selected() ? "tomato" : "cornflowerblue"} />
Expand Down
64 changes: 18 additions & 46 deletions src/pointers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@ export type DispatchEvent<TExtra extends object = {}> = {

/**
* The slice of an `EventRaycaster` a `Pointer` needs: cast its current ray against
* a registry, (for the click-missed phase) re-cast a single object, and — for
* pointer capture — `aim` the live `ray` without casting (to reproject onto the
* captured object's plane). The real `EventRaycaster` (which extends three's
* `Raycaster`) satisfies this structurally.
* a registry, and — for pointer capture — `aim` the live `ray` without casting (to
* reproject onto the captured object's plane). The real `EventRaycaster` (which
* extends three's `Raycaster`) satisfies this structurally.
*/
export type PointerRaycaster = {
cast(registry: Object3D[], context: Context): Intersection<Meta<Object3D>>[]
intersectObject(object: Object3D, recursive?: boolean): Intersection[]
aim(context: Context): void
ray: Ray
}
Expand Down Expand Up @@ -379,52 +377,26 @@ export class Pointer {
)
}

/** Missable gesture: bubbled `onClick`/`onDoubleClick`/`onContextMenu` + `-Missed`. */
/**
* Bubbled `onClick`/`onDoubleClick`/`onContextMenu`, then the canvas-level
* handler — the same {@link propagate} path as `onPointerDown`/`onWheel`/etc.
* The canvas always hears the gesture (unless a handler stopped it);
* `event.object` is `undefined` when the ray hit nothing — the void.
*/
click(kind: "onClick" | "onDoubleClick" | "onContextMenu", nativeEvent: Event) {
const missedType = `${kind}Missed` as const
const registry = this.context.eventRegistry
const props = this.context.props as Record<string, any>
if (registry.length === 0 && !props[kind] && !props[missedType]) return
if (registry.length === 0 && !props[kind]) return

const missed = new Set<Object3D>(registry)
const visited = new Set<Object3D>()
const intersections = this.raycaster.cast(registry, this.context)
const event = createThreeEvent(nativeEvent, { intersections })

// Phase #1 — fire the handler, bubbling down the hit chain.
for (const intersection of intersections) {
event.currentIntersection = intersection
let node: Object3D | null = intersection.object
while (node && !event.stopped && !visited.has(node)) {
missed.delete(node)
visited.add(node)
event.currentObject = node
;(getMeta(node)?.props as any)?.[kind]?.(event)
node = node.parent
}
}
if (!event.stopped) {
delete event.currentIntersection
event.currentObject = undefined
props[kind]?.(event)
}

// Phase #2 — re-raycast remaining objects to mark any genuinely under the ray as hit.
for (const remaining of missed) {
const hits = this.raycaster.intersectObject(remaining, true)
for (const { object } of hits) {
let node: Object3D | null = object
while (node && !visited.has(node)) {
missed.delete(node)
visited.add(node)
node = node.parent
}
}
}

// Phase #3 — fire `-Missed` on the truly-missed objects, and canvas-level on a total miss.
const missedEvent = createThreeEvent(nativeEvent, { stoppable: false })
for (const object of missed) (getMeta(object)?.props as any)?.[missedType]?.(missedEvent)
if (intersections.length === 0) props[missedType]?.(missedEvent)
this.propagate(
event,
kind,
intersections.map((intersection): [Intersection, Object3D] => [
intersection,
intersection.object,
]),
)
}
}
Loading
Loading