Skip to content

feat(animation): curve editor data model + read-only view (Phase 5 slice D3a) - #379

Merged
fernandotonon merged 2 commits into
masterfrom
feat/phase5-slice-d3-curve-editor
May 3, 2026
Merged

feat(animation): curve editor data model + read-only view (Phase 5 slice D3a)#379
fernandotonon merged 2 commits into
masterfrom
feat/phase5-slice-d3-curve-editor

Conversation

@fernandotonon

@fernandotonon fernandotonon commented May 3, 2026

Copy link
Copy Markdown
Owner

Summary

First half of slice D3 from #260 / #378 — the curve editor's data model + read-only QML view. Splitting D3 in two so the PR stays reviewable; D3b lands handle dragging + resample-into-track.

What's in this PR

  • CurveEditModel (C++ singleton) — per-keyframe Bezier tangent handles + interpolation mode (Bezier / Linear / Stepped / Auto), keyed by (skeleton, animation, bone, channel, time). Pure-data so it tests cleanly without Ogre.
  • evaluate() — cubic Hermite spline for Bezier, explicit Linear and Stepped, Catmull-Rom-style auto-tangents for Auto. Returns the curve value at any time given the channel's keyframe times + values.
  • AnimationCurveEditor.qml — Curve Editor view, tabified next to the Dope Sheet. Renders the selected bone's active channels with color coding matching the D2 sub-rows. Time-axis ruler + value-axis grid; per-keyframe squares + tangent handle stubs (non-interactive in D3a).
  • Cmd/Ctrl+wheel zooms the timeline; middle-drag pans. View → Curve Editor toggle.
  • 12 pure-data tests cover tangent storage, mode persistence, invalid-mode rejection, auto-Bezier promotion on tangent edit, key scoping, clearAnimation pruning, and evaluate() across all modes.

Test plan

  • View → Curve Editor opens the dock, tabified next to Dope Sheet
  • Selecting a bone in the Animation Control or Dope Sheet renders its active channels as curves
  • Cmd+wheel zooms; middle-drag pans
  • Empty-state placeholder when no animation / no bone selected
  • Linux CI: 12 new CurveEditModelTest cases pass

Deferred to D3b

  • Handle dragging
  • Resample-into-TransformKeyFrame on edit
  • Per-channel value arrays plumbed from controller
  • Sidecar JSON persistence

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Animation Curve Editor dock panel for visual editing of animation curves
    • Supports per-channel curve display with keyframe and tangent handle visualization
    • Includes horizontal and vertical zoom controls (Ctrl/Cmd + scroll) and pan support (middle-drag)
    • Toggleable from the View menu

Closes part of #378 (D3 of slice D from #260). This PR is the first
half of the curve editor work — the side-table data model and the
QML curve view. Handle dragging + resample-into-TransformKeyFrame
land in a follow-up (D3b) so this PR stays reviewable.

CurveEditModel (C++)
- New singleton storing per-keyframe Bezier tangent handles + an
  interpolation mode (Bezier / Linear / Stepped / Auto) keyed by
  (skeleton, animation, bone, channel, time). Q_INVOKABLE getters /
  setters; in-memory only — sidecar persistence is a follow-up.
- evaluate(skel, anim, bone, ch, time, keyTimes, keyValues) computes
  the channel value at any time. Stepped/Linear are explicit; Bezier
  uses stored tangents (cubic Hermite); Auto derives Catmull-Rom-style
  tangents from neighbors. Pure-data — keeps Ogre out of the model
  for unit-testability.
- Editing tangents on a Linear/Stepped keyframe auto-promotes the
  mode to Bezier (the user's intent is implicit in the drag).
- Sign-agnostic checks aren't relevant here (the model deals in scalar
  channels), unlike collectActiveChannels in slice D2.

