Group timeline rows by object and edit values in the row (#408)

The rows of the animation timeline stood in the order they were keyed.
With several objects and properties the list was hard to read, and a
value could only be changed by picking a key and using the inspector.
The Play button also kept saying Pause after scrubbing had paused.

- Each object gets a header row with its name, indented by depth. The
  groups follow the scene hierarchy, the root first. Rows whose object
  is missing go to the end.
- Under an object the rows follow the order in which it lists its
  properties: X, Y, Z, then the order of the inspector. Only the view
  is sorted, the animation itself is unchanged and there is no undo
  step. After a move in the hierarchy the order is right by itself.
- The row head shows the same field as the inspector with the value at
  the playhead. Without keys it shows the value of the object, for a
  relative row the offset.
- A change in that field sets or changes the key at the playhead,
  selects it and is one undo step. A running playback pauses first.
- The Play/Pause button follows every change of the playing state, so
  scrubbing that pauses shows Play again.

Tests:
- Unit animation + timeline-view: 100 passed, 3 new.
- e2e 25 on melektaus-atx: ALL PASSED, 34 checks, 3 new (groups,
  order and values at frame 4, a typed 55 becomes a selected key on
  frame 4, Play then a click on the ruler shows Play and pauses).
- Gate on melektaus-atx: exit 0, 297 files, 4561 tests.
- tsc, eslint, Prettier clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 17:42:10 +02:00
1 parent dd60a795e1
commit 8679dfbe65
9 files changed
+449 -69

No files matched your search

+71
View File
@@ -577,6 +577,77 @@ export function trackLabel(project: Project, animation: AnimationAsset, track: A
return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`;
}
/**
* A line of the timeline's header column (#408): the head of an object's group, or a row under it,
* named by its property alone. `depth` is the object's in the hierarchy. A row carries its property
* (none once that is gone) and `held`, what it shows before it has keys: it writes nothing then, so
* that is what the object holds – or no offset at all for a relative row.
*/
export type TimelineLine =
| { kind: 'group'; target: string; name: string; depth: number }
| {
kind: 'row';
track: AnimationTrack;
label: string;
def: PropertyDef | undefined;
held: PropertyValue;
depth: number;
};
/**
* The rows grouped by the object they aim at, in the order the objects stand in the hierarchy – the
* root first – and under each object in the order it lists its properties. Rows aiming at something
* gone come last, as they were added. Only the view is sorted: the asset keeps its own order.
*/
export function timelineLines(project: Project, animation: AnimationAsset): TimelineLine[] {
const placed = new Map<string, { at: number; depth: number }>([['', { at: 0, depth: 0 }]]);
animationTargets(project, findAsset(project, animation.root)).forEach((t, i) =>
placed.set(t.id, { at: i + 1, depth: t.depth }),
);
const targets = [...new Set(animation.tracks.map((t) => t.target))];
const rank = (target: string) => placed.get(target)?.at ?? placed.size + targets.indexOf(target);
const lines: TimelineLine[] = [];
for (const target of targets.sort((a, b) => rank(a) - rank(b))) {
const aimed = aimedAt(project, animation, target);
const defs = aimed ? animatableDefsOf(project, aimed.asset, aimed.bag) : [];
const depth = placed.get(target)?.depth ?? 0;
const name = !aimed
? '(missing)'
: aimed.object
? objectName(aimed.object, aimed.asset)
: aimed.asset.name;
lines.push({ kind: 'group', target, name, depth });
const order = (track: AnimationTrack) => {
const i = defs.findIndex((d) => d.key === track.property);
return i < 0 ? defs.length : i;
};
const rows = animation.tracks.filter((t) => t.target === target).sort((a, b) => order(a) - order(b));
for (const track of rows) {
const def = defs.find((d) => d.key === track.property);
const label = def ? def.label : `${track.property} (missing)`;
const held = !aimed || !def ? 0 : track.relative ? 0 : heldBy(aimed, def);
lines.push({ kind: 'row', track, label, def, held, depth });
}
}
return lines;
}
function heldBy(aimed: Aimed, def: PropertyDef): PropertyValue {
if (isPositionRow(aimed.asset.type, def.key)) return aimed.object?.position[def.key] ?? 0;
return aimed.bag[def.key] ?? def.default;
}
/** What a row's header shows at `frame` (#408): the row's value there, else what it holds without keys. */
export function lineValueAt(
line: TimelineLine & { kind: 'row' },
frame: number,
reverse = false,
curves: CurveLookup = NO_CURVES,
): PropertyValue | undefined {
if (!line.def) return undefined;
return trackValueAt(line.track.keys, line.def.kind, frame, reverse, curves) ?? line.held;
}
/** Whether a value names something in the project – what deleting that something has to count. */
export function referencesAny(
track: AnimationTrack,