Curve editor: fix context menu, persist Bezier metadata, always-visible handles

Context menu fix:
- Move RMB popup and hit-test lambdas before the early-return guard in
  HandleCanvasInput so BeginPopup is called every frame while the popup is
  open, regardless of whether the mouse has left the canvas area.
  Previously the early-return fired (hovered=false) the moment the mouse
  moved onto the menu, silently closing it before any item could register.

Bezier metadata persistence:
- On Apply/bake, write each channel's BezierKey array into
  prim.customData["curveEditor"]["channels"][attrName@component] as flat
  double[]/int[] arrays (times, values, inDt, inDv, outDt, outDv, broken).
- FitCurveFromSamples tries LoadBezierFromMetadata first; falls back to
  Catmull-Rom only when no saved entry exists, so tangent shapes survive
  file save/reload.
- Bake undo command snapshots the entire curveEditor customData value and
  restores it on Ctrl+Z, keeping metadata in sync with sample history.

Always-visible tangent handles:
- Remove keySel guard so in/out handle lines and circles render for every
  keyframe, not just selected ones.
- hitTestHandle now iterates all visible channels/keys so any handle can
  be clicked directly without first selecting its parent keyframe.
- Clicking a handle auto-selects the parent key as well, keeping context
  menu operations (Flatten, Break, Delete) consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 09:07:01 +08:00
