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
@@ -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<string, Object3D>
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
// 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 ? <GlbItemLight item={item} key={item.pascalId} object={object} /> : null
})}
{animationItems.map((item) => (
<GlbItemAnimation actions={actions} item={item} key={item.pascalId} />
))}
{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<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
/** Controls overlay for one item — fades in while the item sits inside the
@@ -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, `<id>: 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 ? (
<GlbInteractive identity={identity} items={interactiveItems} zones={zoneEntries} />
<GlbInteractive
actions={actions}
identity={identity}
items={interactiveItems}
zones={zoneEntries}
/>
) : 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