From f7a2262e8061fd425d38c7d5b5f165060d8ea6ce Mon Sep 17 00:00:00 2001 From: indigo Date: Thu, 2 Jul 2026 07:04:22 +0800 Subject: [PATCH] Add multi-select, specifier column, and visibility shortcuts to hierarchy panel Ctrl+click toggles prim selection; Shift+click range-selects using a per-frame visible-order list. Multi-selection is broadcast to the viewport via a new SetOnPrimsSelected callback so Hydra highlights all selected prims. Adds a 5th table column showing each prim's SdfSpecifier (def/over/class) with colour coding. 'over' prims are shown in orange to make overrides immediately visible. Visibility icon updated: invisible prims show EyeSlash in orange instead of grey. Ctrl+H / Shift+H keyboard shortcuts hide / show all selected prims. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/Application.cpp | 5 ++ src/ui/SceneHierarchyPanel.cpp | 131 ++++++++++++++++++++++++++++++--- src/ui/SceneHierarchyPanel.h | 12 ++- src/ui/ViewportPanel.cpp | 13 +++- src/ui/ViewportPanel.h | 4 +- 5 files changed, 151 insertions(+), 14 deletions(-) diff --git a/src/ui/Application.cpp b/src/ui/Application.cpp index 7a1fd97..b97cdb8 100644 --- a/src/ui/Application.cpp +++ b/src/ui/Application.cpp @@ -115,6 +115,11 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig m_curveEditorPanel->SetSelectedPrimPath(path); }); + m_sceneHierarchyPanel->SetOnPrimsSelected( + [this](const std::vector& paths) { + m_viewportPanel->SetSelectedPrimPaths(paths); + }); + m_sceneHierarchyPanel->SetOnStageMetadataChanged( [this]() { RefreshManagers(); diff --git a/src/ui/SceneHierarchyPanel.cpp b/src/ui/SceneHierarchyPanel.cpp index 10f9ff5..afa76ce 100644 --- a/src/ui/SceneHierarchyPanel.cpp +++ b/src/ui/SceneHierarchyPanel.cpp @@ -86,9 +86,58 @@ void SceneHierarchyPanel::SetSelectedPathFromClick(const std::string& path) { m_primarySelectedPath = path; m_primarySdfPath = path.empty() ? SdfPath() : SdfPath(path); if (!path.empty()) m_selectedPaths.insert(path); - // No scroll — user clicked the item directly, it's already visible. + if (!path.empty()) m_rangeAnchorPath = SdfPath(path); m_scrollToSelected = false; - if (m_onPrimSelected) m_onPrimSelected(path); + if (m_onPrimSelected) m_onPrimSelected(path); + if (m_onPrimsSelected) { + std::vector v; + if (!path.empty()) v.push_back(path); + m_onPrimsSelected(v); + } +} + +void SceneHierarchyPanel::ToggleSelectionFromClick(const std::string& path) { + if (m_selectedPaths.count(path)) { + m_selectedPaths.erase(path); + if (m_primarySelectedPath == path) { + m_primarySelectedPath = m_selectedPaths.empty() ? "" : *m_selectedPaths.begin(); + m_primarySdfPath = m_primarySelectedPath.empty() + ? SdfPath() : SdfPath(m_primarySelectedPath); + } + } else { + m_selectedPaths.insert(path); + m_primarySelectedPath = path; + m_primarySdfPath = SdfPath(path); + m_rangeAnchorPath = SdfPath(path); + } + m_scrollToSelected = false; + if (m_onPrimSelected) m_onPrimSelected(m_primarySelectedPath); + if (m_onPrimsSelected) { + std::vector v(m_selectedPaths.begin(), m_selectedPaths.end()); + m_onPrimsSelected(v); + } +} + +void SceneHierarchyPanel::RangeSelectToPath(const SdfPath& path) { + auto it1 = std::find(m_visiblePrimOrder.begin(), m_visiblePrimOrder.end(), m_rangeAnchorPath); + auto it2 = std::find(m_visiblePrimOrder.begin(), m_visiblePrimOrder.end(), path); + if (it1 != m_visiblePrimOrder.end() && it2 != m_visiblePrimOrder.end()) { + int idx1 = static_cast(it1 - m_visiblePrimOrder.begin()); + int idx2 = static_cast(it2 - m_visiblePrimOrder.begin()); + if (idx1 > idx2) std::swap(idx1, idx2); + for (int i = idx1; i <= idx2; ++i) + m_selectedPaths.insert(m_visiblePrimOrder[i].GetString()); + } else { + m_selectedPaths.insert(path.GetString()); + } + m_primarySelectedPath = path.GetString(); + m_primarySdfPath = path; + m_scrollToSelected = false; + if (m_onPrimSelected) m_onPrimSelected(m_primarySelectedPath); + if (m_onPrimsSelected) { + std::vector v(m_selectedPaths.begin(), m_selectedPaths.end()); + m_onPrimsSelected(v); + } } const char* SceneHierarchyPanel::GetPrimTypeIcon(const UsdPrim& prim) const { @@ -185,6 +234,26 @@ void SceneHierarchyPanel::HandleKeyboardShortcuts() { SetSelectedPathFromClick(groupPath.GetString()); } + // Ctrl+H — hide selected prims (visibility = invisible). + if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_H, false) && + !m_selectedPaths.empty() && m_stage) { + for (const auto& pathStr : m_selectedPaths) { + UsdPrim prim = m_stage->GetPrimAtPath(SdfPath(pathStr)); + if (prim.IsValid() && prim.IsA()) + UsdGeomImageable(prim).GetVisibilityAttr().Set(UsdGeomTokens->invisible); + } + } + + // Shift+H — show selected prims (visibility = inherited). + if (ImGui::GetIO().KeyShift && ImGui::IsKeyPressed(ImGuiKey_H, false) && + !m_selectedPaths.empty() && m_stage) { + for (const auto& pathStr : m_selectedPaths) { + UsdPrim prim = m_stage->GetPrimAtPath(SdfPath(pathStr)); + if (prim.IsValid() && prim.IsA()) + UsdGeomImageable(prim).GetVisibilityAttr().Set(UsdGeomTokens->inherited); + } + } + // Delete — open the remove-prim confirmation modal. if (ImGui::IsKeyPressed(ImGuiKey_Delete, false) && !m_primarySdfPath.IsEmpty()) { auto rootLayer = m_stage->GetRootLayer(); @@ -266,6 +335,9 @@ void SceneHierarchyPanel::Render() { } else { UsdPrim root = m_stage->GetPseudoRoot(); + // Rebuilt each frame as RenderPrimNode visits prims — used for range select. + m_visiblePrimOrder.clear(); + // Rebuild local-layer set once per frame (used by RenderPrimNode to // detect attribute overrides). GetLayerStack() returns only the stage's // own layers — root layer, sublayers, session layer — NOT reference layers. @@ -288,12 +360,14 @@ void SceneHierarchyPanel::Render() { ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit; - if (ImGui::BeginTable("##primtree", 4, tblFlags)) { - // Col 0 stretches; cols 1-3 are small fixed-width icon columns. + const float kSpecW = ImGui::CalcTextSize("over").x + 2.f; + if (ImGui::BeginTable("##primtree", 5, tblFlags)) { + // Col 0 stretches; cols 1-4 are small fixed-width columns. ImGui::TableSetupColumn("##prim", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##type", ImGuiTableColumnFlags_WidthFixed, kIconW); ImGui::TableSetupColumn("##vis", ImGuiTableColumnFlags_WidthFixed, kIconW); ImGui::TableSetupColumn("##ref", ImGuiTableColumnFlags_WidthFixed, kIconW); + ImGui::TableSetupColumn("##spec", ImGuiTableColumnFlags_WidthFixed, kSpecW); for (const auto& child : root.GetChildren()) RenderPrimNode(child); @@ -524,6 +598,7 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { bool isInvisible = false; bool hasRefs = prim.HasAuthoredReferences(); bool hasChildren = !prim.GetChildren().empty(); + SdfSpecifier spec = prim.GetSpecifier(); if (isImageable) { UsdGeomImageable img(prim); @@ -565,6 +640,7 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { // ──────────────────────────────────────────────────────────────────────── ImGui::TableNextRow(); + m_visiblePrimOrder.push_back(primPath); // for Shift+LMB range select ImGui::TableNextColumn(); // Col 0 — prim name + tree arrow ImGui::PushID(primStr.c_str()); @@ -642,6 +718,7 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { ImGui::TableNextColumn(); // Col 1 ImGui::TableNextColumn(); // Col 2 ImGui::TableNextColumn(); // Col 3 + ImGui::TableNextColumn(); // Col 4 if (open && hasChildren) ImGui::TreePop(); ImGui::PopID(); @@ -655,9 +732,16 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { } if (!isRenaming) { - // Selection on click (not on toggle arrow). - if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) - SetSelectedPathFromClick(primStr); + // Selection on LMB click (not on toggle arrow). + // Shift+LMB: range-select from anchor. Ctrl+LMB: toggle. Plain: replace. + if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) { + if (ImGui::GetIO().KeyShift) + RangeSelectToPath(primPath); + else if (ImGui::GetIO().KeyCtrl) + ToggleSelectionFromClick(primStr); + else + SetSelectedPathFromClick(primStr); + } // Double-click to start inline rename. if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { @@ -705,7 +789,7 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { ImGui::EndDragDropTarget(); } - // Context menu (must follow the last widget = the tree node). + // Context menu on RMB. RenderContextMenu(prim); // A context menu op (Unparent, Group, etc.) may have moved this prim, @@ -714,6 +798,7 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { ImGui::TableNextColumn(); // Col 1 ImGui::TableNextColumn(); // Col 2 ImGui::TableNextColumn(); // Col 3 + ImGui::TableNextColumn(); // Col 4 if (open && hasChildren) ImGui::TreePop(); ImGui::PopID(); @@ -740,8 +825,9 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { const float iconSz = ImGui::GetTextLineHeight(); const ImVec2 iconVec(iconSz, iconSz); Icon visIcon = isInvisible ? Icon::EyeSlash : Icon::Eye; - ImVec4 visTint = isInvisible ? ImVec4(0.45f, 0.45f, 0.45f, 0.6f) - : ImVec4(0.9f, 0.9f, 0.9f, 1.0f); + // Invisible: EyeSlash in orange; Visible: Eye at full alpha (dimmer tint). + ImVec4 visTint = isInvisible ? ImVec4(1.00f, 0.60f, 0.15f, 1.0f) + : ImVec4(0.60f, 0.60f, 0.60f, 1.0f); ImTextureID id = m_iconManager ? m_iconManager->Get(visIcon) : ImTextureID_Invalid; ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0)); @@ -750,7 +836,8 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0,0)); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 1.f); - if (ImGui::ImageButton("##vis", ImTextureRef(id), iconVec, + std::string visId = "##vis_" + primStr; + if (ImGui::ImageButton(visId.c_str(), ImTextureRef(id), iconVec, ImVec2(0,0), ImVec2(1,1), ImVec4(0,0,0,0), visTint)) { try { UsdGeomImageable img(prim); @@ -789,6 +876,28 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { ImGui::SetTooltip("Has references"); } + // ── Col 4: Specifier (def / over / class) ────────────────────────────── + ImGui::TableNextColumn(); + { + const char* label = nullptr; + ImVec4 color; + switch (spec) { + case SdfSpecifierOver: + label = "over"; + color = ImVec4(1.00f, 0.60f, 0.10f, isActive ? 1.0f : 0.45f); + break; + case SdfSpecifierClass: + label = "class"; + color = ImVec4(0.40f, 0.70f, 1.00f, isActive ? 1.0f : 0.45f); + break; + default: // SdfSpecifierDef + label = "def"; + color = ImVec4(0.45f, 0.45f, 0.45f, isActive ? 0.55f : 0.30f); + break; + } + ImGui::TextColored(color, "%s", label); + } + // ── Recurse into children ─────────────────────────────────────────────── // TreePop must be called in the SAME column as TreeNodeEx (col 0). // Since we called TableNextColumn three more times above, we must move diff --git a/src/ui/SceneHierarchyPanel.h b/src/ui/SceneHierarchyPanel.h index 8a2059a..7b18002 100644 --- a/src/ui/SceneHierarchyPanel.h +++ b/src/ui/SceneHierarchyPanel.h @@ -1,4 +1,4 @@ -#pragma once +#pragma once #include "../core/PropertyManager.h" #include "../core/CommandHistory.h" @@ -62,6 +62,9 @@ public: using PrimSelectCallback = std::function; void SetOnPrimSelected(PrimSelectCallback callback) { m_onPrimSelected = callback; } + using MultiPrimSelectCallback = std::function&)>; + void SetOnPrimsSelected(MultiPrimSelectCallback cb) { m_onPrimsSelected = cb; } + /// Called when stage-level metadata (e.g. up axis) is changed via the hierarchy panel. using StageMetadataChangedCallback = std::function; void SetOnStageMetadataChanged(StageMetadataChangedCallback callback) { m_onStageMetadataChanged = callback; } @@ -96,7 +99,12 @@ private: /// NOT layers that came in through references or payloads. std::unordered_set m_localLayers; + /// Visible prim order rebuilt each frame — used for Shift+RMB range select. + std::vector m_visiblePrimOrder; + pxr::SdfPath m_rangeAnchorPath; + PrimSelectCallback m_onPrimSelected; + MultiPrimSelectCallback m_onPrimsSelected; StageMetadataChangedCallback m_onStageMetadataChanged; /// Remove-prim confirmation state. @@ -121,6 +129,8 @@ private: bool m_renameJustStarted = false; void HandleKeyboardShortcuts(); + void ToggleSelectionFromClick(const std::string& path); + void RangeSelectToPath(const pxr::SdfPath& path); SdfPath FindUniqueChildPath(const SdfPath& parent, const std::string& baseName); }; diff --git a/src/ui/ViewportPanel.cpp b/src/ui/ViewportPanel.cpp index b16cc33..e1a9330 100644 --- a/src/ui/ViewportPanel.cpp +++ b/src/ui/ViewportPanel.cpp @@ -138,6 +138,17 @@ void ViewportPanel::SetSelectedPrimPath(const std::string& path) BroadcastSelection(); } +void ViewportPanel::SetSelectedPrimPaths(const std::vector& paths) +{ + m_selectedSdfPaths.clear(); + m_selectedPrimPath.clear(); + for (const auto& p : paths) + m_selectedSdfPaths.push_back(pxr::SdfPath(p)); + if (!paths.empty()) + m_selectedPrimPath = paths.front(); + BroadcastSelection(); +} + // --------------------------------------------------------------------------- // Forwarding accessors // --------------------------------------------------------------------------- @@ -384,7 +395,7 @@ void ViewportPanel::RenderGlobalLeftToolbar(ImVec2 contentPos, ImVec2 /*contentS const float kIconPad = 5.0f; const float kRounding = 4.0f; const float kSpacing = 3.0f; - const float kPadX = 9.0f; // left padding inside the strip + const float kPadX = 4.0f; // left padding inside the strip const float kPadY = 10.0f; // top padding const float kSepH = 1.0f; // separator line height const float kSepGap = 6.0f; // space around separator diff --git a/src/ui/ViewportPanel.h b/src/ui/ViewportPanel.h index 3f81e8d..b13fd51 100644 --- a/src/ui/ViewportPanel.h +++ b/src/ui/ViewportPanel.h @@ -48,6 +48,8 @@ public: // ── Selection (called by SceneHierarchyPanel) ──────────────────────────── /// Set a single selected prim (clears any multi-selection). void SetSelectedPrimPath(const std::string& path); + /// Set multiple selected prims (from hierarchy Ctrl+click multi-select). + void SetSelectedPrimPaths(const std::vector& paths); // ── Main render (called from Application::RenderUI) ────────────────────── void Render(bool* p_open = nullptr); @@ -99,7 +101,7 @@ private: bool IsMouseOverDivider(ImVec2 origin, ImVec2 total) const; /// Width (px) of the reserved left toolbar strip. - static constexpr float kToolbarW = 52.0f; + static constexpr float kToolbarW = 40.0f; // ── Selection management ───────────────────────────────────────────────── /// Push the current shared selection into every tile and the manipulator.