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:
co-authored by
Claude Opus 4.8
parent
d80ffd67d5
commit
0514ec1858
@@ -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 (`<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
|
||||
* (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
|
||||
|
||||
Reference in New Issue
Block a user