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 }
|
||||
})
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user