schema and interactive store
This commit is contained in:
@@ -41,6 +41,7 @@ export {
|
||||
} from './lib/space-detection'
|
||||
// Schema
|
||||
export * from './schema'
|
||||
export { useInteractive, type ControlValue, type ItemInteractiveState } from './store/use-interactive'
|
||||
export { default as useScene } from './store/use-scene'
|
||||
// Systems
|
||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||
|
||||
@@ -2,6 +2,77 @@ import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
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({
|
||||
id: z.string(),
|
||||
category: z.string(),
|
||||
@@ -17,9 +88,10 @@ const assetSchema = z.object({
|
||||
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
|
||||
surface: z
|
||||
.object({
|
||||
height: z.number(), // where things rest
|
||||
height: z.number(), // where things rest
|
||||
})
|
||||
.optional(), // undefined = can't place things on it
|
||||
interactive: interactiveSchema.optional(),
|
||||
})
|
||||
|
||||
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 { useAnimations } from '@react-three/drei'
|
||||
import { Clone } from '@react-three/drei/core/Clone'
|
||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
@@ -45,8 +46,8 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
||||
<ModelRenderer node={node} />
|
||||
</Suspense>
|
||||
{node.children?.map((childId) => (
|
||||
<NodeRenderer key={childId} nodeId={childId } />
|
||||
))}
|
||||
<NodeRenderer key={childId} nodeId={childId} />
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -73,11 +74,21 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const multiplyScales = (a: [number, number, number], b: [number, number, number]): [number, number, number] =>
|
||||
[a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||
const multiplyScales = (
|
||||
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 { 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) {
|
||||
nodes.cutout.visible = false
|
||||
@@ -117,6 +128,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
|
||||
return (
|
||||
<Clone
|
||||
ref={ref}
|
||||
object={scene}
|
||||
scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])}
|
||||
position={node.asset.offset}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user