Skip to content

Commit 220c2be

Browse files
authored
docs: document plugins + sync #66/#67 docs; fix contributed-prop override typing (#68)
## Summary The #66 events rewrite and the #67 plugin system both shipped without their doc updates — the site was describing an API that no longer exists and a feature that was never documented. This clears that debt, adds an interactive plugins tour chapter, and fixes one plugin-typing bug the demo surfaced. ## Stale docs corrected (#66) - **`events/overview`** — drops the `onMouse*` family (removed in #66; only `onPointer*` / `onClick` / `onWheel` remain) from the supported, stoppable, and non-stoppable lists and the event-applicability table. - **`utilities/raycasters`** — the page documented `EventRaycaster.update(event, context)`, which #66 deleted. Rewritten around the real `cast(registry, context)` interface, with a corrected custom-raycaster example and new sections for `ScreenRaycaster` and `ControllerRaycaster`. ## Plugins documented (#67) - **`utilities/plugin`** — new API page: what a plugin is, the three `plugin()` forms (global / class-filtered / type-guard), registration via `createT` and `<Entity plugins>`, and the typed contributed-prop guarantee. - **Tour chapter 09 "Plugins"** — builds a `lookAt` plugin from the "props set properties, they don't call methods" motivation, ending in a live demo: a field of cones that turn to face the pointer. Inserted before the WebGPU peek (renumbered 09 → 10 so the peek stays the finale); chapter 08's close now hands off to it. ## Library fix: contributed props override native members Writing the `lookAt` demo surfaced a bug in the #67 plugin typing. A plugin intercepts its prop at runtime (`applyProp` checks `pluginMethods` first), so a contributed prop should be able to reuse a native member's name — e.g. `lookAt` driving `Object3D.lookAt`. But `Props` *intersected* the contributed type with the base, producing `nativeMethod & V`, which no value satisfies, so `<T.Mesh lookAt={vec} />` failed to type-check even though it worked at runtime. `Props` now drops the contributed keys from `BaseProps` before adding them, so the contributed type *replaces* the native one — matching the runtime. Guarded so the common no-plugins case is untouched (the loose default `readonly Plugin[]` has `length: number`; only a real inferred tuple contributes override keys). Regression test added. This is what lets the tour demo use the natural `lookAt` prop name. ## Test plan - [x] `pnpm build` + `pnpm lint` (circular + eslint + types) green. - [x] Browser suite green, incl. a new test that a `Vector3` is assignable to a contributed `lookAt` prop. - [x] lookAt demo verified in-browser — cones point at the cursor and track it. - [x] Dev server: no more mid-session re-optimize / reload after the `optimizeDeps.include` change.
1 parent 374450d commit 220c2be

13 files changed

Lines changed: 300 additions & 50 deletions

File tree

site/src/routes/api/events/overview.mdx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,10 @@ These fire on the object that was hit, or — when nothing handles them — as a
2222

