schema and interactive store
This commit is contained in:
Binary file not shown.
@@ -41,6 +41,7 @@ export {
|
|||||||
} from './lib/space-detection'
|
} from './lib/space-detection'
|
||||||
// Schema
|
// Schema
|
||||||
export * from './schema'
|
export * from './schema'
|
||||||
|
export { useInteractive, type ControlValue, type ItemInteractiveState } from './store/use-interactive'
|
||||||
export { default as useScene } from './store/use-scene'
|
export { default as useScene } from './store/use-scene'
|
||||||
// Systems
|
// Systems
|
||||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||||
|
|||||||
@@ -2,6 +2,77 @@ import dedent from 'dedent'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
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({
|
const assetSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
category: z.string(),
|
category: z.string(),
|
||||||
@@ -20,6 +91,7 @@ const assetSchema = z.object({
|
|||||||
height: z.number(), // where things rest
|
height: z.number(), // where things rest
|
||||||
})
|
})
|
||||||
.optional(), // undefined = can't place things on it
|
.optional(), // undefined = can't place things on it
|
||||||
|
interactive: interactiveSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AssetInput = z.input<typeof assetSchema>
|
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 { 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 { 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'
|
||||||
@@ -45,7 +46,7 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
<ModelRenderer node={node} />
|
<ModelRenderer node={node} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
{node.children?.map((childId) => (
|
{node.children?.map((childId) => (
|
||||||
<NodeRenderer key={childId} nodeId={childId } />
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
@@ -73,11 +74,21 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const multiplyScales = (a: [number, number, number], b: [number, number, number]): [number, number, number] =>
|
const multiplyScales = (
|
||||||
[a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
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 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) {
|
if (nodes.cutout) {
|
||||||
nodes.cutout.visible = false
|
nodes.cutout.visible = false
|
||||||
@@ -117,6 +128,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Clone
|
<Clone
|
||||||
|
ref={ref}
|
||||||
object={scene}
|
object={scene}
|
||||||
scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])}
|
scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])}
|
||||||
position={node.asset.offset}
|
position={node.asset.offset}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { loadAssetUrl } from '@pascal-app/core'
|
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:
|
* Resolves an asset URL to the appropriate format:
|
||||||
|
|||||||
Reference in New Issue
Block a user