schema and interactive store

This commit is contained in:
wass08
2026-03-06 13:06:33 +01:00
parent 3e40c1dde0
commit 927ecba054
6 changed files with 168 additions and 7 deletions
Binary file not shown.
+1
View File
@@ -41,6 +41,7 @@ export {
} from './lib/space-detection' } from './lib/space-detection'
// Schema // Schema
export * from './schema' export * from './schema'
export { useInteractive, type ControlValue, type ItemInteractiveState } from './store/use-interactive'
export { default as useScene } from './store/use-scene' export { default as useScene } from './store/use-scene'
// Systems // Systems
export { CeilingSystem } from './systems/ceiling/ceiling-system' export { CeilingSystem } from './systems/ceiling/ceiling-system'
+72
View File
@@ -2,6 +2,77 @@ import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
// --- Control descriptors ---
const toggleControlSchema = z.object({
kind: z.literal('toggle'),
label: z.string().optional(),
})
const sliderControlSchema = z.object({
kind: z.literal('slider'),
label: z.string(),
min: z.number(),
max: z.number(),
step: z.number().default(1),
unit: z.string().optional(),
displayMode: z.enum(['slider', 'stepper', 'dial']).default('slider'),
})
const temperatureControlSchema = z.object({
kind: z.literal('temperature'),
label: z.string().default('Temperature'),
min: z.number().default(16),
max: z.number().default(30),
unit: z.enum(['C', 'F']).default('C'),
})
const controlSchema = z.discriminatedUnion('kind', [
toggleControlSchema,
sliderControlSchema,
temperatureControlSchema,
])
// --- Effect descriptors ---
const animationEffectSchema = z.object({
kind: z.literal('animation'),
clips: z.object({
on: z.string().optional(),
off: z.string().optional(),
loop: z.string().optional(),
}),
})
const lightEffectSchema = z.object({
kind: z.literal('light'),
color: z.string().default('#ffffff'),
intensityRange: z.tuple([z.number(), z.number()]),
distance: z.number().optional(),
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
})
const effectSchema = z.discriminatedUnion('kind', [
animationEffectSchema,
lightEffectSchema,
])
// --- Interactive descriptor ---
const interactiveSchema = z.object({
controls: z.array(controlSchema).default([]),
effects: z.array(effectSchema).default([]),
})
export type ToggleControl = z.infer<typeof toggleControlSchema>
export type SliderControl = z.infer<typeof sliderControlSchema>
export type TemperatureControl = z.infer<typeof temperatureControlSchema>
export type Control = z.infer<typeof controlSchema>
export type AnimationEffect = z.infer<typeof animationEffectSchema>
export type LightEffect = z.infer<typeof lightEffectSchema>
export type Effect = z.infer<typeof effectSchema>
export type Interactive = z.infer<typeof interactiveSchema>
const assetSchema = z.object({ const assetSchema = z.object({
id: z.string(), id: z.string(),
category: z.string(), category: z.string(),
@@ -20,6 +91,7 @@ const assetSchema = z.object({
height: z.number(), // where things rest height: z.number(), // where things rest
}) })
.optional(), // undefined = can't place things on it .optional(), // undefined = can't place things on it
interactive: interactiveSchema.optional(),
}) })
export type AssetInput = z.input<typeof assetSchema> export type AssetInput = z.input<typeof assetSchema>
@@ -0,0 +1,76 @@
'use client'
import { create } from 'zustand'
import type { ItemNode } from '../schema/nodes/item'
// Runtime value for each control (matches discriminated union kinds)
export type ControlValue = boolean | number
export type ItemInteractiveState = {
// Indexed by control position in asset.interactive.controls[]
controlValues: ControlValue[]
}
type InteractiveStore = {
items: Record<string, ItemInteractiveState>
/** Initialize an item's interactive state from its asset definition (idempotent) */
initItem: (node: ItemNode) => void
/** Set a single control value */
setControlValue: (itemId: string, index: number, value: ControlValue) => void
/** Remove an item's state (e.g. on unmount) */
removeItem: (itemId: string) => void
}
const defaultControlValue = (node: ItemNode, index: number): ControlValue => {
const control = node.asset.interactive?.controls[index]
if (!control) return false
switch (control.kind) {
case 'toggle':
return false
case 'slider':
return control.min
case 'temperature':
return control.min
}
}
export const useInteractive = create<InteractiveStore>((set, get) => ({
items: {},
initItem: (node) => {
const controls = node.asset.interactive?.controls ?? []
if (controls.length === 0) return
// Don't overwrite existing state (idempotent)
if (get().items[node.id]) return
set((state) => ({
items: {
...state.items,
[node.id]: {
controlValues: controls.map((_, i) => defaultControlValue(node, i)),
},
},
}))
},
setControlValue: (itemId, index, value) => {
set((state) => {
const item = state.items[itemId]
if (!item) return state
const next = [...item.controlValues]
next[index] = value
return { items: { ...state.items, [itemId]: { controlValues: next } } }
})
},
removeItem: (itemId) => {
set((state) => {
const { [itemId]: _, ...rest } = state.items
return { items: rest }
})
},
}))
@@ -1,4 +1,5 @@
import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-app/core'
import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone' import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf' import { useGLTF } from '@react-three/drei/core/Gltf'
import { Suspense, useEffect, useMemo, useRef } from 'react' import { Suspense, useEffect, useMemo, useRef } from 'react'
@@ -45,7 +46,7 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
<ModelRenderer node={node} /> <ModelRenderer node={node} />
</Suspense> </Suspense>
{node.children?.map((childId) => ( {node.children?.map((childId) => (
<NodeRenderer key={childId} nodeId={childId } /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
</group> </group>
) )
@@ -73,11 +74,21 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
) )
} }
const multiplyScales = (a: [number, number, number], b: [number, number, number]): [number, number, number] => const multiplyScales = (
[a[0] * b[0], a[1] * b[1], a[2] * b[2]] a: [number, number, number],
b: [number, number, number],
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
const ModelRenderer = ({ node }: { node: ItemNode }) => { const ModelRenderer = ({ node }: { node: ItemNode }) => {
const { scene, nodes } = useGLTF(resolveCdnUrl(node.asset.src) || '') const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
const ref = useRef<Group>(null!)
const { actions } = useAnimations(animations, ref)
useEffect(() => {
if (animations.length > 0) {
actions[animations[0]!.name]!.play()
}
}, [actions, animations])
if (nodes.cutout) { if (nodes.cutout) {
nodes.cutout.visible = false nodes.cutout.visible = false
@@ -117,6 +128,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
return ( return (
<Clone <Clone
ref={ref}
object={scene} object={scene}
scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])} scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])}
position={node.asset.offset} position={node.asset.offset}
+1 -1
View File
@@ -1,6 +1,6 @@
import { loadAssetUrl } from '@pascal-app/core' import { loadAssetUrl } from '@pascal-app/core'
export const ASSETS_CDN_URL = 'https://editor.pascal.app' export const ASSETS_CDN_URL = process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app'
/** /**
* Resolves an asset URL to the appropriate format: * Resolves an asset URL to the appropriate format: