diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx index bc173276..f643a9cd 100644 --- a/apps/editor/app/viewer/[id]/page.tsx +++ b/apps/editor/app/viewer/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client' import { initSpatialGridSync, useScene } from '@pascal-app/core' -import { useViewer, Viewer } from '@pascal-app/viewer' +import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import Link from 'next/link' import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' @@ -180,6 +180,7 @@ export default function ViewerPage() { + diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx index 876936cf..094a29b2 100644 --- a/apps/editor/components/ui/item-catalog/catalog-items.tsx +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -612,6 +612,21 @@ export const CATALOG_ITEMS: AssetInput[] = [ rotation: [0, 0, 0], dimensions: [1, 0.5, 1.5], attachTo: "ceiling", + interactive: { + effects: [ + { + kind: 'animation', + clips: { + on: 'On', + }, + } + ], + controls: [ + { + kind: 'toggle', + } + ], + } }, { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index c3de2791..0b8140ef 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -2,7 +2,7 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base' // Camera export { CameraSchema } from './camera' -export type { AssetInput } from './nodes/item' +export type { AnimationEffect, Asset, AssetInput, Control, Effect, Interactive, LightEffect, SliderControl, TemperatureControl, ToggleControl } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item' export { LevelNode } from './nodes/level' // Nodes diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index 03d77e70..faf97b53 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -1,7 +1,8 @@ 'use client' import { create } from 'zustand' -import type { ItemNode } from '../schema/nodes/item' +import type { Interactive } from '../schema/nodes/item' +import type { AnyNodeId } from '../schema/types' // Runtime value for each control (matches discriminated union kinds) export type ControlValue = boolean | number @@ -12,20 +13,20 @@ export type ItemInteractiveState = { } type InteractiveStore = { - items: Record + items: Record - /** Initialize an item's interactive state from its asset definition (idempotent) */ - initItem: (node: ItemNode) => void + /** Initialize a node's interactive state from its asset definition (idempotent) */ + initItem: (itemId: AnyNodeId, interactive: Interactive) => void /** Set a single control value */ - setControlValue: (itemId: string, index: number, value: ControlValue) => void + setControlValue: (itemId: AnyNodeId, index: number, value: ControlValue) => void - /** Remove an item's state (e.g. on unmount) */ - removeItem: (itemId: string) => void + /** Remove a node's state (e.g. on unmount) */ + removeItem: (itemId: AnyNodeId) => void } -const defaultControlValue = (node: ItemNode, index: number): ControlValue => { - const control = node.asset.interactive?.controls[index] +const defaultControlValue = (interactive: Interactive, index: number): ControlValue => { + const control = interactive.controls[index] if (!control) return false switch (control.kind) { case 'toggle': @@ -40,18 +41,18 @@ const defaultControlValue = (node: ItemNode, index: number): ControlValue => { export const useInteractive = create((set, get) => ({ items: {}, - initItem: (node) => { - const controls = node.asset.interactive?.controls ?? [] + initItem: (itemId, interactive) => { + const { controls } = interactive if (controls.length === 0) return // Don't overwrite existing state (idempotent) - if (get().items[node.id]) return + if (get().items[itemId]) return set((state) => ({ items: { ...state.items, - [node.id]: { - controlValues: controls.map((_, i) => defaultControlValue(node, i)), + [itemId]: { + controlValues: controls.map((_, i) => defaultControlValue(interactive, i)), }, }, })) diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index c578a8ff..7c53748f 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -1,8 +1,9 @@ -import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-app/core' +import { type AnimationEffect, type AnyNodeId, type ItemNode, useInteractive, 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' +import { useShallow } from 'zustand/react/shallow' import type { Group, Material, Mesh } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' @@ -83,12 +84,34 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '') const ref = useRef(null!) const { actions } = useAnimations(animations, ref) + // Freeze the interactive definition at mount — asset schemas don't change at runtime + const interactiveRef = useRef(node.asset.interactive) + + // Subscribe only to this node's control values — useShallow prevents re-renders from other nodes' changes + const controlValues = useInteractive( + useShallow((state) => state.items[node.id]?.controlValues), + ) useEffect(() => { - if (animations.length > 0) { - actions[animations[0]!.name]!.play() + const interactive = interactiveRef.current + const animEffect = interactive?.effects.find((e): e is AnimationEffect => e.kind === 'animation') + + if (!animEffect) { + // Non-interactive: play first available animation as default + if (animations.length > 0) actions[animations[0]!.name]?.play() + return } - }, [actions, animations]) + + if (!controlValues) return + + const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle') + const isOn = toggleIndex >= 0 ? Boolean(controlValues[toggleIndex]) : false + + for (const action of Object.values(actions)) action?.stop() + + const clipName = isOn ? animEffect.clips.on : (animEffect.clips.off ?? animEffect.clips.loop) + if (clipName) actions[clipName]?.play() + }, [controlValues, actions, animations]) if (nodes.cutout) { nodes.cutout.visible = false @@ -101,6 +124,13 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) }, [node.parentId]) + useEffect(() => { + const interactive = interactiveRef.current + if (!interactive) return + useInteractive.getState().initItem(node.id, interactive) + return () => useInteractive.getState().removeItem(node.id) + }, [node.id]) + useMemo(() => { scene.traverse((child) => { if ((child as Mesh).isMesh) { diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index ca2f7a93..d97a508c 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,4 +1,5 @@ export { default as Viewer } from './components/viewer' export { default as useViewer } from './store/use-viewer' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' +export { InteractiveSystem } from './systems/interactive/interactive-system' export { snapLevelsToTruePositions } from './systems/level/level-utils' \ No newline at end of file diff --git a/packages/viewer/src/systems/interactive/interactive-system.tsx b/packages/viewer/src/systems/interactive/interactive-system.tsx new file mode 100644 index 00000000..f3cd8035 --- /dev/null +++ b/packages/viewer/src/systems/interactive/interactive-system.tsx @@ -0,0 +1,168 @@ +'use client' + +import { + type AnyNodeId, + type Control, + type ControlValue, + type ItemNode, + sceneRegistry, + useInteractive, + useScene, +} from '@pascal-app/core' +import { Html } from '@react-three/drei' +import { createPortal, useFrame } from '@react-three/fiber' +import { useState } from 'react' +import type { Object3D } from 'three' +import { useShallow } from 'zustand/react/shallow' + +// ---- Parent: one overlay per interactive item ---- + +export const InteractiveSystem = () => { + const interactiveNodeIds = useScene( + useShallow((state) => + Object.values(state.nodes) + .filter((n): n is ItemNode => n.type === 'item' && n.asset.interactive != null) + .map((n) => n.id), + ), + ) + + return ( + <> + {interactiveNodeIds.map((id) => ( + + ))} + + ) +} + +// ---- Child: polls sceneRegistry then portals controls into the item group ---- + +const ItemControlsOverlay = ({ nodeId }: { nodeId: AnyNodeId }) => { + const node = useScene((state) => state.nodes[nodeId] as ItemNode) + const [itemObj, setItemObj] = useState(null) + + useFrame(() => { + if (itemObj) return + const obj = sceneRegistry.nodes.get(nodeId) + if (obj) setItemObj(obj) + }) + + const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues)) + const setControlValue = useInteractive((state) => state.setControlValue) + + if (!itemObj || !controlValues || !node?.asset.interactive) return null + + const { controls } = node.asset.interactive + const [, height] = node.asset.dimensions + + + return createPortal( + +
+ {controls.map((control, i) => ( + setControlValue(nodeId, i, v)} + /> + ))} +
+ , + itemObj, + ) +} + +// ---- Control widgets ---- + +const ControlWidget = ({ + control, + value, + onChange, +}: { + control: Control + value: ControlValue + onChange: (v: ControlValue) => void +}) => { + const labelStyle: React.CSSProperties = { + color: 'white', + fontSize: 11, + fontFamily: 'monospace', + display: 'flex', + flexDirection: 'column', + gap: 2, + } + + if (control.kind === 'toggle') { + return ( + + ) + } + + if (control.kind === 'slider') { + return ( + + ) + } + + if (control.kind === 'temperature') { + return ( + + ) + } + + return null +}