item system controls
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { initSpatialGridSync, useScene } from '@pascal-app/core'
|
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 Link from 'next/link'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -180,6 +180,7 @@ export default function ViewerPage() {
|
|||||||
<Viewer>
|
<Viewer>
|
||||||
<ViewerCameraControls />
|
<ViewerCameraControls />
|
||||||
<ViewerZoneSystem />
|
<ViewerZoneSystem />
|
||||||
|
<InteractiveSystem />
|
||||||
</Viewer>
|
</Viewer>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -612,6 +612,21 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [1, 0.5, 1.5],
|
dimensions: [1, 0.5, 1.5],
|
||||||
attachTo: "ceiling",
|
attachTo: "ceiling",
|
||||||
|
interactive: {
|
||||||
|
effects: [
|
||||||
|
{
|
||||||
|
kind: 'animation',
|
||||||
|
clips: {
|
||||||
|
on: 'On',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
controls: [
|
||||||
|
{
|
||||||
|
kind: 'toggle',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
||||||
// Camera
|
// Camera
|
||||||
export { CameraSchema } from './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 { getScaledDimensions, ItemNode } from './nodes/item'
|
||||||
export { LevelNode } from './nodes/level'
|
export { LevelNode } from './nodes/level'
|
||||||
// Nodes
|
// Nodes
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { create } from 'zustand'
|
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)
|
// Runtime value for each control (matches discriminated union kinds)
|
||||||
export type ControlValue = boolean | number
|
export type ControlValue = boolean | number
|
||||||
@@ -12,20 +13,20 @@ export type ItemInteractiveState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type InteractiveStore = {
|
type InteractiveStore = {
|
||||||
items: Record<string, ItemInteractiveState>
|
items: Record<AnyNodeId, ItemInteractiveState>
|
||||||
|
|
||||||
/** Initialize an item's interactive state from its asset definition (idempotent) */
|
/** Initialize a node's interactive state from its asset definition (idempotent) */
|
||||||
initItem: (node: ItemNode) => void
|
initItem: (itemId: AnyNodeId, interactive: Interactive) => void
|
||||||
|
|
||||||
/** Set a single control value */
|
/** 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) */
|
/** Remove a node's state (e.g. on unmount) */
|
||||||
removeItem: (itemId: string) => void
|
removeItem: (itemId: AnyNodeId) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultControlValue = (node: ItemNode, index: number): ControlValue => {
|
const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
|
||||||
const control = node.asset.interactive?.controls[index]
|
const control = interactive.controls[index]
|
||||||
if (!control) return false
|
if (!control) return false
|
||||||
switch (control.kind) {
|
switch (control.kind) {
|
||||||
case 'toggle':
|
case 'toggle':
|
||||||
@@ -40,18 +41,18 @@ const defaultControlValue = (node: ItemNode, index: number): ControlValue => {
|
|||||||
export const useInteractive = create<InteractiveStore>((set, get) => ({
|
export const useInteractive = create<InteractiveStore>((set, get) => ({
|
||||||
items: {},
|
items: {},
|
||||||
|
|
||||||
initItem: (node) => {
|
initItem: (itemId, interactive) => {
|
||||||
const controls = node.asset.interactive?.controls ?? []
|
const { controls } = interactive
|
||||||
if (controls.length === 0) return
|
if (controls.length === 0) return
|
||||||
|
|
||||||
// Don't overwrite existing state (idempotent)
|
// Don't overwrite existing state (idempotent)
|
||||||
if (get().items[node.id]) return
|
if (get().items[itemId]) return
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
items: {
|
items: {
|
||||||
...state.items,
|
...state.items,
|
||||||
[node.id]: {
|
[itemId]: {
|
||||||
controlValues: controls.map((_, i) => defaultControlValue(node, i)),
|
controlValues: controls.map((_, i) => defaultControlValue(interactive, i)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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 { 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'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import type { Group, Material, Mesh } from 'three'
|
import type { Group, Material, Mesh } from 'three'
|
||||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
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 { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||||
const ref = useRef<Group>(null!)
|
const ref = useRef<Group>(null!)
|
||||||
const { actions } = useAnimations(animations, ref)
|
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(() => {
|
useEffect(() => {
|
||||||
if (animations.length > 0) {
|
const interactive = interactiveRef.current
|
||||||
actions[animations[0]!.name]!.play()
|
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) {
|
if (nodes.cutout) {
|
||||||
nodes.cutout.visible = false
|
nodes.cutout.visible = false
|
||||||
@@ -101,6 +124,13 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||||
}, [node.parentId])
|
}, [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(() => {
|
useMemo(() => {
|
||||||
scene.traverse((child) => {
|
scene.traverse((child) => {
|
||||||
if ((child as Mesh).isMesh) {
|
if ((child as Mesh).isMesh) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as Viewer } from './components/viewer'
|
export { default as Viewer } from './components/viewer'
|
||||||
export { default as useViewer } from './store/use-viewer'
|
export { default as useViewer } from './store/use-viewer'
|
||||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||||
|
export { InteractiveSystem } from './systems/interactive/interactive-system'
|
||||||
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
||||||
@@ -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) => (
|
||||||
|
<ItemControlsOverlay key={id} nodeId={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<Object3D | null>(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(
|
||||||
|
<Html center position={[0, height + 0.3, 0]} zIndexRange={[20, 0]} occlude distanceFactor={8}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
background: 'rgba(0,0,0,0.75)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
minWidth: 120,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{controls.map((control, i) => (
|
||||||
|
<ControlWidget
|
||||||
|
key={i}
|
||||||
|
control={control}
|
||||||
|
value={controlValues[i] ?? false}
|
||||||
|
onChange={(v) => setControlValue(nodeId, i, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Html>,
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(!value)}
|
||||||
|
style={{
|
||||||
|
background: value ? '#4ade80' : '#374151',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{control.label ?? (value ? 'On' : 'Off')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.kind === 'slider') {
|
||||||
|
return (
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span>
|
||||||
|
{control.label}: {value}
|
||||||
|
{control.unit ? ` ${control.unit}` : ''}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={control.min}
|
||||||
|
max={control.max}
|
||||||
|
step={control.step}
|
||||||
|
value={value as number}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.kind === 'temperature') {
|
||||||
|
return (
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span>
|
||||||
|
{control.label}: {value}°{control.unit}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={control.min}
|
||||||
|
max={control.max}
|
||||||
|
step={1}
|
||||||
|
value={value as number}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user