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