From ef4d5f544ba3b232553bd42b7fd4fbdd0999aa16 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 18:23:13 +0200 Subject: [PATCH 1/5] docs(events): correct event + raycaster docs for the #66 rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source-agnostic pointer rewrite (#66) shipped without updating the docs, leaving two pages describing an API that no longer exists: - events/overview: drop 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: `EventRaycaster` adds `cast(registry, context)`, not the deleted `update(event, context)`. Rewrite the interface, the custom-raycaster example (subclass `CursorRaycaster`/override `setCursor`), and document `ScreenRaycaster` and `ControllerRaycaster`. --- site/src/routes/api/events/overview.mdx | 10 ++- site/src/routes/api/utilities/raycasters.mdx | 65 +++++++++++--------- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index e9524347..8e188fa1 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -22,12 +22,10 @@ These fire on the object that was hit, or — when nothing handles them — as a Run in three phases per pointer move — enter, then move, then leave. See [Hover events](#hover-events). -- `onMouseEnter`, `onMouseMove`, `onMouseLeave` - `onPointerEnter`, `onPointerMove`, `onPointerLeave` ### Press and wheel events -- `onMouseDown`, `onMouseUp` - `onPointerDown`, `onPointerUp` - `onWheel` — registered as a passive listener @@ -49,7 +47,7 @@ Not every handler receives every field — what you get depends on the event: | Handler | Receives | | --- | --- | | `onClick`, `onContextMenu`, `onDoubleClick`, the `*Move` / `*Down` / `*Up` handlers, `onWheel` | `nativeEvent`, the intersections, and `stopPropagation` | -| `onMouseEnter`, `onMouseLeave`, `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped | +| `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped | | `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` | only `nativeEvent` — missed events don't raycast |
@@ -143,13 +141,13 @@ Without the `stopPropagation()` call the order would be front mesh → back mesh Not every event accepts `stopPropagation()`. The split mirrors the DOM: -**Stoppable** — `onClick`, `onContextMenu`, `onDoubleClick`, `onMouseDown`, `onMouseUp`, `onMouseMove`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onWheel`. +**Stoppable** — `onClick`, `onContextMenu`, `onDoubleClick`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onWheel`. **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. -- `onMouseEnter`, `onPointerEnter` — enter has to reach the newly-hovered subtree. -- `onMouseLeave`, `onPointerLeave` — leave has to reach the previously-hovered subtree. +- `onPointerEnter` — enter has to reach the newly-hovered subtree. +- `onPointerLeave` — leave has to reach the previously-hovered subtree. ## Missed events diff --git a/site/src/routes/api/utilities/raycasters.mdx b/site/src/routes/api/utilities/raycasters.mdx index a4bc6348..af4ec36a 100644 --- a/site/src/routes/api/utilities/raycasters.mdx +++ b/site/src/routes/api/utilities/raycasters.mdx @@ -4,76 +4,85 @@ title: Raycasters # Raycasters -`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: +`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: | Method | Description | | --- | --- | -| `update(event, context)` | Called automatically before intersection testing, to position the raycaster from the current event. | +| `cast(registry, context)` | Aim the ray for the current pointer, then return its hits against `registry` and its descendants (honoring `raycastable !== false`), nearest-first. | + +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. + +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()`.
-Exact type +Exact types ```tsx interface EventRaycaster extends THREE.Raycaster { - update(event: PointerEvent | MouseEvent | WheelEvent, context: Context): void + cast(registry: Object3D[], context: Context): Intersection[] +} + +interface ScreenRaycaster extends EventRaycaster { + setCursor(ndc: Vector2): void } ```
-`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. - ## CursorRaycaster -The default raycaster; tracks the cursor position. +The default. Tracks the cursor and casts from the active camera. ```tsx import { Canvas, CursorRaycaster } from "solid-three" const App = () => { - const raycaster = new CursorRaycaster() - // CursorRaycaster is the default; set it explicitly if you like: - return {/* Your scene */} + // CursorRaycaster is the default; pass it explicitly only to swap or configure. + return {/* Your scene */} } ``` ## CenterRaycaster -Always casts from the center of the screen. +Ignores the cursor and always casts from the centre of the screen — useful for gaze or crosshair interaction. ```tsx import { Canvas, CenterRaycaster } from "solid-three" -const App = () => { - const raycaster = new CenterRaycaster() - return {/* Your scene */} -} +const App = () => {/* Your scene */} ``` -## Creating your own raycaster +## ControllerRaycaster -Extend `THREE.Raycaster` and implement `EventRaycaster`: +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). ```tsx -import { Raycaster, Vector2 } from "three" -import type { EventRaycaster, Context } from "solid-three" +import { ControllerRaycaster } from "solid-three" -class CustomRaycaster extends Raycaster implements EventRaycaster { - update(event: PointerEvent | MouseEvent | WheelEvent, context: Context) { - const pointer = new Vector2() +const raycaster = new ControllerRaycaster(controllerSpace) +``` + +## Creating your own + +For a screen pointer, the simplest path is to subclass `CursorRaycaster` and reshape the cursor — `cast()` is inherited: - // Scale movement down, as an example transform - pointer.x = ((event.offsetX / context.bounds.width) * 2 - 1) * 0.5 - pointer.y = (-(event.offsetY / context.bounds.height) * 2 + 1) * 0.5 +```tsx +import { Vector2 } from "three" +import { Canvas, CursorRaycaster } from "solid-three" - 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)) } } -const App = () => {/* Your scene */} +const App = () => {/* Your scene */} ``` +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. + ## See also -- [Events overview](/api/events/overview) — when and why `update()` is called. +- [Events overview](/api/events/overview) — how and when `cast()` is called. - [`useThree`](/api/hooks/use-three) — `raycaster` / `setRaycaster` for swapping the active raycaster at runtime. From 2b74b75707277bdb3162c4c6099ab10947d2530d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 18:50:45 +0200 Subject: [PATCH 2/5] =?UTF-8?q?docs(plugins):=20document=20the=20plugin=20?= =?UTF-8?q?system=20=E2=80=94=20API=20page=20+=20interactive=20tour=20chap?= =?UTF-8?q?ter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #67 shipped the plugin system without docs. Add both halves: - API reference (utilities/plugin): what a plugin is, the three plugin() forms, registration via createT and , and the typed contributed-prop guarantee. - Tour chapter 09 "Plugins": builds a lookAt plugin from the "props set properties, not call methods" motivation, ending in a live demo — a field of cones that turn to face the pointer (verified in-browser). Insert it before the WebGPU peek (renumbered 09 -> 10 so the peek stays the finale) and retarget chapter 08's closing handoff. --- site/src/routes/api/utilities/plugin.mdx | 75 +++++++++++++++++++ site/src/routes/tour/08-tetris.mdx | 4 +- site/src/routes/tour/09-plugins.mdx | 72 ++++++++++++++++++ ...{09-webgpu-peek.mdx => 10-webgpu-peek.mdx} | 12 +-- site/src/snippets/09-look-at.tsx | 47 ++++++++++++ ...webgpu-simple.tsx => 10-webgpu-simple.tsx} | 0 ...-uniform.tsx => 10-webgpu-tsl-uniform.tsx} | 0 .../{09-webgpu-tsl.tsx => 10-webgpu-tsl.tsx} | 0 site/vite.config.ts | 4 +- 9 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 site/src/routes/api/utilities/plugin.mdx create mode 100644 site/src/routes/tour/09-plugins.mdx rename site/src/routes/tour/{09-webgpu-peek.mdx => 10-webgpu-peek.mdx} (90%) create mode 100644 site/src/snippets/09-look-at.tsx rename site/src/snippets/{09-webgpu-simple.tsx => 10-webgpu-simple.tsx} (100%) rename site/src/snippets/{09-webgpu-tsl-uniform.tsx => 10-webgpu-tsl-uniform.tsx} (100%) rename site/src/snippets/{09-webgpu-tsl.tsx => 10-webgpu-tsl.tsx} (100%) diff --git a/site/src/routes/api/utilities/plugin.mdx b/site/src/routes/api/utilities/plugin.mdx new file mode 100644 index 00000000..85142b44 --- /dev/null +++ b/site/src/routes/api/utilities/plugin.mdx @@ -0,0 +1,75 @@ +--- +title: Plugins +--- + +# Plugins + +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. + +## `plugin()` + +`plugin()` builds one plugin. It has three forms, differing only in which elements they match: + +```tsx +import { Mesh } from "three" +import { plugin } from "solid-three" + +// 1. Global — every element. +plugin(element => ({ ping: () => {} })) + +// 2. Constructor-filtered — elements that are `instanceof` one of these. +plugin([Mesh], mesh => ({ shake: (intensity: number) => {} })) + +// 3. Type-guard — elements that pass the guard. +plugin((element): element is Mesh => element instanceof Mesh, mesh => ({ setColor: (hex: string) => {} })) +``` + +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. + +## Registering plugins + +Two ways, depending on the scope you want. + +**Whole renderer — pass them to `createT`:** + +```tsx +import * as THREE from "three" +import { Canvas, createT, plugin } from "solid-three" + +const T = createT(THREE, [ + // Every Mesh gains a `shake` prop. + plugin([THREE.Mesh], mesh => ({ + shake: (intensity: number) => { + mesh.position.x += (Math.random() - 0.5) * intensity + }, + })), +]) + +const App = () => ( + + {/* `shake` is a typed prop now — pass a number, the method runs. */} + + + + + +) +``` + +**One element — pass them to ``:** + +```tsx +import { Mesh } from "three" +import { Entity, plugin } from "solid-three" + +const App = () => ( + ({ shake: (i: number) => {} }))]} shake={0.2} /> +) +``` + +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. + +## See also + +- [`T` / `createT`](/api/components/t) — the element factory plugins extend. +- [`Entity`](/api/components/entity) — per-element plugin registration. diff --git a/site/src/routes/tour/08-tetris.mdx b/site/src/routes/tour/08-tetris.mdx index a9c782c6..59c4b5af 100644 --- a/site/src/routes/tour/08-tetris.mdx +++ b/site/src/routes/tour/08-tetris.mdx @@ -82,5 +82,5 @@ this one: signals and stores describing state, JSX describing the scene, `` keeping the scene in sync with state. The [API reference](/api/types) is there when you need a specific export. -If you'd like a peek at where the library is heading next, the encore is -WebGPU. +There's one more kind of power worth knowing: when a prop you want isn't a +property, you can add it yourself. Next up — plugins. diff --git a/site/src/routes/tour/09-plugins.mdx b/site/src/routes/tour/09-plugins.mdx new file mode 100644 index 00000000..9dbf9f6d --- /dev/null +++ b/site/src/routes/tour/09-plugins.mdx @@ -0,0 +1,72 @@ +--- +title: Plugins +--- + +import lookAtSnippet from "../../snippets/09-look-at.tsx?raw" +import lookAtUrl from "../../snippets/09-look-at.tsx?importChunkUrl" + +# Plugins + +Every prop you've set so far has been just that — a *property*. `position`, +`color`, `intensity`: solid-three takes the value and assigns it to the +three.js object. That covers most of what a scene needs, but not all of it. + +Some of the things you'd reach for aren't properties — they're *methods*. +three's `Object3D` has a `lookAt(target)` method that turns an object to +face a point in space. You'd love to write `` +and have it just work. But it won't: props get *assigned*, so solid-three +would overwrite `mesh.lookAt` with your point and the method would be gone. +Props set properties; they don't call methods. + +A **plugin** closes that gap. It teaches solid-three a new prop — one that +runs a function instead of assigning a value. + +## Writing the plugin + +```tsx +import { plugin } from "solid-three" + +const lookAt = plugin([THREE.Object3D], object => ({ + lookAt: (target: THREE.Vector3) => object.lookAt(target), +})) +``` + +That's the whole thing. `plugin([THREE.Object3D], …)` matches every element +that's an `Object3D`, and for each one contributes a `lookAt` method. Now +when you set a `lookAt` prop, solid-three calls this method with the value +instead of assigning it — so `` runs +`mesh.lookAt(point)`, exactly what we wanted. + +## Registering it + +`createT` takes the plugins as its second argument. The `T` it hands back +understands the props they add: + +```tsx +const T = createT(THREE, [lookAt]) +``` + +## Seeing it work + +Move the pointer over the field below — every cone turns to face it. + + + +Each cone is nothing more than ``. A wide, +invisible backdrop reports the pointer's position with the `onPointerMove` +and `intersection.point` from the [pointer chapter](/tour/04-pointer-events), +and that point flows into every cone's `lookAt` prop. When the signal +changes, the prop re-applies and the plugin calls `lookAt` again — the same +reactivity that drives every other prop in the tour, now driving a method. + +## There's more + +`lookAt` is the simplest shape. Plugins can also match by a type-guard +instead of a class, register on a single element with `` +instead of the whole renderer, and the props they contribute are fully +typed — pass a wrong one and the type-checker stops you. The +[Plugins reference](/api/utilities/plugin) covers the rest. + +That's the extension seam: when a prop you want isn't a property, a plugin +makes it one. For the encore, a peek at where three.js — and this renderer — +are heading: WebGPU. diff --git a/site/src/routes/tour/09-webgpu-peek.mdx b/site/src/routes/tour/10-webgpu-peek.mdx similarity index 90% rename from site/src/routes/tour/09-webgpu-peek.mdx rename to site/src/routes/tour/10-webgpu-peek.mdx index 6b295ac7..ffc97bfd 100644 --- a/site/src/routes/tour/09-webgpu-peek.mdx +++ b/site/src/routes/tour/10-webgpu-peek.mdx @@ -2,12 +2,12 @@ title: A peek at WebGPU --- -import webgpuSimple from "../../snippets/09-webgpu-simple.tsx?raw" -import webgpuSimpleUrl from "../../snippets/09-webgpu-simple.tsx?importChunkUrl" -import webgpuTsl from "../../snippets/09-webgpu-tsl.tsx?raw" -import webgpuTslUrl from "../../snippets/09-webgpu-tsl.tsx?importChunkUrl" -import webgpuTslUniform from "../../snippets/09-webgpu-tsl-uniform.tsx?raw" -import webgpuTslUniformUrl from "../../snippets/09-webgpu-tsl-uniform.tsx?importChunkUrl" +import webgpuSimple from "../../snippets/10-webgpu-simple.tsx?raw" +import webgpuSimpleUrl from "../../snippets/10-webgpu-simple.tsx?importChunkUrl" +import webgpuTsl from "../../snippets/10-webgpu-tsl.tsx?raw" +import webgpuTslUrl from "../../snippets/10-webgpu-tsl.tsx?importChunkUrl" +import webgpuTslUniform from "../../snippets/10-webgpu-tsl-uniform.tsx?raw" +import webgpuTslUniformUrl from "../../snippets/10-webgpu-tsl-uniform.tsx?importChunkUrl" # A peek at WebGPU diff --git a/site/src/snippets/09-look-at.tsx b/site/src/snippets/09-look-at.tsx new file mode 100644 index 00000000..06760230 --- /dev/null +++ b/site/src/snippets/09-look-at.tsx @@ -0,0 +1,47 @@ +import * as THREE from "three" +import { createSignal, For } from "solid-js" +import { Canvas, createT, plugin } from "solid-three" + +// A plugin: every Object3D gains a `lookAt` prop that calls three's `lookAt()`. +const lookAt = plugin([THREE.Object3D], object => ({ + lookAt: (target: THREE.Vector3) => object.lookAt(target), +})) + +const T = createT(THREE, [lookAt]) + +// One cone geometry, rotated so its tip points along -Z — the axis `lookAt` aims. +const cone = new THREE.ConeGeometry(0.18, 0.7, 24) +cone.rotateX(-Math.PI / 2) + +// A 5×5 grid of cone positions in the XY-plane. +const positions: [number, number, number][] = [] +for (let x = -2; x <= 2; x++) for (let y = -2; y <= 2; y++) positions.push([x, y, 0]) + +export default () => { + const [target, setTarget] = createSignal(new THREE.Vector3()) + + return ( + + {/* An invisible backdrop catches the pointer; the cones opt out of + raycasting (`raycastable={false}`) so the ray always reaches it. */} + setTarget(event.intersection.point.clone().setZ(0))} + > + + + + + + {position => ( + + + + )} + + + + + + ) +} diff --git a/site/src/snippets/09-webgpu-simple.tsx b/site/src/snippets/10-webgpu-simple.tsx similarity index 100% rename from site/src/snippets/09-webgpu-simple.tsx rename to site/src/snippets/10-webgpu-simple.tsx diff --git a/site/src/snippets/09-webgpu-tsl-uniform.tsx b/site/src/snippets/10-webgpu-tsl-uniform.tsx similarity index 100% rename from site/src/snippets/09-webgpu-tsl-uniform.tsx rename to site/src/snippets/10-webgpu-tsl-uniform.tsx diff --git a/site/src/snippets/09-webgpu-tsl.tsx b/site/src/snippets/10-webgpu-tsl.tsx similarity index 100% rename from site/src/snippets/09-webgpu-tsl.tsx rename to site/src/snippets/10-webgpu-tsl.tsx diff --git a/site/vite.config.ts b/site/vite.config.ts index 031a5e90..757eb398 100644 --- a/site/vite.config.ts +++ b/site/vite.config.ts @@ -26,7 +26,8 @@ const tutorialChapters: Array<[title: string, slug: string]> = [ ["Loaders & Resource", "06-loaders-and-resource"], ["Portal", "07-portal"], ["Let's build Tetris!", "08-tetris"], - ["A peek at WebGPU", "09-webgpu-peek"], + ["Plugins", "09-plugins"], + ["A peek at WebGPU", "10-webgpu-peek"], ] const tutorialSidebar = (base: string) => tutorialChapters.map(([title, slug]) => ({ title, link: `${base}${slug}` })) @@ -108,6 +109,7 @@ export default defineConfig({ collapsed: true, items: [ { title: "Raycasters", link: "/utilities/raycasters" }, + { title: "Plugins", link: "/utilities/plugin" }, { title: "LoaderCache", link: "/utilities/loader-cache" }, { title: "autodispose", link: "/utilities/autodispose" }, { title: "Metadata", link: "/utilities/metadata" }, From 062266019435644f3e3b2b8139b05deafd3accb9 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 19:08:48 +0200 Subject: [PATCH 3/5] chore(site): stop vite dev re-optimize churn (pre-bundle Demo + three deps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor and three are all behind dynamic imports / optimizeDeps.exclude, so vite didn't see them at startup — it discovered them on first demo mount, re-optimized, and forced a full page reload mid-session (and ran the 1MB three.module.js through vite-plugin-solid's Babel on the way). Pre-bundle the editor stack (@bigmistqke/repl, tm-textarea/solid, and its transitive @bigmistqke/solid-whenever + @solid-primitives/resize-observer) and move three from exclude to include, so the optimize cost is paid once at startup and three skips the Babel transform. optimizeDeps is dev-only — the production build is unaffected. --- site/vite.config.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/site/vite.config.ts b/site/vite.config.ts index 757eb398..9d801718 100644 --- a/site/vite.config.ts +++ b/site/vite.config.ts @@ -46,8 +46,23 @@ export default defineConfig({ // them; they're already ESM. Everything that imports `three` resolves to the // same excluded copy, so there's still a single three instance. optimizeDeps: { - exclude: [ + // The code-editor in (lazy-loaded) statically imports `@bigmistqke/repl` + // and dynamically imports `tm-textarea/solid`, so Vite doesn't see them at + // startup — it discovers them on first demo mount, re-optimizes, and forces a + // full reload mid-session. Pre-bundle them up front so that cost is paid once. + include: [ + "@bigmistqke/repl", + "tm-textarea/solid", + // Pulled transitively by the editor, also behind dynamic imports. + "@bigmistqke/solid-whenever", + "@solid-primitives/resize-observer", + // Pre-bundle three up front. Excluding it meant every import was served + // raw and run through vite-plugin-solid's Babel on demand — which chokes on + // the 1MB three.module.js. Pre-bundling (esbuild) skips that transform and + // pays the cost once at startup instead of mid-session. "three", + ], + exclude: [ "cannon-es", "three/examples/jsm/environments/RoomEnvironment.js", "three/examples/jsm/geometries/TextGeometry.js", From bfc742406b460a263f57386d4b2af93633a59da8 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 19:10:59 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(site):=20cone=20lookAt=20orientation=20?= =?UTF-8?q?=E2=80=94=20meshes=20aim=20+Z=20at=20the=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mesh's lookAt() points +Z at the target (three swaps eye/target for non-camera objects; only cameras/lights aim -Z). Rotate the cone tip onto +Z so the tips point at the cursor, not away. Verified in-browser. --- site/src/snippets/09-look-at.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/snippets/09-look-at.tsx b/site/src/snippets/09-look-at.tsx index 06760230..5ccc5609 100644 --- a/site/src/snippets/09-look-at.tsx +++ b/site/src/snippets/09-look-at.tsx @@ -1,6 +1,6 @@ -import * as THREE from "three" import { createSignal, For } from "solid-js" import { Canvas, createT, plugin } from "solid-three" +import * as THREE from "three" // A plugin: every Object3D gains a `lookAt` prop that calls three's `lookAt()`. const lookAt = plugin([THREE.Object3D], object => ({ @@ -9,9 +9,10 @@ const lookAt = plugin([THREE.Object3D], object => ({ const T = createT(THREE, [lookAt]) -// One cone geometry, rotated so its tip points along -Z — the axis `lookAt` aims. +// One cone geometry, rotated so its tip points along +Z — a Mesh's lookAt() +// aims +Z at the target (cameras/lights aim -Z; meshes are the opposite). const cone = new THREE.ConeGeometry(0.18, 0.7, 24) -cone.rotateX(-Math.PI / 2) +cone.rotateX(Math.PI / 2) // A 5×5 grid of cone positions in the XY-plane. const positions: [number, number, number][] = [] From 0b819d19f64fab3ce86c0b689927ecdc4d717289 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 6 Jun 2026 20:34:29 +0200 Subject: [PATCH 5/5] fix(plugins): contributed props override native members of the same name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. a `lookAt` plugin driving `Object3D.lookAt`. But `Props` intersected the contributed type with the base, yielding `nativeMethod & V`, which no value satisfies (`` failed to type-check). Drop the contributed keys from `BaseProps` before adding the contributed props, so the contributed type replaces the native one — matching the runtime. Guarded for the no-plugins case: the loose default `readonly Plugin[]` has `length: number`, so `keyof PluginPropsOf` would be every key; yield `never` (drop nothing) unless a real tuple of plugins is inferred. The inference-critical second half stays a direct `Partial>`. --- src/types.ts | 30 +++++++++++++++++++++++++----- tests/core/plugins.test.tsx | 13 ++++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/types.ts b/src/types.ts index debabce7..d0c33876 100644 --- a/src/types.ts +++ b/src/types.ts @@ -274,13 +274,33 @@ type PluginReturn = : {} /** - * An element's full prop type: its base {@link BaseProps} plus the props contributed by - * `TPlugins` for this element class. `PluginPropsOf` is intersected DIRECTLY (a plain - * top-level intersection, not nested in `Props`'s `Overwrite`) so `TPlugins` stays - * inferable at the JSX/usage site — see the inference notes. Used by `createT`'s + * Base keys to drop from {@link Props} so a contributed prop *overrides* a native member + * of the same name (a plugin intercepts the prop at runtime, so its type must replace the + * native one, not intersect with it — `nativeMethod & V` is satisfiable by nothing). + * + * Guarded for the no-plugins case: when no specific plugins are inferred, `TPlugins` is the + * loose default `readonly Plugin[]` whose `length` is `number` (not a literal). There's no + * contribution to key off, and `keyof PluginPropsOf<…, Plugin[]>` would be every key — so + * yield `never` (drop nothing). Only a real inferred tuple (literal `length`, e.g. from an + * inline or `const` plugin array) contributes keys. A pre-typed `Plugin[]` variable still + * reads as loose, so its overrides fall back to the (harmless) intersection. + */ +type ContributedKeys = number extends TPlugins["length"] + ? never + : keyof PluginPropsOf, TPlugins> + +/** + * An element's full prop type: its base {@link BaseProps} with the props contributed by + * `TPlugins` for this element class layered on top — contributed props override native + * members of the same name (see {@link ContributedKeys}). `PluginPropsOf` is intersected + * DIRECTLY in the second half (a plain top-level intersection, not nested) so `TPlugins` + * stays inferable at the JSX/usage site — see the inference notes. Used by `createT`'s * element proxy and ``. */ -export type Props = BaseProps & +export type Props = Omit< + BaseProps, + ContributedKeys +> & Partial, TPlugins>> /** Resolves the contributed props for element type `TKind` across `TPlugins`. */ diff --git a/tests/core/plugins.test.tsx b/tests/core/plugins.test.tsx index 3e118965..2d60124d 100644 --- a/tests/core/plugins.test.tsx +++ b/tests/core/plugins.test.tsx @@ -1,5 +1,5 @@ import { assertType, describe, expect, it, vi } from "vitest" -import { Mesh, Object3D, PerspectiveCamera } from "three" +import { Mesh, Object3D, PerspectiveCamera, Vector3 } from "three" import { plugin, resolvePluginMethods } from "../../src/plugin.ts" import { createT } from "../../src/create-t.tsx" import { Entity } from "../../src/components.tsx" @@ -81,6 +81,17 @@ describe("plugin prop types", () => { assertType(({} as MeshProps).shake) expect(true).toBe(true) }) + + it("a contributed prop overrides a native member of the same name", () => { + // `lookAt` is a method on Object3D; the contributed prop must *replace* it so a + // Vector3 is assignable. The naive intersection (`method & Vector3`) is satisfiable + // by no value, so this assignment would not type-check. + const TP = createT({ Mesh }, [plugin([Mesh], () => ({ lookAt: (_target: Vector3) => {} }))]) + type MeshProps = Parameters[0] + const props: MeshProps = { lookAt: new Vector3() } + assertType(props.lookAt) + expect(props).toBeDefined() + }) }) describe("meta.ctx + initializePlugin", () => {