2323
Run in three phases per pointer move — enter, then move, then leave. See [Hover events](#hover-events).
2424

25-
- `onMouseEnter`, `onMouseMove`, `onMouseLeave`
2625
- `onPointerEnter`, `onPointerMove`, `onPointerLeave`
2726

2827
### Press and wheel events
2928

30-
- `onMouseDown`, `onMouseUp`
3129
- `onPointerDown`, `onPointerUp`
3230
- `onWheel` — registered as a passive listener
3331

@@ -49,7 +47,7 @@ Not every handler receives every field — what you get depends on the event:
4947
| Handler | Receives |
5048
| --- | --- |
5149
| `onClick`, `onContextMenu`, `onDoubleClick`, the `*Move` / `*Down` / `*Up` handlers, `onWheel` | `nativeEvent`, the intersections, and `stopPropagation` |
52-
| `onMouseEnter`, `onMouseLeave`, `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped |
50+
| `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped |
5351
| `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` | only `nativeEvent` — missed events don't raycast |
5452

5553
<details>
@@ -143,13 +141,13 @@ Without the `stopPropagation()` call the order would be front mesh → back mesh
143141

144142
Not every event accepts `stopPropagation()`. The split mirrors the DOM:
145143

146-
**Stoppable**`onClick`, `onContextMenu`, `onDoubleClick`, `onMouseDown`, `onMouseUp`, `onMouseMove`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onWheel`.
144+
**Stoppable**`onClick`, `onContextMenu`, `onDoubleClick`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onWheel`.
147145

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

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

154152
## Missed events
155153

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
title: Plugins
3+
---
4+
5+
# Plugins
6+
7+
A plugin teaches `solid-three` new **props**. It matches some set of elements and contributes methods to them; each contributed method surfaces as a typed prop, and assigning that prop calls the method. This is how features like XR controller events are layered onto the core without baking them in.
8+
9+
## `plugin()`
10+
11+
`plugin()` builds one plugin. It has three forms, differing only in which elements they match:
12+
13+
```tsx
14+
import { Mesh } from "three"
15+
import { plugin } from "solid-three"
16+
17+
// 1. Global — every element.
18+
plugin(element => ({ ping: () => {} }))
19+
20+
// 2. Constructor-filtered — elements that are `instanceof` one of these.
21+
plugin([Mesh], mesh => ({ shake: (intensity: number) => {} }))
22+
23+
// 3. Type-guard — elements that pass the guard.
24+
plugin((element): element is Mesh => element instanceof Mesh, mesh => ({ setColor: (hex: string) => {} }))
25+
```
26+
27+
The factory receives the matched element and returns an object of methods. A non-matching element contributes nothing. A method's **first parameter type becomes the prop's type**`shake: (intensity: number) => …` makes `shake` a `number` prop.
28+
29+
## Registering plugins
30+
31+
Two ways, depending on the scope you want.
32+
33+
**Whole renderer — pass them to `createT`:**
34+
35+
```tsx
36+
import * as THREE from "three"
37+
import { Canvas, createT, plugin } from "solid-three"
38+
39+
const T = createT(THREE, [
40+
// Every Mesh gains a `shake` prop.
41+
plugin([THREE.Mesh], mesh => ({
42+
shake: (intensity: number) => {
43+
mesh.position.x += (Math.random() - 0.5) * intensity
44+
},
45+
})),
46+
])
47+
48+
const App = () => (
49+
<Canvas>
50+
{/* `shake` is a typed prop now — pass a number, the method runs. */}
51+
<T.Mesh shake={0.2}>
52+
<T.BoxGeometry />
53+
<T.MeshStandardMaterial />
54+
</T.Mesh>
55+
</Canvas>
56+
)
57+
```
58+
59+
**One element — pass them to `<Entity plugins>`:**
60+
61+
```tsx
62+
import { Mesh } from "three"
63+
import { Entity, plugin } from "solid-three"
64+
65+
const App = () => (
66+
<Entity from={Mesh} plugins={[plugin([Mesh], mesh => ({ shake: (i: number) => {} }))]} shake={0.2} />
67+
)
68+
```
69+
70+
Either way the contributed prop is **type-checked**: a contributed method makes its prop available, and a prop that no plugin contributes is rejected by the type-checker. The method is invoked with the prop value; it is not assigned onto the three.js instance.
71+
72+
## See also
73+
74+
- [`T` / `createT`](/api/components/t) — the element factory plugins extend.
75+
- [`Entity`](/api/components/entity) — per-element plugin registration.

site/src/routes/api/utilities/raycasters.mdx

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,76 +4,85 @@ title: Raycasters
44

55
# Raycasters
66

7-
`solid-three` ships custom raycasters that track the pointer for you. They all extend `THREE.Raycaster` and implement the `EventRaycaster` interface, which adds a single method:
7+
`solid-three` ships custom raycasters that decide how a pointer becomes a ray. They all extend `THREE.Raycaster` and implement `EventRaycaster`, which adds one method:
88

99
| Method | Description |
1010
| --- | --- |
11-
| `update(event, context)` | Called automatically before intersection testing, to position the raycaster from the current event. |
11+
| `cast(registry, context)` | Aim the ray for the current pointer, then return its hits against `registry` and its descendants (honoring `raycastable !== false`), nearest-first. |
12+
13+
The pointer system calls `cast()` on every event. How the ray is aimed is the raycaster's own business — from the camera and a cursor position for screen pointers, or from an object's world transform for an XR controller.
14+
15+
Screen-pointer raycasters also implement `ScreenRaycaster`, which adds `setCursor(ndc)`. The pointer system calls it with the cursor in normalized device coordinates before each `cast()`.
1216

1317
<details>
14-
<summary>Exact type</summary>
18+
<summary>Exact types</summary>
1519

1620
```tsx
1721
interface EventRaycaster extends THREE.Raycaster {
18-
update(event: PointerEvent | MouseEvent | WheelEvent, context: Context): void
22+
cast(registry: Object3D[], context: Context): Intersection[]
23+
}
24+
25+
interface ScreenRaycaster extends EventRaycaster {
26+
setCursor(ndc: Vector2): void
1927
}
2028
```
2129

2230
</details>
2331

24-
`solid-three`'s [event system](/api/events/overview) calls `update()` before every intersection test — on mouse events (`click`, `mousedown`, `mouseup`, `mousemove`, `contextmenu`, `dblclick`), pointer events (`pointerdown`, `pointerup`, `pointermove`), and `wheel` — so the raycaster is positioned correctly for accurate hit detection.
25-
2632
## CursorRaycaster
2733

28-
The default raycaster; tracks the cursor position.
34+
The default. Tracks the cursor and casts from the active camera.
2935

3036
```tsx
3137
import { Canvas, CursorRaycaster } from "solid-three"
3238

3339
const App = () => {
34-
const raycaster = new CursorRaycaster()
35-
// CursorRaycaster is the default; set it explicitly if you like:
36-
return <Canvas raycaster={raycaster}>{/* Your scene */}</Canvas>
40+
// CursorRaycaster is the default; pass it explicitly only to swap or configure.
41+
return <Canvas raycaster={new CursorRaycaster()}>{/* Your scene */}</Canvas>
3742
}
3843
```
3944

4045
## CenterRaycaster
4146

42-
Always casts from the center of the screen.
47+
Ignores the cursor and always casts from the centre of the screen — useful for gaze or crosshair interaction.
4348

4449
```tsx
4550
import { Canvas, CenterRaycaster } from "solid-three"
4651

47-
const App = () => {
48-
const raycaster = new CenterRaycaster()
49-
return <Canvas raycaster={raycaster}>{/* Your scene */}</Canvas>
50-
}
52+
const App = () => <Canvas raycaster={new CenterRaycaster()}>{/* Your scene */}</Canvas>
5153
```
5254

53-
## Creating your own raycaster
55+
## ControllerRaycaster
5456

55-
Extend `THREE.Raycaster` and implement `EventRaycaster`:
57+
Casts from an `Object3D`'s world transform — origin at its world position, direction along its local −Z. This is the ray strategy for an XR controller; see [`createXR`](/api/hooks/create-xr).
5658

5759
```tsx
58-
import { Raycaster, Vector2 } from "three"
59-
import type { EventRaycaster, Context } from "solid-three"
60+
import { ControllerRaycaster } from "solid-three"
6061

61-
class CustomRaycaster extends Raycaster implements EventRaycaster {
62-
update(event: PointerEvent | MouseEvent | WheelEvent, context: Context) {
63-
const pointer = new Vector2()
62+
const raycaster = new ControllerRaycaster(controllerSpace)
63+
```
64+
65+
## Creating your own
66+
67+
For a screen pointer, the simplest path is to subclass `CursorRaycaster` and reshape the cursor — `cast()` is inherited:
6468

65-
// Scale movement down, as an example transform
66-
pointer.x = ((event.offsetX / context.bounds.width) * 2 - 1) * 0.5
67-
pointer.y = (-(event.offsetY / context.bounds.height) * 2 + 1) * 0.5
69+
```tsx
70+
import { Vector2 } from "three"
71+
import { Canvas, CursorRaycaster } from "solid-three"
6872

69-
this.setFromCamera(pointer, context.camera)
73+
// Damp pointer movement to half speed.
74+
class DampedRaycaster extends CursorRaycaster {
75+
setCursor(ndc: Vector2) {
76+
super.setCursor(new Vector2(ndc.x * 0.5, ndc.y * 0.5))
7077
}
7178
}
7279

73-
const App = () => <Canvas raycaster={new CustomRaycaster()}>{/* Your scene */}</Canvas>
80+
const App = () => <Canvas raycaster={new DampedRaycaster()}>{/* Your scene */}</Canvas>
7481
```
7582

83+
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.
84+
7685
## See also
7786

78-
- [Events overview](/api/events/overview)when and why `update()` is called.
87+
- [Events overview](/api/events/overview)how and when `cast()` is called.
7988
- [`useThree`](/api/hooks/use-three)`raycaster` / `setRaycaster` for swapping the active raycaster at runtime.

site/src/routes/tour/08-tetris.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,5 @@ this one: signals and stores describing state, JSX describing the scene,
8282
`<T.*>` keeping the scene in sync with state. The
8383
[API reference](/api/types) is there when you need a specific export.
8484

85-
If you'd like a peek at where the library is heading next, the encore is
86-
WebGPU.
85+
There's one more kind of power worth knowing: when a prop you want isn't a
86+
property, you can add it yourself. Next up — plugins.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
title: Plugins
3+
---
4+
5+
import lookAtSnippet from "../../snippets/09-look-at.tsx?raw"
6+
import lookAtUrl from "../../snippets/09-look-at.tsx?importChunkUrl"
7+
8+
# Plugins
9+
10+
Every prop you've set so far has been just that — a *property*. `position`,
11+
`color`, `intensity`: solid-three takes the value and assigns it to the
12+
three.js object. That covers most of what a scene needs, but not all of it.
13+
14+
Some of the things you'd reach for aren't properties — they're *methods*.
15+
three's `Object3D` has a `lookAt(target)` method that turns an object to
16+
face a point in space. You'd love to write `<T.Mesh lookAt={somePoint} />`
17+
and have it just work. But it won't: props get *assigned*, so solid-three
18+
would overwrite `mesh.lookAt` with your point and the method would be gone.
19+
Props set properties; they don't call methods.
20+
21+
A **plugin** closes that gap. It teaches solid-three a new prop — one that
22+
runs a function instead of assigning a value.
23+
24+
## Writing the plugin
25+
26+
```tsx
27+
import { plugin } from "solid-three"
28+
29+
const lookAt = plugin([THREE.Object3D], object => ({
30+
lookAt: (target: THREE.Vector3) => object.lookAt(target),
31+
}))
32+
```
33+
34+
That's the whole thing. `plugin([THREE.Object3D], …)` matches every element
35+
that's an `Object3D`, and for each one contributes a `lookAt` method. Now
36+
when you set a `lookAt` prop, solid-three calls this method with the value
37+
instead of assigning it — so `<T.Mesh lookAt={point} />` runs
38+
`mesh.lookAt(point)`, exactly what we wanted.
39+
40+
## Registering it
41+
42+
`createT` takes the plugins as its second argument. The `T` it hands back
43+
understands the props they add:
44+
45+
```tsx
46+
const T = createT(THREE, [lookAt])
47+
```
48+
49+
## Seeing it work
50+
51+
Move the pointer over the field below — every cone turns to face it.
52+
53+
<Demo code={lookAtSnippet} url={lookAtUrl} />
54+
55+
Each cone is nothing more than `<T.Mesh lookAt={target()} />`. A wide,
56+
invisible backdrop reports the pointer's position with the `onPointerMove`
57+
and `intersection.point` from the [pointer chapter](/tour/04-pointer-events),
58+
and that point flows into every cone's `lookAt` prop. When the signal
59+
changes, the prop re-applies and the plugin calls `lookAt` again — the same
60+
reactivity that drives every other prop in the tour, now driving a method.
61+
62+
## There's more
63+
64+
`lookAt` is the simplest shape. Plugins can also match by a type-guard
65+
instead of a class, register on a single element with `<Entity plugins={…} />`
66+
instead of the whole renderer, and the props they contribute are fully
67+
typed — pass a wrong one and the type-checker stops you. The
68+
[Plugins reference](/api/utilities/plugin) covers the rest.
69+
70+
That's the extension seam: when a prop you want isn't a property, a plugin
71+
makes it one. For the encore, a peek at where three.js — and this renderer —
72+
are heading: WebGPU.
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
title: A peek at WebGPU
33
---
44

5-
import webgpuSimple from "../../snippets/09-webgpu-simple.tsx?raw"
6-
import webgpuSimpleUrl from "../../snippets/09-webgpu-simple.tsx?importChunkUrl"
7-
import webgpuTsl from "../../snippets/09-webgpu-tsl.tsx?raw"
8-
import webgpuTslUrl from "../../snippets/09-webgpu-tsl.tsx?importChunkUrl"
9-
import webgpuTslUniform from "../../snippets/09-webgpu-tsl-uniform.tsx?raw"
10-
import webgpuTslUniformUrl from "../../snippets/09-webgpu-tsl-uniform.tsx?importChunkUrl"
5+
import webgpuSimple from "../../snippets/10-webgpu-simple.tsx?raw"
6+
import webgpuSimpleUrl from "../../snippets/10-webgpu-simple.tsx?importChunkUrl"
7+
import webgpuTsl from "../../snippets/10-webgpu-tsl.tsx?raw"
8+
import webgpuTslUrl from "../../snippets/10-webgpu-tsl.tsx?importChunkUrl"
9+
import webgpuTslUniform from "../../snippets/10-webgpu-tsl-uniform.tsx?raw"
10+
import webgpuTslUniformUrl from "../../snippets/10-webgpu-tsl-uniform.tsx?importChunkUrl"
1111

1212
# A peek at WebGPU
1313

site/src/snippets/09-look-at.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createSignal, For } from "solid-js"
2+
import { Canvas, createT, plugin } from "solid-three"
3+
import * as THREE from "three"
4+
5+
// A plugin: every Object3D gains a `lookAt` prop that calls three's `lookAt()`.
6+
const lookAt = plugin([THREE.Object3D], object => ({
7+
lookAt: (target: THREE.Vector3) => object.lookAt(target),
8+
}))
9+
10+
const T = createT(THREE, [lookAt])
11+
12+
// One cone geometry, rotated so its tip points along +Z — a Mesh's lookAt()
13+
// aims +Z at the target (cameras/lights aim -Z; meshes are the opposite).
14+
const cone = new THREE.ConeGeometry(0.18, 0.7, 24)
15+
cone.rotateX(Math.PI / 2)
16+
17+
// A 5×5 grid of cone positions in the XY-plane.
18+
const positions: [number, number, number][] = []
19+
for (let x = -2; x <= 2; x++) for (let y = -2; y <= 2; y++) positions.push([x, y, 0])
20+
21+
export default () => {
22+
const [target, setTarget] = createSignal(new THREE.Vector3())
23+
24+
return (
25+
<Canvas camera={{ position: [0, 0, 8] }}>
26+
{/* An invisible backdrop catches the pointer; the cones opt out of
27+
raycasting (`raycastable={false}`) so the ray always reaches it. */}
28+
<T.Mesh
29+
position={[0, 0, -0.5]}
30+
onPointerMove={event => setTarget(event.intersection.point.clone().setZ(0))}
31+
>
32+
<T.PlaneGeometry args={[24, 24]} />
33+
<T.MeshBasicMaterial visible={false} />
34+
</T.Mesh>
35+
36+
<For each={positions}>
37+
{position => (
38+
<T.Mesh geometry={cone} position={position} raycastable={false} lookAt={target()}>
39+
<T.MeshStandardMaterial color="cornflowerblue" />
40+
</T.Mesh>
41+
)}
42+
</For>
43+
44+
<T.AmbientLight intensity={0.5} />
45+
<T.DirectionalLight position={[2, 2, 4]} />
46+
</Canvas>
47+
)
48+
}
File renamed without changes.

0 commit comments

Comments
 (0)