From e9ea2a938cbc5e07a84c2bdaf5259aaf04ebf1a3 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 9 Jul 2026 00:59:24 -0700 Subject: [PATCH] Scene Builder: T-pose in clip gaps + drag-preview card for animations - Gaps show the default pose: a disabled three.js action leaves the skeleton frozen on its last frame, so evaluateAt now resets any character with no clip under the playhead to its bind (T) pose via Skeleton.pose() (skeletons cached per character). Fixes blank space after a clip holding the clip's last pose. - Animation drags now show the shared DragGhost tilt card with the clip thumbnail: the drawer's native-DnD handlers drive dragItem/dragPosition/ assetDraggingUnder and suppress the browser's default drag image, matching object/character pickup motion. --- frontend/libs/components/pagescene/CLAUDE.md | 10 ++++- .../AnimationsDrawer/AnimationsDrawer.tsx | 42 ++++++++++++++++--- .../animation/CharacterAnimationManager.ts | 34 +++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/frontend/libs/components/pagescene/CLAUDE.md b/frontend/libs/components/pagescene/CLAUDE.md index 7680354b3f..6748901fcc 100644 --- a/frontend/libs/components/pagescene/CLAUDE.md +++ b/frontend/libs/components/pagescene/CLAUDE.md @@ -219,8 +219,14 @@ then one row per character is the right model. - **Overlap guard (single row)**: `TimelineController.resolveFreeStart` snaps add/move to the nearest gap so a character's strips never overlap; `nextStartAfter` bounds trim (`resizeClipLane`) and auto-length (`resolveClipDuration`) against the following clip. `evaluateAt` then has at most one - active clip at any playhead, so playback is an unambiguous sequence (a gap between strips falls - back to the bind/T-pose — position strips edge-to-edge to avoid it). + active clip at any playhead, so playback is an unambiguous sequence. +- **Bind-pose in gaps**: a disabled three.js action leaves the skeleton frozen on its last frame, so + `evaluateAt` resets any character with no clip under the playhead to its **bind (T) pose** via + `resetToBindPose` → `Skeleton.pose()` (skeletons cached per character in `getMixer`). Gaps between + strips, before the first, and after the last therefore show the default T-pose, not a held frame. +- **Drag preview**: dragging a clip drives the shared `DragGhost` (tilt card) by setting + `dragItem`/`dragPosition`/`assetDraggingUnder` on the store in the drawer's native-DnD handlers, + and suppresses the browser's default drag image — matching object/character pickup motion. - **Real clip length (fix, done)**: a fresh drop seeds `strip.duration` with a placeholder and flags `autoDuration`; when the GLB loads, `CharacterAnimationManager` calls `TimelineController.resolveClipDuration(laneId, clip.duration)` to adopt the clip's true length diff --git a/frontend/libs/components/pagescene/src/lib/comps/AnimationsDrawer/AnimationsDrawer.tsx b/frontend/libs/components/pagescene/src/lib/comps/AnimationsDrawer/AnimationsDrawer.tsx index deb8ab7013..5c45dd634b 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/AnimationsDrawer/AnimationsDrawer.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/AnimationsDrawer/AnimationsDrawer.tsx @@ -1,10 +1,8 @@ // Animations drawer: when a character is selected, lists the curated Mixamo -// clips. Clicking one drops it as a stacked clip lane on that character's -// timeline at the current playhead (the character then animates on scrub/play). -// -// This is the first verifiable slice of the Mixamo-drawer feature — click to -// add. Drag-and-drop onto specific timeline lanes and the stacked strip UI come -// once retargeting/playback is confirmed correct in the viewport. +// clips. Click a clip to drop it on the character's timeline at the playhead, +// or drag it onto a character's timeline row (drop time follows the cursor). +// While dragging, the shared DragGhost (tilt card) previews the clip's +// thumbnail — the same pickup motion as object/character drags. import { useContext } from "react"; import { EngineContext } from "../../contexts/EngineContext"; @@ -13,6 +11,16 @@ import { addClipToCharacter } from "../../actions"; import { demoAnimationItems } from "../../signals/demoAssets/demoAnimationItems"; import { ANIMATION_CLIP_MIME } from "../Timeline/timelineUtils"; +// 1×1 transparent GIF used to suppress the browser's default drag image so only +// the DragGhost tilt card shows. Created once at module load so it's decoded by +// the time a drag starts. +const TRANSPARENT_DRAG_IMAGE = + typeof Image !== "undefined" ? new Image() : null; +if (TRANSPARENT_DRAG_IMAGE) { + TRANSPARENT_DRAG_IMAGE.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; +} + export const AnimationsDrawer = () => { const editor = useContext(EngineContext); const selectedObject = usePageSceneStore((s) => s.selectedObject); @@ -42,6 +50,28 @@ export const AnimationsDrawer = () => { JSON.stringify({ media_id: item.media_id, name: item.name }), ); e.dataTransfer.effectAllowed = "copy"; + // Hide the native drag image; the DragGhost renders the preview. + if (TRANSPARENT_DRAG_IMAGE) { + e.dataTransfer.setDragImage(TRANSPARENT_DRAG_IMAGE, 0, 0); + } + const store = usePageSceneStore.getState(); + store.setDragItem(item); + store.setDragPosition({ currX: e.pageX, currY: e.pageY }); + store.setAssetDraggingUnder(true); + }} + onDrag={(e) => { + // The final native drag event can report (0,0) — ignore it so the + // ghost doesn't snap to the corner on release. + if (e.pageX === 0 && e.pageY === 0) return; + usePageSceneStore + .getState() + .setDragPosition({ currX: e.pageX, currY: e.pageY }); + }} + onDragEnd={() => { + const store = usePageSceneStore.getState(); + store.setDragItem(null); + store.setAssetDraggingUnder(false); + store.setDragPosition({ currX: 0, currY: 0 }); }} onClick={() => editor && addClipToCharacter(editor, character.id, item) diff --git a/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts b/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts index 9e51b7d023..0e5d358f54 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts @@ -35,6 +35,8 @@ export class CharacterAnimationManager { private lanes = new Map(); // laneId → monotonic token, so a superseded async load can't clobber state. private loadTokens = new Map(); + // characterUuid → its skeleton(s), cached so bind-pose resets don't re-traverse. + private skeletons = new Map(); constructor(private readonly editor: Editor) {} @@ -61,6 +63,9 @@ export class CharacterAnimationManager { // Pose every character at `playhead` (seconds). Deterministic: sets each // action's absolute time then applies via mixer.update(0). evaluateAt(playhead: number): void { + // Characters that have a clip covering this playhead. Everyone else is + // reset to their bind (T) pose below. + const posed = new Set(); for (const rt of this.lanes.values()) { const action = rt.action; if (!action) continue; @@ -77,6 +82,14 @@ export class CharacterAnimationManager { action.paused = true; // we drive time ourselves; don't auto-advance action.time = local; action.setEffectiveWeight(1); + posed.add(rt.characterUuid); + } + // Gaps between strips (and before the first / after the last) have no active + // clip. A disabled action leaves the skeleton frozen on its last evaluated + // frame, so explicitly restore the bind pose for those characters — the + // "default state" when nothing is under the scrubber. + for (const uuid of this.mixers.keys()) { + if (!posed.has(uuid)) this.resetToBindPose(uuid); } for (const mixer of this.mixers.values()) mixer.update(0); } @@ -85,6 +98,7 @@ export class CharacterAnimationManager { for (const laneId of [...this.lanes.keys()]) this.disposeLane(laneId); for (const mixer of this.mixers.values()) mixer.stopAllAction(); this.mixers.clear(); + this.skeletons.clear(); } // ─── internals ──────────────────────────────────────────────────────── @@ -170,6 +184,7 @@ export class CharacterAnimationManager { if (!live.has(uuid)) { this.mixers.get(uuid)?.stopAllAction(); this.mixers.delete(uuid); + this.skeletons.delete(uuid); } } } @@ -179,10 +194,29 @@ export class CharacterAnimationManager { if (!mixer) { mixer = new THREE.AnimationMixer(root); this.mixers.set(uuid, mixer); + this.skeletons.set(uuid, this.collectSkeletons(root)); } return mixer; } + // Reset a character's skeleton(s) to their bind (rest) pose. For Mixamo rigs + // the bind pose is the T-pose — the "default state" shown wherever no clip + // covers the playhead. + private resetToBindPose(characterUuid: string): void { + const skeletons = this.skeletons.get(characterUuid); + if (!skeletons) return; + for (const skeleton of skeletons) skeleton.pose(); + } + + private collectSkeletons(root: THREE.Object3D): THREE.Skeleton[] { + const skeletons: THREE.Skeleton[] = []; + root.traverse((o) => { + const sm = o as THREE.SkinnedMesh; + if (sm.isSkinnedMesh && sm.skeleton) skeletons.push(sm.skeleton); + }); + return skeletons; + } + // True if at least one of the clip's tracks names a node that exists under // `character`. Mixamo clips address bones as "mixamorig:Hips.position"; we // strip the ".property" suffix and look the node up by name.