From fdb3b23093a54d035a233a6fd667acc127c5d439 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 17:40:57 +0200 Subject: [PATCH 01/26] feat: add initial plugin system --- playground/App.tsx | 17 +++- playground/examples/EnvironmentExample.tsx | 30 +++++- playground/examples/PluginExample.tsx | 49 ++++++++++ src/canvas.tsx | 60 +++++++++++- src/components.tsx | 24 ++--- src/create-events.ts | 4 +- src/create-t.tsx | 79 ++++++++++------ src/create-three.tsx | 18 ++-- src/internal-context.ts | 12 ++- src/props.ts | 30 +++++- src/types.ts | 104 +++++++++++++++------ src/utils.ts | 35 ++++++- 12 files changed, 372 insertions(+), 90 deletions(-) create mode 100644 playground/examples/PluginExample.tsx diff --git a/playground/App.tsx b/playground/App.tsx index f4b1600..f55ab14 100644 --- a/playground/App.tsx +++ b/playground/App.tsx @@ -3,11 +3,12 @@ import type { ParentProps } from "solid-js" import * as THREE from "three" import { Canvas, createT, Entity } from "../src/index.ts" import { EnvironmentExample } from "./examples/EnvironmentExample.tsx" +import { PluginExample } from "./examples/PluginExample.tsx" import { PortalExample } from "./examples/PortalExample.tsx" import { SolarExample } from "./examples/SolarExample.tsx" import "./index.css" -const T = createT(THREE) +const { T } = createT({ ...THREE, Entity }) function Layout(props: ParentProps) { return ( @@ -67,6 +68,17 @@ function Layout(props: ParentProps) { > Environment + + Plugins + {props.children} @@ -79,6 +91,7 @@ export function App() { + ( @@ -87,7 +100,7 @@ export function App() { scene={{ background: [1, 0, 0] }} style={{ width: "100vw", height: "100vh" }} > - + diff --git a/playground/examples/EnvironmentExample.tsx b/playground/examples/EnvironmentExample.tsx index 86305b1..68462fc 100644 --- a/playground/examples/EnvironmentExample.tsx +++ b/playground/examples/EnvironmentExample.tsx @@ -1,9 +1,27 @@ import * as THREE from "three" -import { Resource } from "../../src/components.tsx" -import { Canvas, createT } from "../../src/index.ts" +import { createT, Resource } from "../../src/index.ts" import { OrbitControls } from "../controls/OrbitControls.tsx" -const T = createT(THREE) +const Plugin1 = () => { + return { + onCustom(callback: (value: string) => void) { + callback("HALLO FROM PLUGIN1!") + }, + onYolo(callback: (value: "yolo") => void) { + callback("yolo") + }, + } +} + +const Plugin2 = () => { + return { + onCustom(callback: (value: number) => void) { + callback(2) + }, + } +} + +const { T, Canvas } = createT(THREE, [Plugin1]) export function EnvironmentExample() { return ( @@ -16,7 +34,11 @@ export function EnvironmentExample() { onPointerEnter={event => console.debug("canvas pointer enter", event)} > - + console.log(value)} + onCustom={value => console.log(value)} + > { + return { + onCustom(callback: (value: "HALLO FROM PLUGIN1!") => void) { + callback("HALLO FROM PLUGIN1!") + }, + onYolo(callback: (value: "yolo") => void) { + callback("yolo") + }, + } +} + +const Plugin2 = () => { + return { + onMouseDown(callback: (value: number) => void) { + callback(2) + }, + } +} + +const { T, Canvas } = createT(THREE, [Plugin1]) + +export function PluginExample() { + return ( + + console.log(value)} + onMouseDown={value => console.log(value)} + onCustom={value => console.log(value)} + > + + + + + + + ) +} diff --git a/src/canvas.tsx b/src/canvas.tsx index 3cf426c..1fd942d 100644 --- a/src/canvas.tsx +++ b/src/canvas.tsx @@ -10,7 +10,7 @@ import { } from "three" import { createThree } from "./create-three.tsx" import type { EventRaycaster } from "./raycasters.tsx" -import type { CanvasEventHandlers, Context, Props } from "./types.ts" +import type { CanvasEventHandlers, Context, Plugin, Props } from "./types.ts" /** * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene. @@ -100,3 +100,61 @@ export function Canvas(props: ParentProps) { ) } + +/** + * Serves as the root component for all 3D scenes created with `solid-three`. It initializes + * the Three.js rendering context, including a WebGL renderer, a scene, and a camera. + * All ``-components must be children of this Canvas. Hooks such as `useThree` and + * `useFrame` should only be used within this component to ensure proper context. + * + * @function Canvas + * @param props - Configuration options include camera settings, style, and children elements. + * @returns A div element containing the WebGL canvas configured to occupy the full available space. + */ +export function createCanvas(plugins: TPlugins) { + return function (props: ParentProps) { + let canvas: HTMLCanvasElement = null! + let container: HTMLDivElement = null! + + onMount(() => { + const context = createThree(canvas, props, plugins) + + // Resize observer for the canvas to adjust camera and renderer on size change + createResizeObserver(container, function onResize() { + const { width, height } = container.getBoundingClientRect() + context.gl.setSize(width, height) + context.gl.setPixelRatio(globalThis.devicePixelRatio) + + if (context.currentCamera instanceof OrthographicCamera) { + context.currentCamera.left = width / -2 + context.currentCamera.right = width / 2 + context.currentCamera.top = height / 2 + context.currentCamera.bottom = height / -2 + } else { + context.currentCamera.aspect = width / height + } + + context.currentCamera.updateProjectionMatrix() + context.render(performance.now()) + }) + }) + + return ( +
+ +
+ ) + } +} diff --git a/src/components.tsx b/src/components.tsx index 15b5472..384de8f 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -12,7 +12,7 @@ import { import { Object3D } from "three" import { threeContext, useThree } from "./hooks.ts" import { useProps } from "./props.ts" -import type { Constructor, Loader, Meta, Overwrite, Props } from "./types.ts" +import type { Constructor, InferPluginProps, Loader, Meta, Plugin, Props } from "./types.ts" import { type InstanceOf } from "./types.ts" import { autodispose, hasMeta, isConstructor, load, meta, withContext } from "./utils.ts" import { whenMemo } from "./utils/conditionals.ts" @@ -74,15 +74,6 @@ export function Portal(props: PortalProps) { /* */ /**********************************************************************************/ -type EntityProps> = Overwrite< - [ - Props, - { - from: T | undefined - children?: JSXElement - }, - ] -> /** * Wraps a `ThreeElement` and allows it to be used as a JSX-component within a `solid-three` scene. * @@ -92,7 +83,15 @@ type EntityProps> = Overwrite< * optional children, and a ref that provides access to the object instance. * @returns The Three.js object wrapped as a JSX element, allowing it to be used within Solid's component system. */ -export function Entity>(props: EntityProps) { +export function Entity< + const T extends object | Constructor = object, + const TPlugins extends Plugin[] = $3.Plugins, +>( + props: + | Props + | { from: T; children?: JSXElement; plugins?: TPlugins } + | InferPluginProps, +) { const [config, rest] = splitProps(props, ["from", "args"]) const memo = whenMemo( () => config.from, @@ -103,6 +102,9 @@ export function Entity>(props: EntityProp isConstructor(from) ? autodispose(new from(...(config.args ?? []))) : from, { props, + get plugins() { + return props.plugins + }, }, ) as Meta useProps(instance, rest) diff --git a/src/create-events.ts b/src/create-events.ts index 67eb1fa..4ddbece 100644 --- a/src/create-events.ts +++ b/src/create-events.ts @@ -1,5 +1,5 @@ import { Object3D, type Intersection } from "three" -import type { Context, EventName, Meta, Prettify, ThreeEvent } from "./types.ts" +import type { Context, Event, EventName, Meta, Prettify } from "./types.ts" import { getMeta } from "./utils.ts" const eventNameMap = { @@ -77,7 +77,7 @@ function createThreeEvent< return event as Prettify< Omit< - ThreeEvent< + Event< TEvent, { stoppable: TConfig["stoppable"] extends false diff --git a/src/create-t.tsx b/src/create-t.tsx index c8757e6..9f7c53e 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,6 +1,8 @@ -import { createMemo, type Component, type JSX } from "solid-js" +import { createMemo, type Component, type JSX, type JSXElement, type MergeProps } from "solid-js" +import { createCanvas } from "./canvas.tsx" +import { $S3C } from "./constants.ts" import { useProps } from "./props.ts" -import type { Props } from "./types.ts" +import type { InferPluginProps, Merge, Plugin, Props } from "./types.ts" import { meta } from "./utils.ts" /**********************************************************************************/ @@ -9,27 +11,49 @@ import { meta } from "./utils.ts" /* */ /**********************************************************************************/ -export function createT>(catalogue: TCatalogue) { +export type InferPluginsFromT = T extends { [$S3C]: infer U } ? U : never + +export function createT< + const TCatalogue extends Record, + const TCataloguePlugins extends Plugin[] = [], +>(catalogue: TCatalogue, plugins?: TCataloguePlugins = []) { const cache = new Map>() - return new Proxy<{ - [K in keyof TCatalogue]: Component> - }>({} as any, { - get: (_, name: string) => { - /* Create and memoize a wrapper component for the specified property. */ - if (!cache.has(name)) { - /* Try and find a constructor within the THREE namespace. */ - const constructor = catalogue[name] + return { + Canvas: createCanvas(plugins), + T: new Proxy< + | { + [K in keyof TCatalogue]: ( + props: { plugins?: TPlugins } & Partial< + TPlugins extends Plugin[] + ? Merge< + [ + Props, + InferPluginProps, + InferPluginProps, + ] + > + : Merge<[Props, InferPluginProps]> + >, + ) => JSXElement + } & { [$S3C]: TCataloguePlugins } + >({} as any, { + get: (_, name: string) => { + /* Create and memoize a wrapper component for the specified property. */ + if (!cache.has(name)) { + /* Try and find a constructor within the THREE namespace. */ + const constructor = catalogue[name] - /* If no constructor is found, return undefined. */ - if (!constructor) return undefined + /* If no constructor is found, return undefined. */ + if (!constructor) return undefined - /* Otherwise, create and memoize a component for that constructor. */ - cache.set(name, createEntity(constructor)) - } + /* Otherwise, create and memoize a component for that constructor. */ + cache.set(name, createEntity(constructor, plugins)) + } - return cache.get(name) - }, - }) + return cache.get(name) + }, + }), + } } /** @@ -39,21 +63,24 @@ export function createT>(catalogue: T * @param Constructor - The constructor from which the component will be created. * @returns The created component. */ -export function createEntity( +export function createEntity( Constructor: TConstructor, -): Component> { - return (props: Props) => { - const memo = createMemo(() => { + plugins: TEntityPlugins, +) { + return function ( + props: MergeProps<[InferPluginsFromT, Props]>, + ) { + const entity = createMemo(() => { // listen to key changes props.key try { - return meta(new (Constructor as any)(...(props.args ?? [])), { props }) + return meta(new (Constructor as any)(...(props.args ?? [])), { props, plugins }) } catch (e) { console.error(e) throw new Error("") } }) - useProps(memo, props) - return memo as unknown as JSX.Element + useProps(entity, props, plugins) + return entity as unknown as JSX.Element } } diff --git a/src/create-three.tsx b/src/create-three.tsx index 8f563cd..dc5d971 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -27,10 +27,10 @@ import type { CanvasProps } from "./canvas.tsx" import { createEvents } from "./create-events.ts" import { Stack } from "./data-structure/stack.ts" import { frameContext, threeContext } from "./hooks.ts" -import { eventContext } from "./internal-context.ts" +import { eventContext, pluginContext } from "./internal-context.ts" import { useProps, useSceneGraph } from "./props.ts" import { CursorRaycaster, type EventRaycaster } from "./raycasters.tsx" -import type { CameraKind, Context, FrameListener, FrameListenerCallback } from "./types.ts" +import type { CameraKind, Context, FrameListener, FrameListenerCallback, Plugin } from "./types.ts" import { binarySearch, defaultProps, @@ -47,7 +47,7 @@ import { useMeasure } from "./utils/use-measure.ts" * camera, renderer, raycaster, and scene, manages the scene graph, setups up an event system * and rendering loop based on the provided properties. */ -export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { +export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugins: Plugin[]) { const canvasProps = defaultProps(props, { frameloop: "always" }) /**********************************************************************************/ @@ -407,11 +407,13 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { /**********************************************************************************/ const c = children(() => ( - - - {canvasProps.children} - - + + + + {canvasProps.children} + + + )) useSceneGraph( diff --git a/src/internal-context.ts b/src/internal-context.ts index 340bf5a..aa4f2ee 100644 --- a/src/internal-context.ts +++ b/src/internal-context.ts @@ -1,6 +1,6 @@ import { type JSX, createContext, useContext } from "solid-js" import { Object3D } from "three" -import type { EventName, Meta } from "./types.ts" +import type { EventName, Meta, Plugin } from "./types.ts" /** * Registers an event listener for an `AugmentedElement` to the nearest Canvas component up the component tree. @@ -34,3 +34,13 @@ export const addPortal = (children: JSX.Element | JSX.Element[]) => { addPortal(children) } export const portalContext = createContext<(children: JSX.Element | JSX.Element[]) => void>() + +export function usePlugins() { + const plugins = useContext(pluginContext) + if (!plugins) { + throw new Error("S3: Hooks can only be used within the Canvas component!") + } + return plugins +} + +export const pluginContext = createContext() diff --git a/src/props.ts b/src/props.ts index a2ba4d7..541280f 100644 --- a/src/props.ts +++ b/src/props.ts @@ -2,9 +2,11 @@ import { type Accessor, children, createComputed, + createMemo, createRenderEffect, type JSXElement, mapArray, + mergeProps, onCleanup, splitProps, untrack, @@ -22,7 +24,7 @@ import { import { isEventType } from "./create-events.ts" import { useThree } from "./hooks.ts" import { addToEventListeners } from "./internal-context.ts" -import type { AccessorMaybe, Context, Meta } from "./types.ts" +import type { AccessorMaybe, Context, Meta, Plugin } from "./types.ts" import { getMeta, hasColorSpace, hasMeta, resolve } from "./utils.ts" function isWritable(object: object, propertyName: string) { @@ -303,13 +305,28 @@ function applyProp>( */ export function useProps>( accessor: T | undefined | Accessor, - props: any, - context: Pick = useThree(), + props: { plugins?: Plugin[] } & Record, + plugins?: Plugin[], ) { - const [local, instanceProps] = splitProps(props, ["ref", "args", "object", "attach", "children"]) + const context: Pick = useThree() + + const [local, instanceProps] = splitProps(props, [ + "ref", + "args", + "object", + "attach", + "children", + "plugins", + ]) useSceneGraph(accessor, props) + const pluginMethods = createMemo(() => + mergeProps( + ...[...(plugins ?? []), ...(props.plugins ?? [])].map(init => () => init(resolve(accessor))), + ), + ) + createRenderEffect(() => { const object = resolve(accessor) @@ -329,6 +346,11 @@ export function useProps>( // p.ex in position's subKeys will be ['position-x'] const subKeys = keys.filter(_key => key !== _key && _key.includes(key)) createRenderEffect(() => { + if (key in pluginMethods()) { + pluginMethods()[key](props[key]) + return + } + applyProp(context, object, key, props[key]) // If property updates, apply its sub-properties immediately after. // NOTE: Discuss - is this expected behavior? Feature or a bug? diff --git a/src/types.ts b/src/types.ts index 731e150..0dc226c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { Accessor, JSX, Ref } from "solid-js" +import type { Accessor, JSX, MergeProps, Ref } from "solid-js" import type { Clock, ColorRepresentation, @@ -24,6 +24,12 @@ import type { $S3C } from "./constants.ts" import type { EventRaycaster } from "./raycasters.tsx" import type { Measure } from "./utils/use-measure.ts" +declare global { + namespace $3 { + // type Plugins = Plugin[] + } +} + /**********************************************************************************/ /* */ /* Utils */ @@ -181,7 +187,7 @@ export type FrameListener = ( export type When = T extends false ? (T extends true ? U : unknown) : U -export type ThreeEvent< +export type Event< TEvent, TConfig extends { stoppable?: boolean; intersections?: boolean } = { stoppable: true @@ -209,23 +215,23 @@ export type ThreeEvent< > type EventHandlersMap = { - onClick: Prettify> - onClickMissed: Prettify> - onDoubleClick: Prettify> - onDoubleClickMissed: Prettify> - onContextMenu: Prettify> - onContextMenuMissed: Prettify> - onMouseDown: Prettify> - onMouseEnter: Prettify> - onMouseLeave: Prettify> - onMouseMove: Prettify> - onMouseUp: Prettify> - onPointerUp: Prettify> - onPointerDown: Prettify> - onPointerMove: Prettify> - onPointerEnter: Prettify> - onPointerLeave: Prettify> - onWheel: Prettify> + onClick: Prettify> + onClickMissed: Prettify> + onDoubleClick: Prettify> + onDoubleClickMissed: Prettify> + onContextMenu: Prettify> + onContextMenuMissed: Prettify> + onMouseDown: Prettify> + onMouseEnter: Prettify> + onMouseLeave: Prettify> + onMouseMove: Prettify> + onMouseUp: Prettify> + onPointerUp: Prettify> + onPointerDown: Prettify> + onPointerMove: Prettify> + onPointerEnter: Prettify> + onPointerLeave: Prettify> + onWheel: Prettify> } export type EventHandlers = { @@ -247,6 +253,11 @@ export type EventName = keyof EventHandlersMap /* */ /**********************************************************************************/ +/** Maps properties of given type to their `solid-three` representations. */ +export type MapToRepresentation = { + [TKey in keyof T]: Representation +} + interface ThreeMathRepresentation { set(...args: number[]): any } @@ -275,10 +286,18 @@ export type Matrix4 = Representation /**********************************************************************************/ /* */ -/* Three To JSX */ +/* Meta */ /* */ /**********************************************************************************/ +export interface Plugin = object> { + (element: U): T +} + +export type InferPluginProps = MergeProps<{ + [TKey in keyof TPlugins]: ReturnType +}> + export type Meta = T & { [$S3C]: Data } @@ -288,16 +307,18 @@ export type Data = { props: Props> parent: any children: Set> + plugins: Plugin[] } -/** Maps properties of given type to their `solid-three` representations. */ -export type MapToRepresentation = { - [TKey in keyof T]: Representation -} +/**********************************************************************************/ +/* */ +/* Props */ +/* */ +/**********************************************************************************/ /** Generic `solid-three` props of a given class. */ -export type Props = Partial< - Overwrite< +export type Props = Partial< + Merge< [ MapToRepresentation>, EventHandlers, @@ -313,7 +334,38 @@ export type Props = Partial< * Object3D can still receive events via propagation from its descendants. */ raycastable: boolean + plugins: TPlugins }, ] > > + +type Simplify = T extends any + ? { + [K in keyof T]: T[K] + } + : T +type _Merge = T extends [ + infer Next | (() => infer Next), + ...infer Rest, +] + ? _Merge> + : T extends [...infer Rest, infer Next] + ? Override<_Merge, Next> + : T extends [] + ? Curr + : // : T extends (infer I)[] + // ? OverrideSpread + Curr +export type Merge = Simplify<_Merge> + +type DistributeOverride = T extends undefined ? F : T +type Override = T extends any + ? U extends any + ? { + [K in keyof T]: K extends keyof U ? DistributeOverride : T[K] + } & { + [K in keyof U]: K extends keyof T ? DistributeOverride : U[K] + } + : T & U + : T & U diff --git a/src/utils.ts b/src/utils.ts index 23a6d56..6e44197 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,7 +10,16 @@ import { Vector3, } from "three" import { $S3C } from "./constants.ts" -import type { CameraKind, Constructor, Data, Loader, Meta } from "./types.ts" +import type { + CameraKind, + Constructor, + Data, + InstanceOf, + Loader, + Meta, + Plugin, + Props, +} from "./types.ts" import type { Measure } from "./utils/use-measure.ts" /**********************************************************************************/ @@ -39,24 +48,40 @@ export function autodispose void }>(object: T): T { /**********************************************************************************/ /* */ -/* Augment */ +/* Meta */ /* */ /**********************************************************************************/ +interface MetaOptions { + props?: Props> + plugins?: Plugin[] +} + /** * A utility to add metadata to a given instance. * This data can be accessed behind the `S3C` symbol and is used internally in `solid-three`. * * @param instance - `three` instance - * @param augmentation - additional data: `{ props }` + * @param options - additional data: `{ props }` * @returns the `three` instance with the additional data */ -export function meta(instance: T, augmentation = { props: {} }) { +export function meta( + instance: T, + { props = {}, plugins = [] }: MetaOptions = {}, +) { if (hasMeta(instance)) { return instance } + const _instance = instance as Meta - _instance[$S3C] = { children: new Set(), parent: undefined, ...augmentation } + + _instance[$S3C] = { + children: new Set(), + parent: undefined, + plugins, + props, + } + return _instance } From cb83bc2a8f2e6aec3e9380b428cbc76bd2de7cac Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 19:31:40 +0200 Subject: [PATCH 02/26] feat: add EventPlugin --- .claude/settings.local.json | 8 ++ package.json | 2 + playground/examples/PluginExample.tsx | 43 ++++---- pnpm-lock.yaml | 45 +++++++++ src/create-three.tsx | 32 +++--- src/{create-events.ts => event-plugin.ts} | 116 +++++++++++++--------- src/index.ts | 1 + src/props.ts | 28 ++---- src/types.ts | 32 +++--- 9 files changed, 181 insertions(+), 126 deletions(-) create mode 100644 .claude/settings.local.json rename src/{create-events.ts => event-plugin.ts} (84%) diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2af7796 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:github.com)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/package.json b/package.json index 89f4afa..a760542 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,9 @@ } }, "dependencies": { + "@solid-primitives/map": "^0.7.2", "@solid-primitives/resize-observer": "^2.0.25", + "@solid-primitives/set": "^0.7.2", "debounce": "^2.1.0" }, "devDependencies": { diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index 5014d31..cc2cd99 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -1,26 +1,25 @@ +import type { InferPluginsFromT } from "create-t.tsx" import * as THREE from "three" -import { createT, Resource } from "../../src/index.ts" +import type { InferPluginProps, Plugin } from "types.ts" +import { createT, Resource, useFrame, useThree } from "../../src/index.ts" +import { OrbitControls } from "../controls/OrbitControls.tsx" -const Plugin1 = () => { - return { - onCustom(callback: (value: "HALLO FROM PLUGIN1!") => void) { - callback("HALLO FROM PLUGIN1!") - }, - onYolo(callback: (value: "yolo") => void) { - callback("yolo") - }, +const Plugin1 = (() => { + return function (element: U) { + return { + lookAt: (target: THREE.Object3D) => { + useFrame(() => { + ;(element as THREE.Object3D).lookAt(target.position) + }) + }, + } } -} +}) satisfies Plugin -const Plugin2 = () => { - return { - onMouseDown(callback: (value: number) => void) { - callback(2) - }, - } -} +const { T, Canvas } = createT(THREE, [Plugin1 /* EventPlugin */]) -const { T, Canvas } = createT(THREE, [Plugin1]) +type X = InferPluginsFromT +type Y = InferPluginProps export function PluginExample() { return ( @@ -28,11 +27,11 @@ export function PluginExample() { style={{ width: "100vw", height: "100vh" }} defaultCamera={{ position: new THREE.Vector3(0, 0, 30) }} > + console.log(value)} - onMouseDown={value => console.log(value)} - onCustom={value => console.log(value)} + lookAt={useThree().currentCamera} + // onMouseMove={event => console.info("mousemove!", event)} + // onMouseDown={event => console.info("mousedown!", event)} > diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 220b77b..f4b08ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: dependencies: + '@solid-primitives/map': + specifier: ^0.7.2 + version: 0.7.2(solid-js@1.8.17) '@solid-primitives/resize-observer': specifier: ^2.0.25 version: 2.0.25(solid-js@1.8.17) + '@solid-primitives/set': + specifier: ^0.7.2 + version: 0.7.2(solid-js@1.8.17) debounce: specifier: ^2.1.0 version: 2.1.0 @@ -1050,6 +1056,11 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/map@0.7.2': + resolution: {integrity: sha512-sXK/rS68B4oq3XXNyLrzVhLtT1pnimmMUahd2FqhtYUuyQsCfnW058ptO1s+lWc2k8F/3zQSNVkZ2ifJjlcNbQ==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/resize-observer@2.0.25': resolution: {integrity: sha512-jVDXkt2MiriYRaz4DYs62185d+6jQ+1DCsR+v7f6XMsIJJuf963qdBRFjtZtKXBaxdPNMyuPeDgf5XQe3EoDJg==} peerDependencies: @@ -1060,16 +1071,31 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/set@0.7.2': + resolution: {integrity: sha512-E4UzC1cQtPWicnbK9ulG0G27d8802DFi4OSC6HZm+yyQOVAb0ebkfJq9FKSYpFxE+gb6M2lM6Zh4ulXRna35CA==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/static-store@0.0.8': resolution: {integrity: sha512-ZecE4BqY0oBk0YG00nzaAWO5Mjcny8Fc06CdbXadH9T9lzq/9GefqcSe/5AtdXqjvY/DtJ5C6CkcjPZO0o/eqg==} peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/trigger@1.2.2': + resolution: {integrity: sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g==} + peerDependencies: + solid-js: ^1.6.12 + '@solid-primitives/utils@6.2.3': resolution: {integrity: sha512-CqAwKb2T5Vi72+rhebSsqNZ9o67buYRdEJrIFzRXz3U59QqezuuxPsyzTSVCacwS5Pf109VRsgCJQoxKRoECZQ==} peerDependencies: solid-js: ^1.6.12 + '@solid-primitives/utils@6.3.2': + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} + peerDependencies: + solid-js: ^1.6.12 + '@solidjs/router@0.15.3': resolution: {integrity: sha512-iEbW8UKok2Oio7o6Y4VTzLj+KFCmQPGEpm1fS3xixwFBdclFVBvaQVeibl1jys4cujfAK5Kn6+uG2uBm3lxOMw==} peerDependencies: @@ -4761,6 +4787,11 @@ snapshots: '@solid-primitives/utils': 6.2.3(solid-js@1.8.17) solid-js: 1.8.17 + '@solid-primitives/map@0.7.2(solid-js@1.8.17)': + dependencies: + '@solid-primitives/trigger': 1.2.2(solid-js@1.8.17) + solid-js: 1.8.17 + '@solid-primitives/resize-observer@2.0.25(solid-js@1.8.17)': dependencies: '@solid-primitives/event-listener': 2.3.3(solid-js@1.8.17) @@ -4774,15 +4805,29 @@ snapshots: '@solid-primitives/utils': 6.2.3(solid-js@1.8.17) solid-js: 1.8.17 + '@solid-primitives/set@0.7.2(solid-js@1.8.17)': + dependencies: + '@solid-primitives/trigger': 1.2.2(solid-js@1.8.17) + solid-js: 1.8.17 + '@solid-primitives/static-store@0.0.8(solid-js@1.8.17)': dependencies: '@solid-primitives/utils': 6.2.3(solid-js@1.8.17) solid-js: 1.8.17 + '@solid-primitives/trigger@1.2.2(solid-js@1.8.17)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.8.17) + solid-js: 1.8.17 + '@solid-primitives/utils@6.2.3(solid-js@1.8.17)': dependencies: solid-js: 1.8.17 + '@solid-primitives/utils@6.3.2(solid-js@1.8.17)': + dependencies: + solid-js: 1.8.17 + '@solidjs/router@0.15.3(solid-js@1.8.17)': dependencies: solid-js: 1.8.17 diff --git a/src/create-three.tsx b/src/create-three.tsx index dc5d971..6ffeef8 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -1,3 +1,4 @@ +import { ReactiveMap } from "@solid-primitives/map" import { children, createEffect, @@ -24,10 +25,9 @@ import { WebGLRenderer, } from "three" import type { CanvasProps } from "./canvas.tsx" -import { createEvents } from "./create-events.ts" import { Stack } from "./data-structure/stack.ts" import { frameContext, threeContext } from "./hooks.ts" -import { eventContext, pluginContext } from "./internal-context.ts" +import { pluginContext } from "./internal-context.ts" import { useProps, useSceneGraph } from "./props.ts" import { CursorRaycaster, type EventRaycaster } from "./raycasters.tsx" import type { CameraKind, Context, FrameListener, FrameListenerCallback, Plugin } from "./types.ts" @@ -235,6 +235,8 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi const clock = new Clock() clock.start() + const pluginMap = new ReactiveMap>() + const context: Context = { get bounds() { return measure.bounds() @@ -245,6 +247,15 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi return this.gl.getPixelRatio() }, props, + registerPlugin(plugin) { + let result = pluginMap.get(plugin) + if (result) { + return result + } + result = plugin() + pluginMap.set(plugin, result) + return result + }, render, requestRender, get viewport() { @@ -391,15 +402,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi onCleanup(() => pendingLoopRequest && cancelAnimationFrame(pendingLoopRequest)) }) - /**********************************************************************************/ - /* */ - /* Events */ - /* */ - /**********************************************************************************/ - - // Initialize event-system - const { addEventListener } = createEvents(context) - /**********************************************************************************/ /* */ /* Scene Graph */ @@ -408,11 +410,9 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi const c = children(() => ( - - - {canvasProps.children} - - + + {canvasProps.children} + )) diff --git a/src/create-events.ts b/src/event-plugin.ts similarity index 84% rename from src/create-events.ts rename to src/event-plugin.ts index 4ddbece..54d8826 100644 --- a/src/create-events.ts +++ b/src/event-plugin.ts @@ -1,5 +1,7 @@ +import { onCleanup } from "solid-js" import { Object3D, type Intersection } from "three" -import type { Context, Event, EventName, Meta, Prettify } from "./types.ts" +import { useThree } from "./hooks.ts" +import type { Context, Event, EventName, Meta, Plugin, Prettify } from "./types.ts" import { getMeta } from "./utils.ts" const eventNameMap = { @@ -376,9 +378,11 @@ function createDefaultEventRegistry( context.canvas.addEventListener( eventNameMap[type], nativeEvent => { - const intersections = raycast(context, registry.array, nativeEvent) + const intersections = raycast(context, registry.array, nativeEvent) /* [0]] */ const event = createThreeEvent(nativeEvent, { intersections }) + const visitedNodes = new Set() + for (const intersection of intersections) { // Update currentIntersection // @ts-expect-error TODO: fix type-error @@ -387,11 +391,12 @@ function createDefaultEventRegistry( // Bubble up let node: Object3D | null = intersection.object - while (node && !event.stopped) { - getMeta(intersection.object)?.props[type]?.( + while (node && !event.stopped && !visitedNodes.has(node)) { + getMeta(node)?.props[type]?.( // @ts-expect-error TODO: fix type-error event, ) + visitedNodes.add(node) node = node.parent } } @@ -420,7 +425,9 @@ function createDefaultEventRegistry( /** * Initializes and manages event handling for all `Instance`. */ -export function createEvents(context: Context) { +export const EventPlugin = (() => { + const context = useThree() + // onMouseMove/onMouseEnter/onMouseLeave const hoverMouseRegistry = createHoverEventRegistry("Mouse", context) // onPointerMove/onPointerEnter/onPointerLeave @@ -442,48 +449,59 @@ export function createEvents(context: Context) { // Default wheel-event const wheelRegistry = createDefaultEventRegistry("onWheel", context, { passive: true }) - return { - /** - * Registers an `AugmentedElement` with the event handling system. - * - * @param object - The 3D object to register. - * @param type - The type of event the object should listen for. - */ - addEventListener(object: Meta, type: EventName) { - switch (type) { - // Missable Events - case "onClick": - case "onClickMissed": - return missableClickRegistry.add(object) - case "onContextMenu": - case "onContextMenuMissed": - return missableContextMenuRegistry.add(object) - case "onDoubleClick": - case "onDoubleClickMissed": - return missableDoubleClickRegistry.add(object) - - // Hover Events - case "onMouseEnter": - case "onMouseLeave": - case "onMouseMove": - return hoverMouseRegistry.add(object) - case "onPointerEnter": - case "onPointerLeave": - case "onPointerMove": - return hoverPointerRegistry.add(object) - - // Default Events - case "onMouseDown": - return mouseDownRegistry.add(object) - case "onMouseUp": - return mouseUpRegistry.add(object) - case "onPointerDown": - return pointerDownRegistry.add(object) - case "onPointerUp": - return pointerUpRegistry.add(object) - case "onWheel": - return wheelRegistry.add(object) - } - }, + return object => { + return { + onClick(callback: (event: Event) => void) { + onCleanup(missableClickRegistry.add(object)) + }, + onClickMissed(callback: (event: Event) => void) { + onCleanup(missableClickRegistry.add(object)) + }, + onDoubleClick(callback: (event: Event) => void) { + onCleanup(missableDoubleClickRegistry.add(object)) + }, + onDoubleClickMissed(callback: (event: Event) => void) { + onCleanup(missableDoubleClickRegistry.add(object)) + }, + onContextMenu(callback: (event: Event) => void) { + onCleanup(missableContextMenuRegistry.add(object)) + }, + onContextMenuMissed(callback: (event: Event) => void) { + onCleanup(missableContextMenuRegistry.add(object)) + }, + onMouseDown(callback: (event: Event) => void) { + onCleanup(mouseDownRegistry.add(object)) + }, + onMouseUp(callback: (event: Event) => void) { + onCleanup(mouseUpRegistry.add(object)) + }, + onMouseMove(callback: (event: Event) => void) { + onCleanup(hoverMouseRegistry.add(object)) + }, + onMouseEnter(callback: (event: Event) => void) { + onCleanup(hoverMouseRegistry.add(object)) + }, + onMouseLeave(callback: (event: Event) => void) { + onCleanup(hoverMouseRegistry.add(object)) + }, + onPointerDown(callback: (event: Event) => void) { + onCleanup(pointerDownRegistry.add(object)) + }, + onPointerUp(callback: (event: Event) => void) { + onCleanup(pointerUpRegistry.add(object)) + }, + onPointerMove(callback: (event: Event) => void) { + onCleanup(hoverPointerRegistry.add(object)) + }, + onPointerEnter(callback: (event: Event) => void) { + onCleanup(hoverPointerRegistry.add(object)) + }, + onPointerLeave(callback: (event: Event) => void) { + onCleanup(hoverMouseRegistry.add(object)) + }, + onwheel(callback: (event: Event) => void) { + onCleanup(wheelRegistry.add(object)) + }, + } } -} +}) satisfies Plugin diff --git a/src/index.ts b/src/index.ts index c2f292f..b73b4fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export { Canvas, type CanvasProps } from "./canvas.tsx" export { Entity, Portal, Resource } from "./components.tsx" export { $S3C } from "./constants.ts" export { createEntity, createT } from "./create-t.tsx" +export { EventPlugin } from "./event-plugin.ts" export { useFrame, useThree } from "./hooks.ts" export { useProps } from "./props.ts" export * from "./raycasters.tsx" diff --git a/src/props.ts b/src/props.ts index 541280f..ad0e27e 100644 --- a/src/props.ts +++ b/src/props.ts @@ -21,11 +21,9 @@ import { Texture, UnsignedByteType, } from "three" -import { isEventType } from "./create-events.ts" import { useThree } from "./hooks.ts" -import { addToEventListeners } from "./internal-context.ts" import type { AccessorMaybe, Context, Meta, Plugin } from "./types.ts" -import { getMeta, hasColorSpace, hasMeta, resolve } from "./utils.ts" +import { getMeta, hasColorSpace, resolve } from "./utils.ts" function isWritable(object: object, propertyName: string) { return Object.getOwnPropertyDescriptor(object, propertyName)?.writable @@ -208,21 +206,6 @@ function applyProp>( } } - if (isEventType(type)) { - if (source instanceof Object3D && hasMeta(source)) { - const cleanup = addToEventListeners(source, type) - onCleanup(cleanup) - } else { - console.error( - "Event handlers can only be added to Three elements extending from Object3D. Ignored event-type:", - type, - "from element", - source, - ) - } - return - } - const target = source[type] try { @@ -308,7 +291,7 @@ export function useProps>( props: { plugins?: Plugin[] } & Record, plugins?: Plugin[], ) { - const context: Pick = useThree() + const context = useThree() const [local, instanceProps] = splitProps(props, [ "ref", @@ -319,13 +302,14 @@ export function useProps>( "plugins", ]) - useSceneGraph(accessor, props) - const pluginMethods = createMemo(() => mergeProps( - ...[...(plugins ?? []), ...(props.plugins ?? [])].map(init => () => init(resolve(accessor))), + ...[...(plugins ?? []), ...(props.plugins ?? [])].map( + init => () => context.registerPlugin(init)(resolve(accessor)), + ), ), ) + useSceneGraph(accessor, props) createRenderEffect(() => { const object = resolve(accessor) diff --git a/src/types.ts b/src/types.ts index 0dc226c..13f34a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { Accessor, JSX, MergeProps, Ref } from "solid-js" +import type { Accessor, JSX, Ref } from "solid-js" import type { Clock, ColorRepresentation, @@ -24,12 +24,6 @@ import type { $S3C } from "./constants.ts" import type { EventRaycaster } from "./raycasters.tsx" import type { Measure } from "./utils/use-measure.ts" -declare global { - namespace $3 { - // type Plugins = Plugin[] - } -} - /**********************************************************************************/ /* */ /* Utils */ @@ -137,6 +131,7 @@ export interface Context { dpr: number gl: Meta props: CanvasProps + registerPlugin(plugin: Plugin): (element: any) => void render: (delta: number) => void requestRender: () => void scene: Meta @@ -290,12 +285,16 @@ export type Matrix4 = Representation /* */ /**********************************************************************************/ +type PluginEntity = (entity: T) => U + export interface Plugin = object> { - (element: U): T + (): (element: U) => T } -export type InferPluginProps = MergeProps<{ - [TKey in keyof TPlugins]: ReturnType +export type InferPluginProps = Merge<{ + [TKey in keyof TPlugins]: TPlugins[TKey] extends () => (element: any) => infer U + ? { [TKey in keyof U]: U[TKey] extends (callback: infer V) => any ? V : never } + : never }> export type Meta = T & { @@ -345,18 +344,17 @@ type Simplify = T extends any [K in keyof T]: T[K] } : T -type _Merge = T extends [ + +type _Merge = T extends [ infer Next | (() => infer Next), ...infer Rest, ] - ? _Merge> + ? _Merge> : T extends [...infer Rest, infer Next] - ? Override<_Merge, Next> + ? Override<_Merge, Next> : T extends [] - ? Curr - : // : T extends (infer I)[] - // ? OverrideSpread - Curr + ? Current + : Current export type Merge = Simplify<_Merge> type DistributeOverride = T extends undefined ? F : T From 268cbee7da501eebcd7471a524ff05285fac3ab6 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 20:13:25 +0200 Subject: [PATCH 03/26] feat: conditional plugins --- playground/examples/PluginExample.tsx | 89 +++++++++++++++----- src/create-t.tsx | 10 +-- src/plugin-guide.md | 115 ++++++++++++++++++++++++++ src/types.ts | 60 +++++++++++++- 4 files changed, 245 insertions(+), 29 deletions(-) create mode 100644 src/plugin-guide.md diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index cc2cd99..e411a21 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -1,38 +1,76 @@ -import type { InferPluginsFromT } from "create-t.tsx" import * as THREE from "three" -import type { InferPluginProps, Plugin } from "types.ts" +import type { Meta, Plugin } from "types.ts" import { createT, Resource, useFrame, useThree } from "../../src/index.ts" import { OrbitControls } from "../controls/OrbitControls.tsx" -const Plugin1 = (() => { - return function (element: U) { - return { - lookAt: (target: THREE.Object3D) => { - useFrame(() => { - ;(element as THREE.Object3D).lookAt(target.position) - }) - }, +// LookAt plugin - works for all Object3D elements +interface LookAtPluginFn { + (element: THREE.Object3D): { + lookAt(target: THREE.Object3D | [number, number, number]): void + } + (element: any): {} +} + +const LookAtPlugin: Plugin = () => { + return ((element: any) => { + if (element instanceof THREE.Object3D) { + return { + lookAt: (target: THREE.Object3D | [number, number, number]) => { + useFrame(() => { + if (Array.isArray(target)) { + element.lookAt(...target) + } else { + element.lookAt(target.position) + } + }) + }, + } } + return {} + }) as LookAtPluginFn +} + +// Shake plugin - works only for Camera elements +interface ShakePluginFn { + (element: THREE.Camera): { + shake(intensity?: number): void } -}) satisfies Plugin + (element: any): {} +} -const { T, Canvas } = createT(THREE, [Plugin1 /* EventPlugin */]) +const ShakePlugin: Plugin = () => { + return ((element: any) => { + if (element instanceof THREE.Camera) { + return { + shake: (intensity = 0.1) => { + const originalPosition = element.position.clone() + useFrame(() => { + element.position.x = originalPosition.x + (Math.random() - 0.5) * intensity + element.position.y = originalPosition.y + (Math.random() - 0.5) * intensity + element.position.z = originalPosition.z + (Math.random() - 0.5) * intensity + }) + }, + } + } + return {} + }) as ShakePluginFn +} -type X = InferPluginsFromT -type Y = InferPluginProps +const { T, Canvas } = createT(THREE, [LookAtPlugin, ShakePlugin]) export function PluginExample() { + let cubeRef: Meta + let cameraRef: Meta + return ( - console.info("mousemove!", event)} - // onMouseDown={event => console.info("mousedown!", event)} - > + + {/* Mesh with lookAt (from LookAtPlugin) */} + + + {/* Camera with shake (from ShakePlugin) */} + + + {/* + These would cause TypeScript errors: + // ❌ Mesh doesn't have shake + // ❌ Light doesn't have shake + // ❌ Light doesn't inherit from Object3D in our type system + */} + + + ) } diff --git a/src/create-t.tsx b/src/create-t.tsx index 9f7c53e..41fbd5b 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -25,14 +25,8 @@ export function createT< [K in keyof TCatalogue]: ( props: { plugins?: TPlugins } & Partial< TPlugins extends Plugin[] - ? Merge< - [ - Props, - InferPluginProps, - InferPluginProps, - ] - > - : Merge<[Props, InferPluginProps]> + ? Props + : Props >, ) => JSXElement } & { [$S3C]: TCataloguePlugins } diff --git a/src/plugin-guide.md b/src/plugin-guide.md new file mode 100644 index 0000000..129691f --- /dev/null +++ b/src/plugin-guide.md @@ -0,0 +1,115 @@ +# Conditional Plugin System for solid-three + +This system allows plugins to provide different methods based on the element type, with full TypeScript support. + +## How It Works + +Due to TypeScript's limitations with conditional generic types, we use function overloads to achieve type-safe conditional plugins. + +## Example + +```typescript +// 1. Define overloaded interface +interface TransformPluginFn { + (element: Mesh): { + lookAt(target: Object3D): void + bounce(height?: number): void + spin(speed?: number): void + } + (element: Camera): { + lookAt(target: Object3D): void + shake(intensity?: number): void + } + (element: Light): { + pulse(minIntensity?: number, maxIntensity?: number): void + } + (element: Object3D): { + lookAt(target: Object3D): void + } + (element: any): {} +} + +// 2. Create plugin with explicit typing +const TransformPlugin: Plugin = (() => { + return ((element: any) => { + const methods: any = {} + + // Base Object3D methods + if (element instanceof Object3D) { + methods.lookAt = (target: Object3D) => { + useFrame(() => element.lookAt(target.position)) + } + } + + // Mesh-specific methods + if (element instanceof Mesh) { + methods.bounce = (height = 1) => { + useFrame((ctx) => { + element.position.y = Math.abs(Math.sin(ctx.clock.elapsedTime)) * height + }) + } + methods.spin = (speed = 1) => { + useFrame((_, delta) => element.rotation.y += delta * speed) + } + } + + // Camera-specific methods + if (element instanceof Camera) { + methods.shake = (intensity = 0.1) => { + useFrame(() => { + element.position.x += (Math.random() - 0.5) * intensity + }) + } + } + + // Light-specific methods + if (element instanceof Light) { + methods.pulse = (min = 0.5, max = 1) => { + useFrame((ctx) => { + const t = (Math.sin(ctx.clock.elapsedTime) + 1) / 2 + element.intensity = min + (max - min) * t + }) + } + } + + return methods + }) as TransformPluginFn +}) + +// 3. Use the plugin +const { T, Canvas } = createT(THREE, [TransformPlugin]) + +function App() { + return ( + + {/* ✅ Mesh gets: lookAt, bounce, spin */} + + + + + {/* ✅ Camera gets: lookAt, shake */} + + + {/* ✅ Light gets: pulse */} + + + {/* ❌ These would cause TypeScript errors: */} + {/* */} + {/* */} + + ) +} +``` + +## Key Points + +1. **Order matters**: Place more specific types before general ones +2. **Runtime checks**: Use `instanceof` to determine available methods +3. **Type safety**: TypeScript enforces correct usage at compile time +4. **Explicit typing**: Use `Plugin` for clear type declarations + +## Result + +- **Type Safety**: Only correct methods are available for each element type +- **IntelliSense**: Full autocompletion support +- **Error Prevention**: TypeScript catches incorrect usage at compile time \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 13f34a7..3da45a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -287,8 +287,8 @@ export type Matrix4 = Representation type PluginEntity = (entity: T) => U -export interface Plugin = object> { - (): (element: U) => T +export interface Plugin any> { + (): TFn } export type InferPluginProps = Merge<{ @@ -297,6 +297,61 @@ export type InferPluginProps = Merge<{ : never }> +/** + * Helper type to resolve overloaded function returns + * Matches overloads from most specific to least specific + */ +type ResolveOverload = + F extends { (element: infer P1): infer R1; (element: infer P2): infer R2; (element: infer P3): infer R3; (element: infer P4): infer R4; (element: infer P5): infer R5 } + ? T extends P1 ? R1 : T extends P2 ? R2 : T extends P3 ? R3 : T extends P4 ? R4 : T extends P5 ? R5 : never + : F extends { (element: infer P1): infer R1; (element: infer P2): infer R2; (element: infer P3): infer R3; (element: infer P4): infer R4 } + ? T extends P1 ? R1 : T extends P2 ? R2 : T extends P3 ? R3 : T extends P4 ? R4 : never + : F extends { (element: infer P1): infer R1; (element: infer P2): infer R2; (element: infer P3): infer R3 } + ? T extends P1 ? R1 : T extends P2 ? R2 : T extends P3 ? R3 : never + : F extends { (element: infer P1): infer R1; (element: infer P2): infer R2 } + ? T extends P1 ? R1 : T extends P2 ? R2 : never + : F extends { (element: infer P): infer R } + ? T extends P ? R : never + : never + +/** + * Resolves what a plugin returns for a specific element type T + * Handles both simple functions and overloaded functions + */ +type ResolvePluginReturn = TPlugin extends Plugin + ? TFn extends (...args: any[]) => any + ? ResolveOverload extends never + ? TFn extends (element: T) => infer R + ? R + : TFn extends (element: any) => infer R + ? R + : {} + : ResolveOverload + : {} + : TPlugin extends () => infer PluginFn + ? ResolveOverload extends never + ? PluginFn extends (element: T) => infer R + ? R + : PluginFn extends (element: any) => infer R + ? R + : {} + : ResolveOverload + : {} + +/** + * Resolves plugin props for a specific element type T + * This allows plugins to provide conditional methods based on the actual element type + */ +export type ResolvePluginPropsForType = Merge<{ + [K in keyof TPlugins]: ResolvePluginReturn extends infer Methods + ? Methods extends Record + ? { + [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never + } + : {} + : {} +}> + export type Meta = T & { [$S3C]: Data } @@ -335,6 +390,7 @@ export type Props = Partial raycastable: boolean plugins: TPlugins }, + TPlugins extends Plugin[] ? ResolvePluginPropsForType, TPlugins> : {}, ] > > From 604f61065bff29c1ded441610ece834a3e27448e Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 20:32:51 +0200 Subject: [PATCH 04/26] feat: implement plugin builder API with conditional type support Add createPlugin() builder API that enables type-safe conditional plugins: - createPlugin().extends(Constructor).provide() for filtered plugins - createPlugin().provide() for global plugins - createPlugin(setup).provide() for plugins requiring initialization - Proper context passing from setup to provide functions Refactor EventPlugin to use new builder pattern, eliminating interface duplication and improving developer experience. The new API provides better type inference and cleaner syntax while maintaining full type safety for conditional plugin methods. --- .claude/settings.local.json | 5 +- playground/examples/PluginExample.tsx | 94 ++++++++--------- src/event-plugin.ts | 44 +++++++- src/index.ts | 1 + src/types.ts | 144 ++++++++++++++++++++++++-- 5 files changed, 220 insertions(+), 68 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2af7796..ecee3b0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "WebFetch(domain:github.com)" + "WebFetch(domain:github.com)", + "Bash(pnpm lint:types:*)", + "Bash(rm:*)", + "Bash(ls:*)" ], "deny": [] } diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index e411a21..9557584 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -1,62 +1,45 @@ import * as THREE from "three" -import type { Meta, Plugin } from "types.ts" -import { createT, Resource, useFrame, useThree } from "../../src/index.ts" +import type { Meta } from "types.ts" +import { + createPlugin, + createT, + EventPlugin, + Resource, + useFrame, + useThree, +} from "../../src/index.ts" import { OrbitControls } from "../controls/OrbitControls.tsx" // LookAt plugin - works for all Object3D elements -interface LookAtPluginFn { - (element: THREE.Object3D): { - lookAt(target: THREE.Object3D | [number, number, number]): void - } - (element: any): {} -} - -const LookAtPlugin: Plugin = () => { - return ((element: any) => { - if (element instanceof THREE.Object3D) { - return { - lookAt: (target: THREE.Object3D | [number, number, number]) => { - useFrame(() => { - if (Array.isArray(target)) { - element.lookAt(...target) - } else { - element.lookAt(target.position) - } - }) - }, - } - } - return {} - }) as LookAtPluginFn -} +const LookAtPlugin = createPlugin() + .extends(THREE.Object3D) + .provide(element => ({ + lookAt: (target: THREE.Object3D | [number, number, number]) => { + useFrame(() => { + if (Array.isArray(target)) { + element.lookAt(...target) + } else { + element.lookAt(target.position) + } + }) + }, + })) // Shake plugin - works only for Camera elements -interface ShakePluginFn { - (element: THREE.Camera): { - shake(intensity?: number): void - } - (element: any): {} -} - -const ShakePlugin: Plugin = () => { - return ((element: any) => { - if (element instanceof THREE.Camera) { - return { - shake: (intensity = 0.1) => { - const originalPosition = element.position.clone() - useFrame(() => { - element.position.x = originalPosition.x + (Math.random() - 0.5) * intensity - element.position.y = originalPosition.y + (Math.random() - 0.5) * intensity - element.position.z = originalPosition.z + (Math.random() - 0.5) * intensity - }) - }, - } - } - return {} - }) as ShakePluginFn -} +const ShakePlugin = createPlugin() + .extends(THREE.Camera) + .provide(element => ({ + shake: (intensity = 0.1) => { + const originalPosition = element.position.clone() + useFrame(() => { + element.position.x = originalPosition.x + (Math.random() - 0.5) * intensity + element.position.y = originalPosition.y + (Math.random() - 0.5) * intensity + element.position.z = originalPosition.z + (Math.random() - 0.5) * intensity + }) + }, + })) -const { T, Canvas } = createT(THREE, [LookAtPlugin, ShakePlugin]) +const { T, Canvas } = createT(THREE, [LookAtPlugin, ShakePlugin, EventPlugin]) export function PluginExample() { let cubeRef: Meta @@ -70,7 +53,12 @@ export function PluginExample() { {/* Mesh with lookAt (from LookAtPlugin) */} - + `. */ -export const EventPlugin = (() => { +export const EventPlugin = createPlugin(() => { const context = useThree() // onMouseMove/onMouseEnter/onMouseLeave @@ -449,7 +456,34 @@ export const EventPlugin = (() => { // Default wheel-event const wheelRegistry = createDefaultEventRegistry("onWheel", context, { passive: true }) - return object => { + return { + hoverMouseRegistry, + hoverPointerRegistry, + missableClickRegistry, + missableContextMenuRegistry, + missableDoubleClickRegistry, + mouseDownRegistry, + mouseUpRegistry, + pointerDownRegistry, + pointerUpRegistry, + wheelRegistry, + } +}).provide( + ( + object, + { + hoverMouseRegistry, + hoverPointerRegistry, + missableClickRegistry, + missableContextMenuRegistry, + missableDoubleClickRegistry, + mouseDownRegistry, + mouseUpRegistry, + pointerDownRegistry, + pointerUpRegistry, + wheelRegistry, + }, + ) => { return { onClick(callback: (event: Event) => void) { onCleanup(missableClickRegistry.add(object)) @@ -503,5 +537,5 @@ export const EventPlugin = (() => { onCleanup(wheelRegistry.add(object)) }, } - } -}) satisfies Plugin + }, +) diff --git a/src/index.ts b/src/index.ts index b73b4fd..c5418c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,4 +7,5 @@ export { useFrame, useThree } from "./hooks.ts" export { useProps } from "./props.ts" export * from "./raycasters.tsx" export * as S3 from "./types.ts" +export { createPlugin } from "./types.ts" export { autodispose, getMeta, hasMeta as hasMeta, load, meta } from "./utils.ts" diff --git a/src/types.ts b/src/types.ts index 3da45a5..23967f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -291,6 +291,88 @@ export interface Plugin any> { (): TFn } +/** + * Creates a plugin with a fluent builder API + * Usage: + * - createPlugin(() => { setup }).filter(Constructor).provide((element, context) => methods) + * - createPlugin(() => { setup }).provide((element, context) => methods) // no filtering + */ +export function createPlugin(setup?: () => void) { + return { + // Direct provide without filtering - applies to all elements + provide>(methods: (element: any, context: any) => Methods) { + type PluginFn = (element: any) => Methods + + const plugin: Plugin = () => { + // Run setup once if provided and store result as context + const context = setup ? setup() : undefined + + return ((element: any) => { + return methods(element, context) + }) as PluginFn + } + + return plugin + }, + + // Filtered provide - only applies to specific types + extends(Constructor: new (...args: any[]) => T) { + return { + provide>( + methods: (element: T, context: any) => Methods, + ) { + type PluginFn = { + (element: T): Methods + (element: any): {} + } + + const plugin: Plugin = () => { + // Run setup once if provided and store result as context + const context = setup ? setup() : undefined + + return ((element: any) => { + if (element instanceof Constructor) { + return methods(element as T, context) + } + return {} + }) as PluginFn + } + + return plugin + }, + } + }, + + // Alternative for custom conditions + where(condition: (element: any) => element is T) { + return { + provide>( + methods: (element: T, context: any) => Methods, + ) { + type PluginFn = { + (element: T): Methods + (element: any): {} + } + + const plugin: Plugin = () => { + // Run setup once if provided and store result as context + const context = setup ? setup() : undefined + + return ((element: any) => { + if (condition(element)) { + return methods(element as T, context) + } + return {} + }) as PluginFn + } + + return plugin + }, + } + }, + } +} + export type InferPluginProps = Merge<{ [TKey in keyof TPlugins]: TPlugins[TKey] extends () => (element: any) => infer U ? { [TKey in keyof U]: U[TKey] extends (callback: infer V) => any ? V : never } @@ -301,17 +383,61 @@ export type InferPluginProps = Merge<{ * Helper type to resolve overloaded function returns * Matches overloads from most specific to least specific */ -type ResolveOverload = - F extends { (element: infer P1): infer R1; (element: infer P2): infer R2; (element: infer P3): infer R3; (element: infer P4): infer R4; (element: infer P5): infer R5 } - ? T extends P1 ? R1 : T extends P2 ? R2 : T extends P3 ? R3 : T extends P4 ? R4 : T extends P5 ? R5 : never - : F extends { (element: infer P1): infer R1; (element: infer P2): infer R2; (element: infer P3): infer R3; (element: infer P4): infer R4 } - ? T extends P1 ? R1 : T extends P2 ? R2 : T extends P3 ? R3 : T extends P4 ? R4 : never - : F extends { (element: infer P1): infer R1; (element: infer P2): infer R2; (element: infer P3): infer R3 } - ? T extends P1 ? R1 : T extends P2 ? R2 : T extends P3 ? R3 : never +type ResolveOverload = F extends { + (element: infer P1): infer R1 + (element: infer P2): infer R2 + (element: infer P3): infer R3 + (element: infer P4): infer R4 + (element: infer P5): infer R5 +} + ? T extends P1 + ? R1 + : T extends P2 + ? R2 + : T extends P3 + ? R3 + : T extends P4 + ? R4 + : T extends P5 + ? R5 + : never + : F extends { + (element: infer P1): infer R1 + (element: infer P2): infer R2 + (element: infer P3): infer R3 + (element: infer P4): infer R4 + } + ? T extends P1 + ? R1 + : T extends P2 + ? R2 + : T extends P3 + ? R3 + : T extends P4 + ? R4 + : never + : F extends { + (element: infer P1): infer R1 + (element: infer P2): infer R2 + (element: infer P3): infer R3 + } + ? T extends P1 + ? R1 + : T extends P2 + ? R2 + : T extends P3 + ? R3 + : never : F extends { (element: infer P1): infer R1; (element: infer P2): infer R2 } - ? T extends P1 ? R1 : T extends P2 ? R2 : never + ? T extends P1 + ? R1 + : T extends P2 + ? R2 + : never : F extends { (element: infer P): infer R } - ? T extends P ? R : never + ? T extends P + ? R + : never : never /** From 713e226b5ac0ecf1e46ef4f462d1441f3ca7ac66 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 20:44:48 +0200 Subject: [PATCH 05/26] feat: add multiple classes to createFilter().extends(...) --- playground/examples/PluginExample.tsx | 37 +++++++++++++++++------ src/types.ts | 43 ++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index 9557584..f452714 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -25,9 +25,9 @@ const LookAtPlugin = createPlugin() }, })) -// Shake plugin - works only for Camera elements +// Shake plugin - works for both Camera and Light elements const ShakePlugin = createPlugin() - .extends(THREE.Camera) + .extends(THREE.Camera, THREE.DirectionalLight) .provide(element => ({ shake: (intensity = 0.1) => { const originalPosition = element.position.clone() @@ -39,7 +39,24 @@ const ShakePlugin = createPlugin() }, })) -const { T, Canvas } = createT(THREE, [LookAtPlugin, ShakePlugin, EventPlugin]) +// Custom filter plugin - works for objects with a 'material' property +const MaterialPlugin = createPlugin() + .filter( + (element): element is THREE.Mesh => + element instanceof THREE.Mesh && element.material !== undefined, + ) + .provide(element => ({ + highlight: (color: string = "yellow") => { + const material = element.material as THREE.MeshBasicMaterial + material.color.set(color) + }, + setColor: (color: string) => { + const material = element.material as THREE.MeshBasicMaterial + material.color.setHex(parseInt(color.replace("#", ""), 16)) + }, + })) + +const { T, Canvas } = createT(THREE, [LookAtPlugin, ShakePlugin, MaterialPlugin, EventPlugin]) export function PluginExample() { let cubeRef: Meta @@ -52,11 +69,12 @@ export function PluginExample() { > - {/* Mesh with lookAt (from LookAtPlugin) */} + {/* Mesh with lookAt (from LookAtPlugin) and material methods (from MaterialPlugin) */} @@ -73,12 +91,13 @@ export function PluginExample() { {/* Camera with shake (from ShakePlugin) */} - {/* + These would cause TypeScript errors: - // ❌ Mesh doesn't have shake - // ❌ Light doesn't have shake - // ❌ Light doesn't inherit from Object3D in our type system - */} + // ❌ Mesh doesn't have shake (Camera/Light only) + // ❌ Light doesn't have shake (Camera/Light only but DirectionalLight not included) + // ❌ Light doesn't inherit from Object3D in our type system + // ❌ Light doesn't have material (MaterialPlugin filter) + diff --git a/src/types.ts b/src/types.ts index 23967f0..6aa5895 100644 --- a/src/types.ts +++ b/src/types.ts @@ -315,8 +315,43 @@ export function createPlugin(setup?: () => void) { return plugin }, - // Filtered provide - only applies to specific types - extends(Constructor: new (...args: any[]) => T) { + // Filtered provide - supports single or multiple types + extends any)[]>( + ...Constructors: T + ) { + type UnionType = T extends readonly (new (...args: any[]) => infer U)[] ? U : never + + return { + provide>( + methods: (element: UnionType, context: any) => Methods, + ) { + type PluginFn = { + (element: UnionType): Methods + (element: any): {} + } + + const plugin: Plugin = () => { + // Run setup once if provided and store result as context + const context = setup ? setup() : undefined + + return ((element: any) => { + // Check if element is instance of any of the constructors + for (const Constructor of Constructors) { + if (element instanceof Constructor) { + return methods(element as UnionType, context) + } + } + return {} + }) as PluginFn + } + + return plugin + }, + } + }, + + // Custom type guard filtering + filter(condition: (element: any) => element is T) { return { provide>( methods: (element: T, context: any) => Methods, @@ -331,7 +366,7 @@ export function createPlugin(setup?: () => void) { const context = setup ? setup() : undefined return ((element: any) => { - if (element instanceof Constructor) { + if (condition(element)) { return methods(element as T, context) } return {} @@ -343,7 +378,7 @@ export function createPlugin(setup?: () => void) { } }, - // Alternative for custom conditions + // Alternative for custom conditions (alias for filter) where(condition: (element: any) => element is T) { return { provide>( From f804a12c713cff024c483dcf2ab50ac04d591351 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 20:58:31 +0200 Subject: [PATCH 06/26] feat: unify plugin API with .prop() method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace separate .extends() and .filter() methods with unified .prop() - Support three filtering patterns: - Single constructor: .prop(THREE.Mesh, element => ...) - Multiple constructors: .prop([THREE.Camera, THREE.Light], element => ...) - Type guards: .prop((element): element is T => condition, element => ...) - Add generic context typing to PluginBuilder - Update examples to demonstrate all three patterns - Maintain full TypeScript inference and runtime type checking 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- playground/examples/PluginExample.tsx | 88 ++++++------- src/types.ts | 174 ++++++++++++-------------- 2 files changed, 122 insertions(+), 140 deletions(-) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index f452714..23a0205 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -11,41 +11,35 @@ import { import { OrbitControls } from "../controls/OrbitControls.tsx" // LookAt plugin - works for all Object3D elements -const LookAtPlugin = createPlugin() - .extends(THREE.Object3D) - .provide(element => ({ - lookAt: (target: THREE.Object3D | [number, number, number]) => { - useFrame(() => { - if (Array.isArray(target)) { - element.lookAt(...target) - } else { - element.lookAt(target.position) - } - }) - }, - })) +const LookAtPlugin = createPlugin().prop(THREE.Object3D, element => ({ + lookAt: (target: THREE.Object3D | [number, number, number]) => { + useFrame(() => { + if (Array.isArray(target)) { + element.lookAt(...target) + } else { + element.lookAt(target.position) + } + }) + }, +})) -// Shake plugin - works for both Camera and Light elements -const ShakePlugin = createPlugin() - .extends(THREE.Camera, THREE.DirectionalLight) - .provide(element => ({ - shake: (intensity = 0.1) => { - const originalPosition = element.position.clone() - useFrame(() => { - element.position.x = originalPosition.x + (Math.random() - 0.5) * intensity - element.position.y = originalPosition.y + (Math.random() - 0.5) * intensity - element.position.z = originalPosition.z + (Math.random() - 0.5) * intensity - }) - }, - })) +// Shake plugin - works for both Camera and Light elements using array syntax +const ShakePlugin = createPlugin().prop([THREE.Camera, THREE.DirectionalLight], element => ({ + shake: (intensity = 0.1) => { + const originalPosition = element.position.clone() + useFrame(() => { + element.position.x = originalPosition.x + (Math.random() - 0.5) * intensity + element.position.y = originalPosition.y + (Math.random() - 0.5) * intensity + element.position.z = originalPosition.z + (Math.random() - 0.5) * intensity + }) + }, +})) -// Custom filter plugin - works for objects with a 'material' property -const MaterialPlugin = createPlugin() - .filter( - (element): element is THREE.Mesh => - element instanceof THREE.Mesh && element.material !== undefined, - ) - .provide(element => ({ +// Custom filter plugin - works for objects with a 'material' property using type guard +const MaterialPlugin = createPlugin().prop( + (element): element is THREE.Mesh => + element instanceof THREE.Mesh && element.material !== undefined, + element => ({ highlight: (color: string = "yellow") => { const material = element.material as THREE.MeshBasicMaterial material.color.set(color) @@ -54,9 +48,10 @@ const MaterialPlugin = createPlugin() const material = element.material as THREE.MeshBasicMaterial material.color.setHex(parseInt(color.replace("#", ""), 16)) }, - })) + }), +) -const { T, Canvas } = createT(THREE, [LookAtPlugin, ShakePlugin, MaterialPlugin, EventPlugin]) +const { T, Canvas } = createT(THREE, [LookAtPlugin, ShakePlugin, EventPlugin, MaterialPlugin]) export function PluginExample() { let cubeRef: Meta @@ -68,16 +63,15 @@ export function PluginExample() { defaultCamera={{ position: new THREE.Vector3(0, 0, 30) }} > - {/* Mesh with lookAt (from LookAtPlugin) and material methods (from MaterialPlugin) */} - + - {/* Camera with shake (from ShakePlugin) */} - - - These would cause TypeScript errors: - // ❌ Mesh doesn't have shake (Camera/Light only) - // ❌ Light doesn't have shake (Camera/Light only but DirectionalLight not included) - // ❌ Light doesn't inherit from Object3D in our type system - // ❌ Light doesn't have material (MaterialPlugin filter) - - + These would cause TypeScript errors: + // ❌ Mesh doesn't have shake (Camera/Light only) + // ❌ Light doesn't have shake (Camera/Light only but + DirectionalLight not included) + // ❌ Light doesn't inherit from Object3D in our type + system + // ❌ Light doesn't inherit from Object3D in our type + system diff --git a/src/types.ts b/src/types.ts index 6aa5895..a679f4e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -297,10 +297,86 @@ export interface Plugin any> { * - createPlugin(() => { setup }).filter(Constructor).provide((element, context) => methods) * - createPlugin(() => { setup }).provide((element, context) => methods) // no filtering */ -export function createPlugin(setup?: () => void) { +// Helper function to create the actual plugin implementation +function createFilteredPlugin( + setup: (() => any) | undefined, + filterArg: any, + methods: any, +): Plugin { + const plugin: Plugin = () => { + // Run setup once if provided and store result as context + const context = setup ? setup() : undefined + + return ((element: any) => { + // Handle single constructor + if (typeof filterArg === "function" && filterArg.prototype) { + if (element instanceof filterArg) { + return methods(element, context) + } + } + // Handle array of constructors + else if (Array.isArray(filterArg)) { + for (const Constructor of filterArg) { + if (element instanceof Constructor) { + return methods(element, context) + } + } + } + // Handle type guard function + else if (typeof filterArg === "function") { + if (filterArg(element)) { + return methods(element, context) + } + } + + return {} + }) as any + } + + return plugin +} + +interface PluginBuilder { + provide>( + methods: (element: any, context: TContext) => Methods, + ): Plugin<(element: any) => Methods> + + prop any, Methods extends Record>( + Constructor: T, + methods: (element: InstanceType, context: TContext) => Methods, + ): Plugin<{ + (element: InstanceType): Methods + (element: any): {} + }> + + prop any)[], Methods extends Record>( + Constructors: T, + methods: ( + element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never, + context: TContext, + ) => Methods, + ): Plugin<{ + (element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never): Methods + (element: any): {} + }> + + prop>( + condition: (element: unknown) => element is T, + methods: (element: T, context: TContext) => Methods, + ): Plugin<{ + (element: T): Methods + (element: any): {} + }> +} + +export function createPlugin( + setup?: () => TContext, +): PluginBuilder { return { // Direct provide without filtering - applies to all elements - provide>(methods: (element: any, context: any) => Methods) { + provide>( + methods: (element: any, context: TContext) => Methods, + ) { type PluginFn = (element: any) => Methods const plugin: Plugin = () => { @@ -315,97 +391,11 @@ export function createPlugin(setup?: () => void) { return plugin }, - // Filtered provide - supports single or multiple types - extends any)[]>( - ...Constructors: T - ) { - type UnionType = T extends readonly (new (...args: any[]) => infer U)[] ? U : never - - return { - provide>( - methods: (element: UnionType, context: any) => Methods, - ) { - type PluginFn = { - (element: UnionType): Methods - (element: any): {} - } - - const plugin: Plugin = () => { - // Run setup once if provided and store result as context - const context = setup ? setup() : undefined - - return ((element: any) => { - // Check if element is instance of any of the constructors - for (const Constructor of Constructors) { - if (element instanceof Constructor) { - return methods(element as UnionType, context) - } - } - return {} - }) as PluginFn - } - - return plugin - }, - } - }, - - // Custom type guard filtering - filter(condition: (element: any) => element is T) { - return { - provide>( - methods: (element: T, context: any) => Methods, - ) { - type PluginFn = { - (element: T): Methods - (element: any): {} - } - - const plugin: Plugin = () => { - // Run setup once if provided and store result as context - const context = setup ? setup() : undefined - - return ((element: any) => { - if (condition(element)) { - return methods(element as T, context) - } - return {} - }) as PluginFn - } - - return plugin - }, - } - }, - - // Alternative for custom conditions (alias for filter) - where(condition: (element: any) => element is T) { - return { - provide>( - methods: (element: T, context: any) => Methods, - ) { - type PluginFn = { - (element: T): Methods - (element: any): {} - } - - const plugin: Plugin = () => { - // Run setup once if provided and store result as context - const context = setup ? setup() : undefined - - return ((element: any) => { - if (condition(element)) { - return methods(element as T, context) - } - return {} - }) as PluginFn - } - - return plugin - }, - } + // Implementation for all filter overloads + prop(filterArg: any, methods: any): Plugin { + return createFilteredPlugin(setup, filterArg, methods) }, - } + } as PluginBuilder } export type InferPluginProps = Merge<{ From ca52caf5bfe10194833b91679d751660f43a30b4 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 21:11:26 +0200 Subject: [PATCH 07/26] feat: consolidate plugin API to single .prop() method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove separate .provide() method in favor of unified .prop() - Support single argument for global plugins: .prop((element, context) => methods) - Support two arguments for filtered plugins: .prop(filter, methods) - Add proper TypeScript interface with method overloads - Update EventPlugin to use new unified API - Add GlobalPlugin example demonstrating single-argument usage - Fix type inference issues with generic context typing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- playground/examples/PluginExample.tsx | 31 ++++++++++------- src/event-plugin.ts | 2 +- src/types.ts | 49 +++++++++++++++------------ 3 files changed, 47 insertions(+), 35 deletions(-) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index 23a0205..149ea3f 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -37,7 +37,7 @@ const ShakePlugin = createPlugin().prop([THREE.Camera, THREE.DirectionalLight], // Custom filter plugin - works for objects with a 'material' property using type guard const MaterialPlugin = createPlugin().prop( - (element): element is THREE.Mesh => + (element: any): element is THREE.Mesh => element instanceof THREE.Mesh && element.material !== undefined, element => ({ highlight: (color: string = "yellow") => { @@ -51,7 +51,20 @@ const MaterialPlugin = createPlugin().prop( }), ) -const { T, Canvas } = createT(THREE, [LookAtPlugin, ShakePlugin, EventPlugin, MaterialPlugin]) +// Global plugin - applies to all elements using single argument +const GlobalPlugin = createPlugin().prop((element, context) => ({ + log: (message: string) => { + console.log(`[${element.constructor.name}] ${message}`) + }, +})) + +const { T, Canvas } = createT(THREE, [ + LookAtPlugin, + ShakePlugin, + EventPlugin, + MaterialPlugin, + GlobalPlugin, +]) export function PluginExample() { let cubeRef: Meta @@ -66,12 +79,13 @@ export function PluginExample() { {/* Mesh with lookAt (from LookAtPlugin) and material methods (from MaterialPlugin) */} - + {/* Camera with shake (from ShakePlugin) */} - These would cause TypeScript errors: - // ❌ Mesh doesn't have shake (Camera/Light only) - // ❌ Light doesn't have shake (Camera/Light only but - DirectionalLight not included) - // ❌ Light doesn't inherit from Object3D in our type - system - // ❌ Light doesn't inherit from Object3D in our type - system + diff --git a/src/event-plugin.ts b/src/event-plugin.ts index cd13285..71c3e8f 100644 --- a/src/event-plugin.ts +++ b/src/event-plugin.ts @@ -468,7 +468,7 @@ export const EventPlugin = createPlugin(() => { pointerUpRegistry, wheelRegistry, } -}).provide( +}).prop( ( object, { diff --git a/src/types.ts b/src/types.ts index a679f4e..fb29b6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -292,10 +292,12 @@ export interface Plugin any> { } /** - * Creates a plugin with a fluent builder API + * Creates a plugin with a unified prop API * Usage: - * - createPlugin(() => { setup }).filter(Constructor).provide((element, context) => methods) - * - createPlugin(() => { setup }).provide((element, context) => methods) // no filtering + * - createPlugin(() => { setup }).prop((element, context) => methods) // apply to all elements + * - createPlugin(() => { setup }).prop(Constructor, (element, context) => methods) // single constructor filter + * - createPlugin(() => { setup }).prop([Constructor1, Constructor2], (element, context) => methods) // multiple constructors + * - createPlugin(() => { setup }).prop((element): element is T => condition, (element, context) => methods) // type guard */ // Helper function to create the actual plugin implementation function createFilteredPlugin( @@ -337,10 +339,12 @@ function createFilteredPlugin( } interface PluginBuilder { - provide>( + // Apply to all elements - single argument + prop>( methods: (element: any, context: TContext) => Methods, ): Plugin<(element: any) => Methods> + // Single constructor filter - two arguments prop any, Methods extends Record>( Constructor: T, methods: (element: InstanceType, context: TContext) => Methods, @@ -349,6 +353,7 @@ interface PluginBuilder { (element: any): {} }> + // Array of constructors filter - two arguments prop any)[], Methods extends Record>( Constructors: T, methods: ( @@ -360,6 +365,7 @@ interface PluginBuilder { (element: any): {} }> + // Type guard filter - two arguments prop>( condition: (element: unknown) => element is T, methods: (element: T, context: TContext) => Methods, @@ -373,27 +379,26 @@ export function createPlugin( setup?: () => TContext, ): PluginBuilder { return { - // Direct provide without filtering - applies to all elements - provide>( - methods: (element: any, context: TContext) => Methods, - ) { - type PluginFn = (element: any) => Methods - - const plugin: Plugin = () => { - // Run setup once if provided and store result as context - const context = setup ? setup() : undefined + // Unified prop method - handles both single and two argument cases + prop(filterArgOrMethods: any, methods?: any): Plugin { + // Single argument case - apply to all elements + if (methods === undefined) { + type PluginFn = (element: any) => any + + const plugin: Plugin = () => { + // Run setup once if provided and store result as context + const context = setup ? setup() : undefined + + return ((element: any) => { + return filterArgOrMethods(element, context) + }) as PluginFn + } - return ((element: any) => { - return methods(element, context) - }) as PluginFn + return plugin } - return plugin - }, - - // Implementation for all filter overloads - prop(filterArg: any, methods: any): Plugin { - return createFilteredPlugin(setup, filterArg, methods) + // Two argument case - use filtering + return createFilteredPlugin(setup, filterArgOrMethods, methods) }, } as PluginBuilder } From 2714041d92fbb587a830b2b8d0f1dc8e6613874c Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 21:23:33 +0200 Subject: [PATCH 08/26] chore: cleanup --- playground/examples/EnvironmentExample.tsx | 29 +------- playground/examples/Gltf.tsx | 0 playground/examples/PluginExample.tsx | 17 ++--- playground/examples/PortalExample.tsx | 4 +- playground/examples/SolarExample.tsx | 14 ++-- src/event-plugin.ts | 12 +--- src/index.ts | 2 +- src/plugin.ts | 71 ++++++++++++++++++++ src/types.ts | 78 +--------------------- 9 files changed, 93 insertions(+), 134 deletions(-) delete mode 100644 playground/examples/Gltf.tsx create mode 100644 src/plugin.ts diff --git a/playground/examples/EnvironmentExample.tsx b/playground/examples/EnvironmentExample.tsx index 68462fc..d5ebf6d 100644 --- a/playground/examples/EnvironmentExample.tsx +++ b/playground/examples/EnvironmentExample.tsx @@ -1,27 +1,8 @@ import * as THREE from "three" -import { createT, Resource } from "../../src/index.ts" +import { createT, EventPlugin, Resource } from "../../src/index.ts" import { OrbitControls } from "../controls/OrbitControls.tsx" -const Plugin1 = () => { - return { - onCustom(callback: (value: string) => void) { - callback("HALLO FROM PLUGIN1!") - }, - onYolo(callback: (value: "yolo") => void) { - callback("yolo") - }, - } -} - -const Plugin2 = () => { - return { - onCustom(callback: (value: number) => void) { - callback(2) - }, - } -} - -const { T, Canvas } = createT(THREE, [Plugin1]) +const { T, Canvas } = createT(THREE, [EventPlugin]) export function EnvironmentExample() { return ( @@ -34,11 +15,7 @@ export function EnvironmentExample() { onPointerEnter={event => console.debug("canvas pointer enter", event)} > - console.log(value)} - onCustom={value => console.log(value)} - > + ({ +const LookAtPlugin = plugin().prop(THREE.Object3D, element => ({ lookAt: (target: THREE.Object3D | [number, number, number]) => { useFrame(() => { if (Array.isArray(target)) { @@ -24,7 +17,7 @@ const LookAtPlugin = createPlugin().prop(THREE.Object3D, element => ({ })) // Shake plugin - works for both Camera and Light elements using array syntax -const ShakePlugin = createPlugin().prop([THREE.Camera, THREE.DirectionalLight], element => ({ +const ShakePlugin = plugin().prop([THREE.Camera, THREE.DirectionalLight], element => ({ shake: (intensity = 0.1) => { const originalPosition = element.position.clone() useFrame(() => { @@ -36,7 +29,7 @@ const ShakePlugin = createPlugin().prop([THREE.Camera, THREE.DirectionalLight], })) // Custom filter plugin - works for objects with a 'material' property using type guard -const MaterialPlugin = createPlugin().prop( +const MaterialPlugin = plugin().prop( (element: any): element is THREE.Mesh => element instanceof THREE.Mesh && element.material !== undefined, element => ({ @@ -52,7 +45,7 @@ const MaterialPlugin = createPlugin().prop( ) // Global plugin - applies to all elements using single argument -const GlobalPlugin = createPlugin().prop((element, context) => ({ +const GlobalPlugin = plugin().prop(element => ({ log: (message: string) => { console.log(`[${element.constructor.name}] ${message}`) }, diff --git a/playground/examples/PortalExample.tsx b/playground/examples/PortalExample.tsx index 9dcda05..34adc9a 100644 --- a/playground/examples/PortalExample.tsx +++ b/playground/examples/PortalExample.tsx @@ -1,7 +1,7 @@ import * as THREE from "three" -import { Canvas, createT, Entity, Portal } from "../../src/index.ts" +import { createT, Entity, Portal } from "../../src/index.ts" -const T = createT(THREE) +const { T, Canvas } = createT(THREE) export function PortalExample() { const group = new THREE.Group() diff --git a/playground/examples/SolarExample.tsx b/playground/examples/SolarExample.tsx index 75ecd3a..c0baa14 100644 --- a/playground/examples/SolarExample.tsx +++ b/playground/examples/SolarExample.tsx @@ -1,10 +1,10 @@ import { createEffect, createMemo, createSignal, Show, type ParentProps, type Ref } from "solid-js" import * as THREE from "three" -import { Canvas, createT, Entity, useFrame } from "../../src/index.ts" +import { createT, Entity, EventPlugin, useFrame } from "../../src/index.ts" import type { Meta } from "../../src/types.ts" import { OrbitControls } from "../controls/OrbitControls.tsx" -const T = createT(THREE) +const { T, Canvas } = createT(THREE, [EventPlugin]) function OrbitPath( props: ParentProps<{ @@ -98,7 +98,7 @@ function CelestialBody( ref={ref} position={props.position || [0, 0, 0]} rotation={props.rotation || [0, 0, 0]} - onPointerDown={console.log} + onPointerDown={console.info} onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)} > @@ -121,10 +121,10 @@ export function SolarExample() { return ( console.debug("canvas clicked", event)} - onClickMissed={event => console.debug("canvas click missed", event)} - onPointerLeave={event => console.debug("canvas pointer leave", event)} - onPointerEnter={event => console.debug("canvas pointer enter", event)} + onClick={event => console.info("canvas clicked", event)} + onClickMissed={event => console.info("canvas click missed", event)} + onPointerLeave={event => console.info("canvas pointer leave", event)} + onPointerEnter={event => console.info("canvas pointer enter", event)} > diff --git a/src/event-plugin.ts b/src/event-plugin.ts index 71c3e8f..a867bd0 100644 --- a/src/event-plugin.ts +++ b/src/event-plugin.ts @@ -1,14 +1,8 @@ import { onCleanup } from "solid-js" import { Object3D, type Intersection } from "three" import { useThree } from "./hooks.ts" -import { - createPlugin, - type Context, - type Event, - type EventName, - type Meta, - type Prettify, -} from "./types.ts" +import { plugin } from "./plugin.ts" +import { type Context, type Event, type EventName, type Meta, type Prettify } from "./types.ts" import { getMeta } from "./utils.ts" const eventNameMap = { @@ -432,7 +426,7 @@ function createDefaultEventRegistry( /** * Initializes and manages event handling for all `Instance`. */ -export const EventPlugin = createPlugin(() => { +export const EventPlugin = plugin(() => { const context = useThree() // onMouseMove/onMouseEnter/onMouseLeave diff --git a/src/index.ts b/src/index.ts index c5418c8..9256757 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,8 @@ export { $S3C } from "./constants.ts" export { createEntity, createT } from "./create-t.tsx" export { EventPlugin } from "./event-plugin.ts" export { useFrame, useThree } from "./hooks.ts" +export { plugin } from "./plugin.ts" export { useProps } from "./props.ts" export * from "./raycasters.tsx" export * as S3 from "./types.ts" -export { createPlugin } from "./types.ts" export { autodispose, getMeta, hasMeta as hasMeta, load, meta } from "./utils.ts" diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..3c78964 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,71 @@ +import type { Plugin, PluginBuilder } from "types" + +export function plugin(setup?: () => TContext): PluginBuilder { + return { + // Unified prop method - handles both single and two argument cases + prop(filterArgOrMethods: any, methods?: any): Plugin { + // Single argument case - apply to all elements + if (methods === undefined) { + type PluginFn = (element: any) => any + + const plugin: Plugin = () => { + // Run setup once if provided and store result as context + const context = setup ? setup() : undefined + + return ((element: any) => { + return filterArgOrMethods(element, context) + }) as PluginFn + } + + return plugin + } + + // Two argument case - use filtering + return filteredPlugin(setup, filterArgOrMethods, methods) + }, + } as PluginBuilder +} + +/** + * Creates a plugin with a unified prop API + * Usage: + * - createPlugin(() => { setup }).prop((element, context) => methods) // apply to all elements + * - createPlugin(() => { setup }).prop(Constructor, (element, context) => methods) // single constructor filter + * - createPlugin(() => { setup }).prop([Constructor1, Constructor2], (element, context) => methods) // multiple constructors + * - createPlugin(() => { setup }).prop((element): element is T => condition, (element, context) => methods) // type guard + */ + +// Helper function to create the actual plugin implementation +function filteredPlugin(setup: (() => any) | undefined, filterArg: any, methods: any): Plugin { + const plugin: Plugin = () => { + // Run setup once if provided and store result as context + const context = setup ? setup() : undefined + + return ((element: any) => { + // Handle single constructor + if (typeof filterArg === "function" && filterArg.prototype) { + if (element instanceof filterArg) { + return methods(element, context) + } + } + // Handle array of constructors + else if (Array.isArray(filterArg)) { + for (const Constructor of filterArg) { + if (element instanceof Constructor) { + return methods(element, context) + } + } + } + // Handle type guard function + else if (typeof filterArg === "function") { + if (filterArg(element)) { + return methods(element, context) + } + } + + return {} + }) as any + } + + return plugin +} diff --git a/src/types.ts b/src/types.ts index fb29b6f..96801c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -291,54 +291,7 @@ export interface Plugin any> { (): TFn } -/** - * Creates a plugin with a unified prop API - * Usage: - * - createPlugin(() => { setup }).prop((element, context) => methods) // apply to all elements - * - createPlugin(() => { setup }).prop(Constructor, (element, context) => methods) // single constructor filter - * - createPlugin(() => { setup }).prop([Constructor1, Constructor2], (element, context) => methods) // multiple constructors - * - createPlugin(() => { setup }).prop((element): element is T => condition, (element, context) => methods) // type guard - */ -// Helper function to create the actual plugin implementation -function createFilteredPlugin( - setup: (() => any) | undefined, - filterArg: any, - methods: any, -): Plugin { - const plugin: Plugin = () => { - // Run setup once if provided and store result as context - const context = setup ? setup() : undefined - - return ((element: any) => { - // Handle single constructor - if (typeof filterArg === "function" && filterArg.prototype) { - if (element instanceof filterArg) { - return methods(element, context) - } - } - // Handle array of constructors - else if (Array.isArray(filterArg)) { - for (const Constructor of filterArg) { - if (element instanceof Constructor) { - return methods(element, context) - } - } - } - // Handle type guard function - else if (typeof filterArg === "function") { - if (filterArg(element)) { - return methods(element, context) - } - } - - return {} - }) as any - } - - return plugin -} - -interface PluginBuilder { +export interface PluginBuilder { // Apply to all elements - single argument prop>( methods: (element: any, context: TContext) => Methods, @@ -375,34 +328,6 @@ interface PluginBuilder { }> } -export function createPlugin( - setup?: () => TContext, -): PluginBuilder { - return { - // Unified prop method - handles both single and two argument cases - prop(filterArgOrMethods: any, methods?: any): Plugin { - // Single argument case - apply to all elements - if (methods === undefined) { - type PluginFn = (element: any) => any - - const plugin: Plugin = () => { - // Run setup once if provided and store result as context - const context = setup ? setup() : undefined - - return ((element: any) => { - return filterArgOrMethods(element, context) - }) as PluginFn - } - - return plugin - } - - // Two argument case - use filtering - return createFilteredPlugin(setup, filterArgOrMethods, methods) - }, - } as PluginBuilder -} - export type InferPluginProps = Merge<{ [TKey in keyof TPlugins]: TPlugins[TKey] extends () => (element: any) => infer U ? { [TKey in keyof U]: U[TKey] extends (callback: infer V) => any ? V : never } @@ -531,7 +456,6 @@ export type Props = Partial Merge< [ MapToRepresentation>, - EventHandlers, { args: T extends Constructor ? ConstructorOverloadParameters : undefined attach: string | ((parent: object, self: Meta>) => () => void) From d6d2c6e18f60b14ed95dd797a7b202427f4c04d4 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 21:31:24 +0200 Subject: [PATCH 09/26] feat: implement direct plugin() API with setup chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace createPlugin().prop() with direct plugin() calls - Add plugin.setup().then() chain for plugins needing context - Support all filtering patterns directly: - Global: plugin(element => methods) - Constructor: plugin(THREE.Mesh, element => methods) - Array: plugin([THREE.Camera, THREE.Light], element => methods) - Type guard: plugin(typeGuard, element => methods) - Update EventPlugin to use setup chain pattern - Add ContextPlugin example demonstrating setup usage - Maintain backward compatibility with plugin().prop() 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- playground/examples/PluginExample.tsx | 24 +++- src/event-plugin.ts | 192 +++++++++++++------------- src/plugin.ts | 183 +++++++++++++++++++----- 3 files changed, 262 insertions(+), 137 deletions(-) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index 8dab48e..7ba98e4 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -4,7 +4,7 @@ import { createT, EventPlugin, plugin, Resource, useFrame, useThree } from "../. import { OrbitControls } from "../controls/OrbitControls.tsx" // LookAt plugin - works for all Object3D elements -const LookAtPlugin = plugin().prop(THREE.Object3D, element => ({ +const LookAtPlugin = plugin(THREE.Object3D, element => ({ lookAt: (target: THREE.Object3D | [number, number, number]) => { useFrame(() => { if (Array.isArray(target)) { @@ -17,7 +17,7 @@ const LookAtPlugin = plugin().prop(THREE.Object3D, element => ({ })) // Shake plugin - works for both Camera and Light elements using array syntax -const ShakePlugin = plugin().prop([THREE.Camera, THREE.DirectionalLight], element => ({ +const ShakePlugin = plugin([THREE.Camera, THREE.DirectionalLight], element => ({ shake: (intensity = 0.1) => { const originalPosition = element.position.clone() useFrame(() => { @@ -29,7 +29,7 @@ const ShakePlugin = plugin().prop([THREE.Camera, THREE.DirectionalLight], elemen })) // Custom filter plugin - works for objects with a 'material' property using type guard -const MaterialPlugin = plugin().prop( +const MaterialPlugin = plugin( (element: any): element is THREE.Mesh => element instanceof THREE.Mesh && element.material !== undefined, element => ({ @@ -45,18 +45,32 @@ const MaterialPlugin = plugin().prop( ) // Global plugin - applies to all elements using single argument -const GlobalPlugin = plugin().prop(element => ({ +const GlobalPlugin = plugin(element => ({ log: (message: string) => { - console.log(`[${element.constructor.name}] ${message}`) + console.info(`[${element.constructor.name}] ${message}`) }, })) +// Example with setup - plugin that needs context from setup function +const ContextPlugin = plugin + .setup(() => { + const context = useThree() + return { scene: context.scene } + }) + .then((element, context) => ({ + addToScene: () => { + // This plugin has access to the context from setup + console.info("Adding to scene", element, context.scene) + }, + })) + const { T, Canvas } = createT(THREE, [ LookAtPlugin, ShakePlugin, EventPlugin, MaterialPlugin, GlobalPlugin, + ContextPlugin, ]) export function PluginExample() { diff --git a/src/event-plugin.ts b/src/event-plugin.ts index a867bd0..bcb1894 100644 --- a/src/event-plugin.ts +++ b/src/event-plugin.ts @@ -426,46 +426,32 @@ function createDefaultEventRegistry( /** * Initializes and manages event handling for all `Instance`. */ -export const EventPlugin = plugin(() => { - const context = useThree() - - // onMouseMove/onMouseEnter/onMouseLeave - const hoverMouseRegistry = createHoverEventRegistry("Mouse", context) - // onPointerMove/onPointerEnter/onPointerLeave - const hoverPointerRegistry = createHoverEventRegistry("Pointer", context) - - // onClick/onClickMissed - const missableClickRegistry = createMissableEventRegistry("onClick", context) - // onContextMenu/onContextMenuMissed - const missableContextMenuRegistry = createMissableEventRegistry("onContextMenu", context) - // onDoubleClick/onDoubleClickMissed - const missableDoubleClickRegistry = createMissableEventRegistry("onDoubleClick", context) - - // Default mouse-events - const mouseDownRegistry = createDefaultEventRegistry("onMouseDown", context) - const mouseUpRegistry = createDefaultEventRegistry("onMouseUp", context) - // Default pointer-events - const pointerDownRegistry = createDefaultEventRegistry("onPointerDown", context) - const pointerUpRegistry = createDefaultEventRegistry("onPointerUp", context) - // Default wheel-event - const wheelRegistry = createDefaultEventRegistry("onWheel", context, { passive: true }) +export const EventPlugin = plugin + .setup(() => { + const context = useThree() + + // onMouseMove/onMouseEnter/onMouseLeave + const hoverMouseRegistry = createHoverEventRegistry("Mouse", context) + // onPointerMove/onPointerEnter/onPointerLeave + const hoverPointerRegistry = createHoverEventRegistry("Pointer", context) + + // onClick/onClickMissed + const missableClickRegistry = createMissableEventRegistry("onClick", context) + // onContextMenu/onContextMenuMissed + const missableContextMenuRegistry = createMissableEventRegistry("onContextMenu", context) + // onDoubleClick/onDoubleClickMissed + const missableDoubleClickRegistry = createMissableEventRegistry("onDoubleClick", context) + + // Default mouse-events + const mouseDownRegistry = createDefaultEventRegistry("onMouseDown", context) + const mouseUpRegistry = createDefaultEventRegistry("onMouseUp", context) + // Default pointer-events + const pointerDownRegistry = createDefaultEventRegistry("onPointerDown", context) + const pointerUpRegistry = createDefaultEventRegistry("onPointerUp", context) + // Default wheel-event + const wheelRegistry = createDefaultEventRegistry("onWheel", context, { passive: true }) - return { - hoverMouseRegistry, - hoverPointerRegistry, - missableClickRegistry, - missableContextMenuRegistry, - missableDoubleClickRegistry, - mouseDownRegistry, - mouseUpRegistry, - pointerDownRegistry, - pointerUpRegistry, - wheelRegistry, - } -}).prop( - ( - object, - { + return { hoverMouseRegistry, hoverPointerRegistry, missableClickRegistry, @@ -476,60 +462,76 @@ export const EventPlugin = plugin(() => { pointerDownRegistry, pointerUpRegistry, wheelRegistry, - }, - ) => { - return { - onClick(callback: (event: Event) => void) { - onCleanup(missableClickRegistry.add(object)) - }, - onClickMissed(callback: (event: Event) => void) { - onCleanup(missableClickRegistry.add(object)) - }, - onDoubleClick(callback: (event: Event) => void) { - onCleanup(missableDoubleClickRegistry.add(object)) - }, - onDoubleClickMissed(callback: (event: Event) => void) { - onCleanup(missableDoubleClickRegistry.add(object)) - }, - onContextMenu(callback: (event: Event) => void) { - onCleanup(missableContextMenuRegistry.add(object)) - }, - onContextMenuMissed(callback: (event: Event) => void) { - onCleanup(missableContextMenuRegistry.add(object)) - }, - onMouseDown(callback: (event: Event) => void) { - onCleanup(mouseDownRegistry.add(object)) - }, - onMouseUp(callback: (event: Event) => void) { - onCleanup(mouseUpRegistry.add(object)) - }, - onMouseMove(callback: (event: Event) => void) { - onCleanup(hoverMouseRegistry.add(object)) - }, - onMouseEnter(callback: (event: Event) => void) { - onCleanup(hoverMouseRegistry.add(object)) - }, - onMouseLeave(callback: (event: Event) => void) { - onCleanup(hoverMouseRegistry.add(object)) - }, - onPointerDown(callback: (event: Event) => void) { - onCleanup(pointerDownRegistry.add(object)) - }, - onPointerUp(callback: (event: Event) => void) { - onCleanup(pointerUpRegistry.add(object)) - }, - onPointerMove(callback: (event: Event) => void) { - onCleanup(hoverPointerRegistry.add(object)) - }, - onPointerEnter(callback: (event: Event) => void) { - onCleanup(hoverPointerRegistry.add(object)) - }, - onPointerLeave(callback: (event: Event) => void) { - onCleanup(hoverMouseRegistry.add(object)) - }, - onwheel(callback: (event: Event) => void) { - onCleanup(wheelRegistry.add(object)) - }, } - }, -) + }) + .then( + ( + object, + { + hoverMouseRegistry, + hoverPointerRegistry, + missableClickRegistry, + missableContextMenuRegistry, + missableDoubleClickRegistry, + mouseDownRegistry, + mouseUpRegistry, + pointerDownRegistry, + pointerUpRegistry, + wheelRegistry, + }, + ) => { + return { + onClick(callback: (event: Event) => void) { + onCleanup(missableClickRegistry.add(object)) + }, + onClickMissed(callback: (event: Event) => void) { + onCleanup(missableClickRegistry.add(object)) + }, + onDoubleClick(callback: (event: Event) => void) { + onCleanup(missableDoubleClickRegistry.add(object)) + }, + onDoubleClickMissed(callback: (event: Event) => void) { + onCleanup(missableDoubleClickRegistry.add(object)) + }, + onContextMenu(callback: (event: Event) => void) { + onCleanup(missableContextMenuRegistry.add(object)) + }, + onContextMenuMissed(callback: (event: Event) => void) { + onCleanup(missableContextMenuRegistry.add(object)) + }, + onMouseDown(callback: (event: Event) => void) { + onCleanup(mouseDownRegistry.add(object)) + }, + onMouseUp(callback: (event: Event) => void) { + onCleanup(mouseUpRegistry.add(object)) + }, + onMouseMove(callback: (event: Event) => void) { + onCleanup(hoverMouseRegistry.add(object)) + }, + onMouseEnter(callback: (event: Event) => void) { + onCleanup(hoverMouseRegistry.add(object)) + }, + onMouseLeave(callback: (event: Event) => void) { + onCleanup(hoverMouseRegistry.add(object)) + }, + onPointerDown(callback: (event: Event) => void) { + onCleanup(pointerDownRegistry.add(object)) + }, + onPointerUp(callback: (event: Event) => void) { + onCleanup(pointerUpRegistry.add(object)) + }, + onPointerMove(callback: (event: Event) => void) { + onCleanup(hoverPointerRegistry.add(object)) + }, + onPointerEnter(callback: (event: Event) => void) { + onCleanup(hoverPointerRegistry.add(object)) + }, + onPointerLeave(callback: (event: Event) => void) { + onCleanup(hoverMouseRegistry.add(object)) + }, + onwheel(callback: (event: Event) => void) { + onCleanup(wheelRegistry.add(object)) + }, + } + }, + ) diff --git a/src/plugin.ts b/src/plugin.ts index 3c78964..2931b7a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,42 +1,11 @@ -import type { Plugin, PluginBuilder } from "types" - -export function plugin(setup?: () => TContext): PluginBuilder { - return { - // Unified prop method - handles both single and two argument cases - prop(filterArgOrMethods: any, methods?: any): Plugin { - // Single argument case - apply to all elements - if (methods === undefined) { - type PluginFn = (element: any) => any - - const plugin: Plugin = () => { - // Run setup once if provided and store result as context - const context = setup ? setup() : undefined - - return ((element: any) => { - return filterArgOrMethods(element, context) - }) as PluginFn - } - - return plugin - } - - // Two argument case - use filtering - return filteredPlugin(setup, filterArgOrMethods, methods) - }, - } as PluginBuilder -} - -/** - * Creates a plugin with a unified prop API - * Usage: - * - createPlugin(() => { setup }).prop((element, context) => methods) // apply to all elements - * - createPlugin(() => { setup }).prop(Constructor, (element, context) => methods) // single constructor filter - * - createPlugin(() => { setup }).prop([Constructor1, Constructor2], (element, context) => methods) // multiple constructors - * - createPlugin(() => { setup }).prop((element): element is T => condition, (element, context) => methods) // type guard - */ +import type { Plugin, PluginBuilder } from "./types.ts" // Helper function to create the actual plugin implementation -function filteredPlugin(setup: (() => any) | undefined, filterArg: any, methods: any): Plugin { +function createFilteredPlugin( + setup: (() => any) | undefined, + filterArg: any, + methods: any, +): Plugin { const plugin: Plugin = () => { // Run setup once if provided and store result as context const context = setup ? setup() : undefined @@ -69,3 +38,143 @@ function filteredPlugin(setup: (() => any) | undefined, filterArg: any, methods: return plugin } + +interface PluginInterface { + // No setup - direct usage with one argument (global) + >(methods: (element: any) => Methods): Plugin< + (element: any) => Methods + > + + // No setup - direct usage with two arguments (single constructor) + any, Methods extends Record>( + Constructor: T, + methods: (element: InstanceType) => Methods, + ): Plugin<{ + (element: InstanceType): Methods + (element: any): {} + }> + + // No setup - direct usage with two arguments (array of constructors) + any)[], Methods extends Record>( + Constructors: T, + methods: ( + element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never, + ) => Methods, + ): Plugin<{ + (element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never): Methods + (element: any): {} + }> + + // No setup - direct usage with two arguments (type guard) + >( + condition: (element: unknown) => element is T, + methods: (element: T) => Methods, + ): Plugin<{ + (element: T): Methods + (element: any): {} + }> + + // Setup function + setup( + setupFn: () => TSetupContext, + ): { + then: { + // With setup - one argument (global) + >( + methods: (element: any, context: TSetupContext) => Methods, + ): Plugin<(element: any) => Methods> + + // With setup - two arguments (single constructor) + any, Methods extends Record>( + Constructor: T, + methods: (element: InstanceType, context: TSetupContext) => Methods, + ): Plugin<{ + (element: InstanceType): Methods + (element: any): {} + }> + + // With setup - two arguments (array of constructors) + any)[], Methods extends Record>( + Constructors: T, + methods: ( + element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never, + context: TSetupContext, + ) => Methods, + ): Plugin<{ + (element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never): Methods + (element: any): {} + }> + + // With setup - two arguments (type guard) + >( + condition: (element: unknown) => element is T, + methods: (element: T, context: TSetupContext) => Methods, + ): Plugin<{ + (element: T): Methods + (element: any): {} + }> + } + } + + // Legacy PluginBuilder for backward compatibility + (): PluginBuilder<{}> +} + +export const plugin: PluginInterface = Object.assign( + // Main function implementation + (filterArgOrMethods?: any, methods?: any): any => { + // No arguments - return PluginBuilder for backward compatibility + if (filterArgOrMethods === undefined && methods === undefined) { + return { + prop(filterArgOrMethods: any, methods?: any): Plugin { + // Single argument case - apply to all elements + if (methods === undefined) { + const plugin: Plugin = () => { + return ((element: any) => { + return filterArgOrMethods(element, undefined) + }) as any + } + return plugin + } + + // Two argument case - use filtering + return createFilteredPlugin(undefined, filterArgOrMethods, methods) + }, + } as PluginBuilder<{}> + } + + // Single argument case - apply to all elements + if (methods === undefined) { + const plugin: Plugin = () => { + return ((element: any) => { + return filterArgOrMethods(element) + }) as any + } + return plugin + } + + // Two argument case - use filtering + return createFilteredPlugin(undefined, filterArgOrMethods, methods) + }, + { + setup(setupFn: () => TSetupContext) { + return { + then: (filterArgOrMethods: any, methods?: any): Plugin => { + // Single argument case - apply to all elements + if (methods === undefined) { + const plugin: Plugin = () => { + const context = setupFn() + return ((element: any) => { + return filterArgOrMethods(element, context) + }) as any + } + return plugin + } + + // Two argument case - use filtering + return createFilteredPlugin(setupFn, filterArgOrMethods, methods) + }, + } + }, + }, +) From 00d93e57c25e14fa280a60997d7b0315690fc990 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 21:57:27 +0200 Subject: [PATCH 10/26] cleanup: move PluginInterface to types.ts --- src/plugin.ts | 163 +++++++++++++------------------------------------- src/types.ts | 87 ++++++++++++++++++--------- 2 files changed, 100 insertions(+), 150 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 2931b7a..81d9490 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,124 +1,4 @@ -import type { Plugin, PluginBuilder } from "./types.ts" - -// Helper function to create the actual plugin implementation -function createFilteredPlugin( - setup: (() => any) | undefined, - filterArg: any, - methods: any, -): Plugin { - const plugin: Plugin = () => { - // Run setup once if provided and store result as context - const context = setup ? setup() : undefined - - return ((element: any) => { - // Handle single constructor - if (typeof filterArg === "function" && filterArg.prototype) { - if (element instanceof filterArg) { - return methods(element, context) - } - } - // Handle array of constructors - else if (Array.isArray(filterArg)) { - for (const Constructor of filterArg) { - if (element instanceof Constructor) { - return methods(element, context) - } - } - } - // Handle type guard function - else if (typeof filterArg === "function") { - if (filterArg(element)) { - return methods(element, context) - } - } - - return {} - }) as any - } - - return plugin -} - -interface PluginInterface { - // No setup - direct usage with one argument (global) - >(methods: (element: any) => Methods): Plugin< - (element: any) => Methods - > - - // No setup - direct usage with two arguments (single constructor) - any, Methods extends Record>( - Constructor: T, - methods: (element: InstanceType) => Methods, - ): Plugin<{ - (element: InstanceType): Methods - (element: any): {} - }> - - // No setup - direct usage with two arguments (array of constructors) - any)[], Methods extends Record>( - Constructors: T, - methods: ( - element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never, - ) => Methods, - ): Plugin<{ - (element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never): Methods - (element: any): {} - }> - - // No setup - direct usage with two arguments (type guard) - >( - condition: (element: unknown) => element is T, - methods: (element: T) => Methods, - ): Plugin<{ - (element: T): Methods - (element: any): {} - }> - - // Setup function - setup( - setupFn: () => TSetupContext, - ): { - then: { - // With setup - one argument (global) - >( - methods: (element: any, context: TSetupContext) => Methods, - ): Plugin<(element: any) => Methods> - - // With setup - two arguments (single constructor) - any, Methods extends Record>( - Constructor: T, - methods: (element: InstanceType, context: TSetupContext) => Methods, - ): Plugin<{ - (element: InstanceType): Methods - (element: any): {} - }> - - // With setup - two arguments (array of constructors) - any)[], Methods extends Record>( - Constructors: T, - methods: ( - element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never, - context: TSetupContext, - ) => Methods, - ): Plugin<{ - (element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never): Methods - (element: any): {} - }> - - // With setup - two arguments (type guard) - >( - condition: (element: unknown) => element is T, - methods: (element: T, context: TSetupContext) => Methods, - ): Plugin<{ - (element: T): Methods - (element: any): {} - }> - } - } - - // Legacy PluginBuilder for backward compatibility - (): PluginBuilder<{}> -} +import type { Plugin, PluginInterface } from "./types.ts" export const plugin: PluginInterface = Object.assign( // Main function implementation @@ -140,7 +20,7 @@ export const plugin: PluginInterface = Object.assign( // Two argument case - use filtering return createFilteredPlugin(undefined, filterArgOrMethods, methods) }, - } as PluginBuilder<{}> + } } // Single argument case - apply to all elements @@ -178,3 +58,42 @@ export const plugin: PluginInterface = Object.assign( }, }, ) + +// Helper function to create the actual plugin implementation +function createFilteredPlugin( + setup: (() => any) | undefined, + filterArg: any, + methods: any, +): Plugin { + const plugin: Plugin = () => { + // Run setup once if provided and store result as context + const context = setup ? setup() : undefined + + return ((element: any) => { + // Handle single constructor + if (typeof filterArg === "function" && filterArg.prototype) { + if (element instanceof filterArg) { + return methods(element, context) + } + } + // Handle array of constructors + else if (Array.isArray(filterArg)) { + for (const Constructor of filterArg) { + if (element instanceof Constructor) { + return methods(element, context) + } + } + } + // Handle type guard function + else if (typeof filterArg === "function") { + if (filterArg(element)) { + return methods(element, context) + } + } + + return {} + }) as any + } + + return plugin +} diff --git a/src/types.ts b/src/types.ts index 96801c8..0fe5580 100644 --- a/src/types.ts +++ b/src/types.ts @@ -285,47 +285,86 @@ export type Matrix4 = Representation /* */ /**********************************************************************************/ -type PluginEntity = (entity: T) => U - -export interface Plugin any> { - (): TFn -} - -export interface PluginBuilder { - // Apply to all elements - single argument - prop>( - methods: (element: any, context: TContext) => Methods, - ): Plugin<(element: any) => Methods> +export interface PluginInterface { + // No setup - direct usage with one argument (global) + >(methods: (element: any) => Methods): Plugin< + (element: any) => Methods + > - // Single constructor filter - two arguments - prop any, Methods extends Record>( + // No setup - direct usage with two arguments (single constructor) + any, Methods extends Record>( Constructor: T, - methods: (element: InstanceType, context: TContext) => Methods, + methods: (element: InstanceType) => Methods, ): Plugin<{ (element: InstanceType): Methods (element: any): {} }> - // Array of constructors filter - two arguments - prop any)[], Methods extends Record>( + // No setup - direct usage with two arguments (array of constructors) + any)[], Methods extends Record>( Constructors: T, methods: ( element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never, - context: TContext, ) => Methods, ): Plugin<{ (element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never): Methods (element: any): {} }> - // Type guard filter - two arguments - prop>( + // No setup - direct usage with two arguments (type guard) + >( condition: (element: unknown) => element is T, - methods: (element: T, context: TContext) => Methods, + methods: (element: T) => Methods, ): Plugin<{ (element: T): Methods (element: any): {} }> + + // Setup function + setup( + setupFn: () => TSetupContext, + ): { + then: { + // With setup - one argument (global) + >( + methods: (element: any, context: TSetupContext) => Methods, + ): Plugin<(element: any) => Methods> + + // With setup - two arguments (single constructor) + any, Methods extends Record>( + Constructor: T, + methods: (element: InstanceType, context: TSetupContext) => Methods, + ): Plugin<{ + (element: InstanceType): Methods + (element: any): {} + }> + + // With setup - two arguments (array of constructors) + any)[], Methods extends Record>( + Constructors: T, + methods: ( + element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never, + context: TSetupContext, + ) => Methods, + ): Plugin<{ + (element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never): Methods + (element: any): {} + }> + + // With setup - two arguments (type guard) + >( + condition: (element: unknown) => element is T, + methods: (element: T, context: TSetupContext) => Methods, + ): Plugin<{ + (element: T): Methods + (element: any): {} + }> + } + } +} + +export interface Plugin any> { + (): TFn } export type InferPluginProps = Merge<{ @@ -409,14 +448,6 @@ type ResolvePluginReturn = TPlugin extends Plugin : {} : ResolveOverload : {} - : TPlugin extends () => infer PluginFn - ? ResolveOverload extends never - ? PluginFn extends (element: T) => infer R - ? R - : PluginFn extends (element: any) => infer R - ? R - : {} - : ResolveOverload : {} /** From 05400ed851c7a2a0e12d3b109930f189234705e7 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 24 Aug 2025 22:48:33 +0200 Subject: [PATCH 11/26] refactor: major internal restructuring and API improvements - Restructure plugin system with simplified type resolution - Consolidate event types and handlers into event-plugin.ts - Reorganize type definitions for better maintainability - Clean up imports and remove unused type utilities - Update PluginExample with Entity component usage - Rename PluginInterface to PluginFn for clarity - Improve plugin prop type resolution with PluginPropsOf - Move event-related types to their proper location --- .claude/settings.local.json | 5 +- playground/examples/PluginExample.tsx | 13 +- src/components.tsx | 2 +- src/create-t.tsx | 2 +- src/event-plugin.ts | 97 +++++-- src/plugin.ts | 4 +- src/types.ts | 370 +++++++++++++------------- 7 files changed, 292 insertions(+), 201 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ecee3b0..965f0b5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,10 @@ "WebFetch(domain:github.com)", "Bash(pnpm lint:types:*)", "Bash(rm:*)", - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(npm run typecheck:*)", + "Bash(npm run:*)", + "Bash(npx tsc:*)" ], "deny": [] } diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index 7ba98e4..8411d16 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -1,6 +1,14 @@ import * as THREE from "three" import type { Meta } from "types.ts" -import { createT, EventPlugin, plugin, Resource, useFrame, useThree } from "../../src/index.ts" +import { + createT, + Entity, + EventPlugin, + plugin, + Resource, + useFrame, + useThree, +} from "../../src/index.ts" import { OrbitControls } from "../controls/OrbitControls.tsx" // LookAt plugin - works for all Object3D elements @@ -83,6 +91,7 @@ export function PluginExample() { defaultCamera={{ position: new THREE.Vector3(0, 0, 30) }} > + {/* Mesh with lookAt (from LookAtPlugin) and material methods (from MaterialPlugin) */} console.info("ok")} > diff --git a/src/components.tsx b/src/components.tsx index 384de8f..6b55558 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -85,7 +85,7 @@ export function Portal(props: PortalProps) { */ export function Entity< const T extends object | Constructor = object, - const TPlugins extends Plugin[] = $3.Plugins, + const TPlugins extends Plugin[] = Plugin[], >( props: | Props diff --git a/src/create-t.tsx b/src/create-t.tsx index 41fbd5b..7def8ee 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -2,7 +2,7 @@ import { createMemo, type Component, type JSX, type JSXElement, type MergeProps import { createCanvas } from "./canvas.tsx" import { $S3C } from "./constants.ts" import { useProps } from "./props.ts" -import type { InferPluginProps, Merge, Plugin, Props } from "./types.ts" +import type { Plugin, Props } from "./types.ts" import { meta } from "./utils.ts" /**********************************************************************************/ diff --git a/src/event-plugin.ts b/src/event-plugin.ts index bcb1894..9ce734a 100644 --- a/src/event-plugin.ts +++ b/src/event-plugin.ts @@ -2,10 +2,84 @@ import { onCleanup } from "solid-js" import { Object3D, type Intersection } from "three" import { useThree } from "./hooks.ts" import { plugin } from "./plugin.ts" -import { type Context, type Event, type EventName, type Meta, type Prettify } from "./types.ts" +import { type Context, type Intersect, type Meta, type Prettify } from "./types.ts" import { getMeta } from "./utils.ts" -const eventNameMap = { +/**********************************************************************************/ +/* */ +/* Event */ +/* */ +/**********************************************************************************/ + +export type When = T extends false ? (T extends true ? U : unknown) : U + +export type Event< + TEvent, + TConfig extends { stoppable?: boolean; intersections?: boolean } = { + stoppable: true + intersections: true + }, +> = Intersect< + [ + { nativeEvent: TEvent }, + When< + TConfig["stoppable"], + { + stopped: boolean + stopPropagation: () => void + } + >, + When< + TConfig["intersections"], + { + currentIntersection: Intersection + intersection: Intersection + intersections: Intersection[] + } + >, + ] +> + +type EventHandlersMap = { + onClick: Prettify> + onClickMissed: Prettify> + onDoubleClick: Prettify> + onDoubleClickMissed: Prettify> + onContextMenu: Prettify> + onContextMenuMissed: Prettify> + onMouseDown: Prettify> + onMouseEnter: Prettify> + onMouseLeave: Prettify> + onMouseMove: Prettify> + onMouseUp: Prettify> + onPointerUp: Prettify> + onPointerDown: Prettify> + onPointerMove: Prettify> + onPointerEnter: Prettify> + onPointerLeave: Prettify> + onWheel: Prettify> +} + +export type EventHandlers = { + [TKey in keyof EventHandlersMap]: (event: EventHandlersMap[TKey]) => void +} + +export type CanvasEventHandlers = { + [TKey in keyof EventHandlersMap]: ( + event: Prettify>, + ) => void +} + +/** The names of all `EventHandlers` */ +export type EventName = keyof EventHandlersMap + +/**********************************************************************************/ +/* */ +/* Event Plugin */ +/* */ +/**********************************************************************************/ + +const EVENT_NAME_MAP = { onClick: "click", onContextMenu: "contextmenu", onDoubleClick: "dblclick", @@ -37,12 +111,6 @@ function createRegistry() { } } -/**********************************************************************************/ -/* */ -/* Is Event Type */ -/* */ -/**********************************************************************************/ - /** * Checks if a given string is a valid event type within the system. * @@ -152,7 +220,7 @@ function createMissableEventRegistry( ) { const registry = createRegistry() - context.canvas.addEventListener(eventNameMap[type], nativeEvent => { + context.canvas.addEventListener(EVENT_NAME_MAP[type], nativeEvent => { if (registry.array.length === 0) return const missedType = `${type}Missed` as const @@ -248,7 +316,7 @@ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { let intersections: Intersection>[] = [] let hoveredCanvas = false - context.canvas.addEventListener(eventNameMap[`on${type}Move`], nativeEvent => { + context.canvas.addEventListener(EVENT_NAME_MAP[`on${type}Move`], nativeEvent => { intersections = raycast(context, registry.array, nativeEvent) // Phase #1 - Enter @@ -330,14 +398,11 @@ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { hoveredSet = enterSet for (const object of leaveSet.values()) { - getMeta(object)?.props[`on${type}Leave`]?.( - // @ts-expect-error TODO: fix type-error - leaveEvent, - ) + getMeta(object)?.props[`on${type}Leave`]?.(leaveEvent) } }) - context.canvas.addEventListener(eventNameMap[`on${type}Leave`], nativeEvent => { + context.canvas.addEventListener(EVENT_NAME_MAP[`on${type}Leave`], nativeEvent => { const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false }) // @ts-expect-error TODO: fix type-error context.props[`on${type}Leave`]?.(leaveEvent) @@ -377,7 +442,7 @@ function createDefaultEventRegistry( const registry = createRegistry() context.canvas.addEventListener( - eventNameMap[type], + EVENT_NAME_MAP[type], nativeEvent => { const intersections = raycast(context, registry.array, nativeEvent) /* [0]] */ const event = createThreeEvent(nativeEvent, { intersections }) diff --git a/src/plugin.ts b/src/plugin.ts index 81d9490..57fda6a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,6 @@ -import type { Plugin, PluginInterface } from "./types.ts" +import type { Plugin, PluginFn } from "./types.ts" -export const plugin: PluginInterface = Object.assign( +export const plugin: PluginFn = Object.assign( // Main function implementation (filterArgOrMethods?: any, methods?: any): any => { // No arguments - return PluginBuilder for backward compatibility diff --git a/src/types.ts b/src/types.ts index 0fe5580..55dc4d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,71 +50,29 @@ export type Prettify = { [K in keyof T]: T[K] } & {} -/** - * Extracts the parameters of all possible overloads of a given constructor. - * - * @example - * class Example { - * constructor(a: string); - * constructor(a: number, b: boolean); - * constructor(a: any, b?: any) { - * // Implementation - * } - * } - * - * type ExampleParameters = ConstructorOverloadParameters; - * // ExampleParameters will be equivalent to: [string] | [number, boolean] - */ -export type ConstructorOverloadParameters = T extends { - new (...o: infer U): void - new (...o: infer U2): void - new (...o: infer U3): void - new (...o: infer U4): void - new (...o: infer U5): void - new (...o: infer U6): void - new (...o: infer U7): void +export type Intersect = T extends [infer U, ...infer Rest] + ? Rest["length"] extends 0 + ? U + : U & Intersect + : T + +/**********************************************************************************/ +/* */ +/* Meta */ +/* */ +/**********************************************************************************/ + +export type Meta = T & { + [$S3C]: Data +} + +/** Metadata of a `solid-three` instance. */ +export type Data = { + props: Props> + parent: any + children: Set> + plugins: Plugin[] } - ? U | U2 | U3 | U4 | U5 | U6 | U7 - : T extends { - new (...o: infer U): void - new (...o: infer U2): void - new (...o: infer U3): void - new (...o: infer U4): void - new (...o: infer U5): void - new (...o: infer U6): void - } - ? U | U2 | U3 | U4 | U5 | U6 - : T extends { - new (...o: infer U): void - new (...o: infer U2): void - new (...o: infer U3): void - new (...o: infer U4): void - new (...o: infer U5): void - } - ? U | U2 | U3 | U4 | U5 - : T extends { - new (...o: infer U): void - new (...o: infer U2): void - new (...o: infer U3): void - new (...o: infer U4): void - } - ? U | U2 | U3 | U4 - : T extends { - new (...o: infer U): void - new (...o: infer U2): void - new (...o: infer U3): void - } - ? U | U2 | U3 - : T extends { - new (...o: infer U): void - new (...o: infer U2): void - } - ? U | U2 - : T extends { - new (...o: infer U): void - } - ? U - : never /**********************************************************************************/ /* s */ @@ -244,28 +202,28 @@ export type EventName = keyof EventHandlersMap /**********************************************************************************/ /* */ -/* Solid Three Representation */ +/* Representations */ /* */ /**********************************************************************************/ /** Maps properties of given type to their `solid-three` representations. */ -export type MapToRepresentation = { +type MapToRepresentation = { [TKey in keyof T]: Representation } -interface ThreeMathRepresentation { +interface ThreeMath { set(...args: number[]): any } -interface ThreeVectorRepresentation extends ThreeMathRepresentation { +interface ThreeVector extends ThreeMath { setScalar(s: number): any } /** Map given type to `solid-three` representation. */ export type Representation = T extends ThreeColor ? ConstructorParameters | ColorRepresentation - : T extends ThreeVectorRepresentation | ThreeLayers | ThreeEuler + : T extends ThreeVector | ThreeLayers | ThreeEuler ? T | Parameters | number - : T extends ThreeMathRepresentation + : T extends ThreeMath ? T | Parameters : T @@ -281,11 +239,140 @@ export type Matrix4 = Representation /**********************************************************************************/ /* */ -/* Meta */ +/* Props */ +/* */ +/**********************************************************************************/ + +/** Generic `solid-three` props of a given class. */ +export type Props = Partial< + Overwrite< + [ + MapToRepresentation>, + { + args: T extends Constructor ? ConstructorOverloadParameters : undefined + attach: string | ((parent: object, self: Meta>) => () => void) + children: JSX.Element + key?: string + onUpdate: (self: Meta>) => void + ref: Ref>> + /** + * Prevents the Object3D from being cast by the ray. + * Object3D can still receive events via propagation from its descendants. + */ + raycastable: boolean + plugins: TPlugins + }, + TPlugins extends Plugin[] ? PluginPropsOf, TPlugins> : {}, + ] + > +> + +type Simplify = T extends any + ? { + [K in keyof T]: T[K] + } + : T + +type _Merge = T extends [ + infer Next | (() => infer Next), + ...infer Rest, +] + ? _Merge> + : T extends [...infer Rest, infer Next] + ? Override<_Merge, Next> + : T extends [] + ? Current + : Current +export type Merge = Simplify<_Merge> + +type DistributeOverride = T extends undefined ? F : T +type Override = T extends any + ? U extends any + ? { + [K in keyof T]: K extends keyof U ? DistributeOverride : T[K] + } & { + [K in keyof U]: K extends keyof T ? DistributeOverride : U[K] + } + : T & U + : T & U + +/** + * Extracts the parameters of all possible overloads of a given constructor. + * + * @example + * class Example { + * constructor(a: string); + * constructor(a: number, b: boolean); + * constructor(a: any, b?: any) { + * // Implementation + * } + * } + * + * type ExampleParameters = ConstructorOverloadParameters; + * // ExampleParameters will be equivalent to: [string] | [number, boolean] + */ +type ConstructorOverloadParameters = T extends { + new (...o: infer U): void + new (...o: infer U2): void + new (...o: infer U3): void + new (...o: infer U4): void + new (...o: infer U5): void + new (...o: infer U6): void + new (...o: infer U7): void +} + ? U | U2 | U3 | U4 | U5 | U6 | U7 + : T extends { + new (...o: infer U): void + new (...o: infer U2): void + new (...o: infer U3): void + new (...o: infer U4): void + new (...o: infer U5): void + new (...o: infer U6): void + } + ? U | U2 | U3 | U4 | U5 | U6 + : T extends { + new (...o: infer U): void + new (...o: infer U2): void + new (...o: infer U3): void + new (...o: infer U4): void + new (...o: infer U5): void + } + ? U | U2 | U3 | U4 | U5 + : T extends { + new (...o: infer U): void + new (...o: infer U2): void + new (...o: infer U3): void + new (...o: infer U4): void + } + ? U | U2 | U3 | U4 + : T extends { + new (...o: infer U): void + new (...o: infer U2): void + new (...o: infer U3): void + } + ? U | U2 | U3 + : T extends { + new (...o: infer U): void + new (...o: infer U2): void + } + ? U | U2 + : T extends { + new (...o: infer U): void + } + ? U + : never + +/**********************************************************************************/ +/* */ +/* Plugin */ /* */ /**********************************************************************************/ -export interface PluginInterface { +export interface Plugin any> { + (): TFn +} + +export interface PluginFn { // No setup - direct usage with one argument (global) >(methods: (element: any) => Methods): Plugin< (element: any) => Methods @@ -363,11 +450,7 @@ export interface PluginInterface { } } -export interface Plugin any> { - (): TFn -} - -export type InferPluginProps = Merge<{ +export type InferPluginProps = Merge<{ [TKey in keyof TPlugins]: TPlugins[TKey] extends () => (element: any) => infer U ? { [TKey in keyof U]: U[TKey] extends (callback: infer V) => any ? V : never } : never @@ -377,59 +460,59 @@ export type InferPluginProps = Merge<{ * Helper type to resolve overloaded function returns * Matches overloads from most specific to least specific */ -type ResolveOverload = F extends { +type ResolvePluginReturn = TFn extends { (element: infer P1): infer R1 (element: infer P2): infer R2 (element: infer P3): infer R3 (element: infer P4): infer R4 (element: infer P5): infer R5 } - ? T extends P1 + ? TTarget extends P1 ? R1 - : T extends P2 + : TTarget extends P2 ? R2 - : T extends P3 + : TTarget extends P3 ? R3 - : T extends P4 + : TTarget extends P4 ? R4 - : T extends P5 + : TTarget extends P5 ? R5 : never - : F extends { + : TFn extends { (element: infer P1): infer R1 (element: infer P2): infer R2 (element: infer P3): infer R3 (element: infer P4): infer R4 } - ? T extends P1 + ? TTarget extends P1 ? R1 - : T extends P2 + : TTarget extends P2 ? R2 - : T extends P3 + : TTarget extends P3 ? R3 - : T extends P4 + : TTarget extends P4 ? R4 : never - : F extends { + : TFn extends { (element: infer P1): infer R1 (element: infer P2): infer R2 (element: infer P3): infer R3 } - ? T extends P1 + ? TTarget extends P1 ? R1 - : T extends P2 + : TTarget extends P2 ? R2 - : T extends P3 + : TTarget extends P3 ? R3 : never - : F extends { (element: infer P1): infer R1; (element: infer P2): infer R2 } - ? T extends P1 + : TFn extends { (element: infer P1): infer R1; (element: infer P2): infer R2 } + ? TTarget extends P1 ? R1 - : T extends P2 + : TTarget extends P2 ? R2 : never - : F extends { (element: infer P): infer R } - ? T extends P + : TFn extends { (element: infer P): infer R } + ? TTarget extends P ? R : never : never @@ -438,15 +521,17 @@ type ResolveOverload = F extends { * Resolves what a plugin returns for a specific element type T * Handles both simple functions and overloaded functions */ -type ResolvePluginReturn = TPlugin extends Plugin +type PluginReturn = TPlugin extends Plugin ? TFn extends (...args: any[]) => any - ? ResolveOverload extends never - ? TFn extends (element: T) => infer R - ? R - : TFn extends (element: any) => infer R - ? R - : {} - : ResolveOverload + ? ResolvePluginReturn extends infer TResult + ? TResult extends never + ? TFn extends (element: TKind) => infer R + ? R + : TFn extends (element: any) => infer R + ? R + : {} + : TResult + : {} : {} : {} @@ -454,8 +539,8 @@ type ResolvePluginReturn = TPlugin extends Plugin * Resolves plugin props for a specific element type T * This allows plugins to provide conditional methods based on the actual element type */ -export type ResolvePluginPropsForType = Merge<{ - [K in keyof TPlugins]: ResolvePluginReturn extends infer Methods +export type PluginPropsOf = Merge<{ + [K in keyof TPlugins]: PluginReturn extends infer Methods ? Methods extends Record ? { [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never @@ -463,74 +548,3 @@ export type ResolvePluginPropsForType = Merge<{ : {} : {} }> - -export type Meta = T & { - [$S3C]: Data -} - -/** Metadata of a `solid-three` instance. */ -export type Data = { - props: Props> - parent: any - children: Set> - plugins: Plugin[] -} - -/**********************************************************************************/ -/* */ -/* Props */ -/* */ -/**********************************************************************************/ - -/** Generic `solid-three` props of a given class. */ -export type Props = Partial< - Merge< - [ - MapToRepresentation>, - { - args: T extends Constructor ? ConstructorOverloadParameters : undefined - attach: string | ((parent: object, self: Meta>) => () => void) - children: JSX.Element - key?: string - onUpdate: (self: Meta>) => void - ref: Ref>> - /** - * Prevents the Object3D from being cast by the ray. - * Object3D can still receive events via propagation from its descendants. - */ - raycastable: boolean - plugins: TPlugins - }, - TPlugins extends Plugin[] ? ResolvePluginPropsForType, TPlugins> : {}, - ] - > -> - -type Simplify = T extends any - ? { - [K in keyof T]: T[K] - } - : T - -type _Merge = T extends [ - infer Next | (() => infer Next), - ...infer Rest, -] - ? _Merge> - : T extends [...infer Rest, infer Next] - ? Override<_Merge, Next> - : T extends [] - ? Current - : Current -export type Merge = Simplify<_Merge> - -type DistributeOverride = T extends undefined ? F : T -type Override = T extends any - ? U extends any - ? { - [K in keyof T]: K extends keyof U ? DistributeOverride : T[K] - } & { - [K in keyof U]: K extends keyof T ? DistributeOverride : U[K] - } - : T & U - : T & U From a97e40e17e155ba6d3802be4597d063894ce3297 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 25 Aug 2025 00:12:01 +0200 Subject: [PATCH 12/26] cleanup: remove legacy code --- playground/examples/PluginExample.tsx | 3 +- src/plugin.ts | 44 ++++++--------------------- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index 8411d16..ca61256 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -25,7 +25,7 @@ const LookAtPlugin = plugin(THREE.Object3D, element => ({ })) // Shake plugin - works for both Camera and Light elements using array syntax -const ShakePlugin = plugin([THREE.Camera, THREE.DirectionalLight], element => ({ +const ShakePlugin = plugin([THREE.Camera, THREE.DirectionalLight, THREE.Mesh], element => ({ shake: (intensity = 0.1) => { const originalPosition = element.position.clone() useFrame(() => { @@ -99,6 +99,7 @@ export function PluginExample() { highlight="red" lookAt={useThree().currentCamera} log="Mesh rendered!" + shake={0.1} onMouseDown={event => console.info("ok")} > diff --git a/src/plugin.ts b/src/plugin.ts index 57fda6a..2dd27e2 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,56 +3,36 @@ import type { Plugin, PluginFn } from "./types.ts" export const plugin: PluginFn = Object.assign( // Main function implementation (filterArgOrMethods?: any, methods?: any): any => { - // No arguments - return PluginBuilder for backward compatibility - if (filterArgOrMethods === undefined && methods === undefined) { - return { - prop(filterArgOrMethods: any, methods?: any): Plugin { - // Single argument case - apply to all elements - if (methods === undefined) { - const plugin: Plugin = () => { - return ((element: any) => { - return filterArgOrMethods(element, undefined) - }) as any - } - return plugin - } - - // Two argument case - use filtering - return createFilteredPlugin(undefined, filterArgOrMethods, methods) - }, - } - } - // Single argument case - apply to all elements if (methods === undefined) { const plugin: Plugin = () => { - return ((element: any) => { + return (element: any) => { return filterArgOrMethods(element) - }) as any + } } return plugin } // Two argument case - use filtering - return createFilteredPlugin(undefined, filterArgOrMethods, methods) + return filteredPlugin(undefined, filterArgOrMethods, methods) }, { setup(setupFn: () => TSetupContext) { return { - then: (filterArgOrMethods: any, methods?: any): Plugin => { + then(filterArgOrMethods: any, methods?: any) { // Single argument case - apply to all elements if (methods === undefined) { const plugin: Plugin = () => { const context = setupFn() - return ((element: any) => { + return (element: any) => { return filterArgOrMethods(element, context) - }) as any + } } return plugin } // Two argument case - use filtering - return createFilteredPlugin(setupFn, filterArgOrMethods, methods) + return filteredPlugin(setupFn, filterArgOrMethods, methods) }, } }, @@ -60,16 +40,12 @@ export const plugin: PluginFn = Object.assign( ) // Helper function to create the actual plugin implementation -function createFilteredPlugin( - setup: (() => any) | undefined, - filterArg: any, - methods: any, -): Plugin { +function filteredPlugin(setup: (() => any) | undefined, filterArg: any, methods: any): Plugin { const plugin: Plugin = () => { // Run setup once if provided and store result as context const context = setup ? setup() : undefined - return ((element: any) => { + return (element: any) => { // Handle single constructor if (typeof filterArg === "function" && filterArg.prototype) { if (element instanceof filterArg) { @@ -92,7 +68,7 @@ function createFilteredPlugin( } return {} - }) as any + } } return plugin From 6798b71a6894cd2905e6aaae055f29b502566aed Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 25 Aug 2025 00:23:56 +0200 Subject: [PATCH 13/26] feat: simplify plugin signature by removing single class-filter --- playground/examples/PluginExample.tsx | 2 +- src/plugin.ts | 97 ++++++++++++++++++++++----- src/types.ts | 32 ++------- 3 files changed, 89 insertions(+), 42 deletions(-) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index ca61256..e14aab8 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -12,7 +12,7 @@ import { import { OrbitControls } from "../controls/OrbitControls.tsx" // LookAt plugin - works for all Object3D elements -const LookAtPlugin = plugin(THREE.Object3D, element => ({ +const LookAtPlugin = plugin([THREE.Object3D], element => ({ lookAt: (target: THREE.Object3D | [number, number, number]) => { useFrame(() => { if (Array.isArray(target)) { diff --git a/src/plugin.ts b/src/plugin.ts index 2dd27e2..7a0f561 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,9 +1,53 @@ +import type { Accessor } from "solid-js" import type { Plugin, PluginFn } from "./types.ts" +/** + * Creates a plugin that extends solid-three components with additional functionality. + * + * Plugins can be used to add custom methods to THREE.js objects in a type-safe way. + * They are applied during component creation and can optionally filter which elements + * they apply to. + * + * @example + * // Global plugin - applies to all elements + * const LogPlugin = plugin(element => ({ + * log: (message: string) => console.log(`[${element.type}] ${message}`) + * })) + * + * @example + * // Filtered plugin - applies only to specific element types + * const ShakePlugin = plugin([THREE.Camera, THREE.Mesh], element => ({ + * shake: (intensity = 0.1) => { + * useFrame(() => { + * element.position.x += (Math.random() - 0.5) * intensity + * }) + * } + * })) + * + * @example + * // Type guard plugin - custom filtering logic + * const MaterialPlugin = plugin( + * (element): element is THREE.Mesh => element instanceof THREE.Mesh, + * element => ({ + * setColor: (color: string) => element.material.color.set(color) + * }) + * ) + * + * @example + * // Plugin with setup context + * const ContextPlugin = plugin + * .setup(() => { + * const scene = useThree().scene + * return { scene } + * }) + * .then([THREE.Object3D], (element, context) => ({ + * addToScene: () => context.scene.add(element) + * })) + */ export const plugin: PluginFn = Object.assign( // Main function implementation (filterArgOrMethods?: any, methods?: any): any => { - // Single argument case - apply to all elements + // Single argument case - global plugin (apply to all elements) if (methods === undefined) { const plugin: Plugin = () => { return (element: any) => { @@ -13,14 +57,40 @@ export const plugin: PluginFn = Object.assign( return plugin } - // Two argument case - use filtering + // Two argument case - filtered plugin (array of constructors or type guard) return filteredPlugin(undefined, filterArgOrMethods, methods) }, { - setup(setupFn: () => TSetupContext) { + /** + * Creates a plugin with access to a setup context. + * + * The setup function runs once when the plugin is initialized and can access + * hooks like useThree(). The returned context is passed to all plugin methods. + * + * @param setupFn - Function that returns context data to be shared with plugin methods + * @returns An object with a `then` method to define the plugin behavior + * + * @example + * const plugin = plugin + * .setup(() => { + * const gl = useThree().gl + * return { renderer: gl } + * }) + * .then((element, context) => ({ + * render: () => context.renderer.render(...) + * })) + */ + setup(setupFn: Accessor) { return { + /** + * Defines the plugin methods after setup. + * + * @param filterArgOrMethods - Either methods (for global plugin) or filter argument + * @param methods - Plugin methods (when using filter argument) + * @returns The configured plugin + */ then(filterArgOrMethods: any, methods?: any) { - // Single argument case - apply to all elements + // Single argument case - global plugin with setup if (methods === undefined) { const plugin: Plugin = () => { const context = setupFn() @@ -31,7 +101,7 @@ export const plugin: PluginFn = Object.assign( return plugin } - // Two argument case - use filtering + // Two argument case - filtered plugin with setup return filteredPlugin(setupFn, filterArgOrMethods, methods) }, } @@ -39,21 +109,18 @@ export const plugin: PluginFn = Object.assign( }, ) -// Helper function to create the actual plugin implementation -function filteredPlugin(setup: (() => any) | undefined, filterArg: any, methods: any): Plugin { +function filteredPlugin( + setup: Accessor | undefined, + filterArg: any, + methods: any, +): Plugin { const plugin: Plugin = () => { // Run setup once if provided and store result as context const context = setup ? setup() : undefined return (element: any) => { - // Handle single constructor - if (typeof filterArg === "function" && filterArg.prototype) { - if (element instanceof filterArg) { - return methods(element, context) - } - } // Handle array of constructors - else if (Array.isArray(filterArg)) { + if (Array.isArray(filterArg)) { for (const Constructor of filterArg) { if (element instanceof Constructor) { return methods(element, context) @@ -67,7 +134,7 @@ function filteredPlugin(setup: (() => any) | undefined, filterArg: any, methods: } } - return {} + return undefined } } diff --git a/src/types.ts b/src/types.ts index 55dc4d2..29559c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -378,23 +378,12 @@ export interface PluginFn { (element: any) => Methods > - // No setup - direct usage with two arguments (single constructor) - any, Methods extends Record>( - Constructor: T, - methods: (element: InstanceType) => Methods, - ): Plugin<{ - (element: InstanceType): Methods - (element: any): {} - }> - // No setup - direct usage with two arguments (array of constructors) - any)[], Methods extends Record>( + >( Constructors: T, - methods: ( - element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never, - ) => Methods, + methods: (element: T extends readonly Constructor[] ? U : never) => Methods, ): Plugin<{ - (element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never): Methods + (element: T extends readonly Constructor[] ? U : never): Methods (element: any): {} }> @@ -417,24 +406,15 @@ export interface PluginFn { methods: (element: any, context: TSetupContext) => Methods, ): Plugin<(element: any) => Methods> - // With setup - two arguments (single constructor) - any, Methods extends Record>( - Constructor: T, - methods: (element: InstanceType, context: TSetupContext) => Methods, - ): Plugin<{ - (element: InstanceType): Methods - (element: any): {} - }> - // With setup - two arguments (array of constructors) - any)[], Methods extends Record>( + >( Constructors: T, methods: ( - element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never, + element: T extends readonly Constructor[] ? U : never, context: TSetupContext, ) => Methods, ): Plugin<{ - (element: T extends readonly (new (...args: any[]) => infer U)[] ? U : never): Methods + (element: T extends readonly Constructor[] ? U : never): Methods (element: any): {} }> From f740cb2c1624689c92cc70ec70a427dec78beb0e Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 25 Aug 2025 00:30:06 +0200 Subject: [PATCH 14/26] feat: pass context as argument to setup function of PluginFn --- playground/examples/PluginExample.tsx | 3 +-- src/create-three.tsx | 2 +- src/event-plugin.ts | 5 +--- src/plugin.ts | 39 +++++++++++++-------------- src/types.ts | 4 +-- 5 files changed, 24 insertions(+), 29 deletions(-) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index e14aab8..e384bff 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -61,8 +61,7 @@ const GlobalPlugin = plugin(element => ({ // Example with setup - plugin that needs context from setup function const ContextPlugin = plugin - .setup(() => { - const context = useThree() + .setup((context) => { return { scene: context.scene } }) .then((element, context) => ({ diff --git a/src/create-three.tsx b/src/create-three.tsx index 6ffeef8..36cec8d 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -252,7 +252,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi if (result) { return result } - result = plugin() + result = plugin(context) pluginMap.set(plugin, result) return result }, diff --git a/src/event-plugin.ts b/src/event-plugin.ts index 9ce734a..8323fe1 100644 --- a/src/event-plugin.ts +++ b/src/event-plugin.ts @@ -1,6 +1,5 @@ import { onCleanup } from "solid-js" import { Object3D, type Intersection } from "three" -import { useThree } from "./hooks.ts" import { plugin } from "./plugin.ts" import { type Context, type Intersect, type Meta, type Prettify } from "./types.ts" import { getMeta } from "./utils.ts" @@ -492,9 +491,7 @@ function createDefaultEventRegistry( * Initializes and manages event handling for all `Instance`. */ export const EventPlugin = plugin - .setup(() => { - const context = useThree() - + .setup((context) => { // onMouseMove/onMouseEnter/onMouseLeave const hoverMouseRegistry = createHoverEventRegistry("Mouse", context) // onPointerMove/onPointerEnter/onPointerLeave diff --git a/src/plugin.ts b/src/plugin.ts index 7a0f561..d260c35 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,5 +1,5 @@ import type { Accessor } from "solid-js" -import type { Plugin, PluginFn } from "./types.ts" +import type { Context, Plugin, PluginFn } from "./types.ts" /** * Creates a plugin that extends solid-three components with additional functionality. @@ -36,9 +36,8 @@ import type { Plugin, PluginFn } from "./types.ts" * @example * // Plugin with setup context * const ContextPlugin = plugin - * .setup(() => { - * const scene = useThree().scene - * return { scene } + * .setup((context) => { + * return { scene: context.scene } * }) * .then([THREE.Object3D], (element, context) => ({ * addToScene: () => context.scene.add(element) @@ -49,7 +48,7 @@ export const plugin: PluginFn = Object.assign( (filterArgOrMethods?: any, methods?: any): any => { // Single argument case - global plugin (apply to all elements) if (methods === undefined) { - const plugin: Plugin = () => { + const plugin: Plugin = (_context: Context) => { return (element: any) => { return filterArgOrMethods(element) } @@ -64,23 +63,23 @@ export const plugin: PluginFn = Object.assign( /** * Creates a plugin with access to a setup context. * - * The setup function runs once when the plugin is initialized and can access - * hooks like useThree(). The returned context is passed to all plugin methods. + * The setup function runs once when the plugin is initialized and receives + * the Three.js context as its argument. The returned data is passed to all + * plugin methods. * - * @param setupFn - Function that returns context data to be shared with plugin methods + * @param setupFn - Function that receives the Three.js context and returns data to share * @returns An object with a `then` method to define the plugin behavior * * @example * const plugin = plugin - * .setup(() => { - * const gl = useThree().gl - * return { renderer: gl } + * .setup((context) => { + * return { renderer: context.gl } * }) * .then((element, context) => ({ * render: () => context.renderer.render(...) * })) */ - setup(setupFn: Accessor) { + setup(setupFn: (context: Context) => TSetupContext) { return { /** * Defines the plugin methods after setup. @@ -92,10 +91,10 @@ export const plugin: PluginFn = Object.assign( then(filterArgOrMethods: any, methods?: any) { // Single argument case - global plugin with setup if (methods === undefined) { - const plugin: Plugin = () => { - const context = setupFn() + const plugin: Plugin = (context: Context) => { + const setupContext = setupFn(context) return (element: any) => { - return filterArgOrMethods(element, context) + return filterArgOrMethods(element, setupContext) } } return plugin @@ -110,27 +109,27 @@ export const plugin: PluginFn = Object.assign( ) function filteredPlugin( - setup: Accessor | undefined, + setup: ((context: Context) => any) | undefined, filterArg: any, methods: any, ): Plugin { - const plugin: Plugin = () => { + const plugin: Plugin = (context: Context) => { // Run setup once if provided and store result as context - const context = setup ? setup() : undefined + const setupContext = setup ? setup(context) : undefined return (element: any) => { // Handle array of constructors if (Array.isArray(filterArg)) { for (const Constructor of filterArg) { if (element instanceof Constructor) { - return methods(element, context) + return methods(element, setupContext) } } } // Handle type guard function else if (typeof filterArg === "function") { if (filterArg(element)) { - return methods(element, context) + return methods(element, setupContext) } } diff --git a/src/types.ts b/src/types.ts index 29559c9..a065369 100644 --- a/src/types.ts +++ b/src/types.ts @@ -369,7 +369,7 @@ type ConstructorOverloadParameters = T extends { /**********************************************************************************/ export interface Plugin any> { - (): TFn + (context: Context): TFn } export interface PluginFn { @@ -398,7 +398,7 @@ export interface PluginFn { // Setup function setup( - setupFn: () => TSetupContext, + setupFn: (context: Context) => TSetupContext, ): { then: { // With setup - one argument (global) From 99f8ddc39eb7952570489959a1e6781a94545c4c Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 25 Aug 2025 00:59:59 +0200 Subject: [PATCH 15/26] cleanup: cleanup EventPlugin, remove Canvas-export --- playground/App.tsx | 4 +- src/{canvas.tsx => create-canvas.tsx} | 64 +---- src/create-t.tsx | 2 +- src/create-three.tsx | 212 ++++++++-------- src/data-structure/augmented-stack.ts | 28 --- src/event-plugin.ts | 330 +++++++++++-------------- src/index.ts | 2 +- src/internal-context.ts | 6 +- src/plugin-guide.md | 115 --------- src/testing/index.tsx | 2 +- src/types.ts | 82 +----- src/utils.ts | 19 +- src/utils/conditionals.ts | 11 +- src/{data-structure => utils}/stack.ts | 0 14 files changed, 285 insertions(+), 592 deletions(-) rename src/{canvas.tsx => create-canvas.tsx} (63%) delete mode 100644 src/data-structure/augmented-stack.ts delete mode 100644 src/plugin-guide.md rename src/{data-structure => utils}/stack.ts (100%) diff --git a/playground/App.tsx b/playground/App.tsx index f55ab14..2c031d5 100644 --- a/playground/App.tsx +++ b/playground/App.tsx @@ -1,14 +1,14 @@ import { A, Route, Router } from "@solidjs/router" import type { ParentProps } from "solid-js" import * as THREE from "three" -import { Canvas, createT, Entity } from "../src/index.ts" +import { createT, Entity } from "../src/index.ts" import { EnvironmentExample } from "./examples/EnvironmentExample.tsx" import { PluginExample } from "./examples/PluginExample.tsx" import { PortalExample } from "./examples/PortalExample.tsx" import { SolarExample } from "./examples/SolarExample.tsx" import "./index.css" -const { T } = createT({ ...THREE, Entity }) +const { T, Canvas } = createT({ ...THREE, Entity }) function Layout(props: ParentProps) { return ( diff --git a/src/canvas.tsx b/src/create-canvas.tsx similarity index 63% rename from src/canvas.tsx rename to src/create-canvas.tsx index 1fd942d..5fe2dab 100644 --- a/src/canvas.tsx +++ b/src/create-canvas.tsx @@ -10,12 +10,12 @@ import { } from "three" import { createThree } from "./create-three.tsx" import type { EventRaycaster } from "./raycasters.tsx" -import type { CanvasEventHandlers, Context, Plugin, Props } from "./types.ts" +import type { Context, Plugin, PluginPropsOf, Props } from "./types.ts" /** * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene. */ -export interface CanvasProps extends ParentProps> { +export interface CanvasProps extends ParentProps { ref?: Ref class?: string /** Configuration for the camera used in the scene. */ @@ -55,64 +55,8 @@ export interface CanvasProps extends ParentProps> { * @param props - Configuration options include camera settings, style, and children elements. * @returns A div element containing the WebGL canvas configured to occupy the full available space. */ -export function Canvas(props: ParentProps) { - let canvas: HTMLCanvasElement = null! - let container: HTMLDivElement = null! - - onMount(() => { - const context = createThree(canvas, props) - - // Resize observer for the canvas to adjust camera and renderer on size change - createResizeObserver(container, function onResize() { - const { width, height } = container.getBoundingClientRect() - context.gl.setSize(width, height) - context.gl.setPixelRatio(globalThis.devicePixelRatio) - - if (context.currentCamera instanceof OrthographicCamera) { - context.currentCamera.left = width / -2 - context.currentCamera.right = width / 2 - context.currentCamera.top = height / 2 - context.currentCamera.bottom = height / -2 - } else { - context.currentCamera.aspect = width / height - } - - context.currentCamera.updateProjectionMatrix() - context.render(performance.now()) - }) - }) - - return ( -
- -
- ) -} - -/** - * Serves as the root component for all 3D scenes created with `solid-three`. It initializes - * the Three.js rendering context, including a WebGL renderer, a scene, and a camera. - * All ``-components must be children of this Canvas. Hooks such as `useThree` and - * `useFrame` should only be used within this component to ensure proper context. - * - * @function Canvas - * @param props - Configuration options include camera settings, style, and children elements. - * @returns A div element containing the WebGL canvas configured to occupy the full available space. - */ -export function createCanvas(plugins: TPlugins) { - return function (props: ParentProps) { +export function createCanvas(plugins: TPlugins) { + return function (props: ParentProps & Partial>) { let canvas: HTMLCanvasElement = null! let container: HTMLDivElement = null! diff --git a/src/create-t.tsx b/src/create-t.tsx index 7def8ee..61ce7c8 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,6 +1,6 @@ import { createMemo, type Component, type JSX, type JSXElement, type MergeProps } from "solid-js" -import { createCanvas } from "./canvas.tsx" import { $S3C } from "./constants.ts" +import { createCanvas } from "./create-canvas.tsx" import { useProps } from "./props.ts" import type { Plugin, Props } from "./types.ts" import { meta } from "./utils.ts" diff --git a/src/create-three.tsx b/src/create-three.tsx index 36cec8d..f03256c 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -1,6 +1,5 @@ import { ReactiveMap } from "@solid-primitives/map" import { - children, createEffect, createMemo, createRenderEffect, @@ -24,8 +23,7 @@ import { VSMShadowMap, WebGLRenderer, } from "three" -import type { CanvasProps } from "./canvas.tsx" -import { Stack } from "./data-structure/stack.ts" +import type { CanvasProps } from "./create-canvas.tsx" import { frameContext, threeContext } from "./hooks.ts" import { pluginContext } from "./internal-context.ts" import { useProps, useSceneGraph } from "./props.ts" @@ -38,14 +36,16 @@ import { meta, removeElementFromArray, useRef, + withContext, withMultiContexts, } from "./utils.ts" +import { Stack } from "./utils/stack.ts" import { useMeasure } from "./utils/use-measure.ts" /** * Creates and manages a `solid-three` scene. It initializes necessary objects like - * camera, renderer, raycaster, and scene, manages the scene graph, setups up an event system - * and rendering loop based on the provided properties. + * camera, renderer, raycaster, and scene, manages the scene graph, setups up a rendering loop + * based on the provided properties. */ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugins: Plugin[]) { const canvasProps = defaultProps(props, { frameloop: "always" }) @@ -206,22 +206,24 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi const raycasterStack = new Stack("raycaster") + const glProp = createMemo(() => props.gl) const gl = createMemo(() => { - const gl = - props.gl instanceof WebGLRenderer - ? // props.gl can be a WebGLRenderer provided by the user - props.gl - : typeof props.gl === "function" + const _glProp = glProp() + return meta( + _glProp instanceof WebGLRenderer + ? // _glProp can be a WebGLRenderer provided by the user + _glProp + : typeof _glProp === "function" ? // or a callback that returns a Renderer - props.gl(canvas) - : // if props.gl is not defined we default to a WebGLRenderer - new WebGLRenderer({ canvas }) - - return meta(gl, { - get props() { - return props.gl || {} + _glProp(canvas) + : // if _glProp is not defined we default to a WebGLRenderer + new WebGLRenderer({ canvas }), + { + get props() { + return glProp() || {} + }, }, - }) + ) }) const measure = useMeasure() @@ -297,92 +299,98 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi /* */ /**********************************************************************************/ - withMultiContexts(() => { - createRenderEffect(() => { - if (props.frameloop === "never") { - context.clock.stop() - context.clock.elapsedTime = 0 - } else { - context.clock.start() - } - }) + withContext( + () => { + createRenderEffect(() => { + if (props.frameloop === "never") { + context.clock.stop() + context.clock.elapsedTime = 0 + } else { + context.clock.start() + } + }) - // Manage camera - createRenderEffect(() => { - if (cameraStack.peek()) return - if (!props.defaultCamera || props.defaultCamera instanceof Camera) return - useProps(defaultCamera, props.defaultCamera) - // NOTE: Manually update camera's matrix with updateMatrixWorld is needed. - // Otherwise casting a ray immediately after start-up will cause the incorrect matrix to be used. - defaultCamera().updateMatrixWorld(true) - }) + // Manage camera + createRenderEffect(() => { + if (cameraStack.peek()) return + if (!props.defaultCamera || props.defaultCamera instanceof Camera) return + useProps(defaultCamera, props.defaultCamera) + // NOTE: Manually update camera's matrix with updateMatrixWorld is needed. + // Otherwise casting a ray immediately after start-up will cause the incorrect matrix to be used. + defaultCamera().updateMatrixWorld(true) + }) - // Manage scene - createRenderEffect(() => { - if (!props.scene || props.scene instanceof Scene) return - useProps(scene, props.scene) - }) + // Manage scene + createRenderEffect(() => { + if (!props.scene || props.scene instanceof Scene) return + useProps(scene, props.scene) + }) - // Manage raycaster - createRenderEffect(() => { - if (!props.defaultRaycaster || props.defaultRaycaster instanceof Raycaster) return - useProps(defaultRaycaster, props.defaultRaycaster) - }) + // Manage raycaster + createRenderEffect(() => { + if (!props.defaultRaycaster || props.defaultRaycaster instanceof Raycaster) return + useProps(defaultRaycaster, props.defaultRaycaster) + }) - // Manage gl - createRenderEffect(() => { - // Set shadow-map + // Manage gl createRenderEffect(() => { - const _gl = gl() - if (_gl.shadowMap) { - const oldEnabled = _gl.shadowMap.enabled - const oldType = _gl.shadowMap.type - _gl.shadowMap.enabled = !!props.shadows - - if (typeof props.shadows === "boolean") { - _gl.shadowMap.type = PCFSoftShadowMap - } else if (typeof props.shadows === "string") { - const types = { - basic: BasicShadowMap, - percentage: PCFShadowMap, - soft: PCFSoftShadowMap, - variance: VSMShadowMap, + // Set shadow-map + createRenderEffect(() => { + const _gl = gl() + + if (_gl.shadowMap) { + const oldEnabled = _gl.shadowMap.enabled + const oldType = _gl.shadowMap.type + _gl.shadowMap.enabled = !!props.shadows + + if (typeof props.shadows === "boolean") { + _gl.shadowMap.type = PCFSoftShadowMap + } else if (typeof props.shadows === "string") { + const types = { + basic: BasicShadowMap, + percentage: PCFShadowMap, + soft: PCFSoftShadowMap, + variance: VSMShadowMap, + } + _gl.shadowMap.type = types[props.shadows] ?? PCFSoftShadowMap + } else if (typeof props.shadows === "object") { + Object.assign(_gl.shadowMap, props.shadows) } - _gl.shadowMap.type = types[props.shadows] ?? PCFSoftShadowMap - } else if (typeof props.shadows === "object") { - Object.assign(_gl.shadowMap, props.shadows) + + if (oldEnabled !== _gl.shadowMap.enabled || oldType !== _gl.shadowMap.type) + _gl.shadowMap.needsUpdate = true } + }) - if (oldEnabled !== _gl.shadowMap.enabled || oldType !== _gl.shadowMap.type) - _gl.shadowMap.needsUpdate = true - } - }) + createEffect(() => { + const renderer = gl() + // Connect to xr if property exists + if (renderer.xr) context.xr.connect() + }) - createEffect(() => { - const renderer = gl() - // Connect to xr if property exists - if (renderer.xr) context.xr.connect() - }) + // Set color space and tonemapping preferences + const LinearEncoding = 3000 + const sRGBEncoding = 3001 + // Color management and tone-mapping + useProps(gl, { + get outputEncoding() { + return props.linear ? LinearEncoding : sRGBEncoding + }, + get toneMapping() { + return props.flat ? NoToneMapping : ACESFilmicToneMapping + }, + }) - // Set color space and tonemapping preferences - const LinearEncoding = 3000 - const sRGBEncoding = 3001 - // Color management and tone-mapping - useProps(gl, { - get outputEncoding() { - return props.linear ? LinearEncoding : sRGBEncoding - }, - get toneMapping() { - return props.flat ? NoToneMapping : ACESFilmicToneMapping - }, + // Manage props + const _glProp = glProp() + if (_glProp && !(_glProp instanceof WebGLRenderer)) { + useProps(gl, _glProp) + } }) - - // Manage props - if (props.gl && !(props.gl instanceof WebGLRenderer)) { - useProps(gl, props.gl) - } - }) - }, [[threeContext, context]]) + }, + threeContext, + context, + ) /**********************************************************************************/ /* */ @@ -408,20 +416,16 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi /* */ /**********************************************************************************/ - const c = children(() => ( - - - {canvasProps.children} - - - )) - useSceneGraph( context.scene, mergeProps(props, { - get children() { - return c() - }, + children: ( + + + {canvasProps.children} + + + ), }), ) diff --git a/src/data-structure/augmented-stack.ts b/src/data-structure/augmented-stack.ts deleted file mode 100644 index 61dc338..0000000 --- a/src/data-structure/augmented-stack.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Accessor } from "solid-js" -import type { Meta } from "../types.ts" -import { meta } from "../utils.ts" -import { Stack } from "./stack.ts" - -/** A generic stack data structure. It augments each value before pushing it onto the stack. */ -export class AugmentedStack { - #stack = new Stack>(null!) - constructor(public name: string) { - this.#stack.name = name - } - all = this.#stack.all.bind(this.#stack) - peek = this.#stack.peek.bind(this.#stack) - /** - * Augments a value `T` or `Accessor` and adds it to the stack. - * Value is automatically removed from stack on cleanup. - * - * @param value - The value to add to the stack. - * @returns A cleanup function to manually remove the value from the stack. - */ - push(value: T | Accessor) { - const cleanup = - typeof value === "function" - ? this.#stack.push(() => meta((value as Accessor)())) - : this.#stack.push(meta(value)) - return cleanup - } -} diff --git a/src/event-plugin.ts b/src/event-plugin.ts index 8323fe1..06894bf 100644 --- a/src/event-plugin.ts +++ b/src/event-plugin.ts @@ -1,42 +1,57 @@ import { onCleanup } from "solid-js" import { Object3D, type Intersection } from "three" import { plugin } from "./plugin.ts" -import { type Context, type Intersect, type Meta, type Prettify } from "./types.ts" +import { type Context, type Intersect, type Meta, type Prettify, type When } from "./types.ts" import { getMeta } from "./utils.ts" +const EVENT_NAME_MAP = { + onClick: "click", + onContextMenu: "contextmenu", + onDoubleClick: "dblclick", + onMouseDown: "mousedown", + onMouseMove: "mousemove", + onMouseUp: "mouseup", + onMouseLeave: "mouseleave", + onPointerUp: "pointerup", + onPointerDown: "pointerdown", + onPointerMove: "pointermove", + onPointerLeave: "pointerleave", + onWheel: "wheel", +} as const + /**********************************************************************************/ /* */ /* Event */ /* */ /**********************************************************************************/ -export type When = T extends false ? (T extends true ? U : unknown) : U - export type Event< TEvent, TConfig extends { stoppable?: boolean; intersections?: boolean } = { stoppable: true intersections: true }, -> = Intersect< - [ - { nativeEvent: TEvent }, - When< - TConfig["stoppable"], - { - stopped: boolean - stopPropagation: () => void - } - >, - When< - TConfig["intersections"], - { - currentIntersection: Intersection - intersection: Intersection - intersections: Intersection[] - } - >, - ] +> = Prettify< + Intersect< + [ + { nativeEvent: TEvent }, + When< + TConfig["stoppable"], + { + stopped: boolean + stopPropagation: () => void + } + >, + When< + TConfig["intersections"], + { + currentIntersection: Intersection + intersection: Intersection + intersections: Intersection[] + } + >, + ] + > > type EventHandlersMap = { @@ -63,71 +78,28 @@ export type EventHandlers = { [TKey in keyof EventHandlersMap]: (event: EventHandlersMap[TKey]) => void } +export type EventListeners = { + [TKey in keyof EventHandlersMap]: (cb: (event: EventHandlersMap[TKey]) => void) => void +} + export type CanvasEventHandlers = { [TKey in keyof EventHandlersMap]: ( event: Prettify>, ) => void } -/** The names of all `EventHandlers` */ export type EventName = keyof EventHandlersMap /**********************************************************************************/ /* */ -/* Event Plugin */ +/* Create Event */ /* */ /**********************************************************************************/ -const EVENT_NAME_MAP = { - onClick: "click", - onContextMenu: "contextmenu", - onDoubleClick: "dblclick", - onMouseDown: "mousedown", - onMouseMove: "mousemove", - onMouseUp: "mouseup", - onMouseLeave: "mouseleave", - onPointerUp: "pointerup", - onPointerDown: "pointerdown", - onPointerMove: "pointermove", - onPointerLeave: "pointerleave", - onWheel: "wheel", -} as const - -function createRegistry() { - const array: T[] = [] - - return { - array, - add(instance: T) { - array.push(instance) - return () => { - array.splice( - array.findIndex(_instance => _instance === instance), - 1, - ) - } - }, - } -} - -/** - * Checks if a given string is a valid event type within the system. - * - * @param type - The type of the event to check. - * @returns `true` if the type is a recognized `EventType`, otherwise `false`. - */ -export const isEventType = (type: string): type is EventName => - /^on(Pointer|Click|DoubleClick|ContextMenu|Wheel|Mouse)/.test(type) - -/**********************************************************************************/ -/* */ -/* Events */ -/* */ -/**********************************************************************************/ // /** Creates a `ThreeEvent` (intersection excluded) from the current `MouseEvent` | `WheelEvent`. */ -function createThreeEvent< - TEvent extends Event, +function createEvent< + TEvent extends globalThis.Event, TConfig extends { stoppable?: boolean; intersections?: Array }, >(nativeEvent: TEvent, { stoppable = true, intersections }: TConfig = {}) { const event: Record = stoppable @@ -201,6 +173,29 @@ function raycast( return context.currentRaycaster.intersectObjects(nodeSet.values().toArray(), false) } +/**********************************************************************************/ +/* */ +/* Auto Registry */ +/* */ +/**********************************************************************************/ + +function createAutoRegistry() { + const array: T[] = [] + + return { + array, + add(instance: T) { + array.push(instance) + onCleanup(() => { + array.splice( + array.findIndex(_instance => _instance === instance), + 1, + ) + }) + }, + } +} + /**********************************************************************************/ /* */ /* Create Missable Event Registry */ @@ -217,7 +212,7 @@ function createMissableEventRegistry( type: "onClick" | "onDoubleClick" | "onContextMenu", context: Context, ) { - const registry = createRegistry() + const registry = createAutoRegistry() context.canvas.addEventListener(EVENT_NAME_MAP[type], nativeEvent => { if (registry.array.length === 0) return @@ -230,7 +225,7 @@ function createMissableEventRegistry( // Phase #1 - Process normal click events const intersections = raycast(context, registry.array, nativeEvent) - const stoppableEvent = createThreeEvent(nativeEvent, { intersections }) + const stoppableEvent = createEvent(nativeEvent, { intersections }) for (const intersection of intersections) { // Update currentIntersection @@ -242,10 +237,7 @@ function createMissableEventRegistry( while (node && !stoppableEvent.stopped && !visitedObjects.has(node)) { missedObjects.delete(node) visitedObjects.add(node) - getMeta(node)?.props[type]?.( - // @ts-expect-error TODO: fix type-error - stoppableEvent, - ) + getMeta(node)?.props[type]?.(stoppableEvent) node = node.parent } } @@ -256,6 +248,8 @@ function createMissableEventRegistry( // Remove currentIntersection // @ts-expect-error TODO: fix type-error delete stoppableEvent.currentIntersection + + // @ts-expect-error TODO: fix type-error context.props[type]?.(stoppableEvent) } @@ -278,13 +272,14 @@ function createMissableEventRegistry( } // Phase #3 - Fire missed event-handler on missed objects - const missedEvent = createThreeEvent(nativeEvent, { stoppable: false }) + const missedEvent = createEvent(nativeEvent, { stoppable: false }) for (const object of missedObjects) { getMeta(object)?.props[missedType]?.(missedEvent) } if (visitedObjects.size > 0) { + // @ts-expect-error TODO: fix type-error context.props[`${type}Missed`]?.(missedEvent) } }) @@ -310,7 +305,7 @@ function createMissableEventRegistry( * - `onPointerLeave` */ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { - const registry = createRegistry() + const registry = createAutoRegistry() let hoveredSet = new Set() let intersections: Intersection>[] = [] let hoveredCanvas = false @@ -319,7 +314,7 @@ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { intersections = raycast(context, registry.array, nativeEvent) // Phase #1 - Enter - const enterEvent = createThreeEvent(nativeEvent, { stoppable: false, intersections }) + const enterEvent = createEvent(nativeEvent, { stoppable: false, intersections }) const enterSet = new Set() for (const intersection of intersections) { @@ -332,10 +327,7 @@ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { while (current && !enterSet.has(current)) { enterSet.add(current) if (!hoveredSet.has(current)) { - getMeta(current)?.props[`on${type}Enter`]?.( - // @ts-expect-error TODO: fix type-error - enterEvent, - ) + getMeta(current)?.props[`on${type}Enter`]?.(enterEvent) } // We bubble a layer down. @@ -344,15 +336,13 @@ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { } if (hoveredCanvas === false) { - context.props[`on${type}Enter`]?.( - // @ts-expect-error TODO: fix type-error - enterEvent, - ) + // @ts-expect-error TODO: fix type-error + context.props[`on${type}Enter`]?.(enterEvent) hoveredCanvas = true } // Phase #2 - Move - const moveEvent = createThreeEvent(nativeEvent, { intersections }) + const moveEvent = createEvent(nativeEvent, { intersections }) const moveSet = new Set() for (const intersection of intersections) { @@ -367,10 +357,7 @@ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { moveSet.add(current) const meta = getMeta(current) if (meta) { - meta.props[`on${type}Move`]?.( - // @ts-expect-error TODO: fix type-error - moveEvent, - ) + meta.props[`on${type}Move`]?.(moveEvent) // Break if event was if (moveEvent.stopped) { break @@ -385,14 +372,12 @@ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { // Remove currentIntersection // @ts-expect-error TODO: fix type-error delete moveEvent.currentIntersection - context.props[`on${type}Move`]?.( - // @ts-expect-error TODO: fix type-error - moveEvent, - ) + // @ts-expect-error TODO: fix type-error + context.props[`on${type}Move`]?.(moveEvent) } // Handle leave-event - const leaveEvent = createThreeEvent(nativeEvent, { intersections, stoppable: false }) + const leaveEvent = createEvent(nativeEvent, { intersections, stoppable: false }) const leaveSet = hoveredSet.difference(enterSet) hoveredSet = enterSet @@ -402,16 +387,13 @@ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) { }) context.canvas.addEventListener(EVENT_NAME_MAP[`on${type}Leave`], nativeEvent => { - const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false }) + const leaveEvent = createEvent(nativeEvent, { stoppable: false }) // @ts-expect-error TODO: fix type-error context.props[`on${type}Leave`]?.(leaveEvent) hoveredCanvas = false for (const object of hoveredSet) { - getMeta(object)?.props[`on${type}Leave`]?.( - // @ts-expect-error TODO: fix type-error - leaveEvent, - ) + getMeta(object)?.props[`on${type}Leave`]?.(leaveEvent) } hoveredSet.clear() }) @@ -438,13 +420,13 @@ function createDefaultEventRegistry( context: Context, options?: AddEventListenerOptions, ) { - const registry = createRegistry() + const registry = createAutoRegistry() context.canvas.addEventListener( EVENT_NAME_MAP[type], nativeEvent => { - const intersections = raycast(context, registry.array, nativeEvent) /* [0]] */ - const event = createThreeEvent(nativeEvent, { intersections }) + const intersections = raycast(context, registry.array, nativeEvent) + const event = createEvent(nativeEvent, { intersections }) const visitedNodes = new Set() @@ -457,10 +439,7 @@ function createDefaultEventRegistry( let node: Object3D | null = intersection.object while (node && !event.stopped && !visitedNodes.has(node)) { - getMeta(node)?.props[type]?.( - // @ts-expect-error TODO: fix type-error - event, - ) + getMeta(node)?.props[type]?.(event) visitedNodes.add(node) node = node.parent } @@ -491,108 +470,93 @@ function createDefaultEventRegistry( * Initializes and manages event handling for all `Instance`. */ export const EventPlugin = plugin - .setup((context) => { + .setup(context => ({ // onMouseMove/onMouseEnter/onMouseLeave - const hoverMouseRegistry = createHoverEventRegistry("Mouse", context) + hoverMouses: createHoverEventRegistry("Mouse", context), // onPointerMove/onPointerEnter/onPointerLeave - const hoverPointerRegistry = createHoverEventRegistry("Pointer", context) - + hoverPointers: createHoverEventRegistry("Pointer", context), // onClick/onClickMissed - const missableClickRegistry = createMissableEventRegistry("onClick", context) + missableClicks: createMissableEventRegistry("onClick", context), // onContextMenu/onContextMenuMissed - const missableContextMenuRegistry = createMissableEventRegistry("onContextMenu", context) + missableContextMenus: createMissableEventRegistry("onContextMenu", context), // onDoubleClick/onDoubleClickMissed - const missableDoubleClickRegistry = createMissableEventRegistry("onDoubleClick", context) - + missableDoubleClicks: createMissableEventRegistry("onDoubleClick", context), // Default mouse-events - const mouseDownRegistry = createDefaultEventRegistry("onMouseDown", context) - const mouseUpRegistry = createDefaultEventRegistry("onMouseUp", context) + mouseDowns: createDefaultEventRegistry("onMouseDown", context), + mouseUps: createDefaultEventRegistry("onMouseUp", context), // Default pointer-events - const pointerDownRegistry = createDefaultEventRegistry("onPointerDown", context) - const pointerUpRegistry = createDefaultEventRegistry("onPointerUp", context) + pointerDowns: createDefaultEventRegistry("onPointerDown", context), + pointerUps: createDefaultEventRegistry("onPointerUp", context), // Default wheel-event - const wheelRegistry = createDefaultEventRegistry("onWheel", context, { passive: true }) - - return { - hoverMouseRegistry, - hoverPointerRegistry, - missableClickRegistry, - missableContextMenuRegistry, - missableDoubleClickRegistry, - mouseDownRegistry, - mouseUpRegistry, - pointerDownRegistry, - pointerUpRegistry, - wheelRegistry, - } - }) + wheels: createDefaultEventRegistry("onWheel", context, { passive: true }), + })) .then( ( object, { - hoverMouseRegistry, - hoverPointerRegistry, - missableClickRegistry, - missableContextMenuRegistry, - missableDoubleClickRegistry, - mouseDownRegistry, - mouseUpRegistry, - pointerDownRegistry, - pointerUpRegistry, - wheelRegistry, + hoverMouses, + hoverPointers, + missableClicks, + missableContextMenus, + missableDoubleClicks, + mouseDowns, + mouseUps, + pointerDowns, + pointerUps, + wheels, }, - ) => { + ): EventListeners => { return { - onClick(callback: (event: Event) => void) { - onCleanup(missableClickRegistry.add(object)) + onClick() { + missableClicks.add(object) }, - onClickMissed(callback: (event: Event) => void) { - onCleanup(missableClickRegistry.add(object)) + onClickMissed() { + missableClicks.add(object) }, - onDoubleClick(callback: (event: Event) => void) { - onCleanup(missableDoubleClickRegistry.add(object)) + onDoubleClick() { + missableDoubleClicks.add(object) }, - onDoubleClickMissed(callback: (event: Event) => void) { - onCleanup(missableDoubleClickRegistry.add(object)) + onDoubleClickMissed() { + missableDoubleClicks.add(object) }, - onContextMenu(callback: (event: Event) => void) { - onCleanup(missableContextMenuRegistry.add(object)) + onContextMenu() { + missableContextMenus.add(object) }, - onContextMenuMissed(callback: (event: Event) => void) { - onCleanup(missableContextMenuRegistry.add(object)) + onContextMenuMissed() { + missableContextMenus.add(object) }, - onMouseDown(callback: (event: Event) => void) { - onCleanup(mouseDownRegistry.add(object)) + onMouseDown() { + mouseDowns.add(object) }, - onMouseUp(callback: (event: Event) => void) { - onCleanup(mouseUpRegistry.add(object)) + onMouseUp() { + mouseUps.add(object) }, - onMouseMove(callback: (event: Event) => void) { - onCleanup(hoverMouseRegistry.add(object)) + onMouseMove() { + hoverMouses.add(object) }, - onMouseEnter(callback: (event: Event) => void) { - onCleanup(hoverMouseRegistry.add(object)) + onMouseEnter() { + hoverMouses.add(object) }, - onMouseLeave(callback: (event: Event) => void) { - onCleanup(hoverMouseRegistry.add(object)) + onMouseLeave() { + hoverMouses.add(object) }, - onPointerDown(callback: (event: Event) => void) { - onCleanup(pointerDownRegistry.add(object)) + onPointerDown() { + pointerDowns.add(object) }, - onPointerUp(callback: (event: Event) => void) { - onCleanup(pointerUpRegistry.add(object)) + onPointerUp() { + pointerUps.add(object) }, - onPointerMove(callback: (event: Event) => void) { - onCleanup(hoverPointerRegistry.add(object)) + onPointerMove() { + hoverPointers.add(object) }, - onPointerEnter(callback: (event: Event) => void) { - onCleanup(hoverPointerRegistry.add(object)) + onPointerEnter() { + hoverPointers.add(object) }, - onPointerLeave(callback: (event: Event) => void) { - onCleanup(hoverMouseRegistry.add(object)) + onPointerLeave() { + hoverMouses.add(object) }, - onwheel(callback: (event: Event) => void) { - onCleanup(wheelRegistry.add(object)) + onWheel() { + wheels.add(object) }, } }, diff --git a/src/index.ts b/src/index.ts index 9256757..da8bcd1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ -export { Canvas, type CanvasProps } from "./canvas.tsx" export { Entity, Portal, Resource } from "./components.tsx" export { $S3C } from "./constants.ts" +export { type CanvasProps } from "./create-canvas.tsx" export { createEntity, createT } from "./create-t.tsx" export { EventPlugin } from "./event-plugin.ts" export { useFrame, useThree } from "./hooks.ts" diff --git a/src/internal-context.ts b/src/internal-context.ts index aa4f2ee..40a4e1a 100644 --- a/src/internal-context.ts +++ b/src/internal-context.ts @@ -1,6 +1,6 @@ import { type JSX, createContext, useContext } from "solid-js" import { Object3D } from "three" -import type { EventName, Meta, Plugin } from "./types.ts" +import type { Meta, Plugin } from "./types.ts" /** * Registers an event listener for an `AugmentedElement` to the nearest Canvas component up the component tree. @@ -10,14 +10,14 @@ import type { EventName, Meta, Plugin } from "./types.ts" * @param type - The type of event to listen for (e.g., 'click', 'mouseenter'). * @throws Throws an error if used outside of the Canvas component context. */ -export const addToEventListeners = (object: Meta, type: EventName) => { +export const addToEventListeners = (object: Meta, type: string) => { const addToEventListeners = useContext(eventContext) if (!addToEventListeners) { throw new Error("S3: Hooks can only be used within the Canvas component!") } return addToEventListeners(object, type) } -export const eventContext = createContext<(object: Meta, type: EventName) => () => void>() +export const eventContext = createContext<(object: Meta, type: string) => () => void>() /** * This function facilitates the rendering of JSX elements outside the normal scene diff --git a/src/plugin-guide.md b/src/plugin-guide.md deleted file mode 100644 index 129691f..0000000 --- a/src/plugin-guide.md +++ /dev/null @@ -1,115 +0,0 @@ -# Conditional Plugin System for solid-three - -This system allows plugins to provide different methods based on the element type, with full TypeScript support. - -## How It Works - -Due to TypeScript's limitations with conditional generic types, we use function overloads to achieve type-safe conditional plugins. - -## Example - -```typescript -// 1. Define overloaded interface -interface TransformPluginFn { - (element: Mesh): { - lookAt(target: Object3D): void - bounce(height?: number): void - spin(speed?: number): void - } - (element: Camera): { - lookAt(target: Object3D): void - shake(intensity?: number): void - } - (element: Light): { - pulse(minIntensity?: number, maxIntensity?: number): void - } - (element: Object3D): { - lookAt(target: Object3D): void - } - (element: any): {} -} - -// 2. Create plugin with explicit typing -const TransformPlugin: Plugin = (() => { - return ((element: any) => { - const methods: any = {} - - // Base Object3D methods - if (element instanceof Object3D) { - methods.lookAt = (target: Object3D) => { - useFrame(() => element.lookAt(target.position)) - } - } - - // Mesh-specific methods - if (element instanceof Mesh) { - methods.bounce = (height = 1) => { - useFrame((ctx) => { - element.position.y = Math.abs(Math.sin(ctx.clock.elapsedTime)) * height - }) - } - methods.spin = (speed = 1) => { - useFrame((_, delta) => element.rotation.y += delta * speed) - } - } - - // Camera-specific methods - if (element instanceof Camera) { - methods.shake = (intensity = 0.1) => { - useFrame(() => { - element.position.x += (Math.random() - 0.5) * intensity - }) - } - } - - // Light-specific methods - if (element instanceof Light) { - methods.pulse = (min = 0.5, max = 1) => { - useFrame((ctx) => { - const t = (Math.sin(ctx.clock.elapsedTime) + 1) / 2 - element.intensity = min + (max - min) * t - }) - } - } - - return methods - }) as TransformPluginFn -}) - -// 3. Use the plugin -const { T, Canvas } = createT(THREE, [TransformPlugin]) - -function App() { - return ( - - {/* ✅ Mesh gets: lookAt, bounce, spin */} - - - - - {/* ✅ Camera gets: lookAt, shake */} - - - {/* ✅ Light gets: pulse */} - - - {/* ❌ These would cause TypeScript errors: */} - {/* */} - {/* */} - - ) -} -``` - -## Key Points - -1. **Order matters**: Place more specific types before general ones -2. **Runtime checks**: Use `instanceof` to determine available methods -3. **Type safety**: TypeScript enforces correct usage at compile time -4. **Explicit typing**: Use `Plugin` for clear type declarations - -## Result - -- **Type Safety**: Only correct methods are available for each element type -- **IntelliSense**: Full autocompletion support -- **Error Prevention**: TypeScript catches incorrect usage at compile time \ No newline at end of file diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 6c89582..a9a9c2b 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -1,5 +1,5 @@ import { type Accessor, type JSX, createRoot, mergeProps } from "solid-js" -import type { CanvasProps } from "../canvas.tsx" +import type { CanvasProps } from "../create-canvas.tsx" import { createThree } from "../create-three.tsx" import { useRef } from "../utils.ts" import { WebGL2RenderingContext } from "./webgl2-rendering-context.ts" diff --git a/src/types.ts b/src/types.ts index a065369..0ab580d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,7 @@ -import type { Accessor, JSX, Ref } from "solid-js" +import type { Accessor, JSX } from "solid-js" import type { Clock, ColorRepresentation, - Intersection, OrthographicCamera, PerspectiveCamera, Raycaster, @@ -18,9 +17,8 @@ import type { Vector4 as ThreeVector4, WebGLRenderer, } from "three" -import type { Intersect } from "../playground/controls/type-utils.ts" -import type { CanvasProps } from "./canvas.tsx" import type { $S3C } from "./constants.ts" +import type { CanvasProps } from "./create-canvas.tsx" import type { EventRaycaster } from "./raycasters.tsx" import type { Measure } from "./utils/use-measure.ts" @@ -56,6 +54,8 @@ export type Intersect = T extends [infer U, ...infer Rest] : U & Intersect : T +export type When = T extends false ? (T extends true ? U : unknown) : U + /**********************************************************************************/ /* */ /* Meta */ @@ -68,7 +68,7 @@ export type Meta = T & { /** Metadata of a `solid-three` instance. */ export type Data = { - props: Props> + props: Props> & Record parent: any children: Set> plugins: Plugin[] @@ -132,74 +132,6 @@ export type FrameListener = ( options?: FrameListenerOptions, ) => () => void -/**********************************************************************************/ -/* */ -/* Event */ -/* */ -/**********************************************************************************/ - -export type When = T extends false ? (T extends true ? U : unknown) : U - -export type Event< - TEvent, - TConfig extends { stoppable?: boolean; intersections?: boolean } = { - stoppable: true - intersections: true - }, -> = Intersect< - [ - { nativeEvent: TEvent }, - When< - TConfig["stoppable"], - { - stopped: boolean - stopPropagation: () => void - } - >, - When< - TConfig["intersections"], - { - currentIntersection: Intersection - intersection: Intersection - intersections: Intersection[] - } - >, - ] -> - -type EventHandlersMap = { - onClick: Prettify> - onClickMissed: Prettify> - onDoubleClick: Prettify> - onDoubleClickMissed: Prettify> - onContextMenu: Prettify> - onContextMenuMissed: Prettify> - onMouseDown: Prettify> - onMouseEnter: Prettify> - onMouseLeave: Prettify> - onMouseMove: Prettify> - onMouseUp: Prettify> - onPointerUp: Prettify> - onPointerDown: Prettify> - onPointerMove: Prettify> - onPointerEnter: Prettify> - onPointerLeave: Prettify> - onWheel: Prettify> -} - -export type EventHandlers = { - [TKey in keyof EventHandlersMap]: (event: EventHandlersMap[TKey]) => void -} - -export type CanvasEventHandlers = { - [TKey in keyof EventHandlersMap]: ( - event: Prettify>, - ) => void -} - -/** The names of all `EventHandlers` */ -export type EventName = keyof EventHandlersMap - /**********************************************************************************/ /* */ /* Representations */ @@ -207,7 +139,7 @@ export type EventName = keyof EventHandlersMap /**********************************************************************************/ /** Maps properties of given type to their `solid-three` representations. */ -type MapToRepresentation = { +export type MapToRepresentation = { [TKey in keyof T]: Representation } @@ -254,7 +186,7 @@ export type Props = Partial children: JSX.Element key?: string onUpdate: (self: Meta>) => void - ref: Ref>> + // ref: Ref>> /** * Prevents the Object3D from being cast by the ray. * Object3D can still receive events via propagation from its descendants. diff --git a/src/utils.ts b/src/utils.ts index 6e44197..c447178 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,16 +10,7 @@ import { Vector3, } from "three" import { $S3C } from "./constants.ts" -import type { - CameraKind, - Constructor, - Data, - InstanceOf, - Loader, - Meta, - Plugin, - Props, -} from "./types.ts" +import type { CameraKind, Constructor, Data, Loader, Meta, Plugin } from "./types.ts" import type { Measure } from "./utils/use-measure.ts" /**********************************************************************************/ @@ -53,7 +44,7 @@ export function autodispose void }>(object: T): T { /**********************************************************************************/ interface MetaOptions { - props?: Props> + props?: Record plugins?: Plugin[] } @@ -370,6 +361,12 @@ export function getCurrentViewport( return { width: w, height: h, top, left, factor: width / w, distance, aspect } } +/**********************************************************************************/ +/* */ +/* Binary Search */ +/* */ +/**********************************************************************************/ + // Find where to insert target to keep array sorted export function binarySearch(array: number[], target: number) { let left = 0 diff --git a/src/utils/conditionals.ts b/src/utils/conditionals.ts index 356915e..70a8a58 100644 --- a/src/utils/conditionals.ts +++ b/src/utils/conditionals.ts @@ -1,4 +1,5 @@ -import { type Accessor, createEffect, createMemo, type Resource } from "solid-js" +import { type Accessor, createEffect, createMemo } from "solid-js" +import { resolve } from "../utils.ts" export function check< T, @@ -105,7 +106,7 @@ export function every< const values = new Array(accessors.length) for (let i = 0; i < accessors.length; i++) { - const _value = typeof accessors[i] === "function" ? (accessors[i] as () => T)() : accessors[i] + const _value = resolve(accessors[i]) if (!_value) return undefined values[i] = _value } @@ -115,12 +116,6 @@ export function every< return callback } -export function wrapNullableResource>( - value: T, -): Accessor]> { - return () => value.state === "ready" && [value()] -} - export function whenEffect< T, const TAccessor extends Accessor | T, diff --git a/src/data-structure/stack.ts b/src/utils/stack.ts similarity index 100% rename from src/data-structure/stack.ts rename to src/utils/stack.ts From 57f2d1b308d0def7e8e75e6947ed89e281fc1a9d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 25 Aug 2025 02:26:54 +0200 Subject: [PATCH 16/26] feat: pass PluginProps to --- playground/examples/PluginExample.tsx | 5 ++- src/create-three.tsx | 63 +++++++++++++++++++++------ src/types.ts | 4 +- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index e384bff..ab80dc2 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -61,7 +61,7 @@ const GlobalPlugin = plugin(element => ({ // Example with setup - plugin that needs context from setup function const ContextPlugin = plugin - .setup((context) => { + .setup(context => { return { scene: context.scene } }) .then((element, context) => ({ @@ -88,6 +88,7 @@ export function PluginExample() { console.info("click missed")} > @@ -99,7 +100,7 @@ export function PluginExample() { lookAt={useThree().currentCamera} log="Mesh rendered!" shake={0.1} - onMouseDown={event => console.info("ok")} + onClick={event => event.stopPropagation()} > diff --git a/src/create-three.tsx b/src/create-three.tsx index f03256c..8a650e9 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -23,6 +23,7 @@ import { VSMShadowMap, WebGLRenderer, } from "three" +import { processProps } from "../playground/controls/process-props.ts" import type { CanvasProps } from "./create-canvas.tsx" import { frameContext, threeContext } from "./hooks.ts" import { pluginContext } from "./internal-context.ts" @@ -31,7 +32,6 @@ import { CursorRaycaster, type EventRaycaster } from "./raycasters.tsx" import type { CameraKind, Context, FrameListener, FrameListenerCallback, Plugin } from "./types.ts" import { binarySearch, - defaultProps, getCurrentViewport, meta, removeElementFromArray, @@ -48,7 +48,22 @@ import { useMeasure } from "./utils/use-measure.ts" * based on the provided properties. */ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugins: Plugin[]) { - const canvasProps = defaultProps(props, { frameloop: "always" }) + const [canvasProps, rest] = processProps(props, { frameloop: "always" }, [ + "children", + "frameloop", + "class", + "defaultCamera", + "defaultRaycaster", + "fallback", + "flat", + "frameloop", + "gl", + "linear", + "orthographic", + "scene", + "shadows", + "style", + ]) /**********************************************************************************/ /* */ @@ -237,8 +252,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi const clock = new Clock() clock.start() - const pluginMap = new ReactiveMap>() - const context: Context = { get bounds() { return measure.bounds() @@ -249,15 +262,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi return this.gl.getPixelRatio() }, props, - registerPlugin(plugin) { - let result = pluginMap.get(plugin) - if (result) { - return result - } - result = plugin(context) - pluginMap.set(plugin, result) - return result - }, + registerPlugin, render, requestRender, get viewport() { @@ -293,6 +298,28 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi ], ) + /**********************************************************************************/ + /* */ + /* Plugins */ + /* */ + /**********************************************************************************/ + + const pluginMap = new ReactiveMap>() + + function registerPlugin(plugin: Plugin) { + let result = pluginMap.get(plugin) + if (result) { + return result + } + result = plugin(context) + pluginMap.set(plugin, result) + return result + } + + const pluginMethods = createMemo(() => + mergeProps(...plugins.map(init => () => registerPlugin(init)(canvas))), + ) + /**********************************************************************************/ /* */ /* Effects */ @@ -310,6 +337,16 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi } }) + // Manage props resolved to plugins + createRenderEffect(() => { + const _pluginMethods = pluginMethods() + for (const key in canvasProps) { + if (key in _pluginMethods) { + _pluginMethods[key]?.(canvasProps[key as keyof typeof canvasProps]) + } + } + }) + // Manage camera createRenderEffect(() => { if (cameraStack.peek()) return diff --git a/src/types.ts b/src/types.ts index 0ab580d..629813a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,7 +89,9 @@ export interface Context { dpr: number gl: Meta props: CanvasProps - registerPlugin(plugin: Plugin): (element: any) => void + registerPlugin( + plugin: Plugin, + ): (element: any) => Record void)> render: (delta: number) => void requestRender: () => void scene: Meta From d0402c905b9a1a451b39667402375daf068dd4c4 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 25 Aug 2025 03:12:10 +0200 Subject: [PATCH 17/26] refactor: simplify plugin type system by removing fallback overloads - Remove complex overload resolution system in favor of direct inheritance checking. - Remove (element: any): {} fallback overloads from PluginFn signatures - Replace complex ResolvePluginReturn with simple TKind extends P inheritance check - Simplify PluginPropsOf type with inline constraint - Fix JSDoc comment for CameraKind type This eliminates the need for R1-R5 overload complexity while maintaining full functionality through direct type inheritance matching. --- .claude/settings.local.json | 3 +- playground/examples/PluginExample.tsx | 11 +- src/components.tsx | 2 +- src/create-canvas.tsx | 4 +- src/types.ts | 217 +++++++++++++------------- 5 files changed, 122 insertions(+), 115 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 965f0b5..317df91 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,8 @@ "Bash(ls:*)", "Bash(npm run typecheck:*)", "Bash(npm run:*)", - "Bash(npx tsc:*)" + "Bash(npx tsc:*)", + "Bash(grep:*)" ], "deny": [] } diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index ab80dc2..569b12e 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -1,5 +1,5 @@ import * as THREE from "three" -import type { Meta } from "types.ts" +import type { Meta, Plugin } from "types.ts" import { createT, Entity, @@ -53,8 +53,11 @@ const MaterialPlugin = plugin( ) // Global plugin - applies to all elements using single argument -const GlobalPlugin = plugin(element => ({ - log: (message: string) => { +const GlobalPlugin: Plugin<{ + (element: THREE.Material): { log(message: number): void } + (element: THREE.Mesh): { log(message: string): void } +}> = plugin(element => ({ + log: (message: string | number) => { console.info(`[${element.constructor.name}] ${message}`) }, })) @@ -103,7 +106,7 @@ export function PluginExample() { onClick={event => event.stopPropagation()} > - + | { from: T; children?: JSXElement; plugins?: TPlugins } - | InferPluginProps, + | InferPluginProps, ) { const [config, rest] = splitProps(props, ["from", "args"]) const memo = whenMemo( diff --git a/src/create-canvas.tsx b/src/create-canvas.tsx index 5fe2dab..d56f7ed 100644 --- a/src/create-canvas.tsx +++ b/src/create-canvas.tsx @@ -10,7 +10,7 @@ import { } from "three" import { createThree } from "./create-three.tsx" import type { EventRaycaster } from "./raycasters.tsx" -import type { Context, Plugin, PluginPropsOf, Props } from "./types.ts" +import type { Context, InferPluginProps, Plugin, Props } from "./types.ts" /** * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene. @@ -56,7 +56,7 @@ export interface CanvasProps extends ParentProps { * @returns A div element containing the WebGL canvas configured to occupy the full available space. */ export function createCanvas(plugins: TPlugins) { - return function (props: ParentProps & Partial>) { + return function (props: ParentProps & Partial>) { let canvas: HTMLCanvasElement = null! let container: HTMLDivElement = null! diff --git a/src/types.ts b/src/types.ts index 629813a..f554945 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,7 +114,7 @@ export interface Viewport { aspect: number } -/** Possible camera types. */ +/** Possible camera kinds. */ export type CameraKind = PerspectiveCamera | OrthographicCamera export type Loader = { @@ -196,7 +196,7 @@ export type Props = Partial raycastable: boolean plugins: TPlugins }, - TPlugins extends Plugin[] ? PluginPropsOf, TPlugins> : {}, + TPlugins extends Plugin[] ? InferPluginProps, TPlugins> : {}, ] > > @@ -306,42 +306,117 @@ export interface Plugin any> { (context: Context): TFn } +/** + * Plugin function interface that defines all possible plugin creation patterns. + * + * Plugins extend solid-three components with additional functionality and can be: + * - Global: apply to all elements + * - Filtered: apply only to specific element types (via constructor array or type guard) + * - With setup: access to the Three.js context during initialization + * + * @example + * // Global plugin + * const LogPlugin = plugin(element => ({ + * log: (message: string) => console.log(`[${element.type}] ${message}`) + * })) + * + * @example + * // Filtered plugin with constructor array + * const ShakePlugin = plugin([THREE.Camera, THREE.Mesh], element => ({ + * shake: (intensity = 0.1) => { + * useFrame(() => { + * element.position.x += (Math.random() - 0.5) * intensity + * }) + * } + * })) + * + * @example + * // Filtered plugin with type guard + * const MaterialPlugin = plugin( + * (element): element is THREE.Mesh => element instanceof THREE.Mesh, + * element => ({ + * setColor: (color: string) => element.material.color.set(color) + * }) + * ) + * + * @example + * // Plugin with setup context + * const ContextPlugin = plugin + * .setup((context) => ({ scene: context.scene })) + * .then([THREE.Object3D], (element, context) => ({ + * addToScene: () => context.scene.add(element) + * })) + */ export interface PluginFn { - // No setup - direct usage with one argument (global) - >(methods: (element: any) => Methods): Plugin< + /** + * Creates a global plugin that applies to all elements. + * + * @param methods - Function that receives an element and returns plugin methods + * @returns Plugin that applies to all elements + */ + >(methods: (element: any) => Methods): Plugin< (element: any) => Methods > - // No setup - direct usage with two arguments (array of constructors) - >( + /** + * Creates a filtered plugin that applies only to specific constructor types. + * + * @param Constructors - Array of constructor functions to filter by + * @param methods - Function that receives a filtered element and returns plugin methods + * @returns Plugin that applies only to matching constructor types + */ + >( Constructors: T, methods: (element: T extends readonly Constructor[] ? U : never) => Methods, ): Plugin<{ (element: T extends readonly Constructor[] ? U : never): Methods - (element: any): {} }> - // No setup - direct usage with two arguments (type guard) - >( + /** + * Creates a filtered plugin that applies only to elements matching a type guard. + * + * @param condition - Type guard function that determines if plugin applies + * @param methods - Function that receives a filtered element and returns plugin methods + * @returns Plugin that applies only to elements matching the type guard + */ + >( condition: (element: unknown) => element is T, methods: (element: T) => Methods, ): Plugin<{ (element: T): Methods - (element: any): {} }> - // Setup function - setup( + /** + * Creates a plugin with access to setup context. + * + * The setup function runs once when the plugin is initialized and receives + * the Three.js context. The returned data is passed to all plugin methods. + * + * @param setupFn - Function that receives the Three.js context and returns setup data + * @returns Object with 'then' method to define the plugin behavior + */ + setup( setupFn: (context: Context) => TSetupContext, ): { then: { - // With setup - one argument (global) - >( + /** + * Creates a global plugin with setup context. + * + * @param methods - Function that receives element and setup context, returns plugin methods + * @returns Plugin that applies to all elements with setup context + */ + >( methods: (element: any, context: TSetupContext) => Methods, ): Plugin<(element: any) => Methods> - // With setup - two arguments (array of constructors) - >( + /** + * Creates a filtered plugin with setup context using constructor array. + * + * @param Constructors - Array of constructor functions to filter by + * @param methods - Function that receives filtered element and setup context, returns plugin methods + * @returns Plugin that applies only to matching constructor types with setup context + */ + >( Constructors: T, methods: ( element: T extends readonly Constructor[] ? U : never, @@ -349,102 +424,29 @@ export interface PluginFn { ) => Methods, ): Plugin<{ (element: T extends readonly Constructor[] ? U : never): Methods - (element: any): {} }> - // With setup - two arguments (type guard) - >( + /** + * Creates a filtered plugin with setup context using type guard. + * + * @param condition - Type guard function that determines if plugin applies + * @param methods - Function that receives filtered element and setup context, returns plugin methods + * @returns Plugin that applies only to elements matching the type guard with setup context + */ + >( condition: (element: unknown) => element is T, methods: (element: T, context: TSetupContext) => Methods, ): Plugin<{ (element: T): Methods - (element: any): {} }> } } } -export type InferPluginProps = Merge<{ - [TKey in keyof TPlugins]: TPlugins[TKey] extends () => (element: any) => infer U - ? { [TKey in keyof U]: U[TKey] extends (callback: infer V) => any ? V : never } - : never -}> - -/** - * Helper type to resolve overloaded function returns - * Matches overloads from most specific to least specific - */ -type ResolvePluginReturn = TFn extends { - (element: infer P1): infer R1 - (element: infer P2): infer R2 - (element: infer P3): infer R3 - (element: infer P4): infer R4 - (element: infer P5): infer R5 -} - ? TTarget extends P1 - ? R1 - : TTarget extends P2 - ? R2 - : TTarget extends P3 - ? R3 - : TTarget extends P4 - ? R4 - : TTarget extends P5 - ? R5 - : never - : TFn extends { - (element: infer P1): infer R1 - (element: infer P2): infer R2 - (element: infer P3): infer R3 - (element: infer P4): infer R4 - } - ? TTarget extends P1 - ? R1 - : TTarget extends P2 - ? R2 - : TTarget extends P3 - ? R3 - : TTarget extends P4 - ? R4 - : never - : TFn extends { - (element: infer P1): infer R1 - (element: infer P2): infer R2 - (element: infer P3): infer R3 - } - ? TTarget extends P1 - ? R1 - : TTarget extends P2 - ? R2 - : TTarget extends P3 - ? R3 - : never - : TFn extends { (element: infer P1): infer R1; (element: infer P2): infer R2 } - ? TTarget extends P1 - ? R1 - : TTarget extends P2 - ? R2 - : never - : TFn extends { (element: infer P): infer R } - ? TTarget extends P - ? R - : never - : never - -/** - * Resolves what a plugin returns for a specific element type T - * Handles both simple functions and overloaded functions - */ type PluginReturn = TPlugin extends Plugin - ? TFn extends (...args: any[]) => any - ? ResolvePluginReturn extends infer TResult - ? TResult extends never - ? TFn extends (element: TKind) => infer R - ? R - : TFn extends (element: any) => infer R - ? R - : {} - : TResult + ? TFn extends { (element: infer P): infer R } + ? TKind extends P + ? R : {} : {} : {} @@ -453,12 +455,13 @@ type PluginReturn = TPlugin extends Plugin * Resolves plugin props for a specific element type T * This allows plugins to provide conditional methods based on the actual element type */ -export type PluginPropsOf = Merge<{ - [K in keyof TPlugins]: PluginReturn extends infer Methods - ? Methods extends Record - ? { - [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never - } - : {} +export type InferPluginProps = Merge<{ + [K in keyof TPlugins]: PluginReturn extends infer Methods extends Record< + string, + any + > + ? { + [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never + } : {} }> From cc062abf78a5007aa110e8f9c13be58a4a5296c9 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 25 Aug 2025 13:16:09 +0200 Subject: [PATCH 18/26] refactor: consolidate types and remove separate canvas module --- playground/controls/OrbitControls.tsx | 3 +- playground/examples/EnvironmentExample.tsx | 9 ++ playground/examples/PluginExample.tsx | 9 +- playground/examples/PortalExample.tsx | 4 +- src/components.tsx | 29 +++-- src/create-canvas.tsx | 104 ----------------- src/create-t.tsx | 122 ++++++++++++++----- src/create-three.tsx | 8 +- src/event-plugin.ts | 3 +- src/index.ts | 3 +- src/plugin.ts | 69 ----------- src/props.ts | 35 +++++- src/raycasters.tsx | 8 +- src/testing/index.tsx | 2 +- src/types.ts | 130 +++++++++++++++++---- src/utils.ts | 64 ++++++---- src/utils/use-measure.ts | 11 -- 17 files changed, 316 insertions(+), 297 deletions(-) delete mode 100644 src/create-canvas.tsx diff --git a/playground/controls/OrbitControls.tsx b/playground/controls/OrbitControls.tsx index 9056f49..b5db470 100644 --- a/playground/controls/OrbitControls.tsx +++ b/playground/controls/OrbitControls.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup, type Ref } from "solid-js" +import { createEffect, createMemo, onCleanup } from "solid-js" import type { Event } from "three" import { OrbitControls as ThreeOrbitControls } from "three-stdlib" import { useFrame, useThree, type S3 } from "../../src/index.ts" @@ -7,7 +7,6 @@ import { whenEffect } from "../../src/utils/conditionals.ts" import { processProps } from "./process-props.ts" export interface OrbitControlsProps extends S3.Props { - ref?: Ref camera?: S3.CameraKind domElement?: HTMLElement enableDamping?: boolean diff --git a/playground/examples/EnvironmentExample.tsx b/playground/examples/EnvironmentExample.tsx index d5ebf6d..40c5225 100644 --- a/playground/examples/EnvironmentExample.tsx +++ b/playground/examples/EnvironmentExample.tsx @@ -1,3 +1,4 @@ +import { createSignal } from "solid-js" import * as THREE from "three" import { createT, EventPlugin, Resource } from "../../src/index.ts" import { OrbitControls } from "../controls/OrbitControls.tsx" @@ -5,6 +6,10 @@ import { OrbitControls } from "../controls/OrbitControls.tsx" const { T, Canvas } = createT(THREE, [EventPlugin]) export function EnvironmentExample() { + const [position, setPosition] = createSignal(0) + + setInterval(() => setPosition(position => position + 1), 500) + return ( console.debug("canvas pointer enter", event)} > + + + + diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index 569b12e..af0068c 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -101,12 +101,13 @@ export function PluginExample() { position={[0, 0, 0]} highlight="red" lookAt={useThree().currentCamera} + plugins={[LookAtPlugin, MaterialPlugin]} log="Mesh rendered!" shake={0.1} onClick={event => event.stopPropagation()} > - + + {/* Camera with shake (from ShakePlugin) */} diff --git a/playground/examples/PortalExample.tsx b/playground/examples/PortalExample.tsx index 34adc9a..8b98d93 100644 --- a/playground/examples/PortalExample.tsx +++ b/playground/examples/PortalExample.tsx @@ -1,7 +1,7 @@ import * as THREE from "three" -import { createT, Entity, Portal } from "../../src/index.ts" +import { createT, Entity, EventPlugin, Portal } from "../../src/index.ts" -const { T, Canvas } = createT(THREE) +const { T, Canvas } = createT(THREE, [EventPlugin]) export function PortalExample() { const group = new THREE.Group() diff --git a/src/components.tsx b/src/components.tsx index cc9c671..b61d441 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -12,8 +12,8 @@ import { import { Object3D } from "three" import { threeContext, useThree } from "./hooks.ts" import { useProps } from "./props.ts" -import type { Constructor, InferPluginProps, Loader, Meta, Plugin, Props } from "./types.ts" -import { type InstanceOf } from "./types.ts" +import type { Constructor, Loader, Meta, Plugin, Props } from "./types.ts" +import { type InstanceOfMaybe } from "./types.ts" import { autodispose, hasMeta, isConstructor, load, meta, withContext } from "./utils.ts" import { whenMemo } from "./utils/conditionals.ts" @@ -24,7 +24,7 @@ import { whenMemo } from "./utils/conditionals.ts" /**********************************************************************************/ type PortalProps = ParentProps<{ - element?: InstanceOf | Meta + element?: InstanceOfMaybe | Meta onUpdate?(value: T): void }> /** @@ -86,20 +86,27 @@ export function Portal(props: PortalProps) { export function Entity< const T extends object | Constructor = object, const TPlugins extends Plugin[] = Plugin[], ->( - props: - | Props - | { from: T; children?: JSXElement; plugins?: TPlugins } - | InferPluginProps, -) { - const [config, rest] = splitProps(props, ["from", "args"]) +>(props: { from: T; children?: JSXElement; plugins?: TPlugins } & Props) { + const [config, rest] = splitProps(props, [ + "from", + // @ts-expect-error TODO: fix type error + "args", + ]) const memo = whenMemo( () => config.from, from => { // listen to key changes + // @ts-expect-error TODO: fix type error props.key const instance = meta( - isConstructor(from) ? autodispose(new from(...(config.args ?? []))) : from, + isConstructor(from) + ? autodispose( + new from( + ...// @ts-expect-error TODO: fix type error + (config.args ?? []), + ), + ) + : from, { props, get plugins() { diff --git a/src/create-canvas.tsx b/src/create-canvas.tsx deleted file mode 100644 index d56f7ed..0000000 --- a/src/create-canvas.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { onMount, type JSX, type ParentProps, type Ref } from "solid-js" -import { - Camera, - OrthographicCamera, - PerspectiveCamera, - Raycaster, - Scene, - WebGLRenderer, -} from "three" -import { createThree } from "./create-three.tsx" -import type { EventRaycaster } from "./raycasters.tsx" -import type { Context, InferPluginProps, Plugin, Props } from "./types.ts" - -/** - * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene. - */ -export interface CanvasProps extends ParentProps { - ref?: Ref - class?: string - /** Configuration for the camera used in the scene. */ - defaultCamera?: Partial | Props> | Camera - /** Configuration for the Raycaster used for mouse and pointer events. */ - defaultRaycaster?: Partial> | EventRaycaster | Raycaster - /** Element to render while the main content is loading asynchronously. */ - fallback?: JSX.Element - /** Toggles flat interpolation for texture filtering. */ - flat?: boolean - /** Controls the rendering loop's operation mode. */ - frameloop?: "never" | "demand" | "always" - /** Options for the WebGLRenderer or a function returning a customized renderer. */ - gl?: - | Partial> - | ((canvas: HTMLCanvasElement) => WebGLRenderer) - | WebGLRenderer - /** Toggles linear interpolation for texture filtering. */ - linear?: boolean - /** Toggles between Orthographic and Perspective camera. */ - orthographic?: boolean - /** Configuration for the Scene instance. */ - scene?: Partial> | Scene - /** Enables and configures shadows in the scene. */ - shadows?: boolean | "basic" | "percentage" | "soft" | "variance" | WebGLRenderer["shadowMap"] - /** Custom CSS styles for the canvas container. */ - style?: JSX.CSSProperties -} - -/** - * Serves as the root component for all 3D scenes created with `solid-three`. It initializes - * the Three.js rendering context, including a WebGL renderer, a scene, and a camera. - * All ``-components must be children of this Canvas. Hooks such as `useThree` and - * `useFrame` should only be used within this component to ensure proper context. - * - * @function Canvas - * @param props - Configuration options include camera settings, style, and children elements. - * @returns A div element containing the WebGL canvas configured to occupy the full available space. - */ -export function createCanvas(plugins: TPlugins) { - return function (props: ParentProps & Partial>) { - let canvas: HTMLCanvasElement = null! - let container: HTMLDivElement = null! - - onMount(() => { - const context = createThree(canvas, props, plugins) - - // Resize observer for the canvas to adjust camera and renderer on size change - createResizeObserver(container, function onResize() { - const { width, height } = container.getBoundingClientRect() - context.gl.setSize(width, height) - context.gl.setPixelRatio(globalThis.devicePixelRatio) - - if (context.currentCamera instanceof OrthographicCamera) { - context.currentCamera.left = width / -2 - context.currentCamera.right = width / 2 - context.currentCamera.top = height / 2 - context.currentCamera.bottom = height / -2 - } else { - context.currentCamera.aspect = width / height - } - - context.currentCamera.updateProjectionMatrix() - context.render(performance.now()) - }) - }) - - return ( -
- -
- ) - } -} diff --git a/src/create-t.tsx b/src/create-t.tsx index 61ce7c8..0185380 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,8 +1,18 @@ -import { createMemo, type Component, type JSX, type JSXElement, type MergeProps } from "solid-js" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { + createMemo, + onMount, + type Component, + type JSX, + type JSXElement, + type MergeProps, + type ParentProps, +} from "solid-js" +import { OrthographicCamera, Scene } from "three" import { $S3C } from "./constants.ts" -import { createCanvas } from "./create-canvas.tsx" +import { createThree } from "./create-three.tsx" import { useProps } from "./props.ts" -import type { Plugin, Props } from "./types.ts" +import type { CanvasProps, Plugin, PluginPropsOf, Props } from "./types.ts" import { meta } from "./utils.ts" /**********************************************************************************/ @@ -15,38 +25,83 @@ export type InferPluginsFromT = T extends { [$S3C]: infer U } export function createT< const TCatalogue extends Record, - const TCataloguePlugins extends Plugin[] = [], ->(catalogue: TCatalogue, plugins?: TCataloguePlugins = []) { + const TCataloguePlugins extends Plugin[], +>(catalogue: TCatalogue, plugins?: TCataloguePlugins) { const cache = new Map>() return { - Canvas: createCanvas(plugins), - T: new Proxy< - | { - [K in keyof TCatalogue]: ( - props: { plugins?: TPlugins } & Partial< - TPlugins extends Plugin[] - ? Props - : Props - >, - ) => JSXElement - } & { [$S3C]: TCataloguePlugins } - >({} as any, { - get: (_, name: string) => { - /* Create and memoize a wrapper component for the specified property. */ - if (!cache.has(name)) { - /* Try and find a constructor within the THREE namespace. */ - const constructor = catalogue[name] + Canvas(props: ParentProps & Partial>) { + let canvas: HTMLCanvasElement = null! + let container: HTMLDivElement = null! - /* If no constructor is found, return undefined. */ - if (!constructor) return undefined + onMount(() => { + const context = createThree(canvas, props, plugins) - /* Otherwise, create and memoize a component for that constructor. */ - cache.set(name, createEntity(constructor, plugins)) - } + // Resize observer for the canvas to adjust camera and renderer on size change + createResizeObserver(container, function onResize() { + const { width, height } = container.getBoundingClientRect() + context.gl.setSize(width, height) + context.gl.setPixelRatio(globalThis.devicePixelRatio) - return cache.get(name) + if (context.currentCamera instanceof OrthographicCamera) { + context.currentCamera.left = width / -2 + context.currentCamera.right = width / 2 + context.currentCamera.top = height / 2 + context.currentCamera.bottom = height / -2 + } else { + context.currentCamera.aspect = width / height + } + + context.currentCamera.updateProjectionMatrix() + context.render(performance.now()) + }) + }) + + return ( +
+ +
+ ) + }, + T: new Proxy( + {} as { + [K in keyof TCatalogue]: ( + props: { plugins?: TPlugins } & Partial< + TPlugins extends Plugin[] + ? Props + : Props + >, + ) => JSXElement + } & { [$S3C]: TCataloguePlugins }, + { + get: (_, name: string) => { + /* Create and memoize a wrapper component for the specified property. */ + if (!cache.has(name)) { + /* Try and find a constructor within the THREE namespace. */ + const constructor = catalogue[name] + + /* If no constructor is found, return undefined. */ + if (!constructor) return undefined + + /* Otherwise, create and memoize a component for that constructor. */ + cache.set(name, createEntity(constructor, plugins ?? [])) + } + + return cache.get(name) + }, }, - }), + ), } } @@ -66,9 +121,16 @@ export function createEntity { // listen to key changes + // @ts-expect-error TODO: fix type error props.key try { - return meta(new (Constructor as any)(...(props.args ?? [])), { props, plugins }) + return meta( + new (Constructor as any)( + ...// @ts-expect-error TODO: fix type error + (props.args ?? []), + ), + { props, plugins }, + ) } catch (e) { console.error(e) throw new Error("") diff --git a/src/create-three.tsx b/src/create-three.tsx index 8a650e9..60a21e0 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -23,17 +23,17 @@ import { VSMShadowMap, WebGLRenderer, } from "three" -import { processProps } from "../playground/controls/process-props.ts" -import type { CanvasProps } from "./create-canvas.tsx" import { frameContext, threeContext } from "./hooks.ts" import { pluginContext } from "./internal-context.ts" import { useProps, useSceneGraph } from "./props.ts" -import { CursorRaycaster, type EventRaycaster } from "./raycasters.tsx" +import { CursorRaycaster } from "./raycasters.tsx" import type { CameraKind, Context, FrameListener, FrameListenerCallback, Plugin } from "./types.ts" +import type { CanvasProps, EventRaycaster } from "./types.tsx" import { binarySearch, getCurrentViewport, meta, + processProps, removeElementFromArray, useRef, withContext, @@ -47,7 +47,7 @@ import { useMeasure } from "./utils/use-measure.ts" * camera, renderer, raycaster, and scene, manages the scene graph, setups up a rendering loop * based on the provided properties. */ -export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugins: Plugin[]) { +export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugins: Plugin[] = []) { const [canvasProps, rest] = processProps(props, { frameloop: "always" }, [ "children", "frameloop", diff --git a/src/event-plugin.ts b/src/event-plugin.ts index 06894bf..2f8f4c8 100644 --- a/src/event-plugin.ts +++ b/src/event-plugin.ts @@ -101,7 +101,8 @@ export type EventName = keyof EventHandlersMap function createEvent< TEvent extends globalThis.Event, TConfig extends { stoppable?: boolean; intersections?: Array }, ->(nativeEvent: TEvent, { stoppable = true, intersections }: TConfig = {}) { +>(nativeEvent: TEvent, config?: TConfig) { + const { stoppable = true, intersections } = config ?? {} const event: Record = stoppable ? { nativeEvent, diff --git a/src/index.ts b/src/index.ts index da8bcd1..297049b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ export { Entity, Portal, Resource } from "./components.tsx" export { $S3C } from "./constants.ts" -export { type CanvasProps } from "./create-canvas.tsx" export { createEntity, createT } from "./create-t.tsx" export { EventPlugin } from "./event-plugin.ts" export { useFrame, useThree } from "./hooks.ts" @@ -8,4 +7,4 @@ export { plugin } from "./plugin.ts" export { useProps } from "./props.ts" export * from "./raycasters.tsx" export * as S3 from "./types.ts" -export { autodispose, getMeta, hasMeta as hasMeta, load, meta } from "./utils.ts" +export { autodispose, getMeta, hasMeta, load, meta } from "./utils.ts" diff --git a/src/plugin.ts b/src/plugin.ts index d260c35..1ef4903 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,48 +1,5 @@ -import type { Accessor } from "solid-js" import type { Context, Plugin, PluginFn } from "./types.ts" -/** - * Creates a plugin that extends solid-three components with additional functionality. - * - * Plugins can be used to add custom methods to THREE.js objects in a type-safe way. - * They are applied during component creation and can optionally filter which elements - * they apply to. - * - * @example - * // Global plugin - applies to all elements - * const LogPlugin = plugin(element => ({ - * log: (message: string) => console.log(`[${element.type}] ${message}`) - * })) - * - * @example - * // Filtered plugin - applies only to specific element types - * const ShakePlugin = plugin([THREE.Camera, THREE.Mesh], element => ({ - * shake: (intensity = 0.1) => { - * useFrame(() => { - * element.position.x += (Math.random() - 0.5) * intensity - * }) - * } - * })) - * - * @example - * // Type guard plugin - custom filtering logic - * const MaterialPlugin = plugin( - * (element): element is THREE.Mesh => element instanceof THREE.Mesh, - * element => ({ - * setColor: (color: string) => element.material.color.set(color) - * }) - * ) - * - * @example - * // Plugin with setup context - * const ContextPlugin = plugin - * .setup((context) => { - * return { scene: context.scene } - * }) - * .then([THREE.Object3D], (element, context) => ({ - * addToScene: () => context.scene.add(element) - * })) - */ export const plugin: PluginFn = Object.assign( // Main function implementation (filterArgOrMethods?: any, methods?: any): any => { @@ -60,34 +17,8 @@ export const plugin: PluginFn = Object.assign( return filteredPlugin(undefined, filterArgOrMethods, methods) }, { - /** - * Creates a plugin with access to a setup context. - * - * The setup function runs once when the plugin is initialized and receives - * the Three.js context as its argument. The returned data is passed to all - * plugin methods. - * - * @param setupFn - Function that receives the Three.js context and returns data to share - * @returns An object with a `then` method to define the plugin behavior - * - * @example - * const plugin = plugin - * .setup((context) => { - * return { renderer: context.gl } - * }) - * .then((element, context) => ({ - * render: () => context.renderer.render(...) - * })) - */ setup(setupFn: (context: Context) => TSetupContext) { return { - /** - * Defines the plugin methods after setup. - * - * @param filterArgOrMethods - Either methods (for global plugin) or filter argument - * @param methods - Plugin methods (when using filter argument) - * @returns The configured plugin - */ then(filterArgOrMethods: any, methods?: any) { // Single argument case - global plugin with setup if (methods === undefined) { diff --git a/src/props.ts b/src/props.ts index ad0e27e..b4a3297 100644 --- a/src/props.ts +++ b/src/props.ts @@ -4,6 +4,7 @@ import { createComputed, createMemo, createRenderEffect, + createSelector, type JSXElement, mapArray, mergeProps, @@ -25,8 +26,21 @@ import { useThree } from "./hooks.ts" import type { AccessorMaybe, Context, Meta, Plugin } from "./types.ts" import { getMeta, hasColorSpace, resolve } from "./utils.ts" +const WRITABLE_CACHE = new WeakMap() + function isWritable(object: object, propertyName: string) { - return Object.getOwnPropertyDescriptor(object, propertyName)?.writable + const cache = WRITABLE_CACHE.get(object.constructor) + + if (cache) { + console.log("cached result!", cache?.writable) + return cache?.writable + } + + const result = Object.getOwnPropertyDescriptor(object, propertyName) + + WRITABLE_CACHE.set(object.constructor, result) + + return result?.writable } function applySceneGraph(parent: object, child: object) { @@ -48,7 +62,7 @@ function applySceneGraph(parent: object, child: object) { // Attach-prop can be a callback. It returns a cleanup-function. if (typeof attachProp === "function") { - const cleanup = attachProp(parent, child as Meta) + const cleanup = attachProp(parent, child as unknown as Meta) onCleanup(cleanup) return } @@ -169,6 +183,8 @@ function applyProp>( type: string, value: any, ) { + console.log("apply prop", source, type, value) + if (!source) { console.error("error while applying prop", source, type, value) return @@ -288,7 +304,7 @@ function applyProp>( */ export function useProps>( accessor: T | undefined | Accessor, - props: { plugins?: Plugin[] } & Record, + props: Record, plugins?: Plugin[], ) { const context = useThree() @@ -309,6 +325,11 @@ export function useProps>( ), ), ) + const isKeyAPluginMethod = createSelector( + pluginMethods, + (prop: string, methods) => prop in methods, + ) + useSceneGraph(accessor, props) createRenderEffect(() => { @@ -330,8 +351,8 @@ export function useProps>( // p.ex in position's subKeys will be ['position-x'] const subKeys = keys.filter(_key => key !== _key && _key.includes(key)) createRenderEffect(() => { - if (key in pluginMethods()) { - pluginMethods()[key](props[key]) + if (isKeyAPluginMethod(key)) { + pluginMethods()[key]!(props[key]) return } @@ -340,6 +361,10 @@ export function useProps>( // NOTE: Discuss - is this expected behavior? Feature or a bug? // Should it be according to order of update instead? for (const subKey of subKeys) { + if (isKeyAPluginMethod(subKey)) { + pluginMethods()[subKey]!(props[key]) + continue + } applyProp(context, object, subKey, props[subKey]) } }) diff --git a/src/raycasters.tsx b/src/raycasters.tsx index 9479c32..7e7b430 100644 --- a/src/raycasters.tsx +++ b/src/raycasters.tsx @@ -1,11 +1,5 @@ import { Raycaster, Vector2 } from "three" -import type { Context } from "./types" - -type RayEvent = PointerEvent | MouseEvent | WheelEvent - -export interface EventRaycaster extends Raycaster { - update(event: RayEvent, context: Context): void -} +import type { Context, EventRaycaster, RayEvent } from "./types" export class CursorRaycaster extends Raycaster implements EventRaycaster { pointer = new Vector2() diff --git a/src/testing/index.tsx b/src/testing/index.tsx index a9a9c2b..9b5acba 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -1,6 +1,6 @@ import { type Accessor, type JSX, createRoot, mergeProps } from "solid-js" -import type { CanvasProps } from "../create-canvas.tsx" import { createThree } from "../create-three.tsx" +import type { CanvasProps } from "../types.tsx" import { useRef } from "../utils.ts" import { WebGL2RenderingContext } from "./webgl2-rendering-context.ts" diff --git a/src/types.ts b/src/types.ts index f554945..1873daa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ -import type { Accessor, JSX } from "solid-js" +import type { Accessor, JSX, ParentProps, Ref } from "solid-js" import type { + Camera, Clock, ColorRepresentation, OrthographicCamera, @@ -18,9 +19,6 @@ import type { WebGLRenderer, } from "three" import type { $S3C } from "./constants.ts" -import type { CanvasProps } from "./create-canvas.tsx" -import type { EventRaycaster } from "./raycasters.tsx" -import type { Measure } from "./utils/use-measure.ts" /**********************************************************************************/ /* */ @@ -34,7 +32,7 @@ export type AccessorMaybe = T | Accessor export type Constructor = new (...args: any[]) => T /** Extracts the instance from a constructor. */ -export type InstanceOf = T extends Constructor ? TObject : T +export type InstanceOfMaybe = T extends Constructor ? TObject : T export type Overwrite = T extends [infer First, ...infer Rest] ? Rest extends [] @@ -56,6 +54,34 @@ export type Intersect = T extends [infer U, ...infer Rest] export type When = T extends false ? (T extends true ? U : unknown) : U +export type Args = T extends new (...args: any) => any ? ConstructorParameters : T + +export type Mandatory = T & { [P in K]-?: T[P] } + +export type KeyOfOptionals = keyof { + [K in keyof T as T extends Record ? never : K]: T[K] +} + +/** Allows using a TS v4 labeled tuple even with older typescript versions */ +export type NamedArrayTuple any> = Parameters + +/**********************************************************************************/ +/* */ +/* Misc */ +/* */ +/**********************************************************************************/ + +export interface Measure { + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly top: number + readonly right: number + readonly bottom: number + readonly left: number +} + /**********************************************************************************/ /* */ /* Meta */ @@ -63,17 +89,68 @@ export type When = T extends false ? (T extends true ? U : unknown) : U /**********************************************************************************/ export type Meta = T & { - [$S3C]: Data + [$S3C]: Data } /** Metadata of a `solid-three` instance. */ -export type Data = { - props: Props> & Record +export type Data = { + props: Record parent: any children: Set> plugins: Plugin[] } +/**********************************************************************************/ +/* */ +/* Raycaster */ +/* */ +/**********************************************************************************/ + +export type RayEvent = PointerEvent | MouseEvent | WheelEvent + +export interface EventRaycaster extends Raycaster { + update(event: RayEvent, context: Context): void +} + +/**********************************************************************************/ +/* */ +/* Canvas Props */ +/* */ +/**********************************************************************************/ + +/** + * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene. + */ +export interface CanvasProps extends ParentProps { + ref?: Ref + class?: string + /** Configuration for the camera used in the scene. */ + defaultCamera?: Partial | Props> | Camera + /** Configuration for the Raycaster used for mouse and pointer events. */ + defaultRaycaster?: Partial> | EventRaycaster | Raycaster + /** Element to render while the main content is loading asynchronously. */ + fallback?: JSX.Element + /** Toggles flat interpolation for texture filtering. */ + flat?: boolean + /** Controls the rendering loop's operation mode. */ + frameloop?: "never" | "demand" | "always" + /** Options for the WebGLRenderer or a function returning a customized renderer. */ + gl?: + | Partial> + | ((canvas: HTMLCanvasElement) => WebGLRenderer) + | WebGLRenderer + /** Toggles linear interpolation for texture filtering. */ + linear?: boolean + /** Toggles between Orthographic and Perspective camera. */ + orthographic?: boolean + /** Configuration for the Scene instance. */ + scene?: Partial> | Scene + /** Enables and configures shadows in the scene. */ + shadows?: boolean | "basic" | "percentage" | "soft" | "variance" | WebGLRenderer["shadowMap"] + /** Custom CSS styles for the canvas container. */ + style?: JSX.CSSProperties +} + /**********************************************************************************/ /* s */ /* Context */ @@ -177,26 +254,31 @@ export type Matrix4 = Representation /* */ /**********************************************************************************/ +export type InferPluginProps = Merge<{ + [TKey in keyof TPlugins]: TPlugins[TKey] extends (context?: any) => (element: any) => infer U + ? { [TKey in keyof U]: U[TKey] extends (callback: infer V) => any ? V : never } + : never +}> + /** Generic `solid-three` props of a given class. */ -export type Props = Partial< +export type Props = Partial< Overwrite< [ - MapToRepresentation>, + MapToRepresentation>, { args: T extends Constructor ? ConstructorOverloadParameters : undefined - attach: string | ((parent: object, self: Meta>) => () => void) + attach: string | ((parent: object, self: Meta>) => () => void) children: JSX.Element - key?: string - onUpdate: (self: Meta>) => void - // ref: Ref>> + key: string + onUpdate: (self: Meta>) => void + ref: InstanceOfMaybe | ((element: Meta>) => void) /** * Prevents the Object3D from being cast by the ray. * Object3D can still receive events via propagation from its descendants. */ raycastable: boolean - plugins: TPlugins }, - TPlugins extends Plugin[] ? InferPluginProps, TPlugins> : {}, + InferPluginProps, ] > > @@ -245,7 +327,7 @@ type Override = T extends any * type ExampleParameters = ConstructorOverloadParameters; * // ExampleParameters will be equivalent to: [string] | [number, boolean] */ -type ConstructorOverloadParameters = T extends { +export type ConstructorOverloadParameters = T extends { new (...o: infer U): void new (...o: infer U2): void new (...o: infer U3): void @@ -443,20 +525,20 @@ export interface PluginFn { } } -type PluginReturn = TPlugin extends Plugin - ? TFn extends { (element: infer P): infer R } - ? TKind extends P - ? R +type PluginReturn = TPlugin extends Plugin + ? TFn extends { (element: infer TElement): infer TReturnType } + ? TKind extends TElement + ? TReturnType : {} : {} : {} /** - * Resolves plugin props for a specific element type T + * Resolves plugin props for a specific element type TKind * This allows plugins to provide conditional methods based on the actual element type */ -export type InferPluginProps = Merge<{ - [K in keyof TPlugins]: PluginReturn extends infer Methods extends Record< +export type PluginPropsOf = Merge<{ + [K in keyof TPlugins]: PluginReturn extends infer Methods extends Record< string, any > diff --git a/src/utils.ts b/src/utils.ts index c447178..29c34ed 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,12 @@ import type { Accessor, Context, JSX } from "solid-js" -import { createRenderEffect, type MergeProps, mergeProps, onCleanup, type Ref } from "solid-js" +import { + createRenderEffect, + type MergeProps, + mergeProps, + onCleanup, + type Ref, + splitProps, +} from "solid-js" import { Camera, Material, @@ -10,8 +17,16 @@ import { Vector3, } from "three" import { $S3C } from "./constants.ts" -import type { CameraKind, Constructor, Data, Loader, Meta, Plugin } from "./types.ts" -import type { Measure } from "./utils/use-measure.ts" +import type { + CameraKind, + Constructor, + Data, + KeyOfOptionals, + Loader, + Measure, + Meta, + Plugin, +} from "./types.ts" /**********************************************************************************/ /* */ @@ -76,8 +91,8 @@ export function meta( return _instance } -export function getMeta(value: Meta): Data -export function getMeta(value: object | Meta): Data | undefined +export function getMeta(value: Meta): Data +export function getMeta(value: object | Meta): Data | undefined export function getMeta(value: any) { return hasMeta(value) ? value[$S3C] : undefined } @@ -108,24 +123,6 @@ export function buildGraph(object: Object3D): ObjectMap { return data } -/**********************************************************************************/ -/* */ -/* Default Props */ -/* */ -/**********************************************************************************/ - -/** Extracts the keys of the optional properties in T. */ -type KeyOfOptionals = keyof { - [K in keyof T as T extends Record ? never : K]: T[K] -} - -export function defaultProps>( - props: T, - defaults: Required>, -): MergeProps<[Required>, T]> { - return mergeProps(defaults, props) -} - /**********************************************************************************/ /* */ /* Has Color Space */ @@ -384,3 +381,24 @@ export function binarySearch(array: number[], target: number) { return left // Insertion point } + +/**********************************************************************************/ +/* */ +/* Prop Utils */ +/* */ +/**********************************************************************************/ + +export function processProps< + const TProps, + const TKey extends KeyOfOptionals, + const TSplit extends readonly (keyof TProps)[], +>(props: TProps, defaults: Required>, split?: TSplit) { + return splitProps(defaultProps(props, defaults), split ?? []) +} + +export function defaultProps>( + props: T, + defaults: Required>, +): MergeProps<[Required>, T]> { + return mergeProps(defaults, props) +} diff --git a/src/utils/use-measure.ts b/src/utils/use-measure.ts index 15a77c8..a7d8ed2 100644 --- a/src/utils/use-measure.ts +++ b/src/utils/use-measure.ts @@ -11,17 +11,6 @@ declare class ResizeObserver { static toString(): string } -export interface Measure { - readonly x: number - readonly y: number - readonly width: number - readonly height: number - readonly top: number - readonly right: number - readonly bottom: number - readonly left: number -} - type HTMLOrSVGElement = HTMLElement | SVGElement export type UseMeasureOptions = { From c779d94506762ec5eccbd801cd73c1261185397d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 25 Aug 2025 14:29:49 +0200 Subject: [PATCH 19/26] feat: add createPluginMethods - instead of mergeProps, let's just merge it together, this prevents the need for proxies. plugins are not going to be hotly updated, but could be hotly accessed, so it's better to optimize for access. --- src/create-three.tsx | 6 ++-- src/props.ts | 70 ++++++++++++++++++++++++++++---------------- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/create-three.tsx b/src/create-three.tsx index 60a21e0..5e84659 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -25,7 +25,7 @@ import { } from "three" import { frameContext, threeContext } from "./hooks.ts" import { pluginContext } from "./internal-context.ts" -import { useProps, useSceneGraph } from "./props.ts" +import { createPluginMethods, useProps, useSceneGraph } from "./props.ts" import { CursorRaycaster } from "./raycasters.tsx" import type { CameraKind, Context, FrameListener, FrameListenerCallback, Plugin } from "./types.ts" import type { CanvasProps, EventRaycaster } from "./types.tsx" @@ -316,9 +316,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi return result } - const pluginMethods = createMemo(() => - mergeProps(...plugins.map(init => () => registerPlugin(init)(canvas))), - ) + const pluginMethods = createPluginMethods(canvas, plugins, context) /**********************************************************************************/ /* */ diff --git a/src/props.ts b/src/props.ts index b4a3297..d155fd7 100644 --- a/src/props.ts +++ b/src/props.ts @@ -7,9 +7,7 @@ import { createSelector, type JSXElement, mapArray, - mergeProps, onCleanup, - splitProps, untrack, } from "solid-js" import { @@ -24,21 +22,26 @@ import { } from "three" import { useThree } from "./hooks.ts" import type { AccessorMaybe, Context, Meta, Plugin } from "./types.ts" -import { getMeta, hasColorSpace, resolve } from "./utils.ts" +import { getMeta, hasColorSpace, processProps, resolve } from "./utils.ts" -const WRITABLE_CACHE = new WeakMap() +const PROPERTY_DESCRIPTOR_CACHE = new WeakMap>() function isWritable(object: object, propertyName: string) { - const cache = WRITABLE_CACHE.get(object.constructor) + let cacheMap = PROPERTY_DESCRIPTOR_CACHE.get(object.constructor) + + if (!cacheMap) { + cacheMap = new Map() + PROPERTY_DESCRIPTOR_CACHE.set(object.constructor, cacheMap) + } + + const cache = cacheMap.get(propertyName) if (cache) { - console.log("cached result!", cache?.writable) - return cache?.writable + return cache.writable } const result = Object.getOwnPropertyDescriptor(object, propertyName) - - WRITABLE_CACHE.set(object.constructor, result) + cacheMap.set(propertyName, result) return result?.writable } @@ -183,8 +186,6 @@ function applyProp>( type: string, value: any, ) { - console.log("apply prop", source, type, value) - if (!source) { console.error("error while applying prop", source, type, value) return @@ -305,11 +306,11 @@ function applyProp>( export function useProps>( accessor: T | undefined | Accessor, props: Record, - plugins?: Plugin[], + plugins: Plugin[] = [], ) { const context = useThree() - const [local, instanceProps] = splitProps(props, [ + const [local, instanceProps] = processProps(props, { plugins: [] }, [ "ref", "args", "object", @@ -318,17 +319,9 @@ export function useProps>( "plugins", ]) - const pluginMethods = createMemo(() => - mergeProps( - ...[...(plugins ?? []), ...(props.plugins ?? [])].map( - init => () => context.registerPlugin(init)(resolve(accessor)), - ), - ), - ) - const isKeyAPluginMethod = createSelector( - pluginMethods, - (prop: string, methods) => prop in methods, - ) + const pluginMethods = createPluginMethods(accessor, () => [...plugins, ...local.plugins]) + + const isPluginMethod = createSelector(pluginMethods, (prop: string, methods) => prop in methods) useSceneGraph(accessor, props) @@ -351,7 +344,7 @@ export function useProps>( // p.ex in position's subKeys will be ['position-x'] const subKeys = keys.filter(_key => key !== _key && _key.includes(key)) createRenderEffect(() => { - if (isKeyAPluginMethod(key)) { + if (isPluginMethod(key)) { pluginMethods()[key]!(props[key]) return } @@ -361,7 +354,7 @@ export function useProps>( // NOTE: Discuss - is this expected behavior? Feature or a bug? // Should it be according to order of update instead? for (const subKey of subKeys) { - if (isKeyAPluginMethod(subKey)) { + if (isPluginMethod(subKey)) { pluginMethods()[subKey]!(props[key]) continue } @@ -375,3 +368,28 @@ export function useProps>( }) }) } + +export function createPluginMethods( + target: AccessorMaybe, + plugins: AccessorMaybe, + { registerPlugin } = useThree(), +) { + return createMemo(() => { + const pluginResults = resolve(plugins).map(init => registerPlugin(init)(resolve(target))) + + const merged: Record = {} + + for (const result of pluginResults) { + for (const key in result) { + const descriptor = Object.getOwnPropertyDescriptor(result, key) + if (descriptor?.get || descriptor?.set) { + Object.defineProperty(merged, key, descriptor) + } else { + merged[key] = result[key] + } + } + } + + return merged + }) +} From 9cd9fda2b7aff718fc47bc6d6d499dc814a92050 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 26 Aug 2025 00:52:50 +0200 Subject: [PATCH 20/26] refactor: pass pluginMethods to applyProps and various cleanups --- src/create-three.tsx | 178 +++++++++++++++++--------------------- src/props.ts | 40 ++++----- src/utils/conditionals.ts | 30 ++++++- 3 files changed, 123 insertions(+), 125 deletions(-) diff --git a/src/create-three.tsx b/src/create-three.tsx index 5e84659..41d4aef 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -1,12 +1,5 @@ import { ReactiveMap } from "@solid-primitives/map" -import { - createEffect, - createMemo, - createRenderEffect, - createRoot, - mergeProps, - onCleanup, -} from "solid-js" +import { createMemo, createRenderEffect, createRoot, mergeProps, onCleanup } from "solid-js" import { ACESFilmicToneMapping, BasicShadowMap, @@ -33,12 +26,12 @@ import { binarySearch, getCurrentViewport, meta, - processProps, removeElementFromArray, useRef, withContext, withMultiContexts, } from "./utils.ts" +import { whenRenderEffect } from "./utils/conditionals.ts" import { Stack } from "./utils/stack.ts" import { useMeasure } from "./utils/use-measure.ts" @@ -48,22 +41,7 @@ import { useMeasure } from "./utils/use-measure.ts" * based on the provided properties. */ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugins: Plugin[] = []) { - const [canvasProps, rest] = processProps(props, { frameloop: "always" }, [ - "children", - "frameloop", - "class", - "defaultCamera", - "defaultRaycaster", - "fallback", - "flat", - "frameloop", - "gl", - "linear", - "orthographic", - "scene", - "shadows", - "style", - ]) + const config = mergeProps({ frameloop: "always" }, props) /**********************************************************************************/ /* */ @@ -130,7 +108,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi // Handle frame behavior in WebXR const handleXRFrame: XRFrameRequestCallback = (timestamp: number, frame?: XRFrame) => { - if (canvasProps.frameloop === "never") return + if (config.frameloop === "never") return render(timestamp, frame) } // Toggle render switching on session @@ -162,7 +140,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi if (!context.gl) { return } - if (props.frameloop === "never") { + if (config.frameloop === "never") { context.clock.elapsedTime = timestamp } pendingRenderRequest = undefined @@ -184,58 +162,57 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi /* */ /**********************************************************************************/ + const cameraStack = new Stack("camera") const defaultCamera = createMemo(() => meta( - props.defaultCamera instanceof Camera - ? (props.defaultCamera as OrthographicCamera | PerspectiveCamera) - : props.orthographic + config.defaultCamera instanceof Camera + ? (config.defaultCamera as OrthographicCamera | PerspectiveCamera) + : config.orthographic ? new OrthographicCamera() : new PerspectiveCamera(), { get props() { - return props.defaultCamera || {} + return config.defaultCamera || {} }, }, ), ) - const cameraStack = new Stack("camera") const scene = createMemo(() => - meta(props.scene instanceof Scene ? props.scene : new Scene(), { + meta(config.scene instanceof Scene ? config.scene : new Scene(), { get props() { - return props.scene || {} + return config.scene || {} }, }), ) + const raycasterStack = new Stack("raycaster") const defaultRaycaster = createMemo(() => meta( - props.defaultRaycaster instanceof Raycaster ? props.defaultRaycaster : new CursorRaycaster(), + config.defaultRaycaster instanceof Raycaster + ? config.defaultRaycaster + : new CursorRaycaster(), { get props() { - return props.defaultRaycaster || {} + return config.defaultRaycaster || {} }, }, ), ) - const raycasterStack = new Stack("raycaster") - - const glProp = createMemo(() => props.gl) const gl = createMemo(() => { - const _glProp = glProp() return meta( - _glProp instanceof WebGLRenderer + config.gl instanceof WebGLRenderer ? // _glProp can be a WebGLRenderer provided by the user - _glProp - : typeof _glProp === "function" + config.gl + : typeof config.gl === "function" ? // or a callback that returns a Renderer - _glProp(canvas) + config.gl(canvas) : // if _glProp is not defined we default to a WebGLRenderer new WebGLRenderer({ canvas }), { get props() { - return glProp() || {} + return config.gl || {} }, }, ) @@ -327,7 +304,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi withContext( () => { createRenderEffect(() => { - if (props.frameloop === "never") { + if (config.frameloop === "never") { context.clock.stop() context.clock.elapsedTime = 0 } else { @@ -338,89 +315,92 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi // Manage props resolved to plugins createRenderEffect(() => { const _pluginMethods = pluginMethods() - for (const key in canvasProps) { + for (const key in config) { if (key in _pluginMethods) { - _pluginMethods[key]?.(canvasProps[key as keyof typeof canvasProps]) + _pluginMethods[key]?.(config[key as keyof typeof config]) } } }) // Manage camera - createRenderEffect(() => { - if (cameraStack.peek()) return - if (!props.defaultCamera || props.defaultCamera instanceof Camera) return - useProps(defaultCamera, props.defaultCamera) - // NOTE: Manually update camera's matrix with updateMatrixWorld is needed. - // Otherwise casting a ray immediately after start-up will cause the incorrect matrix to be used. - defaultCamera().updateMatrixWorld(true) - }) + whenRenderEffect( + () => !(config.defaultCamera instanceof Camera) && config.defaultCamera, + propsCamera => { + useProps(defaultCamera, propsCamera) + // NOTE: Manually update camera's matrix with updateMatrixWorld is needed. + // Otherwise casting a ray immediately after start-up will cause the incorrect matrix to be used. + defaultCamera().updateMatrixWorld(true) + }, + ) // Manage scene - createRenderEffect(() => { - if (!props.scene || props.scene instanceof Scene) return - useProps(scene, props.scene) - }) + whenRenderEffect( + () => !(config.scene instanceof Scene) && config.scene, + propsScene => useProps(scene, propsScene), + ) // Manage raycaster - createRenderEffect(() => { - if (!props.defaultRaycaster || props.defaultRaycaster instanceof Raycaster) return - useProps(defaultRaycaster, props.defaultRaycaster) - }) + whenRenderEffect( + () => !(config.defaultRaycaster instanceof Raycaster) && config.defaultRaycaster, + raycaster => useProps(defaultRaycaster, raycaster), + ) // Manage gl createRenderEffect(() => { - // Set shadow-map - createRenderEffect(() => { - const _gl = gl() + const _gl = gl() - if (_gl.shadowMap) { - const oldEnabled = _gl.shadowMap.enabled - const oldType = _gl.shadowMap.type - _gl.shadowMap.enabled = !!props.shadows - - if (typeof props.shadows === "boolean") { - _gl.shadowMap.type = PCFSoftShadowMap - } else if (typeof props.shadows === "string") { + // Set shadow-map + whenRenderEffect( + () => _gl.shadowMap, + shadowMap => { + const oldEnabled = shadowMap.enabled + const oldType = shadowMap.type + shadowMap.enabled = !!config.shadows + + if (typeof config.shadows === "boolean") { + shadowMap.type = PCFSoftShadowMap + } else if (typeof config.shadows === "string") { const types = { basic: BasicShadowMap, percentage: PCFShadowMap, soft: PCFSoftShadowMap, variance: VSMShadowMap, } - _gl.shadowMap.type = types[props.shadows] ?? PCFSoftShadowMap - } else if (typeof props.shadows === "object") { - Object.assign(_gl.shadowMap, props.shadows) + shadowMap.type = types[config.shadows] ?? PCFSoftShadowMap + } else if (typeof config.shadows === "object") { + Object.assign(shadowMap, config.shadows) } - if (oldEnabled !== _gl.shadowMap.enabled || oldType !== _gl.shadowMap.type) - _gl.shadowMap.needsUpdate = true + if (oldEnabled !== shadowMap.enabled || oldType !== shadowMap.type) { + shadowMap.needsUpdate = true + } + }, + ) + + // Manage connecting XR + whenRenderEffect( + () => _gl.xr, + () => context.xr.connect(), + ) + + // Manage Props + whenRenderEffect(config.gl, glProp => { + if (glProp instanceof WebGLRenderer) { + return } + useProps(gl, glProp) }) - createEffect(() => { - const renderer = gl() - // Connect to xr if property exists - if (renderer.xr) context.xr.connect() - }) - - // Set color space and tonemapping preferences - const LinearEncoding = 3000 - const sRGBEncoding = 3001 // Color management and tone-mapping useProps(gl, { get outputEncoding() { - return props.linear ? LinearEncoding : sRGBEncoding + // Set color space and tonemapping preferences + return config.linear ? /* LinearEncoding */ 3000 : /* sRGBEncoding */ 3001 }, get toneMapping() { - return props.flat ? NoToneMapping : ACESFilmicToneMapping + return config.flat ? NoToneMapping : ACESFilmicToneMapping }, }) - - // Manage props - const _glProp = glProp() - if (_glProp && !(_glProp instanceof WebGLRenderer)) { - useProps(gl, _glProp) - } }) }, threeContext, @@ -439,7 +419,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi context.render(value) } createRenderEffect(() => { - if (canvasProps.frameloop === "always") { + if (config.frameloop === "always") { pendingLoopRequest = requestAnimationFrame(loop) } onCleanup(() => pendingLoopRequest && cancelAnimationFrame(pendingLoopRequest)) @@ -457,7 +437,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi children: ( - {canvasProps.children} + {config.children} ), diff --git a/src/props.ts b/src/props.ts index d155fd7..8c11a4a 100644 --- a/src/props.ts +++ b/src/props.ts @@ -1,10 +1,8 @@ import { type Accessor, children, - createComputed, createMemo, createRenderEffect, - createSelector, type JSXElement, mapArray, onCleanup, @@ -23,6 +21,7 @@ import { import { useThree } from "./hooks.ts" import type { AccessorMaybe, Context, Meta, Plugin } from "./types.ts" import { getMeta, hasColorSpace, processProps, resolve } from "./utils.ts" +import { whenRenderEffect } from "./utils/conditionals.ts" const PROPERTY_DESCRIPTOR_CACHE = new WeakMap>() @@ -136,11 +135,11 @@ export const useSceneGraph = ( props: { children?: JSXElement | JSXElement[]; onUpdate?(event: T): void }, ) => { const c = children(() => props.children) - createComputed( + createRenderEffect( mapArray( () => c.toArray() as unknown as (Meta | undefined)[], _child => - createComputed(() => { + createRenderEffect(() => { const parent = resolve(_parent) if (!parent) return const child = resolve(_child) @@ -185,7 +184,12 @@ function applyProp>( source: T, type: string, value: any, + pluginMethods: Record void>, ) { + if (type in pluginMethods) { + pluginMethods[type](value) + } + if (!source) { console.error("error while applying prop", source, type, value) return @@ -198,7 +202,7 @@ function applyProp>( if (type.indexOf("-") > -1) { const [property, ...rest] = type.split("-") - applyProp(context, source[property], rest.join("-"), value) + applyProp(context, source[property], rest.join("-"), value, pluginMethods) return } @@ -308,8 +312,6 @@ export function useProps>( props: Record, plugins: Plugin[] = [], ) { - const context = useThree() - const [local, instanceProps] = processProps(props, { plugins: [] }, [ "ref", "args", @@ -321,15 +323,10 @@ export function useProps>( const pluginMethods = createPluginMethods(accessor, () => [...plugins, ...local.plugins]) - const isPluginMethod = createSelector(pluginMethods, (prop: string, methods) => prop in methods) - + const context = useThree() useSceneGraph(accessor, props) - createRenderEffect(() => { - const object = resolve(accessor) - - if (!object) return - + whenRenderEffect(accessor, object => { // Assign ref createRenderEffect(() => { if (local.ref instanceof Function) local.ref(object) @@ -343,22 +340,15 @@ export function useProps>( // An array of sub-property-keys: // p.ex in position's subKeys will be ['position-x'] const subKeys = keys.filter(_key => key !== _key && _key.includes(key)) - createRenderEffect(() => { - if (isPluginMethod(key)) { - pluginMethods()[key]!(props[key]) - return - } - applyProp(context, object, key, props[key]) + createRenderEffect(() => { + applyProp(context, object, key, props[key], pluginMethods()) // If property updates, apply its sub-properties immediately after. + // NOTE: Discuss - is this expected behavior? Feature or a bug? // Should it be according to order of update instead? for (const subKey of subKeys) { - if (isPluginMethod(subKey)) { - pluginMethods()[subKey]!(props[key]) - continue - } - applyProp(context, object, subKey, props[subKey]) + applyProp(context, object, subKey, props[subKey], pluginMethods()) } }) } diff --git a/src/utils/conditionals.ts b/src/utils/conditionals.ts index 70a8a58..9ff4b61 100644 --- a/src/utils/conditionals.ts +++ b/src/utils/conditionals.ts @@ -1,4 +1,10 @@ -import { type Accessor, createEffect, createMemo } from "solid-js" +import { + type Accessor, + createComputed, + createEffect, + createMemo, + createRenderEffect, +} from "solid-js" import { resolve } from "../utils.ts" export function check< @@ -127,6 +133,28 @@ export function whenEffect< createEffect(when(accessor, callback)) } +export function whenRenderEffect< + T, + const TAccessor extends Accessor | T, + const TValues extends TAccessor extends ((...args: any[]) => any) | undefined + ? Exclude>, null | undefined | false> + : Exclude, + const TResult, +>(accessor: TAccessor, callback: (value: TValues) => TResult) { + createRenderEffect(when(accessor, callback)) +} + +export function whenComputed< + T, + const TAccessor extends Accessor | T, + const TValues extends TAccessor extends ((...args: any[]) => any) | undefined + ? Exclude>, null | undefined | false> + : Exclude, + const TResult, +>(accessor: TAccessor, callback: (value: TValues) => TResult) { + createComputed(when(accessor, callback)) +} + export function whenMemo< T, const TAccessor extends Accessor | T, From 63dfc0ee5513805fc64cac289404ade6f4df1e1f Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 26 Aug 2025 04:25:25 +0200 Subject: [PATCH 21/26] feat: remove setup-function of plugin - simplifies signature/implementation significantly - removes the question of where/who/what/when the setup should happen - instead of setup-method in plugin, if you want to do setup and context: use context. e.g. attaching a .Provider to your plugin and passing the plugin to Canvas' context-prop: -> you could also inject the provider like ..., but for plugins that want to modify the canvas-props, you will have to pass it to canvas' context-prop. --- playground/examples/PluginExample.tsx | 49 ++--- playground/examples/Repl.tsx | 190 ++++++++++++++++++++ src/create-t.tsx | 9 +- src/create-three.tsx | 60 +++---- src/{event-plugin.ts => event-plugin.tsx} | 206 ++++++++++++---------- src/index.ts | 2 +- src/plugin.ts | 85 +++------ src/props.ts | 41 ++--- src/types.ts | 68 +------ 9 files changed, 398 insertions(+), 312 deletions(-) create mode 100644 playground/examples/Repl.tsx rename src/{event-plugin.ts => event-plugin.tsx} (81%) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index af0068c..7d680ad 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -1,5 +1,5 @@ import * as THREE from "three" -import type { Meta, Plugin } from "types.ts" +import type { Meta } from "types.ts" import { createT, Entity, @@ -12,17 +12,19 @@ import { import { OrbitControls } from "../controls/OrbitControls.tsx" // LookAt plugin - works for all Object3D elements -const LookAtPlugin = plugin([THREE.Object3D], element => ({ - lookAt: (target: THREE.Object3D | [number, number, number]) => { - useFrame(() => { - if (Array.isArray(target)) { - element.lookAt(...target) - } else { - element.lookAt(target.position) - } - }) - }, -})) +const LookAtPlugin = plugin([THREE.Object3D], element => { + return { + lookAt: (target: THREE.Object3D | [number, number, number]) => { + useFrame(() => { + if (Array.isArray(target)) { + element.lookAt(...target) + } else { + element.lookAt(target.position) + } + }) + }, + } +}) // Shake plugin - works for both Camera and Light elements using array syntax const ShakePlugin = plugin([THREE.Camera, THREE.DirectionalLight, THREE.Mesh], element => ({ @@ -53,34 +55,21 @@ const MaterialPlugin = plugin( ) // Global plugin - applies to all elements using single argument -const GlobalPlugin: Plugin<{ +const GlobalPlugin: { (element: THREE.Material): { log(message: number): void } (element: THREE.Mesh): { log(message: string): void } -}> = plugin(element => ({ +} = plugin(element => ({ log: (message: string | number) => { console.info(`[${element.constructor.name}] ${message}`) }, })) -// Example with setup - plugin that needs context from setup function -const ContextPlugin = plugin - .setup(context => { - return { scene: context.scene } - }) - .then((element, context) => ({ - addToScene: () => { - // This plugin has access to the context from setup - console.info("Adding to scene", element, context.scene) - }, - })) - const { T, Canvas } = createT(THREE, [ LookAtPlugin, ShakePlugin, EventPlugin, MaterialPlugin, GlobalPlugin, - ContextPlugin, ]) export function PluginExample() { @@ -90,8 +79,8 @@ export function PluginExample() { return ( console.info("click missed")} + defaultCamera={{ position: new THREE.Vector3(0, 0, 5) }} + contexts={[EventPlugin]} > @@ -104,7 +93,7 @@ export function PluginExample() { plugins={[LookAtPlugin, MaterialPlugin]} log="Mesh rendered!" shake={0.1} - onClick={event => event.stopPropagation()} + onClick={event => console.log("clicked mesh!")} > diff --git a/playground/examples/Repl.tsx b/playground/examples/Repl.tsx new file mode 100644 index 0000000..0684527 --- /dev/null +++ b/playground/examples/Repl.tsx @@ -0,0 +1,190 @@ +import * as babel from "@babel/standalone" +import { + babelTransform, + createFileUrlSystem, + getExtension, + resolvePath, + transformModulePaths, +} from "@bigmistqke/repl" +import loader from "@monaco-editor/loader" +import { ReactiveMap } from "@solid-primitives/map" +import { languages } from "monaco-editor" +import { createEffect, createResource, createSignal, mapArray, onCleanup } from "solid-js" +import * as THREE from "three" +import { createT, Entity, EventPlugin, Portal } from "../../src/index.ts" +import { every, whenEffect, whenMemo } from "../../src/utils/conditionals.ts" + +const { T, Canvas } = createT(THREE, [EventPlugin]) + +function Repl() { + const [path, setPath] = createSignal("index.tsx") + const [monaco] = createResource(() => loader.init()) + const [transform] = createResource(() => + babelTransform({ babel, presets: ["babel-preset-solid"] }), + ) + + const element =
+ + const fs = new ReactiveMap() + + fs.set("index.tsx", "export const log = () =>
hallo world
") + fs.set("index.html", "export const log = () =>
hallo world
") + + const system = whenMemo(transform, transform => + createFileUrlSystem(fs.get.bind(fs), { + ts: { + type: "javascript", + transform({ source, path, fileUrls }) { + const result = transformModulePaths(source, importPath => { + if (importPath.startsWith(".")) { + const resolvedPath = resolvePath(path, importPath) + console.log(resolvePath(path, importPath)) + return fileUrls.get(resolvedPath) + } + }) + + const transformed = transform(result ?? source, path) + return transformed + }, + }, + }), + ) + + // whenEffect( + // () => system()?.get("index.ts"), + // url => import(url).then(({ log }) => document.body.append(log())), + // ) + + const editor = whenMemo(monaco, monaco => monaco.editor.create(element as HTMLDivElement)) + + whenEffect(every(monaco, editor), ([monaco, editor]) => { + const languages = { + tsx: "typescript", + ts: "typescript", + } + + function getType(path: string) { + const extension = getExtension(path) + if (extension && extension in languages) { + return languages[extension]! + } + // return type + } + + createEffect(() => { + editor.onDidChangeModelContent(event => { + fs.set(path(), editor.getModel()!.getValue()) + }) + }) + + console.log("fs", fs) + + createEffect( + mapArray( + () => [...fs.keys()], + path => { + console.log("path?", path) + + createEffect(() => { + const type = getType(path) + if (type === "dir") return + const uri = monaco.Uri.parse(`file:///${path}`) + const model = monaco.editor.getModel(uri) || monaco.editor.createModel("", type, uri) + console + console.log("model is ", model) + createEffect(() => { + const value = fs.get(path) + + console.log("value", value) + + if (value !== model.getValue()) { + model.setValue(value || "") + } + }) + onCleanup(() => model.dispose()) + }) + }, + ), + ) + + createEffect(async () => { + console.log("path", path()) + const uri = monaco.Uri.parse(`file:///${path()}`) + // let type = await getType(path()) + const model = monaco.editor.getModel(uri) || monaco.editor.createModel("", "typescript", uri) + editor.setModel(model) + }) + + // createEffect(() => { + // if (props.tsconfig) { + const tsconfig = { + target: 2, + module: 5, + moduleResolution: 2, + jsx: 1, + jsxImportSource: "solid-js", + esModuleInterop: true, + allowSyntheticDefaultImports: true, + forceConsistentCasingInFileNames: true, + isolatedModules: true, + resolveJsonModule: true, + skipLibCheck: true, + strict: true, + noEmit: false, + outDir: "./dist", + } satisfies languages.typescript.CompilerOptions + + monaco.languages.typescript.typescriptDefaults.setCompilerOptions(tsconfig) + monaco.languages.typescript.javascriptDefaults.setCompilerOptions(tsconfig) + // } + // }) + + // createEffect( + // mapArray( + // () => Object.keys(props.types ?? {}), + // name => { + // createEffect(() => { + // const declaration = props.types?.[name] + // if (!declaration) return + // const path = `file:///${name}` + // monaco.languages.typescript.typescriptDefaults.addExtraLib(declaration, path) + // monaco.languages.typescript.javascriptDefaults.addExtraLib(declaration, path) + // }) + // }, + // ), + // ) + }) + + return element +} + +export function ReplExample() { + const group = new THREE.Group() + return ( +
+ console.debug("canvas clicked", event)} + onClickMissed={event => console.debug("canvas click missed", event)} + onPointerLeave={event => console.debug("canvas pointer leave", event)} + onPointerEnter={event => console.debug("canvas pointer enter", event)} + > + + + + + + + + + +
+ ) +} diff --git a/src/create-t.tsx b/src/create-t.tsx index 0185380..9a2a1c3 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,4 +1,5 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" +import type {} from "node:vm" import { createMemo, onMount, @@ -8,11 +9,11 @@ import { type MergeProps, type ParentProps, } from "solid-js" -import { OrthographicCamera, Scene } from "three" +import { OrthographicCamera } from "three" import { $S3C } from "./constants.ts" import { createThree } from "./create-three.tsx" import { useProps } from "./props.ts" -import type { CanvasProps, Plugin, PluginPropsOf, Props } from "./types.ts" +import type { CanvasProps, Plugin, Props } from "./types.ts" import { meta } from "./utils.ts" /**********************************************************************************/ @@ -29,7 +30,9 @@ export function createT< >(catalogue: TCatalogue, plugins?: TCataloguePlugins) { const cache = new Map>() return { - Canvas(props: ParentProps & Partial>) { + Canvas( + props: ParentProps /* & Partial> */, + ) { let canvas: HTMLCanvasElement = null! let container: HTMLDivElement = null! diff --git a/src/create-three.tsx b/src/create-three.tsx index 41d4aef..149bd89 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -1,5 +1,11 @@ -import { ReactiveMap } from "@solid-primitives/map" -import { createMemo, createRenderEffect, createRoot, mergeProps, onCleanup } from "solid-js" +import { + createMemo, + createRenderEffect, + createRoot, + mergeProps, + onCleanup, + type Context as SolidContext, +} from "solid-js" import { ACESFilmicToneMapping, BasicShadowMap, @@ -18,7 +24,7 @@ import { } from "three" import { frameContext, threeContext } from "./hooks.ts" import { pluginContext } from "./internal-context.ts" -import { createPluginMethods, useProps, useSceneGraph } from "./props.ts" +import { mergePluginMethods, useProps, useSceneGraph } from "./props.ts" import { CursorRaycaster } from "./raycasters.tsx" import type { CameraKind, Context, FrameListener, FrameListenerCallback, Plugin } from "./types.ts" import type { CanvasProps, EventRaycaster } from "./types.tsx" @@ -239,7 +245,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi return this.gl.getPixelRatio() }, props, - registerPlugin, render, requestRender, get viewport() { @@ -275,26 +280,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi ], ) - /**********************************************************************************/ - /* */ - /* Plugins */ - /* */ - /**********************************************************************************/ - - const pluginMap = new ReactiveMap>() - - function registerPlugin(plugin: Plugin) { - let result = pluginMap.get(plugin) - if (result) { - return result - } - result = plugin(context) - pluginMap.set(plugin, result) - return result - } - - const pluginMethods = createPluginMethods(canvas, plugins, context) - /**********************************************************************************/ /* */ /* Effects */ @@ -314,7 +299,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi // Manage props resolved to plugins createRenderEffect(() => { - const _pluginMethods = pluginMethods() + const _pluginMethods = mergePluginMethods(canvas, plugins) for (const key in config) { if (key in _pluginMethods) { _pluginMethods[key]?.(config[key as keyof typeof config]) @@ -431,18 +416,19 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi /* */ /**********************************************************************************/ - useSceneGraph( - context.scene, - mergeProps(props, { - children: ( - - - {config.children} - - - ), - }), - ) + createRenderEffect(() => { + withMultiContexts( + () => useSceneGraph(context.scene, props), + [ + ...(props.contexts?.map( + context => [context, null] as unknown as readonly [SolidContext, unknown], + ) ?? []), + [threeContext, context], + [pluginContext, plugins], + [frameContext, addFrameListener], + ], + ) + }) // Return context merged with `addFrameListeners`` // This is used in `@solid-three/testing` diff --git a/src/event-plugin.ts b/src/event-plugin.tsx similarity index 81% rename from src/event-plugin.ts rename to src/event-plugin.tsx index 2f8f4c8..15cf6df 100644 --- a/src/event-plugin.ts +++ b/src/event-plugin.tsx @@ -1,5 +1,6 @@ -import { onCleanup } from "solid-js" +import { createContext, onCleanup, useContext, type ParentProps } from "solid-js" import { Object3D, type Intersection } from "three" +import { useThree } from "./hooks.ts" import { plugin } from "./plugin.ts" import { type Context, type Intersect, type Meta, type Prettify, type When } from "./types.ts" import { getMeta } from "./utils.ts" @@ -180,7 +181,12 @@ function raycast( /* */ /**********************************************************************************/ -function createAutoRegistry() { +type AutoRegistry = { + array: T[] + add(instance: T): void +} + +function createAutoRegistry(): AutoRegistry { const array: T[] = [] return { @@ -467,98 +473,114 @@ function createDefaultEventRegistry( /* */ /**********************************************************************************/ +const EventContext = createContext<{ + hoverMouses: AutoRegistry + hoverPointers: AutoRegistry + missableClicks: AutoRegistry + missableContextMenus: AutoRegistry + missableDoubleClicks: AutoRegistry + mouseDowns: AutoRegistry + mouseUps: AutoRegistry + pointerDowns: AutoRegistry + pointerUps: AutoRegistry + wheels: AutoRegistry +}>() + /** * Initializes and manages event handling for all `Instance`. */ -export const EventPlugin = plugin - .setup(context => ({ - // onMouseMove/onMouseEnter/onMouseLeave - hoverMouses: createHoverEventRegistry("Mouse", context), - // onPointerMove/onPointerEnter/onPointerLeave - hoverPointers: createHoverEventRegistry("Pointer", context), - // onClick/onClickMissed - missableClicks: createMissableEventRegistry("onClick", context), - // onContextMenu/onContextMenuMissed - missableContextMenus: createMissableEventRegistry("onContextMenu", context), - // onDoubleClick/onDoubleClickMissed - missableDoubleClicks: createMissableEventRegistry("onDoubleClick", context), - // Default mouse-events - mouseDowns: createDefaultEventRegistry("onMouseDown", context), - mouseUps: createDefaultEventRegistry("onMouseUp", context), - // Default pointer-events - pointerDowns: createDefaultEventRegistry("onPointerDown", context), - pointerUps: createDefaultEventRegistry("onPointerUp", context), - // Default wheel-event - wheels: createDefaultEventRegistry("onWheel", context, { passive: true }), - })) - .then( - ( - object, - { - hoverMouses, - hoverPointers, - missableClicks, - missableContextMenus, - missableDoubleClicks, - mouseDowns, - mouseUps, - pointerDowns, - pointerUps, - wheels, +export const EventPlugin = Object.assign( + plugin([Object3D], (object): EventListeners => { + const context = useContext(EventContext) + + if (!context) { + throw "Solid Three entities with EventPlugin should be declared inside " + } + + return { + onClick() { + context.missableClicks.add(object) }, - ): EventListeners => { - return { - onClick() { - missableClicks.add(object) - }, - onClickMissed() { - missableClicks.add(object) - }, - onDoubleClick() { - missableDoubleClicks.add(object) - }, - onDoubleClickMissed() { - missableDoubleClicks.add(object) - }, - onContextMenu() { - missableContextMenus.add(object) - }, - onContextMenuMissed() { - missableContextMenus.add(object) - }, - onMouseDown() { - mouseDowns.add(object) - }, - onMouseUp() { - mouseUps.add(object) - }, - onMouseMove() { - hoverMouses.add(object) - }, - onMouseEnter() { - hoverMouses.add(object) - }, - onMouseLeave() { - hoverMouses.add(object) - }, - onPointerDown() { - pointerDowns.add(object) - }, - onPointerUp() { - pointerUps.add(object) - }, - onPointerMove() { - hoverPointers.add(object) - }, - onPointerEnter() { - hoverPointers.add(object) - }, - onPointerLeave() { - hoverMouses.add(object) - }, - onWheel() { - wheels.add(object) - }, - } + onClickMissed() { + context.missableClicks.add(object) + }, + onDoubleClick() { + context.missableDoubleClicks.add(object) + }, + onDoubleClickMissed() { + context.missableDoubleClicks.add(object) + }, + onContextMenu() { + context.missableContextMenus.add(object) + }, + onContextMenuMissed() { + context.missableContextMenus.add(object) + }, + onMouseDown() { + context.mouseDowns.add(object) + }, + onMouseUp() { + context.mouseUps.add(object) + }, + onMouseMove() { + context.hoverMouses.add(object) + }, + onMouseEnter() { + context.hoverMouses.add(object) + }, + onMouseLeave() { + context.hoverMouses.add(object) + }, + onPointerDown() { + context.pointerDowns.add(object) + }, + onPointerUp() { + context.pointerUps.add(object) + }, + onPointerMove() { + context.hoverPointers.add(object) + }, + onPointerEnter() { + context.hoverPointers.add(object) + }, + onPointerLeave() { + context.hoverMouses.add(object) + }, + onWheel() { + context.wheels.add(object) + }, + } + }), + { + Provider(props: ParentProps) { + const context = useThree() + + return ( + + {props.children} + + ) }, - ) + }, +) diff --git a/src/index.ts b/src/index.ts index 297049b..0108ddc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ export { Entity, Portal, Resource } from "./components.tsx" export { $S3C } from "./constants.ts" export { createEntity, createT } from "./create-t.tsx" -export { EventPlugin } from "./event-plugin.ts" +export { EventPlugin } from "./event-plugin.tsx" export { useFrame, useThree } from "./hooks.ts" export { plugin } from "./plugin.ts" export { useProps } from "./props.ts" diff --git a/src/plugin.ts b/src/plugin.ts index 1ef4903..b7b0104 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,72 +1,31 @@ -import type { Context, Plugin, PluginFn } from "./types.ts" +import type { Plugin, PluginFn } from "./types.ts" -export const plugin: PluginFn = Object.assign( - // Main function implementation - (filterArgOrMethods?: any, methods?: any): any => { - // Single argument case - global plugin (apply to all elements) - if (methods === undefined) { - const plugin: Plugin = (_context: Context) => { - return (element: any) => { - return filterArgOrMethods(element) - } - } - return plugin +// Main function implementation +export const plugin: PluginFn = (selectorOrMethods?: any, methods?: any): Plugin => { + // Single argument case - global plugin (apply to all elements) + if (methods === undefined) { + return (element: any) => { + return selectorOrMethods(element) } + } - // Two argument case - filtered plugin (array of constructors or type guard) - return filteredPlugin(undefined, filterArgOrMethods, methods) - }, - { - setup(setupFn: (context: Context) => TSetupContext) { - return { - then(filterArgOrMethods: any, methods?: any) { - // Single argument case - global plugin with setup - if (methods === undefined) { - const plugin: Plugin = (context: Context) => { - const setupContext = setupFn(context) - return (element: any) => { - return filterArgOrMethods(element, setupContext) - } - } - return plugin - } - - // Two argument case - filtered plugin with setup - return filteredPlugin(setupFn, filterArgOrMethods, methods) - }, - } - }, - }, -) - -function filteredPlugin( - setup: ((context: Context) => any) | undefined, - filterArg: any, - methods: any, -): Plugin { - const plugin: Plugin = (context: Context) => { - // Run setup once if provided and store result as context - const setupContext = setup ? setup(context) : undefined - - return (element: any) => { - // Handle array of constructors - if (Array.isArray(filterArg)) { - for (const Constructor of filterArg) { - if (element instanceof Constructor) { - return methods(element, setupContext) - } + // Two argument case - filtered plugin (array of constructors or type guard) + return (element: any) => { + // Handle array of constructors + if (Array.isArray(selectorOrMethods)) { + for (const Constructor of selectorOrMethods) { + if (element instanceof Constructor) { + return methods(element) } } - // Handle type guard function - else if (typeof filterArg === "function") { - if (filterArg(element)) { - return methods(element, setupContext) - } + } + // Handle type guard function + else if (typeof selectorOrMethods === "function") { + if (selectorOrMethods(element)) { + return methods(element) } - - return undefined } - } - return plugin + return undefined + } } diff --git a/src/props.ts b/src/props.ts index 8c11a4a..a990c21 100644 --- a/src/props.ts +++ b/src/props.ts @@ -188,6 +188,7 @@ function applyProp>( ) { if (type in pluginMethods) { pluginMethods[type](value) + return } if (!source) { @@ -308,7 +309,7 @@ function applyProp>( * and special properties like `ref` and `children`. */ export function useProps>( - accessor: T | undefined | Accessor, + accessor: T | Accessor, props: Record, plugins: Plugin[] = [], ) { @@ -321,7 +322,9 @@ export function useProps>( "plugins", ]) - const pluginMethods = createPluginMethods(accessor, () => [...plugins, ...local.plugins]) + const pluginMethods = createMemo(() => + mergePluginMethods(resolve(accessor), [...plugins, ...local.plugins]), + ) const context = useThree() useSceneGraph(accessor, props) @@ -359,27 +362,21 @@ export function useProps>( }) } -export function createPluginMethods( - target: AccessorMaybe, - plugins: AccessorMaybe, - { registerPlugin } = useThree(), -) { - return createMemo(() => { - const pluginResults = resolve(plugins).map(init => registerPlugin(init)(resolve(target))) - - const merged: Record = {} - - for (const result of pluginResults) { - for (const key in result) { - const descriptor = Object.getOwnPropertyDescriptor(result, key) - if (descriptor?.get || descriptor?.set) { - Object.defineProperty(merged, key, descriptor) - } else { - merged[key] = result[key] - } +export function mergePluginMethods(target: object, plugins: Plugin[]) { + const pluginResults = resolve(plugins).map(plugin => plugin(resolve(target))) + + const merged: Record = {} + + for (const result of pluginResults) { + for (const key in result) { + const descriptor = Object.getOwnPropertyDescriptor(result, key) + if (descriptor?.get || descriptor?.set) { + Object.defineProperty(merged, key, descriptor) + } else { + merged[key] = result[key] } } + } - return merged - }) + return merged } diff --git a/src/types.ts b/src/types.ts index 1873daa..4e03dba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { Accessor, JSX, ParentProps, Ref } from "solid-js" +import type { Accessor, Component, JSX, ParentProps, Ref } from "solid-js" import type { Camera, Clock, @@ -124,6 +124,7 @@ export interface EventRaycaster extends Raycaster { export interface CanvasProps extends ParentProps { ref?: Ref class?: string + contexts?: { Provider: Component }[] /** Configuration for the camera used in the scene. */ defaultCamera?: Partial | Props> | Camera /** Configuration for the Raycaster used for mouse and pointer events. */ @@ -166,9 +167,6 @@ export interface Context { dpr: number gl: Meta props: CanvasProps - registerPlugin( - plugin: Plugin, - ): (element: any) => Record void)> render: (delta: number) => void requestRender: () => void scene: Meta @@ -255,7 +253,7 @@ export type Matrix4 = Representation /**********************************************************************************/ export type InferPluginProps = Merge<{ - [TKey in keyof TPlugins]: TPlugins[TKey] extends (context?: any) => (element: any) => infer U + [TKey in keyof TPlugins]: TPlugins[TKey] extends (element: any) => infer U ? { [TKey in keyof U]: U[TKey] extends (callback: infer V) => any ? V : never } : never }> @@ -384,9 +382,7 @@ export type ConstructorOverloadParameters = T extends { /* */ /**********************************************************************************/ -export interface Plugin any> { - (context: Context): TFn -} +export type Plugin any> = TFn /** * Plugin function interface that defines all possible plugin creation patterns. @@ -467,62 +463,6 @@ export interface PluginFn { ): Plugin<{ (element: T): Methods }> - - /** - * Creates a plugin with access to setup context. - * - * The setup function runs once when the plugin is initialized and receives - * the Three.js context. The returned data is passed to all plugin methods. - * - * @param setupFn - Function that receives the Three.js context and returns setup data - * @returns Object with 'then' method to define the plugin behavior - */ - setup( - setupFn: (context: Context) => TSetupContext, - ): { - then: { - /** - * Creates a global plugin with setup context. - * - * @param methods - Function that receives element and setup context, returns plugin methods - * @returns Plugin that applies to all elements with setup context - */ - >( - methods: (element: any, context: TSetupContext) => Methods, - ): Plugin<(element: any) => Methods> - - /** - * Creates a filtered plugin with setup context using constructor array. - * - * @param Constructors - Array of constructor functions to filter by - * @param methods - Function that receives filtered element and setup context, returns plugin methods - * @returns Plugin that applies only to matching constructor types with setup context - */ - >( - Constructors: T, - methods: ( - element: T extends readonly Constructor[] ? U : never, - context: TSetupContext, - ) => Methods, - ): Plugin<{ - (element: T extends readonly Constructor[] ? U : never): Methods - }> - - /** - * Creates a filtered plugin with setup context using type guard. - * - * @param condition - Type guard function that determines if plugin applies - * @param methods - Function that receives filtered element and setup context, returns plugin methods - * @returns Plugin that applies only to elements matching the type guard with setup context - */ - >( - condition: (element: unknown) => element is T, - methods: (element: T, context: TSetupContext) => Methods, - ): Plugin<{ - (element: T): Methods - }> - } - } } type PluginReturn = TPlugin extends Plugin From a537e9afbad2fb637a6f845fd902df67d3c10575 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 26 Aug 2025 13:10:25 +0200 Subject: [PATCH 22/26] chore: update LICENSE include @react-three/fiber's license, as we do in solid-drei --- LICENSE | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/LICENSE b/LICENSE index 0364982..56aa915 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,30 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +This project is based on @react-three/fiber (https://github.com/pmndrs/react-three-fiber) +Original + +MIT License + +Copyright (c) 2019-2025 Poimandres + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 286f00229b1632ab927c07aa6a9543587542ea7d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Wed, 27 Aug 2025 10:23:23 +0200 Subject: [PATCH 23/26] feat: prevent default canvas prop resolution if plugin overrides method --- playground/examples/PluginExample.tsx | 4 +- src/constants.ts | 4 + src/create-t.tsx | 8 +- src/create-three.tsx | 136 +++++++++++++------------- src/event-plugin.tsx | 4 +- src/types.ts | 2 +- 6 files changed, 82 insertions(+), 76 deletions(-) diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx index 7d680ad..e5e96ef 100644 --- a/playground/examples/PluginExample.tsx +++ b/playground/examples/PluginExample.tsx @@ -81,6 +81,7 @@ export function PluginExample() { style={{ width: "100vw", height: "100vh" }} defaultCamera={{ position: new THREE.Vector3(0, 0, 5) }} contexts={[EventPlugin]} + onClickMissed={() => console.info("missed!")} > @@ -90,10 +91,9 @@ export function PluginExample() { position={[0, 0, 0]} highlight="red" lookAt={useThree().currentCamera} - plugins={[LookAtPlugin, MaterialPlugin]} log="Mesh rendered!" shake={0.1} - onClick={event => console.log("clicked mesh!")} + onClick={event => console.info("clicked mesh!")} > diff --git a/src/constants.ts b/src/constants.ts index 3b95f19..8c556dd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1,5 @@ export const $S3C = Symbol("solid-three") + +// Deprecated three-js constants +export const LinearEncoding = 3000 +export const sRGBEncoding = 3001 diff --git a/src/create-t.tsx b/src/create-t.tsx index 9a2a1c3..ee52298 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -9,11 +9,11 @@ import { type MergeProps, type ParentProps, } from "solid-js" -import { OrthographicCamera } from "three" +import { OrthographicCamera, Scene } from "three" import { $S3C } from "./constants.ts" import { createThree } from "./create-three.tsx" import { useProps } from "./props.ts" -import type { CanvasProps, Plugin, Props } from "./types.ts" +import type { CanvasProps, Plugin, PluginPropsOf, Props } from "./types.ts" import { meta } from "./utils.ts" /**********************************************************************************/ @@ -30,9 +30,7 @@ export function createT< >(catalogue: TCatalogue, plugins?: TCataloguePlugins) { const cache = new Map>() return { - Canvas( - props: ParentProps /* & Partial> */, - ) { + Canvas(props: ParentProps & Partial>) { let canvas: HTMLCanvasElement = null! let container: HTMLDivElement = null! diff --git a/src/create-three.tsx b/src/create-three.tsx index 149bd89..0e7bd0a 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -2,6 +2,7 @@ import { createMemo, createRenderEffect, createRoot, + createSelector, mergeProps, onCleanup, type Context as SolidContext, @@ -22,6 +23,7 @@ import { VSMShadowMap, WebGLRenderer, } from "three" +import { LinearEncoding, sRGBEncoding } from "./constants.ts" import { frameContext, threeContext } from "./hooks.ts" import { pluginContext } from "./internal-context.ts" import { mergePluginMethods, useProps, useSceneGraph } from "./props.ts" @@ -34,7 +36,6 @@ import { meta, removeElementFromArray, useRef, - withContext, withMultiContexts, } from "./utils.ts" import { whenRenderEffect } from "./utils/conditionals.ts" @@ -280,14 +281,42 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi ], ) + /**********************************************************************************/ + /* */ + /* Render Loop */ + /* */ + /**********************************************************************************/ + + let pendingLoopRequest: number | undefined + function loop(value: number) { + pendingLoopRequest = requestAnimationFrame(loop) + context.render(value) + } + createRenderEffect(() => { + if (config.frameloop === "always") { + pendingLoopRequest = requestAnimationFrame(loop) + } + onCleanup(() => pendingLoopRequest && cancelAnimationFrame(pendingLoopRequest)) + }) + /**********************************************************************************/ /* */ /* Effects */ /* */ /**********************************************************************************/ - withContext( - () => { + createRenderEffect(() => { + withMultiContexts(() => { + const pluginMethods = createMemo(() => mergePluginMethods(scene(), plugins)) + const hasPluginMethod = createSelector( + pluginMethods, + (key: keyof CanvasProps, methods) => key in methods, + ) + + // Handle scene graph + useSceneGraph(context.scene, props) + + // Manage clock createRenderEffect(() => { if (config.frameloop === "never") { context.clock.stop() @@ -298,18 +327,20 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi }) // Manage props resolved to plugins - createRenderEffect(() => { - const _pluginMethods = mergePluginMethods(canvas, plugins) + whenRenderEffect(pluginMethods, pluginMethods => { for (const key in config) { - if (key in _pluginMethods) { - _pluginMethods[key]?.(config[key as keyof typeof config]) + if (key in pluginMethods) { + pluginMethods[key]?.(config[key as keyof typeof config]) } } }) // Manage camera whenRenderEffect( - () => !(config.defaultCamera instanceof Camera) && config.defaultCamera, + () => + !hasPluginMethod("defaultCamera") && + !(config.defaultCamera instanceof Camera) && + config.defaultCamera, propsCamera => { useProps(defaultCamera, propsCamera) // NOTE: Manually update camera's matrix with updateMatrixWorld is needed. @@ -320,23 +351,24 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi // Manage scene whenRenderEffect( - () => !(config.scene instanceof Scene) && config.scene, + () => !hasPluginMethod("scene") && !(config.scene instanceof Scene) && config.scene, propsScene => useProps(scene, propsScene), ) // Manage raycaster whenRenderEffect( - () => !(config.defaultRaycaster instanceof Raycaster) && config.defaultRaycaster, + () => + !hasPluginMethod("defaultRaycaster") && + !(config.defaultRaycaster instanceof Raycaster) && + config.defaultRaycaster, raycaster => useProps(defaultRaycaster, raycaster), ) // Manage gl - createRenderEffect(() => { - const _gl = gl() - + whenRenderEffect(gl, gl => { // Set shadow-map whenRenderEffect( - () => _gl.shadowMap, + () => gl.shadowMap, shadowMap => { const oldEnabled = shadowMap.enabled const oldType = shadowMap.type @@ -364,70 +396,42 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugi // Manage connecting XR whenRenderEffect( - () => _gl.xr, + () => gl.xr, () => context.xr.connect(), ) // Manage Props - whenRenderEffect(config.gl, glProp => { - if (glProp instanceof WebGLRenderer) { - return - } - useProps(gl, glProp) - }) + whenRenderEffect( + () => !hasPluginMethod("gl") && !(config.gl instanceof WebGLRenderer) && config.gl, + prop => useProps(gl, prop), + ) - // Color management and tone-mapping + // Set color space and tonemapping preferences useProps(gl, { get outputEncoding() { - // Set color space and tonemapping preferences - return config.linear ? /* LinearEncoding */ 3000 : /* sRGBEncoding */ 3001 + return hasPluginMethod("linear") + ? undefined + : config.linear + ? LinearEncoding + : sRGBEncoding }, get toneMapping() { - return config.flat ? NoToneMapping : ACESFilmicToneMapping + return hasPluginMethod("flat") + ? undefined + : config.flat + ? NoToneMapping + : ACESFilmicToneMapping }, }) }) - }, - threeContext, - context, - ) - - /**********************************************************************************/ - /* */ - /* Render Loop */ - /* */ - /**********************************************************************************/ - - let pendingLoopRequest: number | undefined - function loop(value: number) { - pendingLoopRequest = requestAnimationFrame(loop) - context.render(value) - } - createRenderEffect(() => { - if (config.frameloop === "always") { - pendingLoopRequest = requestAnimationFrame(loop) - } - onCleanup(() => pendingLoopRequest && cancelAnimationFrame(pendingLoopRequest)) - }) - - /**********************************************************************************/ - /* */ - /* Scene Graph */ - /* */ - /**********************************************************************************/ - - createRenderEffect(() => { - withMultiContexts( - () => useSceneGraph(context.scene, props), - [ - ...(props.contexts?.map( - context => [context, null] as unknown as readonly [SolidContext, unknown], - ) ?? []), - [threeContext, context], - [pluginContext, plugins], - [frameContext, addFrameListener], - ], - ) + }, [ + ...(props.contexts?.map( + context => [context, null] as unknown as readonly [SolidContext, unknown], + ) ?? []), + [threeContext, context], + [pluginContext, plugins], + [frameContext, addFrameListener], + ]) }) // Return context merged with `addFrameListeners`` diff --git a/src/event-plugin.tsx b/src/event-plugin.tsx index 15cf6df..1865aeb 100644 --- a/src/event-plugin.tsx +++ b/src/event-plugin.tsx @@ -1,5 +1,5 @@ import { createContext, onCleanup, useContext, type ParentProps } from "solid-js" -import { Object3D, type Intersection } from "three" +import { Mesh, Object3D, Scene, type Intersection } from "three" import { useThree } from "./hooks.ts" import { plugin } from "./plugin.ts" import { type Context, type Intersect, type Meta, type Prettify, type When } from "./types.ts" @@ -490,7 +490,7 @@ const EventContext = createContext<{ * Initializes and manages event handling for all `Instance`. */ export const EventPlugin = Object.assign( - plugin([Object3D], (object): EventListeners => { + plugin([Scene, Mesh], (object): EventListeners => { const context = useContext(EventContext) if (!context) { diff --git a/src/types.ts b/src/types.ts index 4e03dba..0078c92 100644 --- a/src/types.ts +++ b/src/types.ts @@ -276,7 +276,7 @@ export type Props = Partial< */ raycastable: boolean }, - InferPluginProps, + PluginPropsOf, TPlugins>, ] > > From 15f3b42298c5a056f64eb94e158954160c48957b Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Wed, 27 Aug 2025 10:29:15 +0200 Subject: [PATCH 24/26] =?UTF-8?q?chore:=20update=20gitignore=20goodbye=20.?= =?UTF-8?q?claude=20=F0=9F=91=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 15 --------------- .gitignore | 3 ++- 2 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 317df91..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebFetch(domain:github.com)", - "Bash(pnpm lint:types:*)", - "Bash(rm:*)", - "Bash(ls:*)", - "Bash(npm run typecheck:*)", - "Bash(npm run:*)", - "Bash(npx tsc:*)", - "Bash(grep:*)" - ], - "deny": [] - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6fa23c6..fb066fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ +.claude .scratch dist types node_modules -packed/ +packed/ \ No newline at end of file From e4572d38d932f10bb85b2b52dd2b5c27d7761b2d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 20 Sep 2025 12:22:04 +0200 Subject: [PATCH 25/26] demo: fix SolarExample --- playground/examples/SolarExample.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/playground/examples/SolarExample.tsx b/playground/examples/SolarExample.tsx index c0baa14..5f15882 100644 --- a/playground/examples/SolarExample.tsx +++ b/playground/examples/SolarExample.tsx @@ -125,6 +125,7 @@ export function SolarExample() { onClickMissed={event => console.info("canvas click missed", event)} onPointerLeave={event => console.info("canvas pointer leave", event)} onPointerEnter={event => console.info("canvas pointer enter", event)} + contexts={[EventPlugin]} > From 9d8b987a98b8ce49432d157a582d6ea6b26162df Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sat, 20 Sep 2025 14:23:48 +0200 Subject: [PATCH 26/26] demo: add vanilla example --- playground/App.tsx | 13 +++++++++++++ playground/examples/VanillaExample.tsx | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 playground/examples/VanillaExample.tsx diff --git a/playground/App.tsx b/playground/App.tsx index 2c031d5..13d39a4 100644 --- a/playground/App.tsx +++ b/playground/App.tsx @@ -6,6 +6,7 @@ import { EnvironmentExample } from "./examples/EnvironmentExample.tsx" import { PluginExample } from "./examples/PluginExample.tsx" import { PortalExample } from "./examples/PortalExample.tsx" import { SolarExample } from "./examples/SolarExample.tsx" +import { VanillaExample } from "./examples/VanillaExample.tsx" import "./index.css" const { T, Canvas } = createT({ ...THREE, Entity }) @@ -79,6 +80,17 @@ function Layout(props: ParentProps) { > Plugins + + Vanilla + {props.children} @@ -92,6 +104,7 @@ export function App() { + ( diff --git a/playground/examples/VanillaExample.tsx b/playground/examples/VanillaExample.tsx new file mode 100644 index 0000000..7521988 --- /dev/null +++ b/playground/examples/VanillaExample.tsx @@ -0,0 +1,24 @@ +import * as THREE from "three" +import { createT, EventPlugin, useThree } from "../../src/index.ts" +import { OrbitControls } from "../controls/OrbitControls.tsx" + +const { Canvas } = createT(THREE, [EventPlugin]) + +export function VanillaExample() { + return ( + + {(() => { + const three = useThree() + + const geometry = new THREE.BoxGeometry() + const material = new THREE.MeshBasicMaterial({ color: "red" }) + const mesh = new THREE.Mesh(geometry, material) + + three.scene.add(mesh) + + return null! + })()} + + + ) +}