feat(bake): bake catalog item clips (fan spin) into the GLB + play in viewer

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
(`<id>__lamp_018`) — every fan animates independently.

The baked viewer plays these as looping ambient motion: `<id>: 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) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-24 18:13:48 -04:00
co-authored by Claude Opus 4.8
parent d80ffd67d5
commit 0514ec1858
6 changed files with 145 additions and 6 deletions
@@ -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<string, ItemClipEntry>()
+1
View File
@@ -35,6 +35,7 @@ export type {
ZoneEvent, ZoneEvent,
} from './events/bus' } from './events/bus'
export { emitter, eventSuffixes } from './events/bus' export { emitter, eventSuffixes } from './events/bus'
export { type ItemClipEntry, itemClipRegistry } from './hooks/scene-registry/item-clip-registry'
export { export {
sceneRegistry, sceneRegistry,
useRegistry, useRegistry,
+51 -1
View File
@@ -2,6 +2,7 @@ import {
type AnyNode, type AnyNode,
emitter, emitter,
getLevelDisplayName, getLevelDisplayName,
itemClipRegistry,
type LevelNode, type LevelNode,
sceneRegistry, sceneRegistry,
type WindowNode, type WindowNode,
@@ -342,7 +343,9 @@ function bakeAnimationClips(
? bakeDoorClip(id, node, target) ? bakeDoorClip(id, node, target)
: node.type === 'window' : node.type === 'window'
? bakeWindowClip(id, node as WindowNode, target) ? bakeWindowClip(id, node as WindowNode, target)
: null : node.type === 'item'
? bakeItemClip(id, target)
: null
if (clip) { if (clip) {
clips.push(clip) clips.push(clip)
@@ -353,6 +356,47 @@ function bakeAnimationClips(
return { clips, clipNamesByNode } 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 (`<id>: 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<string, THREE.Object3D>()
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 * 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 * (rest pose) to its fully-open angle and emitted as a 1-second quaternion
@@ -545,6 +589,12 @@ function stampIdentity(
extras.clips = clipNames 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') { if (node.type === 'zone') {
// Zone fills are stripped from the bake; /viewer rebuilds the room from // Zone fills are stripped from the bake; /viewer rebuilds the room from
// this polygon. Force the identity node visible so GLTFExporter's // this polygon. Force the identity node visible so GLTFExporter's
+15
View File
@@ -8,6 +8,7 @@ import {
type Interactive, type Interactive,
type ItemNode, type ItemNode,
isSlotMaterialName, isSlotMaterialName,
itemClipRegistry,
LIBRARY_MATERIAL_REF_PREFIX, LIBRARY_MATERIAL_REF_PREFIX,
type LightEffect, type LightEffect,
SCENE_MATERIAL_REF_PREFIX, SCENE_MATERIAL_REF_PREFIX,
@@ -387,6 +388,20 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
const lightEffects = const lightEffects =
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? [] 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. // 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. // Undo can unmount one item while another clone of the same asset still needs them.
return ( return (
@@ -14,7 +14,7 @@ import {
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { createPortal } from '@react-three/fiber' import { createPortal } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react' 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 { lerp } from 'three/src/math/MathUtils.js'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
@@ -102,10 +102,14 @@ export function GlbInteractive({
items, items,
identity, identity,
zones, zones,
actions,
}: { }: {
items: GlbInteractiveItem[] items: GlbInteractiveItem[]
identity: Map<string, Object3D> identity: Map<string, Object3D>
zones: GlbZoneRef[] zones: GlbZoneRef[]
/** Baked animation actions keyed by clip name — ambient item loops play from
* `<pascalId>: loop`. */
actions: Record<string, AnimationAction | null>
}) { }) {
// Seed control state for every interactive item. The viewer shows a baked // Seed control state for every interactive item. The viewer shows a baked
// scene "lit": toggles default ON (the editor defaults them off) and sliders // 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.filter((item) => item.interactive.effects.some((e) => e.kind === 'light')),
[items], [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 // 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 // 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) const object = identity.get(item.pascalId)
return object ? <GlbItemLight item={item} key={item.pascalId} object={object} /> : null return object ? <GlbItemLight item={item} key={item.pascalId} object={object} /> : null
})} })}
{animationItems.map((item) => (
<GlbItemAnimation actions={actions} item={item} key={item.pascalId} />
))}
{items.map((item) => { {items.map((item) => {
const object = identity.get(item.pascalId) const object = identity.get(item.pascalId)
return object ? ( 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<string, AnimationAction | null>
}) {
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 const FADE_MS = 300
/** Controls overlay for one item — fades in while the item sits inside the /** Controls overlay for one item — fades in while the item sits inside the
@@ -607,10 +607,18 @@ export function GlbScene({
}, [zoneEntries]) }, [zoneEntries])
useEffect(() => { useEffect(() => {
for (const action of Object.values(actions)) { for (const [name, action] of Object.entries(actions)) {
if (!action) continue if (!action) continue
action.loop = THREE.LoopOnce // Ambient item loops (a fan's spin, `<id>: loop`) repeat; door/window
action.clampWhenFinished = true // 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]) }, [actions])
@@ -980,7 +988,12 @@ export function GlbScene({
{/* Re-light + re-animate the baked artifact from the DB scene graph, {/* Re-light + re-animate the baked artifact from the DB scene graph,
joined to the baked nodes by pascalId. */} joined to the baked nodes by pascalId. */}
{interactiveItems?.length ? ( {interactiveItems?.length ? (
<GlbInteractive identity={identity} items={interactiveItems} zones={zoneEntries} /> <GlbInteractive
actions={actions}
identity={identity}
items={interactiveItems}
zones={zoneEntries}
/>
) : null} ) : null}
{/* Floating room labels. Each group's matrix is synced to its zone node {/* 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 every frame (above) so the label rides level stacking; the div fades