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
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.
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/App.tsx b/playground/App.tsx
index f4b1600..13d39a4 100644
--- a/playground/App.tsx
+++ b/playground/App.tsx
@@ -1,13 +1,15 @@
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 { VanillaExample } from "./examples/VanillaExample.tsx"
import "./index.css"
-const T = createT(THREE)
+const { T, Canvas } = createT({ ...THREE, Entity })
function Layout(props: ParentProps) {
return (
@@ -67,6 +69,28 @@ function Layout(props: ParentProps) {
>
Environment
+
+ Plugins
+
+
+ Vanilla
+
{props.children}
>
@@ -79,6 +103,8 @@ export function App() {
+
+
(
@@ -87,7 +113,7 @@ export function App() {
scene={{ background: [1, 0, 0] }}
style={{ width: "100vw", height: "100vh" }}
>
-
+
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 86305b1..40c5225 100644
--- a/playground/examples/EnvironmentExample.tsx
+++ b/playground/examples/EnvironmentExample.tsx
@@ -1,11 +1,15 @@
+import { createSignal } from "solid-js"
import * as THREE from "three"
-import { Resource } from "../../src/components.tsx"
-import { Canvas, createT } from "../../src/index.ts"
+import { createT, EventPlugin, Resource } from "../../src/index.ts"
import { OrbitControls } from "../controls/OrbitControls.tsx"
-const T = createT(THREE)
+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/Gltf.tsx b/playground/examples/Gltf.tsx
deleted file mode 100644
index e69de29..0000000
diff --git a/playground/examples/PluginExample.tsx b/playground/examples/PluginExample.tsx
new file mode 100644
index 0000000..e5e96ef
--- /dev/null
+++ b/playground/examples/PluginExample.tsx
@@ -0,0 +1,121 @@
+import * as THREE from "three"
+import type { Meta } from "types.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
+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 => ({
+ 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 using type guard
+const MaterialPlugin = plugin(
+ (element: any): 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)
+ },
+ setColor: (color: string) => {
+ const material = element.material as THREE.MeshBasicMaterial
+ material.color.setHex(parseInt(color.replace("#", ""), 16))
+ },
+ }),
+)
+
+// Global plugin - applies to all elements using single argument
+const GlobalPlugin: {
+ (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}`)
+ },
+}))
+
+const { T, Canvas } = createT(THREE, [
+ LookAtPlugin,
+ ShakePlugin,
+ EventPlugin,
+ MaterialPlugin,
+ GlobalPlugin,
+])
+
+export function PluginExample() {
+ let cubeRef: Meta
+ let cameraRef: Meta
+
+ return (
+ console.info("missed!")}
+ >
+
+
+ {/* Mesh with lookAt (from LookAtPlugin) and material methods (from MaterialPlugin) */}
+ console.info("clicked mesh!")}
+ >
+
+
+
+
+
+
+ {/* Camera with shake (from ShakePlugin) */}
+
+
+
+
+
+ )
+}
diff --git a/playground/examples/PortalExample.tsx b/playground/examples/PortalExample.tsx
index 9dcda05..8b98d93 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, EventPlugin, Portal } from "../../src/index.ts"
-const T = createT(THREE)
+const { T, Canvas } = createT(THREE, [EventPlugin])
export function PortalExample() {
const group = new THREE.Group()
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/playground/examples/SolarExample.tsx b/playground/examples/SolarExample.tsx
index 75ecd3a..5f15882 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,11 @@ 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)}
+ contexts={[EventPlugin]}
>
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!
+ })()}
+
+
+ )
+}
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/canvas.tsx b/src/canvas.tsx
deleted file mode 100644
index 3cf426c..0000000
--- a/src/canvas.tsx
+++ /dev/null
@@ -1,102 +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 { CanvasEventHandlers, Context, 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 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 (
-
-
-
- )
-}
diff --git a/src/components.tsx b/src/components.tsx
index 15b5472..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, Loader, Meta, Overwrite, 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
}>
/**
@@ -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,17 +83,35 @@ 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) {
- const [config, rest] = splitProps(props, ["from", "args"])
+export function Entity<
+ const T extends object | Constructor = object,
+ const TPlugins extends Plugin[] = Plugin[],
+>(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() {
+ return props.plugins
+ },
},
) as Meta
useProps(instance, rest)
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 c8757e6..ee52298 100644
--- a/src/create-t.tsx
+++ b/src/create-t.tsx
@@ -1,6 +1,19 @@
-import { createMemo, type Component, type JSX } from "solid-js"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
+import type {} from "node:vm"
+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 { createThree } from "./create-three.tsx"
import { useProps } from "./props.ts"
-import type { Props } from "./types.ts"
+import type { CanvasProps, Plugin, PluginPropsOf, Props } from "./types.ts"
import { meta } from "./utils.ts"
/**********************************************************************************/
@@ -9,27 +22,88 @@ 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]
-
- /* 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))
- }
+ return {
+ Canvas(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
+ }
- return cache.get(name)
+ 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)
+ },
+ },
+ ),
+ }
}
/**
@@ -39,21 +113,31 @@ 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
+ // @ts-expect-error TODO: fix type error
props.key
try {
- return meta(new (Constructor as any)(...(props.args ?? [])), { props })
+ 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("")
}
})
- 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..0e7bd0a 100644
--- a/src/create-three.tsx
+++ b/src/create-three.tsx
@@ -1,11 +1,11 @@
import {
- children,
- createEffect,
createMemo,
createRenderEffect,
createRoot,
+ createSelector,
mergeProps,
onCleanup,
+ type Context as SolidContext,
} from "solid-js"
import {
ACESFilmicToneMapping,
@@ -23,32 +23,32 @@ import {
VSMShadowMap,
WebGLRenderer,
} from "three"
-import type { CanvasProps } from "./canvas.tsx"
-import { createEvents } from "./create-events.ts"
-import { Stack } from "./data-structure/stack.ts"
+import { LinearEncoding, sRGBEncoding } from "./constants.ts"
import { frameContext, threeContext } from "./hooks.ts"
-import { eventContext } 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 { pluginContext } from "./internal-context.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"
import {
binarySearch,
- defaultProps,
getCurrentViewport,
meta,
removeElementFromArray,
useRef,
withMultiContexts,
} from "./utils.ts"
+import { whenRenderEffect } from "./utils/conditionals.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) {
- const canvasProps = defaultProps(props, { frameloop: "always" })
+export function createThree(canvas: HTMLCanvasElement, props: CanvasProps, plugins: Plugin[] = []) {
+ const config = mergeProps({ frameloop: "always" }, props)
/**********************************************************************************/
/* */
@@ -115,7 +115,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) {
// 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
@@ -147,7 +147,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) {
if (!context.gl) {
return
}
- if (props.frameloop === "never") {
+ if (config.frameloop === "never") {
context.clock.elapsedTime = timestamp
}
pendingRenderRequest = undefined
@@ -169,59 +169,60 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) {
/* */
/**********************************************************************************/
+ 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 gl = createMemo(() => {
- const gl =
- props.gl instanceof WebGLRenderer
- ? // props.gl can be a WebGLRenderer provided by the user
- props.gl
- : typeof props.gl === "function"
+ return meta(
+ config.gl instanceof WebGLRenderer
+ ? // _glProp can be a WebGLRenderer provided by the user
+ config.gl
+ : typeof config.gl === "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 || {}
+ config.gl(canvas)
+ : // if _glProp is not defined we default to a WebGLRenderer
+ new WebGLRenderer({ canvas }),
+ {
+ get props() {
+ return config.gl || {}
+ },
},
- })
+ )
})
const measure = useMeasure()
@@ -280,99 +281,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) {
],
)
- /**********************************************************************************/
- /* */
- /* Effects */
- /* */
- /**********************************************************************************/
-
- withMultiContexts(() => {
- 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 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 gl
- createRenderEffect(() => {
- // 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)
- }
-
- 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()
- })
-
- // 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
- if (props.gl && !(props.gl instanceof WebGLRenderer)) {
- useProps(gl, props.gl)
- }
- })
- }, [[threeContext, context]])
-
/**********************************************************************************/
/* */
/* Render Loop */
@@ -385,7 +293,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) {
context.render(value)
}
createRenderEffect(() => {
- if (canvasProps.frameloop === "always") {
+ if (config.frameloop === "always") {
pendingLoopRequest = requestAnimationFrame(loop)
}
onCleanup(() => pendingLoopRequest && cancelAnimationFrame(pendingLoopRequest))
@@ -393,35 +301,138 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) {
/**********************************************************************************/
/* */
- /* Events */
+ /* Effects */
/* */
/**********************************************************************************/
- // Initialize event-system
- const { addEventListener } = createEvents(context)
+ createRenderEffect(() => {
+ withMultiContexts(() => {
+ const pluginMethods = createMemo(() => mergePluginMethods(scene(), plugins))
+ const hasPluginMethod = createSelector(
+ pluginMethods,
+ (key: keyof CanvasProps, methods) => key in methods,
+ )
- /**********************************************************************************/
- /* */
- /* Scene Graph */
- /* */
- /**********************************************************************************/
+ // Handle scene graph
+ useSceneGraph(context.scene, props)
- const c = children(() => (
-
-
- {canvasProps.children}
-
-
- ))
-
- useSceneGraph(
- context.scene,
- mergeProps(props, {
- get children() {
- return c()
- },
- }),
- )
+ // Manage clock
+ createRenderEffect(() => {
+ if (config.frameloop === "never") {
+ context.clock.stop()
+ context.clock.elapsedTime = 0
+ } else {
+ context.clock.start()
+ }
+ })
+
+ // Manage props resolved to plugins
+ whenRenderEffect(pluginMethods, pluginMethods => {
+ for (const key in config) {
+ if (key in pluginMethods) {
+ pluginMethods[key]?.(config[key as keyof typeof config])
+ }
+ }
+ })
+
+ // Manage camera
+ whenRenderEffect(
+ () =>
+ !hasPluginMethod("defaultCamera") &&
+ !(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
+ whenRenderEffect(
+ () => !hasPluginMethod("scene") && !(config.scene instanceof Scene) && config.scene,
+ propsScene => useProps(scene, propsScene),
+ )
+
+ // Manage raycaster
+ whenRenderEffect(
+ () =>
+ !hasPluginMethod("defaultRaycaster") &&
+ !(config.defaultRaycaster instanceof Raycaster) &&
+ config.defaultRaycaster,
+ raycaster => useProps(defaultRaycaster, raycaster),
+ )
+
+ // Manage gl
+ whenRenderEffect(gl, gl => {
+ // 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,
+ }
+ shadowMap.type = types[config.shadows] ?? PCFSoftShadowMap
+ } else if (typeof config.shadows === "object") {
+ Object.assign(shadowMap, config.shadows)
+ }
+
+ if (oldEnabled !== shadowMap.enabled || oldType !== shadowMap.type) {
+ shadowMap.needsUpdate = true
+ }
+ },
+ )
+
+ // Manage connecting XR
+ whenRenderEffect(
+ () => gl.xr,
+ () => context.xr.connect(),
+ )
+
+ // Manage Props
+ whenRenderEffect(
+ () => !hasPluginMethod("gl") && !(config.gl instanceof WebGLRenderer) && config.gl,
+ prop => useProps(gl, prop),
+ )
+
+ // Set color space and tonemapping preferences
+ useProps(gl, {
+ get outputEncoding() {
+ return hasPluginMethod("linear")
+ ? undefined
+ : config.linear
+ ? LinearEncoding
+ : sRGBEncoding
+ },
+ get toneMapping() {
+ return hasPluginMethod("flat")
+ ? undefined
+ : config.flat
+ ? NoToneMapping
+ : ACESFilmicToneMapping
+ },
+ })
+ })
+ }, [
+ ...(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/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/create-events.ts b/src/event-plugin.tsx
similarity index 57%
rename from src/create-events.ts
rename to src/event-plugin.tsx
index 67eb1fa..1865aeb 100644
--- a/src/create-events.ts
+++ b/src/event-plugin.tsx
@@ -1,8 +1,11 @@
-import { Object3D, type Intersection } from "three"
-import type { Context, EventName, Meta, Prettify, ThreeEvent } from "./types.ts"
+import { createContext, onCleanup, useContext, type ParentProps } from "solid-js"
+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"
import { getMeta } from "./utils.ts"
-const eventNameMap = {
+const EVENT_NAME_MAP = {
onClick: "click",
onContextMenu: "contextmenu",
onDoubleClick: "dblclick",
@@ -17,49 +20,90 @@ const eventNameMap = {
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,
- )
- }
- },
- }
-}
-
/**********************************************************************************/
/* */
-/* Is Event Type */
+/* Event */
/* */
/**********************************************************************************/
-/**
- * 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)
+export type Event<
+ TEvent,
+ TConfig extends { stoppable?: boolean; intersections?: boolean } = {
+ stoppable: true
+ intersections: true
+ },
+> = Prettify<
+ 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 EventListeners = {
+ [TKey in keyof EventHandlersMap]: (cb: (event: EventHandlersMap[TKey]) => void) => void
+}
+
+export type CanvasEventHandlers = {
+ [TKey in keyof EventHandlersMap]: (
+ event: Prettify>,
+ ) => void
+}
+
+export type EventName = keyof EventHandlersMap
/**********************************************************************************/
/* */
-/* Events */
+/* Create Event */
/* */
/**********************************************************************************/
+
//
/** 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 = {}) {
+>(nativeEvent: TEvent, config?: TConfig) {
+ const { stoppable = true, intersections } = config ?? {}
const event: Record = stoppable
? {
nativeEvent,
@@ -77,7 +121,7 @@ function createThreeEvent<
return event as Prettify<
Omit<
- ThreeEvent<
+ Event<
TEvent,
{
stoppable: TConfig["stoppable"] extends false
@@ -131,6 +175,34 @@ function raycast(
return context.currentRaycaster.intersectObjects(nodeSet.values().toArray(), false)
}
+/**********************************************************************************/
+/* */
+/* Auto Registry */
+/* */
+/**********************************************************************************/
+
+type AutoRegistry = {
+ array: T[]
+ add(instance: T): void
+}
+
+function createAutoRegistry(): AutoRegistry {
+ const array: T[] = []
+
+ return {
+ array,
+ add(instance: T) {
+ array.push(instance)
+ onCleanup(() => {
+ array.splice(
+ array.findIndex(_instance => _instance === instance),
+ 1,
+ )
+ })
+ },
+ }
+}
+
/**********************************************************************************/
/* */
/* Create Missable Event Registry */
@@ -147,9 +219,9 @@ function createMissableEventRegistry(
type: "onClick" | "onDoubleClick" | "onContextMenu",
context: Context,
) {
- const registry = createRegistry()
+ const registry = createAutoRegistry()
- 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
@@ -160,7 +232,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
@@ -172,10 +244,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
}
}
@@ -186,6 +255,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)
}
@@ -208,13 +279,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)
}
})
@@ -240,16 +312,16 @@ 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
- 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
- const enterEvent = createThreeEvent(nativeEvent, { stoppable: false, intersections })
+ const enterEvent = createEvent(nativeEvent, { stoppable: false, intersections })
const enterSet = new Set()
for (const intersection of intersections) {
@@ -262,10 +334,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.
@@ -274,15 +343,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) {
@@ -297,10 +364,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
@@ -315,36 +379,28 @@ 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
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 => {
- const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false })
+ context.canvas.addEventListener(EVENT_NAME_MAP[`on${type}Leave`], nativeEvent => {
+ 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()
})
@@ -371,13 +427,15 @@ function createDefaultEventRegistry(
context: Context,
options?: AddEventListenerOptions,
) {
- const registry = createRegistry()
+ const registry = createAutoRegistry()
context.canvas.addEventListener(
- eventNameMap[type],
+ EVENT_NAME_MAP[type],
nativeEvent => {
const intersections = raycast(context, registry.array, nativeEvent)
- const event = createThreeEvent(nativeEvent, { intersections })
+ const event = createEvent(nativeEvent, { intersections })
+
+ const visitedNodes = new Set()
for (const intersection of intersections) {
// Update currentIntersection
@@ -387,11 +445,9 @@ function createDefaultEventRegistry(
// Bubble up
let node: Object3D | null = intersection.object
- while (node && !event.stopped) {
- getMeta(intersection.object)?.props[type]?.(
- // @ts-expect-error TODO: fix type-error
- event,
- )
+ while (node && !event.stopped && !visitedNodes.has(node)) {
+ getMeta(node)?.props[type]?.(event)
+ visitedNodes.add(node)
node = node.parent
}
}
@@ -417,73 +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 function createEvents(context: Context) {
- // 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 = Object.assign(
+ plugin([Scene, Mesh], (object): EventListeners => {
+ const context = useContext(EventContext)
- 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)
- }
+ if (!context) {
+ throw "Solid Three entities with EventPlugin should be declared inside "
+ }
+
+ return {
+ onClick() {
+ context.missableClicks.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 c2f292f..0108ddc 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,9 +1,10 @@
-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.tsx"
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 { autodispose, getMeta, hasMeta as hasMeta, load, meta } from "./utils.ts"
+export { autodispose, getMeta, hasMeta, load, meta } from "./utils.ts"
diff --git a/src/internal-context.ts b/src/internal-context.ts
index 340bf5a..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 } 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 } 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
@@ -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/plugin.ts b/src/plugin.ts
new file mode 100644
index 0000000..b7b0104
--- /dev/null
+++ b/src/plugin.ts
@@ -0,0 +1,31 @@
+import type { Plugin, PluginFn } from "./types.ts"
+
+// 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 (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 selectorOrMethods === "function") {
+ if (selectorOrMethods(element)) {
+ return methods(element)
+ }
+ }
+
+ return undefined
+ }
+}
diff --git a/src/props.ts b/src/props.ts
index a2ba4d7..a990c21 100644
--- a/src/props.ts
+++ b/src/props.ts
@@ -1,12 +1,11 @@
import {
type Accessor,
children,
- createComputed,
+ createMemo,
createRenderEffect,
type JSXElement,
mapArray,
onCleanup,
- splitProps,
untrack,
} from "solid-js"
import {
@@ -19,14 +18,31 @@ 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 } from "./types.ts"
-import { getMeta, hasColorSpace, hasMeta, resolve } from "./utils.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>()
function isWritable(object: object, propertyName: string) {
- return Object.getOwnPropertyDescriptor(object, propertyName)?.writable
+ 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) {
+ return cache.writable
+ }
+
+ const result = Object.getOwnPropertyDescriptor(object, propertyName)
+ cacheMap.set(propertyName, result)
+
+ return result?.writable
}
function applySceneGraph(parent: object, child: object) {
@@ -48,7 +64,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
}
@@ -119,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)
@@ -168,7 +184,13 @@ function applyProp>(
source: T,
type: string,
value: any,
+ pluginMethods: Record void>,
) {
+ if (type in pluginMethods) {
+ pluginMethods[type](value)
+ return
+ }
+
if (!source) {
console.error("error while applying prop", source, type, value)
return
@@ -181,7 +203,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
}
@@ -206,21 +228,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 {
@@ -302,19 +309,27 @@ function applyProp>(
* and special properties like `ref` and `children`.
*/
export function useProps>(
- accessor: T | undefined | Accessor,
- props: any,
- context: Pick = useThree(),
+ accessor: T | Accessor,
+ props: Record,
+ plugins: Plugin[] = [],
) {
- const [local, instanceProps] = splitProps(props, ["ref", "args", "object", "attach", "children"])
+ const [local, instanceProps] = processProps(props, { plugins: [] }, [
+ "ref",
+ "args",
+ "object",
+ "attach",
+ "children",
+ "plugins",
+ ])
+
+ const pluginMethods = createMemo(() =>
+ mergePluginMethods(resolve(accessor), [...plugins, ...local.plugins]),
+ )
+ 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)
@@ -328,13 +343,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(() => {
- 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())
}
})
}
@@ -344,3 +361,22 @@ export function useProps>(
})
})
}
+
+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
+}
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 6c89582..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 "../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 731e150..0078c92 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,8 +1,8 @@
-import type { Accessor, JSX, Ref } from "solid-js"
+import type { Accessor, Component, JSX, ParentProps, Ref } from "solid-js"
import type {
+ Camera,
Clock,
ColorRepresentation,
- Intersection,
OrthographicCamera,
PerspectiveCamera,
Raycaster,
@@ -18,11 +18,7 @@ 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 { EventRaycaster } from "./raycasters.tsx"
-import type { Measure } from "./utils/use-measure.ts"
/**********************************************************************************/
/* */
@@ -36,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 []
@@ -50,71 +46,111 @@ export type Prettify = {
[K in keyof T]: T[K]
} & {}
+export type Intersect = T extends [infer U, ...infer Rest]
+ ? Rest["length"] extends 0
+ ? U
+ : U & Intersect
+ : T
+
+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 */
+/* */
+/**********************************************************************************/
+
+export type Meta = T & {
+ [$S3C]: Data
+}
+
+/** Metadata of a `solid-three` instance. */
+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 */
+/* */
+/**********************************************************************************/
+
/**
- * 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]
+ * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene.
*/
-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 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. */
+ 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
}
- ? 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 */
@@ -153,7 +189,7 @@ export interface Viewport {
aspect: number
}
-/** Possible camera types. */
+/** Possible camera kinds. */
export type CameraKind = PerspectiveCamera | OrthographicCamera
export type Loader = {
@@ -175,91 +211,28 @@ export type FrameListener = (
/**********************************************************************************/
/* */
-/* Event */
+/* Representations */
/* */
/**********************************************************************************/
-export type When = T extends false ? (T extends true ? U : unknown) : U
-
-export type ThreeEvent<
- 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
+/** Maps properties of given type to their `solid-three` representations. */
+export type MapToRepresentation = {
+ [TKey in keyof T]: Representation
}
-/** The names of all `EventHandlers` */
-export type EventName = keyof EventHandlersMap
-
-/**********************************************************************************/
-/* */
-/* Solid Three 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
@@ -275,45 +248,242 @@ export type Matrix4 = Representation
/**********************************************************************************/
/* */
-/* Three To JSX */
+/* Props */
/* */
/**********************************************************************************/
-export type Meta = T & {
- [$S3C]: Data
-}
-
-/** Metadata of a `solid-three` instance. */
-export type Data = {
- props: Props>
- parent: any
- children: Set >
-}
-
-/** Maps properties of given type to their `solid-three` representations. */
-export type MapToRepresentation = {
- [TKey in keyof T]: Representation
-}
+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
+}>
/** Generic `solid-three` props of a given class. */
-export type Props = Partial<
+export type Props = Partial<
Overwrite<
[
- MapToRepresentation>,
- EventHandlers,
+ 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
},
+ 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]
+ */
+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
+}
+ ? 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 type Plugin any> = 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 {
+ /**
+ * 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
+ >
+
+ /**
+ * 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
+ }>
+
+ /**
+ * 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
+ }>
+}
+
+type PluginReturn = TPlugin extends Plugin
+ ? TFn extends { (element: infer TElement): infer TReturnType }
+ ? TKind extends TElement
+ ? TReturnType
+ : {}
+ : {}
+ : {}
+
+/**
+ * Resolves plugin props for a specific element type TKind
+ * 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 extends Record<
+ string,
+ any
+ >
+ ? {
+ [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never
+ }
+ : {}
+}>
diff --git a/src/utils.ts b/src/utils.ts
index 23a6d56..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 } from "./types.ts"
-import type { Measure } from "./utils/use-measure.ts"
+import type {
+ CameraKind,
+ Constructor,
+ Data,
+ KeyOfOptionals,
+ Loader,
+ Measure,
+ Meta,
+ Plugin,
+} from "./types.ts"
/**********************************************************************************/
/* */
@@ -39,29 +54,45 @@ export function autodispose void }>(object: T): T {
/**********************************************************************************/
/* */
-/* Augment */
+/* Meta */
/* */
/**********************************************************************************/
+interface MetaOptions {
+ props?: Record
+ 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
}
-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
}
@@ -92,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