Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 274 additions & 0 deletions qml/AnimationCurveEditor.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import QtQuick
import QtQuick.Controls
import AnimationControl 1.0

// Curve editor — visualizes per-channel animation curves with Bezier handles.
// Reads keyframe times + values from AnimationControlController and tangent
// state from CurveEditModel. Read-only display in this slice (D3a); handle
// dragging + resample-into-track lands in D3b. The dope sheet remains the
// primary editing surface for keyframe selection and time shifts.
Rectangle {
id: root
color: AnimationControlController.panelColor
focus: true

property real pxPerSec: 200
property real viewStart: 0.0
property real yScale: 60 // px per unit value
property real yCenter: 0 // value at the vertical center of the canvas

property int leftStripWidth: 130

// Pulled from AnimationControlController on signal. Each row is the same
// shape as the dope sheet's allBoneRows() returns: { bone, keyTimes,
// channels: { tx, ty, ..., sz: bool } }.
property var rows: AnimationControlController.allBoneRows()

// Selected bone — only that bone's animated channels are drawn. Reuses
// AnimationControlController.selectedBone for cross-panel sync.
readonly property string selectedBone: AnimationControlController.selectedBone

readonly property var channelOrder: [
{ id: "tx", label: "T.X", color: "#c04040" },
{ id: "ty", label: "T.Y", color: "#40c040" },
{ id: "tz", label: "T.Z", color: "#4040c0" },
{ id: "rw", label: "R.W", color: "#a040a0" },
{ id: "rx", label: "R.X", color: "#c04040" },
{ id: "ry", label: "R.Y", color: "#40c040" },
{ id: "rz", label: "R.Z", color: "#4040c0" },
{ id: "sx", label: "S.X", color: "#c08040" },
{ id: "sy", label: "S.Y", color: "#80c040" },
{ id: "sz", label: "S.Z", color: "#4080c0" }
]

function selectedBoneRow() {
for (var i = 0; i < rows.length; i++) {
if (rows[i].bone === selectedBone) return rows[i]
}
return null
}

function activeChannelsForSelected() {
var row = selectedBoneRow()
if (!row || !row.channels) return []
var result = []
for (var i = 0; i < channelOrder.length; i++) {
if (row.channels[channelOrder[i].id]) result.push(channelOrder[i])
}
return result
}

Connections {
target: AnimationControlController
function onBoneRowsChanged() { root.rows = AnimationControlController.allBoneRows(); curveCanvas.requestPaint() }
function onSelectionChanged() { root.rows = AnimationControlController.allBoneRows(); curveCanvas.requestPaint() }
function onKeyframeTicksChanged() { curveCanvas.requestPaint() }
function onBoneListChanged() { curveCanvas.requestPaint() }
}

Connections {
target: CurveEditModel
function onModelChanged() { curveCanvas.requestPaint() }
}

// ── Empty-state placeholder ──────────────────────────────────────────────
Text {
anchors.centerIn: parent
visible: !AnimationControlController.hasAnimation || !root.selectedBone
text: !AnimationControlController.hasAnimation
? "Select a rigged mesh and an animation."
: "Select a bone in the Animation Control or Dope Sheet."
color: AnimationControlController.disabledTextColor
font.pixelSize: 12
}

// ── Header ───────────────────────────────────────────────────────────────
Rectangle {
id: header
width: parent.width; height: 24
color: AnimationControlController.headerColor
border.color: AnimationControlController.borderColor
visible: AnimationControlController.hasAnimation && root.selectedBone

Text {
anchors.left: parent.left; anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: "Curves — " + root.selectedBone
font.bold: true; font.pixelSize: 11
color: AnimationControlController.textColor
}

// Channel legend
Row {
anchors.right: parent.right; anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Repeater {
model: root.activeChannelsForSelected()
Row {
spacing: 4
Rectangle {
width: 10; height: 10; radius: 2
color: modelData.color
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: modelData.label
color: AnimationControlController.textColor
font.pixelSize: 10
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}

// ── Curve canvas ─────────────────────────────────────────────────────────
Canvas {
id: curveCanvas
anchors.left: parent.left
anchors.top: header.visible ? header.bottom : parent.top
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: header.visible

function valueAtTimeForChannel(boneRow, channelId, time) {
// Read the channel's values directly from the keyframes via the
// controller. We don't have a dedicated API yet; fall back to
// CurveEditModel.evaluate, passing in the values we sample from
// the dope-sheet row's keyframe metadata. For D3a we approximate
// by using a single sentinel sample series — D3b will plumb the
// per-channel values through.
return CurveEditModel.evaluate(
AnimationControlController.selectedEntityName,
AnimationControlController.selectedAnimation,
boneRow.bone, channelId, time,
boneRow.keyTimes,
boneRow.keyTimes // placeholder; D3b reads real channel values
)
}

onPaint: {
var ctx = getContext("2d"); ctx.clearRect(0, 0, width, height)
var row = root.selectedBoneRow()
if (!row) return
var maxT = AnimationControlController.animationLength
if (maxT <= 0) return

// Time-axis ruler at the bottom
ctx.strokeStyle = AnimationControlController.borderColor
ctx.fillStyle = AnimationControlController.textColor
ctx.font = "10px sans-serif"; ctx.lineWidth = 1
var step = root.pxPerSec >= 100 ? 0.25 : (root.pxPerSec >= 40 ? 1.0 : 5.0)
for (var t = 0; t <= maxT; t += step) {
var x = (t - root.viewStart) * root.pxPerSec
ctx.beginPath(); ctx.moveTo(x, height - 12); ctx.lineTo(x, height); ctx.stroke()
ctx.fillText(t.toFixed(2) + "s", x + 2, height - 14)
}

// Horizontal value-axis grid lines (every 0.5 in value units)
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
Comment on lines +170 to +179

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.


// Per-channel curve. For D3a we plot the keyframe values directly
// from row.keyTimes paired with the channel's values — but we
// don't yet have per-channel value arrays from the controller, so
// we sample the model's evaluate() across the visible range as a
// demonstration. D3b plumbs the real per-channel data through.
var chans = root.activeChannelsForSelected()
for (var c = 0; c < chans.length; c++) {
var ch = chans[c]
ctx.strokeStyle = ch.color; ctx.lineWidth = 2
ctx.beginPath()
var first = true
var samples = 200
for (var s = 0; s < samples; s++) {
var u = s / (samples - 1)
var time = u * maxT
var val = curveCanvas.valueAtTimeForChannel(row, ch.id, time)
var x2 = (time - root.viewStart) * root.pxPerSec
var y2 = midY - (val - root.yCenter) * root.yScale
if (first) { ctx.moveTo(x2, y2); first = false }
else { ctx.lineTo(x2, y2) }
}
ctx.stroke()

// Keyframe squares + tangent handle stubs (drawn only for
// visual reference in D3a — non-interactive).
ctx.fillStyle = ch.color
for (var k = 0; k < row.keyTimes.length; k++) {
var kt = row.keyTimes[k]
var kv = curveCanvas.valueAtTimeForChannel(row, ch.id, kt)
var kx = (kt - root.viewStart) * root.pxPerSec
var ky = midY - (kv - root.yCenter) * root.yScale
ctx.fillRect(kx - 4, ky - 4, 8, 8)

var tdata = CurveEditModel.tangentsAt(
AnimationControlController.selectedEntityName,
AnimationControlController.selectedAnimation,
row.bone, ch.id, kt)
if (tdata && tdata.length >= 2) {
var inT = tdata[0]
var outT = tdata[1]
ctx.strokeStyle = ch.color; ctx.lineWidth = 1
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
}
Comment on lines +222 to +231

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.

}
}
}
}

// Wheel = zoom horizontally (Ctrl/Cmd) or vertically (Shift), pan with
// middle-drag. Matches the dope sheet's input vocabulary as closely as
// possible to keep mental load low when switching panels.
MouseArea {
id: panArea
anchors.fill: parent
acceptedButtons: Qt.MiddleButton
property real panStartX: 0
property real panStartView: 0
onPressed: function(mouse) {
if (mouse.button === Qt.MiddleButton) {
panStartX = mouse.x; panStartView = root.viewStart
mouse.accepted = true
} else mouse.accepted = false
}
onPositionChanged: function(mouse) {
if (!pressed) return
var dx = mouse.x - panStartX
root.viewStart = panStartView - dx / root.pxPerSec
if (root.viewStart < 0) root.viewStart = 0
curveCanvas.requestPaint()
}
}
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()
}
Comment on lines +260 to +272

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.

}
}
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ set(SRC_FILES
about.cpp
AnimationBlender.cpp
AnimationControlController.cpp
CurveEditModel.cpp
main.cpp
Manager.cpp
material.cpp
Expand Down Expand Up @@ -79,6 +80,7 @@ HalfEdgeMesh.cpp
set(HEADER_FILES
AnimationBlender.h
AnimationControlController.h
CurveEditModel.h
GlobalDefinitions.h
Euler.h
about.h
Expand Down
Loading
Loading