Skip to content
6 changes: 6 additions & 0 deletions .github/workflows/pkg-pr-new.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ jobs:
- name: Install dependencies
run: pnpm install

- name: Typecheck
run: pnpm lint:types

- name: Test
run: pnpm test --run

- name: Build
run: pnpm build

Expand Down
2 changes: 1 addition & 1 deletion playground/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/// <reference types="vite/client" />
import { A, Route, Router } from "@solidjs/router"
import { createSignal, For, lazy, type ParentProps } from "solid-js"
import * as THREE from "three"
Expand Down Expand Up @@ -171,6 +172,5 @@ export function App() {
/>
</Router>
)
console.log(router.toArray())
return router
}
10 changes: 5 additions & 5 deletions playground/controls/process-props.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { splitProps } from "solid-js"
import { defaultProps } from "./default-props.ts"
import { mergeProps, type MergeProps, splitProps } from "solid-js"
import type { KeyOfOptionals } from "./type-utils.ts"

export function processProps<
const TProps,
const TDefaults extends Required<Pick<TProps, KeyOfOptionals<TProps>>>,
const TSplit extends readonly (keyof TProps)[],
const TDefaults extends Partial<Pick<TProps, KeyOfOptionals<TProps>>>,
const TSplit extends readonly (keyof MergeProps<[TDefaults, TProps]>)[],
>(props: TProps, defaults: TDefaults, split?: TSplit) {
return splitProps(defaultProps(props, defaults), split ?? [])
const merged = mergeProps(defaults, props)
return splitProps(merged, (split ?? []) as readonly (keyof typeof merged)[])
}
11 changes: 6 additions & 5 deletions playground/src/api/canvas/usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,20 @@ export default function () {
fov: 75,
}}
fallback={<div style={{ color: "white", padding: "20px" }}>Loading Canvas...</div>}
gl={{
antialias: true,
alpha: true,
powerPreference: "high-performance",
}}
gl={canvas =>
new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "high-performance" })
}
scene={{
background: new THREE.Color(0x202020),
fog: new THREE.Fog(0x202020, 10, 50),
}}
defaultRaycaster={{
params: {
Mesh: {},
Line: { threshold: 0.1 },
LOD: {},
Points: { threshold: 0.1 },
Sprite: {},
},
}}
shadows={shadows()}
Expand Down
10 changes: 1 addition & 9 deletions playground/src/api/use-loader/single-texture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,7 @@ function SkyboxSphere() {
"https://threejs.org/examples/textures/cube/SwedishRoyalCastle/ny.jpg", // negative y
"https://threejs.org/examples/textures/cube/SwedishRoyalCastle/pz.jpg", // positive z
"https://threejs.org/examples/textures/cube/SwedishRoyalCastle/nz.jpg", // negative z
],
// CubeTextureLoader properties
{
mapping: THREE.CubeReflectionMapping,
wrapS: THREE.ClampToEdgeWrapping,
wrapT: THREE.ClampToEdgeWrapping,
magFilter: THREE.LinearFilter,
minFilter: THREE.LinearMipmapLinearFilter,
},
] as string[],
)

return (
Expand Down
34 changes: 13 additions & 21 deletions src/components.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { whenMemo } from "@bigmistqke/solid-whenever"
import {
Show,
createEffect,
createMemo,
mergeProps,
splitProps,
Expand Down Expand Up @@ -94,22 +92,18 @@ type EntityProps<T extends object | Constructor<object>> = Overwrite<
*/
export function Entity<T extends object | Constructor<object>>(props: EntityProps<T>) {
const [config, rest] = splitProps(props, ["from", "args"])
const memo = whenMemo(
() => config.from,
from => {
// listen to key changes
props.key
const instance = meta(
isConstructor(from) ? autodispose(new from(...(config.args ?? []))) : from,
{
props,
},
) as Meta<T>
useProps(instance, rest)
return instance
},
)
return memo as unknown as JSX.Element
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>
})
useProps(instance, rest)
return instance as unknown as JSX.Element
}

/**********************************************************************************/
Expand Down Expand Up @@ -194,12 +188,10 @@ export function Resource<const TLoader extends Loader<object, any>>(props: Resou
options,
)

createEffect(() => console.log("resource", resource()))

useProps(resource, rest)

return (
<Show when={"children" in config && resource()} fallback={resource()}>
<Show when={"children" in config && resource()} fallback={resource() as JSX.Element}>
{resource => props.children?.(resource)}
</Show>
)
Expand Down
11 changes: 6 additions & 5 deletions src/create-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const isEventType = (type: string): type is EventName =>
function createThreeEvent<
TEvent extends Event,
TConfig extends { stoppable?: boolean; intersections?: Array<Intersection> },
>(nativeEvent: TEvent, { stoppable = true, intersections }: TConfig = {}) {
>(nativeEvent: TEvent, { stoppable = true, intersections }: TConfig = {} as TConfig) {
const event: Record<string, any> = stoppable
? {
nativeEvent,
Expand Down Expand Up @@ -128,7 +128,7 @@ function raycast<TNativeEvent extends MouseEvent | WheelEvent>(
stack.push(...object.children)
}

return context.raycaster.intersectObjects(nodeSet.values().toArray(), false)
return context.raycaster.intersectObjects(Array.from(nodeSet), false)
}

/**********************************************************************************/
Expand Down Expand Up @@ -323,10 +323,11 @@ function createHoverEventRegistry(type: "Mouse" | "Pointer", context: Context) {

// Handle leave-event
const leaveEvent = createThreeEvent(nativeEvent, { intersections, stoppable: false })
const leaveSet = hoveredSet.difference(enterSet)
const prevHoveredSet = hoveredSet
hoveredSet = enterSet

for (const object of leaveSet.values()) {
for (const object of prevHoveredSet) {
if (enterSet.has(object)) continue
getMeta(object)?.props[`on${type}Leave`]?.(
// @ts-expect-error TODO: fix type-error
leaveEvent,
Expand Down Expand Up @@ -388,7 +389,7 @@ function createDefaultEventRegistry(
let node: Object3D | null = intersection.object

while (node && !event.stopped) {
getMeta(intersection.object)?.props[type]?.(
getMeta(node)?.props[type]?.(
// @ts-expect-error TODO: fix type-error
event,
)
Expand Down
3 changes: 1 addition & 2 deletions src/data-structure/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ export class Stack<T = any> {
array.push(value)
return array
})
// @ts-expect-error TODO: fix type-error
if (import.meta.env?.MODE === "development") {
if (process.env.NODE_ENV === "development") {
const array = untrack(this.#array.bind(this))
if (array.length > 2) {
// TODO: write better warning message
Expand Down
10 changes: 5 additions & 5 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export function useLoader<
input: TInput,
): PromiseMaybe<LoadOutput<TLoader, TInput>> {
if (isRecord(input)) {
return awaitMapObject(input, value => getOrInsert(registry, loader, value)) as Promise<
return awaitMapObject(input, async value => getOrInsert(registry, loader, value)) as PromiseMaybe<
LoadOutput<TLoader, TInput>
>
} else {
Expand All @@ -225,19 +225,19 @@ export function useLoader<
const cachedPromise = registry.get(loader, _input, false)

if (cachedPromise) {
return cachedPromise
return cachedPromise as PromiseMaybe<LoadOutput<TLoader, TInput>>
}

const promise = load(loader, input)
registry.set(loader, input, promise)
const promise = load(loader, _input)
registry.set(loader, _input, promise)

return promise as Promise<LoadOutput<TLoader, TInput>>
}
}

function loadUrl<TInput extends LoadInput<TLoader>>(
url: TInput,
): Promise<LoadOutput<TLoader, TInput>> {
): PromiseMaybe<LoadOutput<TLoader, TInput>> {
if (config.cache === true) {
if (!useLoader.cache) {
return load(loader(), url)
Expand Down
59 changes: 47 additions & 12 deletions src/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,35 +32,30 @@ function isWritable(object: object, propertyName: string) {
function applySceneGraph(parent: object, child: object) {
const parentMeta = getMeta(parent)
if (parentMeta) {
// Update parent's augmented children-property.
parentMeta.children.add(child)
onCleanup(() => parentMeta.children.delete(child))
}

const childMeta = getMeta(child)
if (childMeta) {
// Update parent's augmented children-property.
childMeta.parent = parent
onCleanup(() => (childMeta.parent = undefined))
}

let attachProp = childMeta?.props.attach

// 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 Meta<object>)
onCleanup(cleanup)
return
}

// Defaults for Material, BufferGeometry and Fog.
if (!attachProp) {
if (child instanceof Material) attachProp = "material"
else if (child instanceof BufferGeometry) attachProp = "geometry"
else if (child instanceof Fog) attachProp = "fog"
}

// If an attachProp is defined, attach the child to the parent.
if (attachProp) {
let target = parent
let property: string | undefined
Expand All @@ -83,12 +78,8 @@ function applySceneGraph(parent: object, child: object) {
return
}

// If no attach-prop is defined, add the child to the parent.
if (child instanceof Object3D && parent instanceof Object3D && !parent.children.includes(child)) {
parent.add(child)
onCleanup(() => parent.remove(child))
return child
}
// Object3D children are managed by the ordering loop in useSceneGraph
if (child instanceof Object3D && parent instanceof Object3D) return

console.error(
"Error while connecting/attaching child: child does not have attach-props defined and is not an Object3D",
Expand Down Expand Up @@ -119,6 +110,8 @@ export const useSceneGraph = <T extends object>(
props: { children?: JSXElement | JSXElement[]; onUpdate?(event: T): void },
) => {
const c = children(() => props.children)

// Per-item: metadata, attach props, events
createComputed(
mapArray(
() => c.toArray() as unknown as (Meta<object> | undefined)[],
Expand All @@ -133,6 +126,48 @@ export const useSceneGraph = <T extends object>(
}),
),
)

// Object3D scene graph sync: add, remove, reorder
createComputed((previousManagedChildren: Set<Object3D>) => {
const parent = resolve(_parent)
if (!(parent instanceof Object3D)) {
return previousManagedChildren
}

const childArray = c.toArray() as unknown as Array<object | undefined>
const managedChildren = new Set<Object3D>()

for (const child of childArray) {
if (!(child instanceof Object3D) || getMeta(child)?.props.attach) continue
managedChildren.add(child)
if (child.parent !== parent) {
parent.add(child)
}
}

for (const child of previousManagedChildren) {
if (!managedChildren.has(child)) {
parent.remove(child)
}
}

// Reorder: walk parent.children, assign desired order at managed slots
let childArrayIndex = 0
for (let i = 0; i < parent.children.length; i++) {
if (!managedChildren.has(parent.children[i]!)) {
continue
}
while (childArrayIndex < childArray.length) {
const child = childArray[childArrayIndex++]
if (child instanceof Object3D && !getMeta(child)?.props.attach) {
parent.children[i] = child
break
}
}
}

return managedChildren
}, new Set<Object3D>())
}

/**********************************************************************************/
Expand Down
6 changes: 5 additions & 1 deletion src/testing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function test(
get children() {
return children()
},
camera: {
defaultCamera: {
position: [0, 0, 5] as [number, number, number],
},
},
Expand Down Expand Up @@ -112,6 +112,10 @@ const createTestCanvas = ({ width = 1280, height = 800 } = {}) => {
canvas.width = width
canvas.height = height

// jsdom's getBoundingClientRect always returns zeros, which breaks raycasting.
canvas.getBoundingClientRect = () =>
({ width, height, top: 0, left: 0, right: width, bottom: height, x: 0, y: 0 }) as DOMRect

// eslint-disable-next-line
if (globalThis.HTMLCanvasElement) {
const getContext = HTMLCanvasElement.prototype.getContext
Expand Down
Loading
Loading