Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions src/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
} from "three"
import { createThree } from "./create-three.tsx"
import type { EventRaycaster } from "./raycasters.tsx"
import type { CanvasEventHandlers, Context, Props, RefWithCleanup, ResolvedRenderer } from "./types.ts"
import type {
BaseProps,
CanvasEventHandlers,
Context,
RefWithCleanup,
ResolvedRenderer,
} from "./types.ts"

/**
* Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene.
Expand All @@ -20,9 +26,9 @@ export interface CanvasProps extends ParentProps<Partial<CanvasEventHandlers>> {
ref?: RefWithCleanup<Context>
class?: string
/** Configuration for the camera used in the scene. */
camera?: Partial<Props<PerspectiveCamera> | Props<OrthographicCamera>> | Camera
camera?: Partial<BaseProps<PerspectiveCamera> | BaseProps<OrthographicCamera>> | Camera
/** Configuration for the Raycaster used for mouse and pointer events. */
raycaster?: Partial<Props<EventRaycaster>> | EventRaycaster | Raycaster
raycaster?: Partial<BaseProps<EventRaycaster>> | EventRaycaster | Raycaster
/** Element to render while the main content is loading asynchronously. */
fallback?: JSX.Element
/** Toggles flat interpolation for texture filtering. */
Expand All @@ -43,16 +49,15 @@ export interface CanvasProps extends ParentProps<Partial<CanvasEventHandlers>> {
* The accepted renderer type narrows when you declare it via the `Register`
* module-augmentation interface — see {@link Register} in `types.ts`.
*/
gl?:
// Flat object accepts both `WebGLRendererParameters` (constructor-only,
gl?: // Flat object accepts both `WebGLRendererParameters` (constructor-only,
// e.g. `antialias`, `alpha`) and writable instance props (e.g.
// `toneMapping`). solid-three splits them at construction: constructor args
// are baked once; instance props stay reactive. Inspired by r3f's `gl` prop.
// When `Register` narrows `ResolvedRenderer` away from WebGL this branch
// collapses to `never` so the user is forced into the factory or instance
// form that matches their declared renderer.
| (WebGLRenderer extends ResolvedRenderer
? Partial<Props<WebGLRenderer> & WebGLRendererParameters>
? Partial<BaseProps<WebGLRenderer> & WebGLRendererParameters>
: never)
| ((canvas: HTMLCanvasElement) => ResolvedRenderer)
| ResolvedRenderer
Expand All @@ -61,7 +66,7 @@ export interface CanvasProps extends ParentProps<Partial<CanvasEventHandlers>> {
/** Toggles between Orthographic and Perspective camera. */
orthographic?: boolean
/** Configuration for the Scene instance. */
scene?: Partial<Props<Scene>> | Scene
scene?: Partial<BaseProps<Scene>> | Scene
/** Enables and configures shadows in the scene. */
shadows?: boolean | "basic" | "percentage" | "soft" | "variance" | WebGLRenderer["shadowMap"]
/** Custom CSS styles for the canvas container. */
Expand Down
54 changes: 28 additions & 26 deletions src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { Loader, Object3D } from "three"
import { threeContext, useLoader, useThree, type UseLoaderOptions } from "./hooks.ts"
import { useProps } from "./props.ts"
import type { Constructor, LoaderData, LoaderUrl, Meta, Overwrite, Props } from "./types.ts"
import type { BaseProps, Constructor, LoaderData, LoaderUrl, Meta, Plugin, Props } from "./types.ts"
import { type InstanceOf } from "./types.ts"
import { autodispose, hasMeta, isConstructor, meta, withContext, type LoadOutput } from "./utils.ts"

Expand Down Expand Up @@ -72,37 +72,39 @@ export function Portal<T extends Object3D>(props: PortalProps<T>) {
/* */
/**********************************************************************************/

type EntityProps<T extends object | Constructor<object>> = Overwrite<
[
Props<T>,
{
from: T | undefined
children?: JSXElement
},
]
>
/**
* Wraps a `ThreeElement` and allows it to be used as a JSX-component within a `solid-three` scene.
*
* @function Entity
* @template T - Extends `ThreeInstance`
* @param props - The properties for the Three.js object including the object instance's methods,
* 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.
* The `& PropsWithPlugins<T, TPlugins>` intersection + `const TPlugins` is what lets
* the JSX `plugins` array const-infer into a tuple, so a per-element plugin's
* contributed methods surface as typed (and enforced) props.
*
* @param props - The Three.js object's props (methods, children, ref) plus the
* element-scoped `plugins` and their contributed props.
* @returns The Three.js object wrapped as a JSX element.
*/
export function Entity<T extends object | Constructor<object>>(props: EntityProps<T>) {
const [config, rest] = splitProps(props, ["from", "args"])
export function Entity<
const T extends object | Constructor<object> = object,
const TPlugins extends readonly Plugin[] = readonly Plugin[],
>(
// PropsWithPlugins keeps PluginPropsOf a direct top-level intersection (not buried in
// Props's Overwrite) so TPlugins stays inferable from the JSX `plugins` prop.
props: { from: T; children?: JSXElement; plugins?: TPlugins } & Props<T, TPlugins>,
) {
// `plugins` is split out of `rest` so it isn't applied to the three instance;
// its contributed methods are resolved once (gated) inside useProps.
const [config, rest] = splitProps(props as any, ["from", "args", "plugins"])
const instance = createMemo(() => {
const from = config.from
if (!from) return undefined
// track key changes to force reconstruction
props.key
return meta(
isConstructor(from) ? autodispose(new from(...(config.args ?? []))) : from,
{ props },
) as Meta<T>
if (!from)
return undefined
// track key changes to force reconstruction
;(props as any).key
return meta(isConstructor(from) ? autodispose(new from(...(config.args ?? []))) : from, {
props,
}) as Meta<T>
})
useProps(instance, rest)
useProps(instance, rest, undefined, config.plugins ? [...config.plugins] : [])
return instance as unknown as JSX.Element
}

Expand All @@ -116,7 +118,7 @@ type ResourceProps<TLoader extends Loader<object, any>> = UseLoaderOptions<
TLoader,
LoaderUrl<TLoader>
> &
Omit<Props<LoaderData<TLoader>>, "children"> & {
Omit<BaseProps<LoaderData<TLoader>>, "children"> & {
loader: Constructor<TLoader>
url: LoaderUrl<TLoader>
children?: (result: Accessor<LoadOutput<TLoader, LoaderUrl<TLoader>>>) => JSXElement
Expand Down
21 changes: 14 additions & 7 deletions src/create-t.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createMemo, type Component, type JSX } from "solid-js"
import { useProps } from "./props.ts"
import type { Props } from "./types.ts"
import type { BaseProps, Plugin, Props } from "./types.ts"
import { autodispose, meta } from "./utils.ts"

/**********************************************************************************/
Expand All @@ -9,10 +9,14 @@ import { autodispose, meta } from "./utils.ts"
/* */
/**********************************************************************************/

export function createT<TCatalogue extends Record<string, unknown>>(catalogue: TCatalogue) {
export function createT<
const TCatalogue extends Record<string, unknown>,
const TPlugins extends readonly Plugin[] = readonly Plugin[],
>(catalogue: TCatalogue, plugins?: TPlugins) {
const pluginList: Plugin[] = plugins ? [...plugins] : []
const cache = new Map<string, Component<any>>()
return new Proxy<{
[K in keyof TCatalogue]: Component<Props<TCatalogue[K]>>
[K in keyof TCatalogue]: Component<Props<TCatalogue[K], TPlugins>>
}>({} as any, {
get: (_, name: string) => {
/* Create and memoize a wrapper component for the specified property. */
Expand All @@ -24,7 +28,7 @@ export function createT<TCatalogue extends Record<string, unknown>>(catalogue: T
if (!constructor) return undefined

/* Otherwise, create and memoize a component for that constructor. */
cache.set(name, createEntity(constructor))
cache.set(name, createEntity(constructor, pluginList))
}

return cache.get(name)
Expand All @@ -41,8 +45,9 @@ export function createT<TCatalogue extends Record<string, unknown>>(catalogue: T
*/
export function createEntity<TConstructor>(
Constructor: TConstructor,
): Component<Props<TConstructor>> {
return (props: Props<TConstructor>) => {
plugins: Plugin[] = [],
): Component<BaseProps<TConstructor>> {
return (props: BaseProps<TConstructor>) => {
const memo = createMemo(() => {
// listen to key changes
props.key
Expand All @@ -53,7 +58,9 @@ export function createEntity<TConstructor>(
throw new Error("")
}
})
useProps(memo, props)
// Plugin methods are resolved once per element inside useProps, gated by
// plugins.length — a no-plugin namespace never touches the plugin path.
useProps(memo, props, undefined, plugins)
return memo as unknown as JSX.Element
}
}
10 changes: 10 additions & 0 deletions src/create-three.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createRenderEffect,
createResource,
createRoot,
getOwner,
untrack,
mergeProps,
onCleanup,
Expand Down Expand Up @@ -351,10 +352,19 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) {
const clock = new Clock()
clock.start()

// Per-context dedup set for `initializePlugin` (private — exposed only via the method).
const initializedPlugins = new Set<unknown>()

const context: Context = {
get bounds() {
return measure.bounds()
},
owner: getOwner(),
initializePlugin(token: unknown, fn: () => void) {
if (initializedPlugins.has(token)) return
initializedPlugins.add(token)
fn()
},
canvas,
clock,
eventRegistry: [],
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ export { createEntity, createT } from "./create-t.tsx"
export { createXR, useXR } from "./create-xr.tsx"
export type { XRContext, XRState } from "./create-xr.tsx"
export { useFrame, useLoader, useThree } from "./hooks.ts"
export { plugin } from "./plugin.ts"
export { Pointer, createThreeEvent, type PointerRaycaster } from "./pointers.ts"
export { useProps } from "./props.ts"
export * from "./raycasters.tsx"
export * as S3 from "./types.ts"
// Direct re-exports of types that users commonly need at the top level.
// `Register` is augmentable from `declare module "solid-three"` (see its
// JSDoc); `Renderer` and `ResolvedRenderer` show up in advanced typings.
export type { Register, Renderer, ResolvedRenderer } from "./types.ts"
export type { Plugin, Register, Renderer, ResolvedRenderer } from "./types.ts"
export { autodispose, getMeta, hasMeta, load, meta } from "./utils.ts"
51 changes: 51 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Constructor, Plugin, PluginFn } from "./types.ts"

/**
* Create a plugin. Three forms:
* - `plugin(el => methods)` — applies to every element.
* - `plugin([Mesh, Camera], el => methods)` — only elements `instanceof` one of the constructors.
* - `plugin(guard, el => methods)` — only elements passing the type-guard.
*
* Returns `(element) => methods | undefined`; a non-matching element yields `undefined`.
* A contributed method's first-param type becomes the element prop type.
*/
export const plugin: PluginFn = (selectorOrMethods: any, methods?: any): Plugin<any> => {
if (methods === undefined) {
return (element: any) => selectorOrMethods(element)
}
return (element: any) => {
if (Array.isArray(selectorOrMethods)) {
for (const Ctor of selectorOrMethods as Constructor[]) {
if (element instanceof Ctor) return methods(element)
}
return undefined
}
if (typeof selectorOrMethods === "function" && selectorOrMethods(element)) {
return methods(element)
}
return undefined
}
}

/**
* Run each plugin's factory against `element` and merge the returned method
* objects into one plain object (no proxy — optimize for access). Non-matching
* plugins return `undefined` and are skipped. Called once per plugged element at
* creation (gated by `plugins.length`), never on the per-attach path.
*/
export function resolvePluginMethods(
element: object,
plugins: Plugin[],
): Record<string, (value: any) => void> {
const merged: Record<string, any> = {}
for (const plugin of plugins) {
const result = (plugin as (el: object) => Record<string, any> | undefined)(element)
if (!result) continue
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
}
15 changes: 10 additions & 5 deletions src/pointers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,22 @@ export class Pointer {
}

down(nativeEvent: Event) {
this.dispatchBubbled("onPointerDown", nativeEvent)
this.dispatch("onPointerDown", nativeEvent)
}
up(nativeEvent: Event) {
this.dispatchBubbled("onPointerUp", nativeEvent)
this.dispatch("onPointerUp", nativeEvent)
}
wheel(nativeEvent: Event) {
this.dispatchBubbled("onWheel", nativeEvent)
this.dispatch("onWheel", nativeEvent)
}

/** Shared body for the down/up/wheel "default" gestures (bubble + canvas-level). */
private dispatchBubbled(handler: "onPointerDown" | "onPointerUp" | "onWheel", nativeEvent: Event) {
/**
* Bubble a "default"-style gesture to an arbitrary handler name (plugin-extensible:
* the built-in sources fire `onPointerDown`/`onPointerUp`/`onWheel`; a plugin source
* can fire its own names, e.g. `onXRSelect`). Bubbles up the hit chain honoring
* `stopPropagation`, then fires canvas-level if unstopped.
*/
dispatch(handler: string, nativeEvent: Event) {
const intersections = this.raycaster.cast(this.context.eventRegistry, this.context)
const event: any = createThreeEvent(nativeEvent, { intersections })
for (const intersection of intersections) {
Expand Down
31 changes: 27 additions & 4 deletions src/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ 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 { resolvePluginMethods } from "./plugin.ts"
import type { AccessorMaybe, Context, Meta, Plugin } from "./types.ts"
import {
getMeta,
hasColorSpace,
Expand Down Expand Up @@ -214,7 +215,14 @@ function applyProp<T extends Record<string, any>>(
source: T,
type: string,
value: any,
pluginMethods: Record<string, (value: any) => void>,
) {
// A plugin-contributed prop: invoke its method instead of assigning to the instance.
if (type in pluginMethods) {
pluginMethods[type](value)
return
}

if (!source) {
console.error("error while applying prop", source, type, value)
return
Expand All @@ -227,7 +235,7 @@ function applyProp<T extends Record<string, any>>(
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
}

Expand Down Expand Up @@ -347,10 +355,13 @@ function applyProp<T extends Record<string, any>>(
* @param props - An object containing the props to apply. This includes both direct properties
* and special properties like `ref` and `children`.
*/
const EMPTY_METHODS: Record<string, (value: any) => void> = {}

export function useProps<T extends Record<string, any>>(
accessor: T | undefined | Accessor<T | undefined>,
props: any,
context: Pick<Context, "requestRender" | "gl" | "props"> = useThree(),
plugins: Plugin[] = [],
) {
const [local, instanceProps] = splitProps(props, ["ref", "args", "object", "attach", "children"])

Expand All @@ -361,6 +372,18 @@ export function useProps<T extends Record<string, any>>(

if (!object) return

// Gated: a no-plugin element does one length check and resolves nothing —
// keeps plugin resolution off the per-element hot path (see plugin-system spec).
let pluginMethods = EMPTY_METHODS
if (plugins.length) {
pluginMethods = resolvePluginMethods(object, plugins)
// Give plugin code the mount-site context via getMeta(element).ctx. Set at
// creation (here), not attach: contributed methods run during applyProp, before
// a top-level element attaches to the scene. Gated, so no-plugin elements pay nothing.
const childMeta = getMeta(object)
if (childMeta) childMeta.ctx = context as Context
}

// Assign ref
createRenderEffect(() => {
if (local.ref instanceof Function) local.ref(object)
Expand All @@ -375,12 +398,12 @@ export function useProps<T extends Record<string, any>>(
// p.ex in <T.Mesh position={} position-x={}/> position's subKeys will be ['position-x']
const subKeys = keys.filter(_key => key !== _key && _key.includes(key))
createRenderEffect(() => {
applyProp(context, object, key, props[key])
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) {
applyProp(context, object, subKey, props[subKey])
applyProp(context, object, subKey, props[subKey], pluginMethods)
}
})
}
Expand Down
Loading
Loading