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
@@ -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>()
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user