From 0514ec185833621230a4280bb96057d62c54b013 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 24 Jun 2026 18:13:48 -0400 Subject: [PATCH] feat(bake): bake catalog item clips (fan spin) into the GLB + play in viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A catalog GLB ships its own animation clips (a ceiling fan's spin), but those clips aren't in the editor scene graph, so the bake couldn't see them. Add an `itemClipRegistry` (core, type-only three) that the item renderer fills with the resolved clip per node while the scene is live; the GLB export reads it and re-emits each item's clip onto the baked subtree, rebinding tracks to the cloned spinning node's uuid. Catalog node names repeat across instances and the glTF roundtrip rebinds by name, so the targeted node is uniquified per item (`__lamp_018`) — every fan animates independently. The baked viewer plays these as looping ambient motion: `: loop` clips are set to LoopRepeat (not the door/window LoopOnce) and GlbItemAnimation drives them off each item's toggle (lit/spinning by default). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scene-registry/item-clip-registry.ts | 19 +++++++ packages/core/src/index.ts | 1 + packages/editor/src/lib/glb-export.ts | 52 ++++++++++++++++++- packages/nodes/src/item/renderer.tsx | 15 ++++++ .../src/components/viewer/glb-interactive.tsx | 43 ++++++++++++++- .../src/components/viewer/glb-scene.tsx | 21 ++++++-- 6 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/hooks/scene-registry/item-clip-registry.ts diff --git a/packages/core/src/hooks/scene-registry/item-clip-registry.ts b/packages/core/src/hooks/scene-registry/item-clip-registry.ts new file mode 100644 index 00000000..3b8d63b0 --- /dev/null +++ b/packages/core/src/hooks/scene-registry/item-clip-registry.ts @@ -0,0 +1,19 @@ +import type * as THREE from 'three' + +export type ItemClipEntry = { + /** The catalog clip to re-emit (e.g. a fan's "On" spin). */ + clip: THREE.AnimationClip + /** Plays looping in the baked viewer (ambient motion) vs once. */ + loop: boolean +} + +/** + * Catalog-item animation clips the bake needs to re-emit. A catalog GLB ships + * its own clips (the live item renderer loads + plays them), but those clips + * are not part of the editor scene graph, so the GLB export can't see them on + * its own. The item renderer registers the resolved clip per node id while the + * scene is live; `glb-export` reads this and retargets the clip onto the baked + * item subtree. Door/window motion is synthesized separately and never goes + * here. Keyed by node id; cleared with the rest of the scene refs on unload. + */ +export const itemClipRegistry = new Map() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 67ffb151..fc35f98f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,7 @@ export type { ZoneEvent, } from './events/bus' export { emitter, eventSuffixes } from './events/bus' +export { type ItemClipEntry, itemClipRegistry } from './hooks/scene-registry/item-clip-registry' export { sceneRegistry, useRegistry, diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 29ef52ce..e795ca76 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -2,6 +2,7 @@ import { type AnyNode, emitter, getLevelDisplayName, + itemClipRegistry, type LevelNode, sceneRegistry, type WindowNode, @@ -342,7 +343,9 @@ function bakeAnimationClips( ? bakeDoorClip(id, node, target) : node.type === 'window' ? bakeWindowClip(id, node as WindowNode, target) - : null + : node.type === 'item' + ? bakeItemClip(id, target) + : null if (clip) { clips.push(clip) @@ -353,6 +356,47 @@ function bakeAnimationClips( return { clips, clipNamesByNode } } +/** + * Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked + * subtree. The source clip targets the item GLB's nodes by name (`lamp_018`); + * since every fan shares those names, we rebind each track to the specific + * cloned node's uuid so multiple fans animate independently. The clip is named + * per node (`: loop`) so the baked viewer can drive each one on its own. + */ +function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip | null { + const entry = itemClipRegistry.get(id) + if (!entry) return null + + const tracks: THREE.KeyframeTrack[] = [] + // The catalog node names (e.g. "lamp_018") repeat across every instance of the + // item, and the glTF export→import roundtrip rebinds clip tracks by node name — + // so a shared name would make all fans share one clip. Uniquify the targeted + // node's name per item once, then bind tracks by its (stable) uuid. + const renamed = new Map() + for (const track of entry.clip.tracks) { + const dot = track.name.lastIndexOf('.') + if (dot < 0) continue + const targetName = track.name.slice(0, dot) + const property = track.name.slice(dot + 1) + let targetNode = renamed.get(targetName) + if (!targetNode) { + const found = itemObject.getObjectByName(targetName) + if (!found) continue + found.name = `${id}__${targetName}` + renamed.set(targetName, found) + targetNode = found + } + const retargeted = track.clone() + retargeted.name = `${targetNode.uuid}.${property}` + tracks.push(retargeted) + } + + if (tracks.length === 0) return null + const clip = new THREE.AnimationClip(`${id}: loop`, entry.clip.duration, tracks) + clip.userData = { loop: entry.loop } + return clip +} + /** * Bake a swing door's open motion. Each marked leaf is rotated from closed * (rest pose) to its fully-open angle and emitted as a 1-second quaternion @@ -545,6 +589,12 @@ function stampIdentity( extras.clips = clipNames } } + // Items with a baked ambient clip (a fan's spin) carry the clip name but no + // `openable` flag — nothing opens; the clip just loops. + if (node.type === 'item') { + const clipNames = clipNamesByNode.get(id) + if (clipNames?.length) extras.clips = clipNames + } if (node.type === 'zone') { // Zone fills are stripped from the bake; /viewer rebuilds the room from // this polygon. Force the identity node visible so GLTFExporter's diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index dc18848c..9d5eceec 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -8,6 +8,7 @@ import { type Interactive, type ItemNode, isSlotMaterialName, + itemClipRegistry, LIBRARY_MATERIAL_REF_PREFIX, type LightEffect, SCENE_MATERIAL_REF_PREFIX, @@ -387,6 +388,20 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { const lightEffects = interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? [] + // Expose this item's ambient clip (e.g. a fan's spin) to the GLB bake. The + // catalog GLB owns the clip; it isn't in the scene graph, so the export can't + // find it without this registry. The bake retargets it onto the baked subtree. + useEffect(() => { + if (!animEffect) return + const clipName = animEffect.clips.on ?? animEffect.clips.loop + const clip = clipName ? animations.find((c) => c.name === clipName) : undefined + if (!clip) return + itemClipRegistry.set(node.id, { clip, loop: true }) + return () => { + itemClipRegistry.delete(node.id) + } + }, [node.id, animEffect, animations]) + // useGLTF caches scenes, and Clone shares child geometry/material references. // Undo can unmount one item while another clone of the same asset still needs them. return ( diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index c4411d7e..b2da2cdb 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -14,7 +14,7 @@ import { import { Html } from '@react-three/drei' import { createPortal } from '@react-three/fiber' import { useEffect, useMemo, useState } from 'react' -import { type Object3D, Vector3 } from 'three' +import { type AnimationAction, LoopRepeat, type Object3D, Vector3 } from 'three' import { lerp } from 'three/src/math/MathUtils.js' import { useShallow } from 'zustand/react/shallow' import useViewer from '../../store/use-viewer' @@ -102,10 +102,14 @@ export function GlbInteractive({ items, identity, zones, + actions, }: { items: GlbInteractiveItem[] identity: Map zones: GlbZoneRef[] + /** Baked animation actions keyed by clip name — ambient item loops play from + * `: loop`. */ + actions: Record }) { // Seed control state for every interactive item. The viewer shows a baked // scene "lit": toggles default ON (the editor defaults them off) and sliders @@ -130,6 +134,10 @@ export function GlbInteractive({ () => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'light')), [items], ) + const animationItems = useMemo( + () => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')), + [items], + ) // Controls overlay is scoped to the focused zone (matches the parametric // viewer). Project the zone's baked-local polygon into world space once so an @@ -152,6 +160,9 @@ export function GlbInteractive({ const object = identity.get(item.pascalId) return object ? : null })} + {animationItems.map((item) => ( + + ))} {items.map((item) => { const object = identity.get(item.pascalId) return object ? ( @@ -187,6 +198,36 @@ function GlbItemLight({ item, object }: { item: GlbInteractiveItem; object: Obje ) } +/** Plays an item's baked ambient loop (a fan's spin), gated on its toggle. + * The clip and its targets are already in the GLB; we only start/stop it. */ +function GlbItemAnimation({ + item, + actions, +}: { + item: GlbInteractiveItem + actions: Record +}) { + const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues)) + const toggleIndex = item.interactive.controls.findIndex((c) => c.kind === 'toggle') + const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex] ?? true) : true + + useEffect(() => { + const action = actions[`${item.pascalId}: loop`] + if (!action) return + action.loop = LoopRepeat + action.clampWhenFinished = false + if (isOn) { + action.enabled = true + action.paused = false + if (!action.isRunning()) action.play() + } else { + action.stop() + } + }, [actions, item.pascalId, isOn]) + + return null +} + const FADE_MS = 300 /** Controls overlay for one item — fades in while the item sits inside the diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index cd518ad8..fe669780 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -607,10 +607,18 @@ export function GlbScene({ }, [zoneEntries]) useEffect(() => { - for (const action of Object.values(actions)) { + for (const [name, action] of Object.entries(actions)) { if (!action) continue - action.loop = THREE.LoopOnce - action.clampWhenFinished = true + // Ambient item loops (a fan's spin, `: loop`) repeat; door/window + // open clips play once and hold their end pose. GlbInteractive plays the + // loops, gated on the item's toggle. + if (name.endsWith(': loop')) { + action.loop = THREE.LoopRepeat + action.clampWhenFinished = false + } else { + action.loop = THREE.LoopOnce + action.clampWhenFinished = true + } } }, [actions]) @@ -980,7 +988,12 @@ export function GlbScene({ {/* Re-light + re-animate the baked artifact from the DB scene graph, joined to the baked nodes by pascalId. */} {interactiveItems?.length ? ( - + ) : null} {/* Floating room labels. Each group's matrix is synced to its zone node every frame (above) so the label rides level stacking; the div fades