From 927ecba0549f59cab4c537963ee154097c419d9b Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 5 Mar 2026 10:26:23 +0100 Subject: [PATCH] schema and interactive store --- .../editor/public/items/ceiling-fan/model.glb | Bin 10832 -> 6124 bytes packages/core/src/index.ts | 1 + packages/core/src/schema/nodes/item.ts | 74 ++++++++++++++++- packages/core/src/store/use-interactive.ts | 76 ++++++++++++++++++ .../renderers/item/item-renderer.tsx | 22 +++-- packages/viewer/src/lib/asset-url.ts | 2 +- 6 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/store/use-interactive.ts diff --git a/apps/editor/public/items/ceiling-fan/model.glb b/apps/editor/public/items/ceiling-fan/model.glb index f8f3724f53abb629c77d621497d996caae04fc7d..4e6c0ab066132fa2c2b5a8b00a98ef7fcde94b18 100644 GIT binary patch literal 6124 zcmbtX3tUuX7C-k6Z)HF&MKK*mB*O{sW1grmDB=r&P+V3t7+`>LhZ&d|6rWs0TX3>$48r)TWD&Mr6^ghL8AFwf!TBK42YH5ZX@%%=l^}@`9J6T?wDnq z98Cg1V=sXCE&$^v#l_B*>a141O&T^=nx!}CEjpXoA`O#H$hMeGW~(F%O(Y|YdQ*np zA{jR{PBK#-stA>jk0-eS-httr-AeR{r8XFp$$+`@BnZ*cvvnqu-sl8Y^kCE@y_k_rXUTHX&PNoMa&M+A_Pd2ldZNFy2Jk7?Mt}|lVLfy_f;!M~8S3qad22*Z6j)z(% zHJGqjxn@^tNa5ts@u+Riw+R{=K3A7EjTQb}J!V|&=*Wal1~lEt;@SRbX1>s&j?oYZ z&M@bv8TFG48T>%lEcyD*qry-1Q0l;M_FJM7>RCG#b(q%=uZ_COiXtYB{dO zjs#>8HI*b%V~u|=odeo!r*pH^h8_Mc_Y`dNb4|0f{NpTU%2NawR;WzzOO3l4LHLn6lsz#(&;IiX+|!@sRwxlGz# zuj27~mA|82r0#dut9oL+)6si6a$+@GoClJ>Ma?F?$u@a*E?yi8no-K6>E?VBo>E4E z=RHSPz+Y&g3XPJY)GS4*)HI`zy-bBtO1Ye+Sw^m;Xj;X}WvE0eX@#6tQ51`#qtIZu z90OkJ$*9gUv|6FkFdBx@sBoL*eXw%5nq?@K(a>rQIu{%ZUNn?KrKDA~TBSfQHX%Fd zl+mMD;l_Ms+P}kqc|ru7A{Q~NioXb*DNPy`8G{!QKS@tZ3hC*f5-Vd^Jbe_ysAxt- zsjzA~RHM=G5s47nQ7>(#RRPvo9*S zMyXV3Fbbn&R2pGXu2!m549(C=4fcms|Bs~d`TsARKCb_4O67QE|B}(p7XecKG=e)u zmBwr9v5%^QG3rs#o!Ztph|uGwoQRGjJ0`KSHFET{6y@$f5gnVG_`K#D&VPtcaDkgd+j zXs&*WKF&vHZ5(a+Q?&6U{7jw2m(UZFp_RYTSKuZ7M17?KS$r%V1@p9Gf1VCYtCalj zBquj6Ry$FNS0aUPCkfXTVC1-1h)ReY6-SZ;fxov~hE06NA8AtFTHB=BJf@Du&r`&f^~ zb?z^BQHWpgJSA%KNzPANf81A;w`cbC&276{&aEpx;S-Xy<(yoUwEDpv$)YJ|Z^YV% zlRa%|d!+lN;j{7H)&I#?>= zB0+-df9@sQaS7~2t;-_A0yuZ9D9Im+35ggyh=Ks_vgFmWeSW3BP`o>)#{gL64Q;1m zYD@mKHh6~@)kC|30qFbj3E<2JOGGd)vpbo(u2&y`!b@J@-|uqFnq(yjW$*X!gxe3*{qq>K@t?v3)1jvLXz3cTGw5 z52N3zC3!hGu&m)H6$E{@067QD_PiO!_HFY(0JwUoEB<#%=7lB1=RrVBU%s5epPYX9J=NbE-e)oZBD# zZ!8dT;_r?^z?bH(rwF9iqKFMO6}^z-9`L5q1gq zi>R-UcIkfa>MkOEdv$jAsTfFmbFKH{I{(wh_G69qAa5xBWJM5YgTF5TZE@*9(B3z? zLBFfru!DWd#|;UULw)PHn*iM&L_+~J#RIqoj4|RJHS%J*lD%jhUsvt@n5oP2*6bru z-dCHs=w9;2?|MgNU^vOAtoNe78l-M~jsEjQcI(x`Q`AmuRkj;IDgnbvAN1Pw+7$T5 zPz`MQ!%w4MDpdftG0R>1u#Bu|eHXG1^-FE38Byca=84X#z^*$a-hE(ynK|jzxFMT1NAy)Zdnmj ztnW8&NUIN=(e)kVod!|eZ!w_XT!(xZeZAM`q<&8e-0u#db`=+1dTy-&QOKllBLBUhm|JIUM_fhzV|2XWv> z60Pk!4nWz2t6yy;UK&xiLHmJ2h(FdbD-8R+XAf-gvR?(xqskjnDzs4KNsjR<;DT#k zUk%Ii#KwR1Pj#0akMqq)=_RSVTwh0=@vV5{g4O`qa%9j=6pz=61CKpc2|oM<>nmy% z*SY_TT=|^NOYhyon~e8dS2KIDW%&zh_hl~IwqYByzt`*$e=IJ# zSX@z`m;ZS;aeQFu_~<3|5uuxY_~-lA)gd2FTQcRXLd8FHrwc9wFFdT@&~&RY&UAS1 zMc<9wEK%)6-&*()z7aQG0N;9XZPUb42L}c>uOGH;;rMMnZmU9a#Zf+8TQ%D4Vs7W4 zFgNv*ra7uI@}jSHuB!IOZ?wD3qNal;B{SDNn&BTn-koXO5GKTo4 zu|!N{FFf#sXfN(M=u^`;fElvZeYr>a%&>~%mE-{FjBC>V(w+H|?xMlE{XX8*;GpKy z*Fw9C`wTov4yh;u@1?t|?f1Xx+GTLi;Lq-o4KrRG{HkBd0cHK*b;^3GMp=(-J@37= z$X6{o-=&nlP{D!ovWJ--v4GhcKJCKI@UD-&H>LihKY+)jHpc~gACjxJ4sw?8{Vi`p zw#0L`+8t8YF5(n}0D9+r4_tMl7Xf#eF?dAbWFPo?BGi}d*$xo(;hrN?39jbz6=555 zmbLYVX6vpgQFAsa-xC{>-(4|s2sm#iN3ANtLs!0F~}irV-GtM0q91*JXX zpyGJ5gW$qvH4yNhY3Tc!)i6G4jv}JT@82H}UKP1XMz0|H}?CclSc<^-`j;P zmS@Np0N1U}(an(2=!n?(20h7V`3^jjyG1f7OrlMfR+d}(mU;h{IRv+y;FqN!lH@8kO2OZn0OWo z>-caT624w}d240Jh^>{CBTx)SIXp2jF&ww>xe{?D%662`qI?}?h}c@`6OowsJWm$N zAt)av91iQC@>`1Ez- zQ30nE83k<3yCz_wcZPrqj=1wrt|EG>fP2sGDd6hBI01)^3>NVHzmF8~Z8x3+;BGme z;9BQgdf>9-0-Szez0)r@(-{XyXI#$Y%mZ9`anC#Rggj^7T!FI=7~!moi+0utLC(6l zX-*!%#S0hg;t5kbg!w^e^)Qyt6ZZwoy7tjtz-cZXkv9Rq;^Gl`6Yv}tkFORP1>DQU zBl0F-i;Ktd=&4S97mo)5;{+V&;t}}~a66C4pMcx>8#XeS$J|{PzrAMn6mX4;f8$`Y_?l+DKxV^vgRqF)Y-hZ4A0k_Y`R7xUX7mpCMv!dF?Qv^Pdw*vxi K7y|LHfBy@e$j=-A literal 10832 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 +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 (