parent e0597ae31d
commit e8a73674ad
2 changed files with 254 additions and 88 deletions
+249 -87
View File
@@ -12,8 +12,11 @@
#include <pxr/base/gf/vec4d.h> #include <pxr/base/gf/vec4d.h>
#include <pxr/usd/sdf/valueTypeName.h> #include <pxr/usd/sdf/valueTypeName.h>
#include <pxr/usd/usd/notice.h> #include <pxr/usd/usd/notice.h>
#include <pxr/usd/usd/prim.h>
#include <pxr/base/tf/notice.h> #include <pxr/base/tf/notice.h>
#include <pxr/base/tf/weakPtr.h> #include <pxr/base/tf/weakPtr.h>
#include <pxr/base/vt/dictionary.h>
#include <pxr/base/vt/array.h>
#include <imgui.h> #include <imgui.h>
#include <imgui_internal.h> #include <imgui_internal.h>
@@ -278,6 +281,140 @@ void CurveEditorPanel::RefreshFromStage()
if (!m_channels.empty()) FrameAll(); if (!m_channels.empty()) FrameAll();
} }
// ── Bezier metadata persistence ───────────────────────────────────────────────
//
// Stored under prim.customData["curveEditor"]["channels"][channelKey].
// The channel key uses '@' to separate attribute name from component index
// (avoiding the ':' path separator used by SetCustomDataByKey).
//
// Saved USDA example:
// customData = {
// dictionary curveEditor = {
// dictionary channels = {
// dictionary "xformOp:translate@0" = {
// double[] times = [1, 12, 24]
// double[] values = [0, 3, 0]
// double[] inDt = [-4, -4, -4]
// double[] inDv = [0, -1, 0]
// double[] outDt = [4, 4, 4]
// double[] outDv = [0, 1, 0]
// int[] broken = [0, 0, 0]
// }
// }
// }
// }
/*static*/
std::string CurveEditorPanel::ChannelMetadataKey(const CurveChannel& ch)
{
return ch.attr.GetName().GetString() + "@" + std::to_string(ch.component);
}
bool CurveEditorPanel::SaveBezierToMetadata(const UsdPrim& prim,
const CurveChannel& ch) const
{
if (!prim || ch.keys.empty()) return false;
// Build flat arrays from keys
VtArray<double> times, values, inDt, inDv, outDt, outDv;
VtArray<int> broken;
times.reserve(ch.keys.size());
for (const BezierKey& k : ch.keys) {
times .push_back(k.time);
values.push_back(k.value);
inDt .push_back(k.inTangentDt);
inDv .push_back(k.inTangentDv);
outDt .push_back(k.outTangentDt);
outDv .push_back(k.outTangentDv);
broken.push_back(k.tangentsBroken ? 1 : 0);
}
VtDictionary keyData;
keyData["times"] = VtValue(times);
keyData["values"] = VtValue(values);
keyData["inDt"] = VtValue(inDt);
keyData["inDv"] = VtValue(inDv);
keyData["outDt"] = VtValue(outDt);
keyData["outDv"] = VtValue(outDv);
keyData["broken"] = VtValue(broken);
// Read existing curveEditor dict
VtDictionary ceDict;
VtValue existing = prim.GetCustomDataByKey(TfToken("curveEditor"));
if (existing.IsHolding<VtDictionary>())
ceDict = existing.UncheckedGet<VtDictionary>();
// Read / create channels sub-dict
VtDictionary channels;
{
auto it = ceDict.find("channels");
if (it != ceDict.end() && it->second.IsHolding<VtDictionary>())
channels = it->second.UncheckedGet<VtDictionary>();
}
channels[ChannelMetadataKey(ch)] = VtValue(keyData);
ceDict["channels"] = VtValue(channels);
prim.SetCustomDataByKey(TfToken("curveEditor"), VtValue(ceDict));
return true;
}
bool CurveEditorPanel::LoadBezierFromMetadata(const UsdPrim& prim,
CurveChannel& ch) const
{
if (!prim) return false;
VtValue ceVal = prim.GetCustomDataByKey(TfToken("curveEditor"));
if (!ceVal.IsHolding<VtDictionary>()) return false;
const VtDictionary& ceDict = ceVal.UncheckedGet<VtDictionary>();
auto chIt = ceDict.find("channels");
if (chIt == ceDict.end() || !chIt->second.IsHolding<VtDictionary>()) return false;
const VtDictionary& channels = chIt->second.UncheckedGet<VtDictionary>();
auto kIt = channels.find(ChannelMetadataKey(ch));
if (kIt == channels.end() || !kIt->second.IsHolding<VtDictionary>()) return false;
const VtDictionary& kd = kIt->second.UncheckedGet<VtDictionary>();
auto getDoubleArr = [&](const char* name) -> VtArray<double> {
auto it = kd.find(name);
if (it != kd.end() && it->second.IsHolding<VtArray<double>>())
return it->second.UncheckedGet<VtArray<double>>();
return {};
};
auto getIntArr = [&](const char* name) -> VtArray<int> {
auto it = kd.find(name);
if (it != kd.end() && it->second.IsHolding<VtArray<int>>())
return it->second.UncheckedGet<VtArray<int>>();
return {};
};
VtArray<double> times = getDoubleArr("times");
VtArray<double> values = getDoubleArr("values");
VtArray<double> inDt = getDoubleArr("inDt");
VtArray<double> inDv = getDoubleArr("inDv");
VtArray<double> outDt = getDoubleArr("outDt");
VtArray<double> outDv = getDoubleArr("outDv");
VtArray<int> broken = getIntArr("broken");
size_t n = times.size();
if (n == 0 || values.size() != n) return false;
ch.keys.clear();
ch.keys.reserve(n);
for (size_t i = 0; i < n; ++i) {
BezierKey k;
k.time = times[i];
k.value = values[i];
k.inTangentDt = (inDt.size() > i) ? inDt[i] : -1.0;
k.inTangentDv = (inDv.size() > i) ? inDv[i] : 0.0;
k.outTangentDt = (outDt.size() > i) ? outDt[i] : 1.0;
k.outTangentDv = (outDv.size() > i) ? outDv[i] : 0.0;
k.tangentsBroken = (broken.size() > i) ? broken[i] != 0 : false;
ch.keys.push_back(k);
}
return true;
}
// ── Catmull-Rom fit ─────────────────────────────────────────────────────────── // ── Catmull-Rom fit ───────────────────────────────────────────────────────────
void CurveEditorPanel::FitCurveFromSamples(CurveChannel& ch) void CurveEditorPanel::FitCurveFromSamples(CurveChannel& ch)
@@ -300,6 +437,15 @@ void CurveEditorPanel::FitCurveFromSamples(CurveChannel& ch)
ch.origTimes = times; ch.origTimes = times;
ch.origValues = vals; ch.origValues = vals;
// Prefer saved Bezier tangents over Catmull-Rom re-fit
if (m_stage && !m_selectedPrimPath.empty()) {
UsdPrim prim = m_stage->GetPrimAtPath(SdfPath(m_selectedPrimPath));
if (prim && LoadBezierFromMetadata(prim, ch)) {
ch.dirty = false;
return;
}
}
int n = int(times.size()); int n = int(times.size());
for (int i = 0; i < n; ++i) { for (int i = 0; i < n; ++i) {
BezierKey k; BezierKey k;
@@ -465,6 +611,13 @@ void CurveEditorPanel::BakeChannelToUsd(CurveChannel& ch)
UsdAttribute attr = ch.attr; UsdAttribute attr = ch.attr;
int comp = ch.component; int comp = ch.component;
// Snapshot current Bezier keys and old prim metadata for undo
auto newKeys = ch.keys;
UsdPrim prim = m_stage ? m_stage->GetPrimAtPath(SdfPath(m_selectedPrimPath))
: UsdPrim{};
VtValue oldCEMeta;
if (prim) oldCEMeta = prim.GetCustomDataByKey(TfToken("curveEditor"));
if (!m_commandHistory) { if (!m_commandHistory) {
ClearAllTimeSamples(attr); ClearAllTimeSamples(attr);
for (int i = 0; i < int(newTimes.size()); ++i) for (int i = 0; i < int(newTimes.size()); ++i)
@@ -472,6 +625,7 @@ void CurveEditorPanel::BakeChannelToUsd(CurveChannel& ch)
ch.origTimes = newTimes; ch.origTimes = newTimes;
ch.origValues = newValues; ch.origValues = newValues;
ch.dirty = false; ch.dirty = false;
if (prim) SaveBezierToMetadata(prim, ch);
return; return;
} }
@@ -479,19 +633,27 @@ void CurveEditorPanel::BakeChannelToUsd(CurveChannel& ch)
CurveChannel* chPtr = &ch; CurveChannel* chPtr = &ch;
m_commandHistory->Push(std::make_unique<AttributeSetCommand>( m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
"Bake F-Curve: " + ch.displayName, "Bake F-Curve: " + ch.displayName,
[this, attr, comp, newTimes, newValues, chPtr]() { [this, attr, comp, newTimes, newValues, newKeys, prim, chPtr]() {
ClearAllTimeSamples(attr); ClearAllTimeSamples(attr);
for (int i = 0; i < int(newTimes.size()); ++i) for (int i = 0; i < int(newTimes.size()); ++i)
WriteChannelValue(attr, comp, newTimes[i], newValues[i]); WriteChannelValue(attr, comp, newTimes[i], newValues[i]);
// Update channel cache — safe because bake triggers before prim switch
chPtr->origTimes = newTimes; chPtr->origTimes = newTimes;
chPtr->origValues = newValues; chPtr->origValues = newValues;
chPtr->dirty = false; chPtr->keys = newKeys;
chPtr->dirty = false;
if (prim) SaveBezierToMetadata(prim, *chPtr);
}, },
[this, attr, comp, oldTimes, oldValues, chPtr]() { [this, attr, comp, oldTimes, oldValues, oldCEMeta, prim, chPtr]() {
ClearAllTimeSamples(attr); ClearAllTimeSamples(attr);
for (int i = 0; i < int(oldTimes.size()); ++i) for (int i = 0; i < int(oldTimes.size()); ++i)
WriteChannelValue(attr, comp, oldTimes[i], oldValues[i]); WriteChannelValue(attr, comp, oldTimes[i], oldValues[i]);
// Restore metadata snapshot
if (prim) {
if (oldCEMeta.IsEmpty())
prim.ClearCustomDataByKey(TfToken("curveEditor"));
else
prim.SetCustomDataByKey(TfToken("curveEditor"), oldCEMeta);
}
chPtr->origTimes = oldTimes; chPtr->origTimes = oldTimes;
chPtr->origValues = oldValues; chPtr->origValues = oldValues;
FitCurveFromSamples(*chPtr); FitCurveFromSamples(*chPtr);
@@ -771,29 +933,9 @@ void CurveEditorPanel::HandleCanvasInput(ImVec2 cMin, ImVec2 cMax)
bool hovered = (mouse.x >= cMin.x && mouse.x <= cMax.x && bool hovered = (mouse.x >= cMin.x && mouse.x <= cMax.x &&
mouse.y >= cMin.y && mouse.y <= cMax.y); mouse.y >= cMin.y && mouse.y <= cMax.y);
if (!hovered && !m_isPanning && !m_isDragging && !m_isDraggingCursor // ── Hit test helpers — defined early so the RMB popup can use them ──────────
&& !m_isBoxSelecting) const float kKeyRadius = 8.f;
return; const float kHandleRadius = 7.f;
// ── Time cursor drag ─────────────────────────────────────────────────────
if (!m_displayTime.IsDefault()) {
float cx = TimeToX(m_displayTime.GetValue(), cMin, cMax);
bool nearCursor = (std::fabs(mouse.x - cx) < 8.f && mouse.y >= cMin.y &&
mouse.y <= cMin.y + 18.f);
if (nearCursor && ImGui::IsMouseClicked(0))
m_isDraggingCursor = true;
if (m_isDraggingCursor && ImGui::IsMouseDown(0)) {
double t = XToTime(mouse.x, cMin, cMax);
if (m_snapToFrame) t = std::round(t);
if (OnScrub) OnScrub(t);
}
if (ImGui::IsMouseReleased(0)) m_isDraggingCursor = false;
}
if (m_isDraggingCursor) return;
// ── Hit test helpers ─────────────────────────────────────────────────────
const float kKeyRadius = 8.f;
const float kHandleRadius = 7.f;
auto hitTestKey = [&](ImVec2 p) -> std::pair<int,int> { auto hitTestKey = [&](ImVec2 p) -> std::pair<int,int> {
for (int ci = 0; ci < int(m_channels.size()); ++ci) { for (int ci = 0; ci < int(m_channels.size()); ++ci) {
@@ -809,7 +951,6 @@ void CurveEditorPanel::HandleCanvasInput(ImVec2 cMin, ImVec2 cMax)
}; };
auto hitTestHandle = [&](ImVec2 p) -> SelectedHandle { auto hitTestHandle = [&](ImVec2 p) -> SelectedHandle {
// Test all visible channels/keys — handles are always shown
for (int ci = 0; ci < int(m_channels.size()); ++ci) { for (int ci = 0; ci < int(m_channels.size()); ++ci) {
const CurveChannel& ch = m_channels[ci]; const CurveChannel& ch = m_channels[ci];
if (!ch.visible) continue; if (!ch.visible) continue;
@@ -834,6 +975,86 @@ void CurveEditorPanel::HandleCanvasInput(ImVec2 cMin, ImVec2 cMax)
return {-1,-1,HandlePart::Key}; return {-1,-1,HandlePart::Key};
}; };
// ── RMB context menu — must be processed before the early-return so the
// popup stays alive after the mouse leaves the canvas into the menu. ────────
if (ImGui::IsMouseClicked(1) && hovered) {
auto [ci, ki] = hitTestKey(mouse);
if (ci >= 0) {
if (!IsSelected(ci, ki, HandlePart::Key))
Select(ci, ki, HandlePart::Key, false);
ImGui::OpenPopup("##keyCtx");
}
}
if (ImGui::BeginPopup("##keyCtx")) {
if (ImGui::MenuItem("Delete Key"))
DeleteSelectedKeys();
ImGui::Separator();
if (ImGui::MenuItem("Flatten Tangents")) {
for (const auto& s : m_selection) {
if (s.part != HandlePart::Key) continue;
if (s.chanIdx < 0 || s.chanIdx >= int(m_channels.size())) continue;
CurveChannel& ch = m_channels[s.chanIdx];
if (s.keyIdx < 0 || s.keyIdx >= int(ch.keys.size())) continue;
BezierKey& bk = ch.keys[s.keyIdx];
bk.inTangentDv = 0.0;
bk.outTangentDv = 0.0;
ch.dirty = true;
}
}
if (ImGui::MenuItem("Break Tangents")) {
for (const auto& s : m_selection) {
if (s.part != HandlePart::Key) continue;
if (s.chanIdx < 0 || s.chanIdx >= int(m_channels.size())) continue;
CurveChannel& ch = m_channels[s.chanIdx];
if (s.keyIdx < 0 || s.keyIdx >= int(ch.keys.size())) continue;
ch.keys[s.keyIdx].tangentsBroken = true;
ch.dirty = true;
}
}
if (ImGui::MenuItem("Unify Tangents")) {
for (const auto& s : m_selection) {
if (s.part != HandlePart::Key) continue;
if (s.chanIdx < 0 || s.chanIdx >= int(m_channels.size())) continue;
CurveChannel& ch = m_channels[s.chanIdx];
if (s.keyIdx < 0 || s.keyIdx >= int(ch.keys.size())) continue;
BezierKey& bk = ch.keys[s.keyIdx];
bk.tangentsBroken = false;
bk.inTangentDv = -bk.outTangentDv *
((-bk.inTangentDt > 1e-10) ? -bk.inTangentDt/bk.outTangentDt : 1.0);
ch.dirty = true;
}
}
if (ImGui::MenuItem("Auto Tangents")) {
for (const auto& s : m_selection) {
if (s.part != HandlePart::Key) continue;
if (s.chanIdx < 0 || s.chanIdx >= int(m_channels.size())) continue;
RefitNeighborTangents(m_channels[s.chanIdx], s.keyIdx);
m_channels[s.chanIdx].dirty = true;
}
}
ImGui::EndPopup();
}
if (!hovered && !m_isPanning && !m_isDragging && !m_isDraggingCursor
&& !m_isBoxSelecting)
return;
// ── Time cursor drag ─────────────────────────────────────────────────────
if (!m_displayTime.IsDefault()) {
float cx = TimeToX(m_displayTime.GetValue(), cMin, cMax);
bool nearCursor = (std::fabs(mouse.x - cx) < 8.f && mouse.y >= cMin.y &&
mouse.y <= cMin.y + 18.f);
if (nearCursor && ImGui::IsMouseClicked(0))
m_isDraggingCursor = true;
if (m_isDraggingCursor && ImGui::IsMouseDown(0)) {
double t = XToTime(mouse.x, cMin, cMax);
if (m_snapToFrame) t = std::round(t);
if (OnScrub) OnScrub(t);
}
if (ImGui::IsMouseReleased(0)) m_isDraggingCursor = false;
}
if (m_isDraggingCursor) return;
// ── Pan: MMB or Alt+LMB ────────────────────────────────────────────────── // ── Pan: MMB or Alt+LMB ──────────────────────────────────────────────────
bool wantPan = ImGui::IsMouseDown(2) || bool wantPan = ImGui::IsMouseDown(2) ||
(ImGui::IsMouseDown(0) && io.KeyAlt); (ImGui::IsMouseDown(0) && io.KeyAlt);
@@ -1050,65 +1271,6 @@ void CurveEditorPanel::HandleCanvasInput(ImVec2 cMin, ImVec2 cMax)
} }
} }
// ── RMB context menu on key ───────────────────────────────────────────────
if (ImGui::IsMouseClicked(1) && hovered) {
auto [ci, ki] = hitTestKey(mouse);
if (ci >= 0) {
if (!IsSelected(ci, ki, HandlePart::Key))
Select(ci, ki, HandlePart::Key, false);
ImGui::OpenPopup("##keyCtx");
}
}
if (ImGui::BeginPopup("##keyCtx")) {
if (ImGui::MenuItem("Delete Key"))
DeleteSelectedKeys();
ImGui::Separator();
if (ImGui::MenuItem("Flatten Tangents")) {
for (const auto& s : m_selection) {
if (s.part != HandlePart::Key) continue;
if (s.chanIdx < 0 || s.chanIdx >= int(m_channels.size())) continue;
CurveChannel& ch = m_channels[s.chanIdx];
if (s.keyIdx < 0 || s.keyIdx >= int(ch.keys.size())) continue;
BezierKey& bk = ch.keys[s.keyIdx];
bk.inTangentDv = 0.0;
bk.outTangentDv = 0.0;
ch.dirty = true;
}
}
if (ImGui::MenuItem("Break Tangents")) {
for (const auto& s : m_selection) {
if (s.part != HandlePart::Key) continue;
if (s.chanIdx < 0 || s.chanIdx >= int(m_channels.size())) continue;
CurveChannel& ch = m_channels[s.chanIdx];
if (s.keyIdx < 0 || s.keyIdx >= int(ch.keys.size())) continue;
ch.keys[s.keyIdx].tangentsBroken = true;
ch.dirty = true;
}
}
if (ImGui::MenuItem("Unify Tangents")) {
for (const auto& s : m_selection) {
if (s.part != HandlePart::Key) continue;
if (s.chanIdx < 0 || s.chanIdx >= int(m_channels.size())) continue;
CurveChannel& ch = m_channels[s.chanIdx];
if (s.keyIdx < 0 || s.keyIdx >= int(ch.keys.size())) continue;
BezierKey& bk = ch.keys[s.keyIdx];
bk.tangentsBroken = false;
// Force mirror
bk.inTangentDv = -bk.outTangentDv *
((-bk.inTangentDt > 1e-10) ? -bk.inTangentDt/bk.outTangentDt : 1.0);
ch.dirty = true;
}
}
if (ImGui::MenuItem("Auto Tangents")) {
for (const auto& s : m_selection) {
if (s.part != HandlePart::Key) continue;
if (s.chanIdx < 0 || s.chanIdx >= int(m_channels.size())) continue;
RefitNeighborTangents(m_channels[s.chanIdx], s.keyIdx);
m_channels[s.chanIdx].dirty = true;
}
}
ImGui::EndPopup();
}
} }
// ── Toolbar ─────────────────────────────────────────────────────────────────── // ── Toolbar ───────────────────────────────────────────────────────────────────
+5 -1
View File
@@ -97,9 +97,13 @@ private:
void RevertAll(); void RevertAll();
void DeleteSelectedKeys(); void DeleteSelectedKeys();
void AddKeyAt(int chanIdx, double time, double value); void AddKeyAt(int chanIdx, double time, double value);
// Re-fit Catmull-Rom tangents for neighbors of a modified key
void RefitNeighborTangents(CurveChannel& ch, int keyIdx); void RefitNeighborTangents(CurveChannel& ch, int keyIdx);
// Bezier metadata persistence (stored in prim customData["curveEditor"])
static std::string ChannelMetadataKey(const CurveChannel& ch);
bool SaveBezierToMetadata(const pxr::UsdPrim& prim, const CurveChannel& ch) const;
bool LoadBezierFromMetadata(const pxr::UsdPrim& prim, CurveChannel& ch) const;
// ── State ──────────────────────────────────────────────────────────────── // ── State ────────────────────────────────────────────────────────────────
pxr::UsdStageRefPtr m_stage; pxr::UsdStageRefPtr m_stage;