Overview
First chunk of slice D from #260 — the polish that makes the dope sheet (slice C, #372/#373) actually usable for editing more than one keyframe at a time. Defers per-channel rows (D2) and the curve editor + Bezier handles (D3) to their own follow-up issues so this PR stays reviewable.
The original audit comment for slice D bundled everything together; this issue splits it into three sub-slices:
- D1 (this issue): multi-select, copy/paste, bulk move. Data model unchanged — uses Ogre's existing
TransformKeyFrame.
- D2 (follow-up): per-channel rows (T.X / T.Y / R.W / etc.) under each bone.
- D3 (follow-up): curve editor + Bezier handles (requires a tangent side-table since
TransformKeyFrame has no handle data).
Scope (this slice)
Selection state
- New
selectedKeyframes set on the dope sheet's QML view: { bone, time } pairs.
- Click a diamond → replaces selection with just that one (existing behavior preserved).
- Ctrl+click → toggles a diamond in the selection.
- Shift+click → range-select on the same bone (from last clicked to this one).
- Drag a rectangle on empty timeline area → marquee select all diamonds inside the rect (across rows).
- Esc → clear selection.
- Selected diamonds render with a thicker red outline + larger size.
Bulk move
- Dragging any selected diamond moves the whole selection by the same
dt.
- Constraint: clamp the bulk delta so no member of the selection lands < 0 or > animation length, and no member collides with an unselected keyframe at the target time.
- Single
MoveKeyframesCommand per release — undo brings the entire selection back as one step.
Copy / paste
- New
commands/CopyPasteKeyframesCommand — pure paste side; copy itself is just a clipboard string.
- Copy (Ctrl+C while hovering the dope sheet): serialize selected keyframes (bone + relative time + TRS) to JSON in the system clipboard. The earliest selected time becomes
t0 so paste is relative.
- Paste (Ctrl+V): parse JSON; for each entry, create a new keyframe at
currentSliderTime + (entryTime - t0). Skip entries whose target time would collide with an existing keyframe (warn via Sentry breadcrumb, don't fail). One PasteKeyframesCommand for the batch — undo removes everything pasted.
Controller (C++) additions
Q_INVOKABLE bool moveKeyframes(QVariantList selection, double dt) — atomic bulk move, undoable.
Q_INVOKABLE QString serializeKeyframes(QVariantList selection) — clipboard payload (JSON).
Q_INVOKABLE int pasteKeyframesAt(QString json, double atTime) — returns count actually pasted (skipping collisions).
- New
keyframeSelectionChanged() signal so the dope sheet's selection-overlay redraws.
Tests
Pure-data:
serializeKeyframes round-trip (parse what we wrote and verify shape).
pasteKeyframesAt rejects when the target collides with existing keyframes.
Ogre fixture:
moveKeyframes shifts every member by dt; one command on the undo stack.
MoveKeyframesCommand undo restores all keyframes; redo re-applies.
- Bulk move that would push one selection member past
length is clamped (entire selection moves by the maximum legal dt).
PasteKeyframesCommand round-trips: paste, undo, paste again — count is consistent.
Out of scope (separate issues)
- Per-channel rows (slice D2)
- Bezier handles + curve editor (slice D3)
Acceptance criteria
Phase 5 audit comment
Follows: #373 (slice C — dope sheet view).
Overview
First chunk of slice D from #260 — the polish that makes the dope sheet (slice C, #372/#373) actually usable for editing more than one keyframe at a time. Defers per-channel rows (D2) and the curve editor + Bezier handles (D3) to their own follow-up issues so this PR stays reviewable.
The original audit comment for slice D bundled everything together; this issue splits it into three sub-slices:
TransformKeyFrame.TransformKeyFramehas no handle data).Scope (this slice)
Selection state
selectedKeyframesset on the dope sheet's QML view:{ bone, time }pairs.Bulk move
dt.MoveKeyframesCommandper release — undo brings the entire selection back as one step.Copy / paste
commands/CopyPasteKeyframesCommand— pure paste side; copy itself is just a clipboard string.t0so paste is relative.currentSliderTime + (entryTime - t0). Skip entries whose target time would collide with an existing keyframe (warn via Sentry breadcrumb, don't fail). OnePasteKeyframesCommandfor the batch — undo removes everything pasted.Controller (C++) additions
Q_INVOKABLE bool moveKeyframes(QVariantList selection, double dt)— atomic bulk move, undoable.Q_INVOKABLE QString serializeKeyframes(QVariantList selection)— clipboard payload (JSON).Q_INVOKABLE int pasteKeyframesAt(QString json, double atTime)— returns count actually pasted (skipping collisions).keyframeSelectionChanged()signal so the dope sheet's selection-overlay redraws.Tests
Pure-data:
serializeKeyframesround-trip (parse what we wrote and verify shape).pasteKeyframesAtrejects when the target collides with existing keyframes.Ogre fixture:
moveKeyframesshifts every member bydt; one command on the undo stack.MoveKeyframesCommandundo restores all keyframes; redo re-applies.lengthis clamped (entire selection moves by the maximum legaldt).PasteKeyframesCommandround-trips: paste, undo, paste again — count is consistent.Out of scope (separate issues)
Acceptance criteria
Plan (#260)
Phase 5 audit comment
Follows: #373 (slice C — dope sheet view).