QML (AnimationCurveEditor.qml)
- New view, hosted in a QDockWidget tabified next to the Dope Sheet.
  View → Curve Editor toggle in the menu (uses toggleViewAction so
  mainwindow.ui doesn't need a new action).
- Renders the selected bone's active channels as colored curves
  (matches D2 sub-row colors). Per-keyframe squares + tangent stubs
  for visual reference. Empty-state placeholder when no bone is
  selected.
- Time-axis ruler + value-axis grid lines.
- Cmd/Ctrl+wheel zooms timeline; middle-drag pans. Plain wheel is
  reserved for future vertical scroll.
- Read-only this slice: handles draw but don't drag. D3b implements
  drag-to-edit + resample-into-track.

Tests
- CurveEditModelTest: 12 pure-data cases covering tangent round-trip,
  mode persistence + invalid-mode rejection, auto-promotion to Bezier,
  per-key scoping (skeleton/anim/bone/channel), clearAnimation
  pruning, and evaluate() across all modes (empty / single key /
  Linear / Stepped / exact-key / past-end / Auto-Catmull-Rom-midpoint).

Out of scope (D3b follow-up)
- Handle dragging (mouse interaction on tangents/keys)
- Resample-into-TransformKeyFrame on edit
- Per-channel value plumbing — D3a uses CurveEditModel.evaluate as a
  display-only sample; D3b will pipe per-channel keyframe values
  through from AnimationControlController.
- Sidecar JSON persistence

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a complete animation curve editor system consisting of a C++ model (CurveEditModel) that stores and evaluates keyframe tangents and interpolation modes, a QML component (AnimationCurveEditor) that visualizes curves with interactive zoom/pan, and integration into the main window as a dock widget with menu entry.

Changes

Animation Curve Editor System

Layer / File(s) Summary
Data Model
src/CurveEditModel.h
Defines CurveEditModel QObject singleton with InterpMode enum (Bezier/Linear/Stepped/Auto), QML-invokable API for tangent/mode manipulation, and evaluate() for cubic Hermite spline interpolation with auto tangent derivation.
Core Implementation
src/CurveEditModel.cpp
Implements singleton lifecycle, key generation by quantizing time to milliseconds, tangent/mode storage with map-based side-table, and evaluate() logic handling empty/single-key tracks, mode-specific interpolation (Stepped/Linear/Hermite), and auto tangent computation from neighboring keyframes.
QML UI Component
qml/AnimationCurveEditor.qml
Renders per-channel animation curves with time-axis ruler, value-grid lines, sampled curve polylines, keyframe squares, and tangent stubs; supports horizontal/vertical zoom via Ctrl/Cmd-wheel and panning via middle-drag; displays empty state when no animation/bone selected.
Resource & Main Window Integration
src/qml_resources.qrc, src/mainwindow.h, src/mainwindow.cpp
Registers QML resource under /AnimationControl namespace, adds m_curveEditorDock dock widget to main window, registers CurveEditModel as QML singleton, adds "Curve Editor" view menu toggle, and calls CurveEditModel::kill() on teardown.
Build System
src/CMakeLists.txt, tests/CMakeLists.txt
Adds CurveEditModel.cpp and CurveEditModel.h to build sources and headers; includes CurveEditModel.cpp in test compilation.
Unit Tests
src/CurveEditModel_test.cpp
Validates tangent round-trip, mode persistence, key scoping per skeleton/animation, clearAnimation behavior, and evaluate() across empty/single/linear/stepped/auto modes and edge cases (clamping, non-uniform spacing, Catmull-Rom equivalence).

Sequence Diagram

sequenceDiagram
    participant User
    participant AnimationCurveEditor as AnimationCurveEditor.qml
    participant Controller as AnimationControlController
    participant Model as CurveEditModel
    participant Canvas

    User->>AnimationCurveEditor: Select bone
    AnimationCurveEditor->>Controller: Get allBoneRows()
    Controller-->>AnimationCurveEditor: Row data + selection state
    AnimationCurveEditor->>AnimationCurveEditor: Compute activeChannelsForSelected()
    AnimationCurveEditor->>Canvas: requestPaint()

    Canvas->>Canvas: For each active channel:
    Canvas->>Model: evaluate(time, keyframeTimes, keyframeValues)
    Model-->>Canvas: Interpolated value
    Canvas->>Model: tangentsAt(bone, channel, time)
    Model-->>Canvas: [inTangent, outTangent, mode]
    Canvas->>Canvas: Draw curve polyline + keyframe squares + tangent stubs

    User->>AnimationCurveEditor: Drag middle-mouse / Ctrl-wheel
    AnimationCurveEditor->>AnimationCurveEditor: Update viewStart / pxPerSec
    AnimationCurveEditor->>Canvas: requestPaint()
    Canvas-->>Canvas: Re-render with new view transform

    User->>AnimationCurveEditor: Click tangent handle (future interaction)
    AnimationCurveEditor->>Model: setTangents(bone, channel, time, inT, outT)
    Model->>Model: Store tangent + promote mode to Bezier
    Model-->>AnimationCurveEditor: modelChanged signal
    AnimationCurveEditor->>Canvas: requestPaint()
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related Issues

Possibly Related PRs

Poem

🐰 A curve editor hops into view,
Bezier splines and tangents too!
Drag and zoom, the bones now sing,
Hermite magic makes curves spring! 🎨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately describes the main feature: implementation of curve editor data model and read-only view as part of Phase 5 slice D3a.
Description check ✅ Passed Description matches the template structure with Summary and Technical Details sections. Includes clear bullet points of features, comprehensive What's in this PR section, explicit Test plan with checkboxes, and deferred items.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/phase5-slice-d3-curve-editor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e1b8d28a4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/CurveEditModel.cpp
Comment on lines +129 to +131
int lo = 0;
while (lo + 1 < n && keyframeTimes[lo + 1].toDouble() <= time) ++lo;
if (lo == n - 1) return keyframeValues[n - 1].toDouble();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clamp times before first keyframe

When time is earlier than the first keyframe, the bracketing logic leaves lo = 0 and proceeds to interpolate with a negative u, so Linear/Bezier/Auto modes extrapolate instead of holding the first value. This produces incorrect curve values whenever a track’s first key is not at t=0 (for example after shifting keys right), and the editor will render pre-roll segments with spurious values.

Useful? React with 👍 / 👎.

Comment thread src/CurveEditModel.cpp Outdated
Comment on lines +167 to +168
tangentOut = (vHi - neighborPrev) * 0.5;
tangentIn = (neighborNext - vLo) * 0.5;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize auto tangents by keyframe time spacing

The Auto mode tangent estimates use only neighboring values and ignore neighboring keyframe times, but these tangents are later multiplied by dt in Hermite evaluation. On non-uniformly spaced keys this inflates or shrinks slopes dramatically (e.g., wide intervals overshoot heavily), so Auto interpolation is mathematically wrong unless keys are uniformly spaced.

Useful? React with 👍 / 👎.

Codex P1 (clamp before first keyframe):
- evaluate() with time < firstTime used to keep lo = 0, then compute
  u < 0, then evaluate the cubic Hermite with negative u —
  extrapolating the curve past the first keyframe instead of holding
  it. Hold the first value explicitly when time is at or before the
  first keyframe time. Matches Ogre playback semantics.
- New EvaluateBeforeFirstKeyClampsToFirst test covers Bezier and
  Linear paths.

Codex P1 (Auto tangent ignores spacing):
- Auto-mode tangent estimates used (vNeighbor - vSelf) * 0.5, which
  silently assumes uniform keyframe spacing. With non-uniform
  spacing, Hermite's  term produced wrong slopes —
  e.g. on a perfect linear ramp at times {0, 1, 5}, the bracket
  [1, 5] would compute a tangent against the unit-spacing assumption
  and ring above/below the line.
- Switch to standard Catmull-Rom-style time-normalized tangents:
    tangent[i] = (v[i+1] - v[i-1]) / (t[i+1] - t[i-1])
  Collapses to the half-difference for uniform spacing (existing test
  still passes); now correct for non-uniform.
- New EvaluateAutoModeNonUniformSpacing test guards against
  regression.

Sonar S5276 (cpp:S5276):
- explicit static_cast<int>(keyframeTimes.size()) so the qsizetype
  → int conversion is intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
qml/AnimationCurveEditor.qml (2)

173-177: ⚡ Quick win

Hardcoded value-axis range [-2, 2] clips most real-world channel data.

Translation channels routinely carry values in the tens or hundreds of world-units. With the fixed grid loop for (var v = -2; v <= 2; v += 0.5) and no adaptive yCenter/yScale, curves for those channels are entirely off-screen with no visual feedback to the user. Consider computing vMin/vMax from the sampled keyframe values and centering the grid around the actual data range, or at minimum let the user know their data exceeds the visible range.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@qml/AnimationCurveEditor.qml` around lines 173 - 177, The horizontal grid
loop uses a hardcoded value range (for (var v = -2; v <= 2; v += 0.5)) which
causes curves to clip; update the logic that draws horizontal ticks to compute
vMin/vMax from the actual sampled keyframe values (or from
root.yCenter/root.yScale inputs) and derive tick step and range from those
values so ticks are centered and scaled to the data; modify the loop to iterate
from computed vMin to vMax (or clamp and render overflow indicators) and ensure
midY, root.yCenter and root.yScale are used consistently to map value->pixel so
large translation channels remain visible.

162-167: ⚡ Quick win

Time-axis ruler draws off-screen ticks unconditionally.

For a 60-second animation at pxPerSec = 200 with step = 0.25, the ruler loop runs 240 iterations, most of which produce fillText calls well outside [0, width]. A simple bounds guard keeps the work proportional to what's actually visible:

♻️ Proposed guard
             for (var t = 0; t <= maxT; t += step) {
                 var x = (t - root.viewStart) * root.pxPerSec
+                if (x < -20 || x > width + 20) continue
                 ctx.beginPath(); ctx.moveTo(x, height - 12); ctx.lineTo(x, height); ctx.stroke()
                 ctx.fillText(t.toFixed(2) + "s", x + 2, height - 14)
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@qml/AnimationCurveEditor.qml` around lines 162 - 167, The time-axis loop in
AnimationCurveEditor.qml (calculating step from root.pxPerSec and iterating t up
to maxT) draws ticks and calls ctx.fillText/ctx.stroke for values off-screen;
add a bounds check using the computed x (x = (t - root.viewStart) *
root.pxPerSec) against the visible range [0, width] (or a small margin) and skip
drawing when x is outside that range so work is proportional to visible pixels;
update the for loop that uses step, t, x,
ctx.beginPath()/ctx.moveTo()/ctx.lineTo(), ctx.stroke() and ctx.fillText() to
only perform drawing when x >= -margin and x <= width + margin (or equivalent).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@qml/AnimationCurveEditor.qml`:
- Around line 170-179: The grid and labels are drawn with ctx.globalAlpha set to
0.25, which also dims the text; restore the alpha before drawing labels so grid
lines remain dim but labels are fully opaque. In AnimationCurveEditor.qml adjust
the loop that draws horizontal grid lines and labels (uses midY, root.yCenter,
root.yScale, ctx.strokeStyle) so you set ctx.globalAlpha = 0.25 for the
stroke(), then reset ctx.globalAlpha = 1.0 immediately before calling
ctx.fillText(v.toFixed(1), ...) for each label (or after stroke and before any
fillText calls) to ensure labels render at full opacity.
- Around line 222-231: The tangent handle drawing uses raw slope values
(inT/outT) as pixel offsets, which ignores axis scaling; convert each tangent
(from CurveEditModel.tangentsAt()) into a screen-space dy using dy = tangent *
(handlePx / pxPerSec) * yScale and use that dy for the handle endpoints (replace
occurrences of inT * handlePx * 0.5 and outT * handlePx * 0.5 with the scaled
dy, keeping the existing 0.5 factor if intended), referencing variables kx, ky,
handlePx, pxPerSec, yScale, inT, outT and the drawing calls
(ctx.moveTo/ctx.lineTo) in AnimationCurveEditor.qml.
- Around line 260-272: The WheelHandler currently uses acceptedModifiers:
Qt.ControlModifier | Qt.MetaModifier which requires both modifiers together;
change acceptedModifiers to Qt.ControlModifier (remove the bitwise OR with
Qt.MetaModifier) in the WheelHandler block (the instance named WheelHandler with
the onWheel handler) so the wheel zoom triggers when the platform’s primary
control key is pressed; keep the rest of the onWheel logic (root.pxPerSec,
viewStart, curveCanvas.requestPaint()) unchanged.

In `@src/CurveEditModel.cpp`:
- Around line 34-48: makeKey currently joins raw fields with '|' which allows
ambiguity when any skeleton/anim/bone/channel contains '|' and also breaks
clearAnimation's prefix matching; change makeKey to produce an unambiguous
structured key (for example, encode each field as a length-prefixed segment like
"<len>:<data>" or apply a reversible escape on '|' and backslashes) so
collisions are impossible, and update clearAnimation to use the same
encoding/decoding logic or to parse the structured key instead of naive prefix
matching; ensure both makeKey and clearAnimation share the exact same encoding
routine so lookups and prefix-removals remain correct.
- Around line 71-87: In setTangents(...) update the promotion logic so editing
tangents on an auto key actually takes effect: when you modify
entry.inTangent/entry.outTangent in CurveEditModel::setTangents, treat ModeAuto
the same as ModeLinear/ModeStepped and set entry.mode = ModeBezier so evaluate()
won't recompute Catmull‑Rom handles; i.e. include entry.mode == ModeAuto in the
condition that promotes the mode to ModeBezier.
- Around line 105-113: CurveEditModel::clearAnimation currently erases matching
entries from m_entries but doesn't notify QML; after the loop that erases
entries in CurveEditModel::clearAnimation, emit the modelChanged() signal so
views (e.g., AnimationCurveEditor.qml) update. Locate the clearAnimation method
and add a call to emit modelChanged() (or the appropriate signal/method used by
this model) immediately after the removal loop completes.

---

Nitpick comments:
In `@qml/AnimationCurveEditor.qml`:
- Around line 173-177: The horizontal grid loop uses a hardcoded value range
(for (var v = -2; v <= 2; v += 0.5)) which causes curves to clip; update the
logic that draws horizontal ticks to compute vMin/vMax from the actual sampled
keyframe values (or from root.yCenter/root.yScale inputs) and derive tick step
and range from those values so ticks are centered and scaled to the data; modify
the loop to iterate from computed vMin to vMax (or clamp and render overflow
indicators) and ensure midY, root.yCenter and root.yScale are used consistently
to map value->pixel so large translation channels remain visible.
- Around line 162-167: The time-axis loop in AnimationCurveEditor.qml
(calculating step from root.pxPerSec and iterating t up to maxT) draws ticks and
calls ctx.fillText/ctx.stroke for values off-screen; add a bounds check using
the computed x (x = (t - root.viewStart) * root.pxPerSec) against the visible
range [0, width] (or a small margin) and skip drawing when x is outside that
range so work is proportional to visible pixels; update the for loop that uses
step, t, x, ctx.beginPath()/ctx.moveTo()/ctx.lineTo(), ctx.stroke() and
ctx.fillText() to only perform drawing when x >= -margin and x <= width + margin
(or equivalent).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c7d23ae4-c0d6-481b-896c-cdf6a414423a

📥 Commits

Reviewing files that changed from the base of the PR and between 7ecdb10 and 935be73.

📒 Files selected for processing (9)
  • qml/AnimationCurveEditor.qml
  • src/CMakeLists.txt
  • src/CurveEditModel.cpp
  • src/CurveEditModel.h
  • src/CurveEditModel_test.cpp
  • src/mainwindow.cpp
  • src/mainwindow.h
  • src/qml_resources.qrc
  • tests/CMakeLists.txt

Comment on lines +170 to +179
var midY = (height - 16) / 2
ctx.strokeStyle = AnimationControlController.borderColor
ctx.globalAlpha = 0.25
for (var v = -2; v <= 2; v += 0.5) {
var y = midY - (v - root.yCenter) * root.yScale
if (y < 0 || y > height - 16) continue
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke()
ctx.fillText(v.toFixed(1), 4, y - 2)
}
ctx.globalAlpha = 1.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Value-axis grid labels are rendered at 25% opacity — nearly invisible.

ctx.globalAlpha = 0.25 is set at line 172 to dim the grid lines, but the ctx.fillText calls on line 177 are inside the same alpha scope. The alpha is not restored until line 179 (ctx.globalAlpha = 1.0), after all labels have been drawn.

🐛 Proposed fix — reset alpha before drawing text
             ctx.globalAlpha = 0.25
             for (var v = -2; v <= 2; v += 0.5) {
                 var y = midY - (v - root.yCenter) * root.yScale
                 if (y < 0 || y > height - 16) continue
                 ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke()
+                ctx.globalAlpha = 1.0
                 ctx.fillText(v.toFixed(1), 4, y - 2)
+                ctx.globalAlpha = 0.25
             }
             ctx.globalAlpha = 1.0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var midY = (height - 16) / 2
ctx.strokeStyle = AnimationControlController.borderColor
ctx.globalAlpha = 0.25
for (var v = -2; v <= 2; v += 0.5) {
var y = midY - (v - root.yCenter) * root.yScale
if (y < 0 || y > height - 16) continue
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke()
ctx.fillText(v.toFixed(1), 4, y - 2)
}
ctx.globalAlpha = 1.0
var midY = (height - 16) / 2
ctx.strokeStyle = AnimationControlController.borderColor
ctx.globalAlpha = 0.25
for (var v = -2; v <= 2; v += 0.5) {
var y = midY - (v - root.yCenter) * root.yScale
if (y < 0 || y > height - 16) continue
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke()
ctx.globalAlpha = 1.0
ctx.fillText(v.toFixed(1), 4, y - 2)
ctx.globalAlpha = 0.25
}
ctx.globalAlpha = 1.0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@qml/AnimationCurveEditor.qml` around lines 170 - 179, The grid and labels are
drawn with ctx.globalAlpha set to 0.25, which also dims the text; restore the
alpha before drawing labels so grid lines remain dim but labels are fully
opaque. In AnimationCurveEditor.qml adjust the loop that draws horizontal grid
lines and labels (uses midY, root.yCenter, root.yScale, ctx.strokeStyle) so you
set ctx.globalAlpha = 0.25 for the stroke(), then reset ctx.globalAlpha = 1.0
immediately before calling ctx.fillText(v.toFixed(1), ...) for each label (or
after stroke and before any fillText calls) to ensure labels render at full
opacity.

Comment on lines +222 to +231
ctx.globalAlpha = 0.6
// Draw a short handle in each direction
var handlePx = 30
ctx.beginPath()
ctx.moveTo(kx - handlePx, ky + inT * handlePx * 0.5)
ctx.lineTo(kx, ky)
ctx.lineTo(kx + handlePx, ky - outT * handlePx * 0.5)
ctx.stroke()
ctx.globalAlpha = 1.0
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tangent handle pixel-space formula ignores axis scaling.

The in/out tangent values returned by CurveEditModel.tangentsAt() are slopes in value/time units (dv/dt). Applying them directly to pixel offsets (inT * handlePx * 0.5) yields an angle that scales with pxPerSec and yScale in opposite ways; the handles won't visually match the curve's actual slope. The correct screen-space offset is:

dy = tangent * (handlePx / pxPerSec) * yScale
🐛 Proposed fix
-                        ctx.moveTo(kx - handlePx, ky + inT  * handlePx * 0.5)
-                        ctx.lineTo(kx, ky)
-                        ctx.lineTo(kx + handlePx, ky - outT * handlePx * 0.5)
+                        var dtPx = handlePx / root.pxPerSec
+                        ctx.moveTo(kx - handlePx, ky + inT  * dtPx * root.yScale)
+                        ctx.lineTo(kx, ky)
+                        ctx.lineTo(kx + handlePx, ky - outT * dtPx * root.yScale)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@qml/AnimationCurveEditor.qml` around lines 222 - 231, The tangent handle
drawing uses raw slope values (inT/outT) as pixel offsets, which ignores axis
scaling; convert each tangent (from CurveEditModel.tangentsAt()) into a
screen-space dy using dy = tangent * (handlePx / pxPerSec) * yScale and use that
dy for the handle endpoints (replace occurrences of inT * handlePx * 0.5 and
outT * handlePx * 0.5 with the scaled dy, keeping the existing 0.5 factor if
intended), referencing variables kx, ky, handlePx, pxPerSec, yScale, inT, outT
and the drawing calls (ctx.moveTo/ctx.lineTo) in AnimationCurveEditor.qml.

Comment on lines +260 to +272
WheelHandler {
target: null
acceptedModifiers: Qt.ControlModifier | Qt.MetaModifier
onWheel: function(event) {
var factor = event.angleDelta.y > 0 ? 1.15 : (1.0 / 1.15)
var newPx = Math.max(20, Math.min(2000, root.pxPerSec * factor))
if (newPx === root.pxPerSec) return
var tCursor = root.viewStart + event.point.position.x / root.pxPerSec
root.pxPerSec = newPx
root.viewStart = tCursor - event.point.position.x / newPx
if (root.viewStart < 0) root.viewStart = 0
curveCanvas.requestPaint()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -name "AnimationCurveEditor.qml" 2>/dev/null | head -5

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

cat -n qml/AnimationCurveEditor.qml | sed -n '260,272p'

Repository: fernandotonon/QtMeshEditor

Length of output: 785


🌐 Web query:

Qt WheelHandler acceptedModifiers keyboard modifiers semantics documentation

💡 Result:

In Qt Quick QML, WheelHandler has an acceptedModifiers property of type flags (Qt::KeyboardModifiers). It specifies which keyboard modifiers must be pressed for the handler to react to wheel events; otherwise, it ignores them. Default is Qt.KeyboardModifierMask, ignoring modifiers. Semantics: Setting acceptedModifiers requires exactly those modifiers (via bitwise OR) to be active. Use multiple handlers for different combinations, e.g., one with Qt.ControlModifier for rotation, another with Qt.NoModifier for scaling. Available modifiers: - Qt.NoModifier: No modifiers allowed. - Qt.ShiftModifier: Shift pressed. - Qt.ControlModifier: Ctrl pressed. - Qt.AltModifier: Alt pressed. - Qt.MetaModifier: Meta pressed. - Qt.KeypadModifier: Keypad button pressed. - Qt.GroupSwitchModifier: X11 Mode_switch (optional on Windows). - Qt.KeyboardModifierMask: Ignore modifiers. Example from docs: WheelHandler { property: "rotation" acceptedModifiers: Qt.ControlModifier } WheelHandler { property: "scale" acceptedModifiers: Qt.NoModifier } For complex checks, use point.modifiers or wheel.modifiers in onWheel with bitwise AND (&), e.g., if (wheel.modifiers & Qt.ControlModifier). This matches PointerHandler behavior and WheelEvent.modifiers (bitwise Qt::KeyboardModifiers at event time). Official Qt 6 docs consistent across versions.

Citations:


🌐 Web query:

Qt 6 acceptedModifiers combined flags Control Meta bitwise AND matching logic

💡 Result:

In Qt 6 QML input handlers like MouseArea, TapHandler, HoverHandler, and Keys attached property, the acceptedModifiers property uses Qt::KeyboardModifiers flags combined with bitwise OR (|). The matching logic between the event's modifiers (mouse.modifiers or event.modifiers, also Qt::KeyboardModifiers) and acceptedModifiers is a bitwise AND (&) check: the handler accepts the event if (event.modifiers & acceptedModifiers) == acceptedModifiers. This means the event modifiers must have exactly the bits set that are set in acceptedModifiers—no more, no less among those bits. For example: - acceptedModifiers: Qt::ControlModifier accepts only Ctrl (no other modifiers from the checked set). - acceptedModifiers: Qt::ControlModifier | Qt::MetaModifier accepts exactly Ctrl+Meta. - acceptedModifiers: Qt::KeyboardModifierMask accepts any combination. To check if specific modifiers are present (allowing others), use (event.modifiers & Qt::ControlModifier) inside the handler signal, with acceptedModifiers: Qt::KeyboardModifierMask. This behavior is unchanged in Qt 6 from previous versions, as confirmed by code examples and explanations in technical resources.

Citations:


🌐 Web query:

Qt WheelHandler "acceptedModifiers" "Qt.ControlModifier | Qt.MetaModifier" requires both

💡 Result:

Yes, setting acceptedModifiers: Qt.ControlModifier | Qt.MetaModifier in Qt WheelHandler requires both the Control and Meta modifiers to be pressed simultaneously for the handler to react to wheel events. If either is missing, it ignores the event. This behavior is by design in Qt's PointerHandler system (including WheelHandler). The documentation explicitly states: "If you set acceptedModifiers to an OR combination of modifier keys, it means all of those modifiers must be pressed to activate the handler." The bitwise OR (|) creates a bitmask where the handler checks that point.modifiers exactly matches the set bits for those modifiers (using a bitwise AND check internally). Example from docs: Item { TapHandler { // Same logic applies to WheelHandler acceptedModifiers: Qt.ControlModifier | Qt.AltModifier | Qt.ShiftModifier onTapped: console.log("control-alt-shift-tapped") } } For WheelHandler specifically, the Qt 6.11 docs confirm acceptedModifiers works the same way, requiring the specified modifiers.

Citations:


Zoom gesture is broken: acceptedModifiers requires BOTH modifiers simultaneously.

acceptedModifiers: Qt.ControlModifier | Qt.MetaModifier will fire only when both modifiers are pressed at once. Qt's matching logic is (event.modifiers & acceptedModifiers) == acceptedModifiers, so all bits must match exactly. This requires Ctrl+Win on Windows/Linux and ⌘+physical Ctrl on macOS—effectively unreachable in practice.

Qt.ControlModifier alone correctly handles zoom across platforms (Cmd on macOS, Ctrl on Windows/Linux).

Fix
-        acceptedModifiers: Qt.ControlModifier | Qt.MetaModifier
+        acceptedModifiers: Qt.ControlModifier
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
WheelHandler {
target: null
acceptedModifiers: Qt.ControlModifier | Qt.MetaModifier
onWheel: function(event) {
var factor = event.angleDelta.y > 0 ? 1.15 : (1.0 / 1.15)
var newPx = Math.max(20, Math.min(2000, root.pxPerSec * factor))
if (newPx === root.pxPerSec) return
var tCursor = root.viewStart + event.point.position.x / root.pxPerSec
root.pxPerSec = newPx
root.viewStart = tCursor - event.point.position.x / newPx
if (root.viewStart < 0) root.viewStart = 0
curveCanvas.requestPaint()
}
WheelHandler {
target: null
acceptedModifiers: Qt.ControlModifier
onWheel: function(event) {
var factor = event.angleDelta.y > 0 ? 1.15 : (1.0 / 1.15)
var newPx = Math.max(20, Math.min(2000, root.pxPerSec * factor))
if (newPx === root.pxPerSec) return
var tCursor = root.viewStart + event.point.position.x / root.pxPerSec
root.pxPerSec = newPx
root.viewStart = tCursor - event.point.position.x / newPx
if (root.viewStart < 0) root.viewStart = 0
curveCanvas.requestPaint()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@qml/AnimationCurveEditor.qml` around lines 260 - 272, The WheelHandler
currently uses acceptedModifiers: Qt.ControlModifier | Qt.MetaModifier which
requires both modifiers together; change acceptedModifiers to Qt.ControlModifier
(remove the bitwise OR with Qt.MetaModifier) in the WheelHandler block (the
instance named WheelHandler with the onWheel handler) so the wheel zoom triggers
when the platform’s primary control key is pressed; keep the rest of the onWheel
logic (root.pxPerSec, viewStart, curveCanvas.requestPaint()) unchanged.

Comment thread src/CurveEditModel.cpp
Comment on lines +34 to +48
std::string CurveEditModel::makeKey(const QString& skeleton,
const QString& anim,
const QString& bone,
const QString& channel,
double time)
{
// Quantize time to ms so floating-point round-trip doesn't break the key.
// Same precision as the dope sheet's keyframe-tick comparisons.
std::ostringstream ss;
ss << skeleton.toStdString() << '|'
<< anim.toStdString() << '|'
<< bone.toStdString() << '|'
<< channel.toStdString() << '|'
<< static_cast<long long>(std::llround(time * 1000.0));
return ss.str();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the composite key unambiguous.

makeKey() joins raw names with |, so any imported skeleton/animation/bone name containing that character can alias a different (skeleton, anim, bone, channel, time) tuple. clearAnimation() inherits the same ambiguity through its prefix match, so one asset can overwrite or prune another's curve data. Use a structured key or length-prefix/escape each field before concatenation.

🧰 Tools
🪛 Cppcheck (2.20.0)

[error] 42-42: There is an unknown macro here somewhere. Configuration is required. If Q_ENUM is a macro then please configure it.

(unknownMacro)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CurveEditModel.cpp` around lines 34 - 48, makeKey currently joins raw
fields with '|' which allows ambiguity when any skeleton/anim/bone/channel
contains '|' and also breaks clearAnimation's prefix matching; change makeKey to
produce an unambiguous structured key (for example, encode each field as a
length-prefixed segment like "<len>:<data>" or apply a reversible escape on '|'
and backslashes) so collisions are impossible, and update clearAnimation to use
the same encoding/decoding logic or to parse the structured key instead of naive
prefix matching; ensure both makeKey and clearAnimation share the exact same
encoding routine so lookups and prefix-removals remain correct.

Comment thread src/CurveEditModel.cpp
Comment on lines +71 to +87
void CurveEditModel::setTangents(const QString& skeleton,
const QString& anim,
const QString& bone,
const QString& channel,
double time,
double inTangent, double outTangent)
{
const auto key = makeKey(skeleton, anim, bone, channel, time);
auto& entry = m_entries[key];
entry.inTangent = inTangent;
entry.outTangent = outTangent;
if (entry.mode == ModeLinear || entry.mode == ModeStepped) {
// Editing a tangent implies the user wants curve control —
// promote the mode to Bezier so the tangents take effect.
entry.mode = ModeBezier;
}
emit modelChanged(skeleton, anim, bone, channel);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Promote ModeAuto to ModeBezier when tangents are edited.

Right now a key in ModeAuto keeps that mode after setTangents(), and evaluate() recomputes Catmull-Rom tangents instead of using the stored handles. That makes manual tangent edits on auto keys a no-op.

Suggested fix
-    if (entry.mode == ModeLinear || entry.mode == ModeStepped) {
+    if (entry.mode == ModeLinear || entry.mode == ModeStepped || entry.mode == ModeAuto) {
         // Editing a tangent implies the user wants curve control —
         // promote the mode to Bezier so the tangents take effect.
         entry.mode = ModeBezier;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CurveEditModel.cpp` around lines 71 - 87, In setTangents(...) update the
promotion logic so editing tangents on an auto key actually takes effect: when
you modify entry.inTangent/entry.outTangent in CurveEditModel::setTangents,
treat ModeAuto the same as ModeLinear/ModeStepped and set entry.mode =
ModeBezier so evaluate() won't recompute Catmull‑Rom handles; i.e. include
entry.mode == ModeAuto in the condition that promotes the mode to ModeBezier.

Comment thread src/CurveEditModel.cpp
Comment on lines +105 to +113
void CurveEditModel::clearAnimation(const QString& skeleton, const QString& anim)
{
const std::string prefix =
skeleton.toStdString() + "|" + anim.toStdString() + "|";
for (auto it = m_entries.begin(); it != m_entries.end(); ) {
if (it->first.rfind(prefix, 0) == 0) it = m_entries.erase(it);
else ++it;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Emit modelChanged() after clearAnimation().

This erases entries without notifying QML. AnimationCurveEditor.qml repaints off CurveEditModel.onModelChanged, so cleared handles/modes can remain visible until some unrelated repaint happens.

Suggested fix
 void CurveEditModel::clearAnimation(const QString& skeleton, const QString& anim)
 {
     const std::string prefix =
         skeleton.toStdString() + "|" + anim.toStdString() + "|";
+    bool removedAny = false;
     for (auto it = m_entries.begin(); it != m_entries.end(); ) {
-        if (it->first.rfind(prefix, 0) == 0) it = m_entries.erase(it);
+        if (it->first.rfind(prefix, 0) == 0) {
+            it = m_entries.erase(it);
+            removedAny = true;
+        }
         else                                  ++it;
     }
+    if (removedAny) emit modelChanged(skeleton, anim, QString(), QString());
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/CurveEditModel.cpp` around lines 105 - 113,
CurveEditModel::clearAnimation currently erases matching entries from m_entries
but doesn't notify QML; after the loop that erases entries in
CurveEditModel::clearAnimation, emit the modelChanged() signal so views (e.g.,
AnimationCurveEditor.qml) update. Locate the clearAnimation method and add a
call to emit modelChanged() (or the appropriate signal/method used by this
model) immediately after the removal loop completes.

@sonarqubecloud

sonarqubecloud Bot commented May 3, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant