diff --git a/apps/editor/public/items/ceiling-fan/model.glb b/apps/editor/public/items/ceiling-fan/model.glb index f8f3724f..4e6c0ab0 100644 Binary files a/apps/editor/public/items/ceiling-fan/model.glb and b/apps/editor/public/items/ceiling-fan/model.glb differ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0f5ea252..80bb3b25 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index bda8f4c5..e1f7a4ae 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -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 +export type SliderControl = z.infer +export type TemperatureControl = z.infer +export type Control = z.infer +export type AnimationEffect = z.infer +export type LightEffect = z.infer +export type Effect = z.infer +export type Interactive = z.infer + 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 diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts new file mode 100644 index 00000000..03d77e70 --- /dev/null +++ b/packages/core/src/store/use-interactive.ts @@ -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 + + /** 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((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 } + }) + }, +})) diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 4eda3deb..c578a8ff 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -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 }) => { {node.children?.map((childId) => ( - - ))} + + ))} ) } @@ -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(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 (