From e8a73674ad8ed43e74e177e5ea40b732581614e0 Mon Sep 17 00:00:00 2001 From: indigo Date: Wed, 24 Jun 2026 09:07:01 +0800 Subject: [PATCH] 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 --- src/ui/CurveEditorPanel.cpp | 336 ++++++++++++++++++++++++++---------- src/ui/CurveEditorPanel.h | 6 +- 2 files changed, 254 insertions(+), 88 deletions(-) diff --git a/src/ui/CurveEditorPanel.cpp b/src/ui/CurveEditorPanel.cpp index adf7074..c39410c 100644 --- a/src/ui/CurveEditorPanel.cpp +++ b/src/ui/CurveEditorPanel.cpp @@ -12,8 +12,11 @@ #include #include #include +#include #include #include +#include +#include #include #include @@ -278,6 +281,140 @@ void CurveEditorPanel::RefreshFromStage() 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 times, values, inDt, inDv, outDt, outDv; + VtArray 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()) + ceDict = existing.UncheckedGet(); + + // Read / create channels sub-dict + VtDictionary channels; + { + auto it = ceDict.find("channels"); + if (it != ceDict.end() && it->second.IsHolding()) + channels = it->second.UncheckedGet(); + } + + 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()) return false; + const VtDictionary& ceDict = ceVal.UncheckedGet(); + + auto chIt = ceDict.find("channels"); + if (chIt == ceDict.end() || !chIt->second.IsHolding()) return false; + const VtDictionary& channels = chIt->second.UncheckedGet(); + + auto kIt = channels.find(ChannelMetadataKey(ch)); + if (kIt == channels.end() || !kIt->second.IsHolding()) return false; + const VtDictionary& kd = kIt->second.UncheckedGet(); + + auto getDoubleArr = [&](const char* name) -> VtArray { + auto it = kd.find(name); + if (it != kd.end() && it->second.IsHolding>()) + return it->second.UncheckedGet>(); + return {}; + }; + auto getIntArr = [&](const char* name) -> VtArray { + auto it = kd.find(name); + if (it != kd.end() && it->second.IsHolding>()) + return it->second.UncheckedGet>(); + return {}; + }; + + VtArray times = getDoubleArr("times"); + VtArray values = getDoubleArr("values"); + VtArray inDt = getDoubleArr("inDt"); + VtArray inDv = getDoubleArr("inDv"); + VtArray outDt = getDoubleArr("outDt"); + VtArray outDv = getDoubleArr("outDv"); + VtArray 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 ─────────────────────────────────────────────────────────── void CurveEditorPanel::FitCurveFromSamples(CurveChannel& ch) @@ -300,6 +437,15 @@ void CurveEditorPanel::FitCurveFromSamples(CurveChannel& ch) ch.origTimes = times; 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()); for (int i = 0; i < n; ++i) { BezierKey k; @@ -465,6 +611,13 @@ void CurveEditorPanel::BakeChannelToUsd(CurveChannel& ch) UsdAttribute attr = ch.attr; 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) { ClearAllTimeSamples(attr); for (int i = 0; i < int(newTimes.size()); ++i) @@ -472,6 +625,7 @@ void CurveEditorPanel::BakeChannelToUsd(CurveChannel& ch) ch.origTimes = newTimes; ch.origValues = newValues; ch.dirty = false; + if (prim) SaveBezierToMetadata(prim, ch); return; } @@ -479,19 +633,27 @@ void CurveEditorPanel::BakeChannelToUsd(CurveChannel& ch) CurveChannel* chPtr = &ch; m_commandHistory->Push(std::make_unique( "Bake F-Curve: " + ch.displayName, - [this, attr, comp, newTimes, newValues, chPtr]() { + [this, attr, comp, newTimes, newValues, newKeys, prim, chPtr]() { ClearAllTimeSamples(attr); for (int i = 0; i < int(newTimes.size()); ++i) WriteChannelValue(attr, comp, newTimes[i], newValues[i]); - // Update channel cache — safe because bake triggers before prim switch chPtr->origTimes = newTimes; 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); for (int i = 0; i < int(oldTimes.size()); ++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->origValues = oldValues; FitCurveFromSamples(*chPtr); @@ -771,29 +933,9 @@ void CurveEditorPanel::HandleCanvasInput(ImVec2 cMin, ImVec2 cMax) bool hovered = (mouse.x >= cMin.x && mouse.x <= cMax.x && mouse.y >= cMin.y && mouse.y <= cMax.y); - 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; - - // ── Hit test helpers ───────────────────────────────────────────────────── - const float kKeyRadius = 8.f; - const float kHandleRadius = 7.f; + // ── Hit test helpers — defined early so the RMB popup can use them ────────── + const float kKeyRadius = 8.f; + const float kHandleRadius = 7.f; auto hitTestKey = [&](ImVec2 p) -> std::pair { 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 { - // Test all visible channels/keys — handles are always shown for (int ci = 0; ci < int(m_channels.size()); ++ci) { const CurveChannel& ch = m_channels[ci]; if (!ch.visible) continue; @@ -834,6 +975,86 @@ void CurveEditorPanel::HandleCanvasInput(ImVec2 cMin, ImVec2 cMax) 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 ────────────────────────────────────────────────── bool wantPan = ImGui::IsMouseDown(2) || (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 ─────────────────────────────────────────────────────────────────── diff --git a/src/ui/CurveEditorPanel.h b/src/ui/CurveEditorPanel.h index ddacb36..a1e1b81 100644 --- a/src/ui/CurveEditorPanel.h +++ b/src/ui/CurveEditorPanel.h @@ -97,9 +97,13 @@ private: void RevertAll(); void DeleteSelectedKeys(); 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); + // 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 ──────────────────────────────────────────────────────────────── pxr::UsdStageRefPtr m_stage;