Add Maya-style prim editing to SceneHierarchyPanel

- Inline rename: double-click prim label or F2 to edit name in-place;
  Enter commits, click-away or Esc cancels (undo-able)
- Drag-and-drop reparent: drag any prim onto another to make it a child;
  invisible root drop zone below tree to reparent to stage root (undo-able)
- Ctrl+G group: creates Xform group at common parent, reparents selection
  under it; filters descendant duplication; selects new group (undo-able)
- Delete key shortcut: opens existing remove-prim modal when panel focused
- Context menu: Rename (F2) and Group (Ctrl+G) items added
- Three new undo-able commands using UsdNamespaceEditor (USD 25.05):
  RenamePrimCommand, ReparentPrimCommand, GroupPrimsCommand

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 19:44:55 +08:00
parent 88ae8ccefa
commit b31ca69d4f
8 changed files with 586 additions and 28 deletions
+229 -28
View File
@@ -5,6 +5,9 @@
#include "../core/commands/DeletePrimCommand.h"
#include "../core/commands/AddReferenceCommand.h"
#include "../core/commands/ReplaceReferenceCommand.h"
#include "../core/commands/RenamePrimCommand.h"
#include "../core/commands/ReparentPrimCommand.h"
#include "../core/commands/GroupPrimsCommand.h"
#include <pxr/usd/usd/prim.h>
#include <pxr/usd/usd/primRange.h>
#include <pxr/usd/usd/references.h>
@@ -118,12 +121,88 @@ Icon SceneHierarchyPanel::GetPrimTypeIconEnum(const UsdPrim& prim) const {
return Icon::CircleDot;
}
SdfPath SceneHierarchyPanel::FindUniqueChildPath(const SdfPath& parent,
const std::string& baseName) {
std::string name = baseName;
int n = 1;
while (m_stage->GetPrimAtPath(parent.AppendChild(TfToken(name))).IsValid())
name = baseName + "_" + std::to_string(n++);
return parent.AppendChild(TfToken(name));
}
void SceneHierarchyPanel::HandleKeyboardShortcuts() {
if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) return;
// Don't steal keys while any InputText (rename, arc modal, etc.) is active.
if (ImGui::GetIO().WantTextInput) return;
// F2 — start inline rename on the primary selection.
if (ImGui::IsKeyPressed(ImGuiKey_F2, false) && !m_primarySdfPath.IsEmpty()) {
UsdPrim prim = m_stage->GetPrimAtPath(m_primarySdfPath);
if (prim.IsValid()) {
m_renamingPath = m_primarySdfPath;
std::string name = prim.GetName().GetString();
strncpy(m_renameBuf, name.c_str(), sizeof(m_renameBuf) - 1);
m_renameBuf[sizeof(m_renameBuf) - 1] = '\0';
m_renameJustStarted = true;
m_scrollToSelected = true; // ensure the prim is visible
}
}
// Ctrl+G — group selected prims under a new Xform.
if (ImGui::GetIO().KeyCtrl &&
ImGui::IsKeyPressed(ImGuiKey_G, false) &&
!m_selectedPaths.empty() && m_commandHistory && m_stage) {
std::vector<SdfPath> srcs;
for (const auto& s : m_selectedPaths)
srcs.emplace_back(s);
// Filter out paths that are descendants of other selected paths
// (they'll move with their ancestor, so grouping them separately is wrong).
std::vector<SdfPath> filtered;
for (const SdfPath& p : srcs) {
bool hasAncestorInSet = false;
for (const SdfPath& other : srcs) {
if (other != p && p.HasPrefix(other)) {
hasAncestorInSet = true;
break;
}
}
if (!hasAncestorInSet)
filtered.push_back(p);
}
if (filtered.empty()) return;
// Compute common parent of the filtered set.
SdfPath commonParent = filtered[0].GetParentPath();
for (const SdfPath& p : filtered) {
while (!p.HasPrefix(commonParent))
commonParent = commonParent.GetParentPath();
}
SdfPath groupPath = FindUniqueChildPath(commonParent, "group1");
m_commandHistory->Push(std::make_unique<GroupPrimsCommand>(m_stage, filtered, groupPath));
SetSelectedPathFromClick(groupPath.GetString());
}
// Delete — open the remove-prim confirmation modal.
if (ImGui::IsKeyPressed(ImGuiKey_Delete, false) && !m_primarySdfPath.IsEmpty()) {
auto rootLayer = m_stage->GetRootLayer();
if (rootLayer && !!rootLayer->GetPrimAtPath(m_primarySdfPath)) {
m_pendingRemovePrimPath = m_primarySdfPath;
m_showRemovePrimConfirm = true;
}
}
}
void SceneHierarchyPanel::Render() {
if (!m_stage) {
ImGui::TextDisabled("No stage loaded");
return;
}
HandleKeyboardShortcuts();
// ── Edit-target layer switcher ────────────────────────────────────
{
struct LayerEntry { SdfLayerHandle layer; std::string label; };
@@ -222,6 +301,25 @@ void SceneHierarchyPanel::Render() {
ImGui::EndTable();
}
// ── Stage-root drop zone ───────────────────────────────────────────
// Invisible strip below the tree: drop here to reparent a prim to root.
ImGui::InvisibleButton("##rootdrop", ImVec2(-1.0f, 8.0f));
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("USD_PRIM_PATH")) {
SdfPath srcPath(static_cast<const char*>(payload->Data));
// Only reparent if not already a root prim.
if (m_commandHistory && srcPath.GetPathElementCount() > 1) {
std::string name = FindUniqueChildPath(SdfPath("/"), srcPath.GetName()).GetName();
m_commandHistory->Push(std::make_unique<ReparentPrimCommand>(
m_stage, srcPath, SdfPath("/"), name));
SetSelectedPathFromClick(SdfPath("/").AppendChild(TfToken(name)).GetString());
}
}
ImGui::EndDragDropTarget();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Drop here to reparent to stage root");
// Deselect when left-clicking on blank space (no prim item hovered).
if (ImGui::IsWindowHovered(ImGuiHoveredFlags_RootAndChildWindows) &&
ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
@@ -490,45 +588,111 @@ void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) {
pushedColor = true;
}
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow |
ImGuiTreeNodeFlags_OpenOnDoubleClick |
ImGuiTreeNodeFlags_SpanAllColumns; // ← key: full-row item rect
if (isSelected) flags |= ImGuiTreeNodeFlags_Selected;
if (!hasChildren) flags |= ImGuiTreeNodeFlags_Leaf |
ImGuiTreeNodeFlags_NoTreePushOnOpen;
bool isRenaming = (m_renamingPath == primPath);
bool open = ImGui::TreeNodeEx(displayName.c_str(), flags);
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow |
ImGuiTreeNodeFlags_SpanAllColumns; // full-row item rect
if (!isRenaming) flags |= ImGuiTreeNodeFlags_OpenOnDoubleClick;
if (isSelected) flags |= ImGuiTreeNodeFlags_Selected;
if (!hasChildren) flags |= ImGuiTreeNodeFlags_Leaf |
ImGuiTreeNodeFlags_NoTreePushOnOpen;
if (pushedColor)
ImGui::PopStyleColor();
bool open = false;
if (isRenaming) {
// Render just the arrow (hidden label) so we keep indentation and open/close,
// then overlay an InputText inline for the prim name.
open = ImGui::TreeNodeEx("##renaming", flags);
if (pushedColor)
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::SetNextItemWidth(-1.0f);
if (m_renameJustStarted) {
ImGui::SetKeyboardFocusHere();
m_renameJustStarted = false;
}
bool commit = ImGui::InputText("##rename_input", m_renameBuf, sizeof(m_renameBuf),
ImGuiInputTextFlags_EnterReturnsTrue |
ImGuiInputTextFlags_AutoSelectAll);
bool canceled = ImGui::IsItemDeactivated() && !commit;
if (commit && m_renameBuf[0] != '\0') {
std::string newName = SanitizeUsdName(m_renameBuf);
if (!newName.empty() && newName != displayName && m_commandHistory) {
m_commandHistory->Push(std::make_unique<RenamePrimCommand>(
m_stage, primPath, newName));
SdfPath newPath = primPath.GetParentPath().AppendChild(TfToken(newName));
SetSelectedPathFromClick(newPath.GetString());
}
m_renamingPath = SdfPath();
}
if (canceled)
m_renamingPath = SdfPath();
} else {
open = ImGui::TreeNodeEx(displayName.c_str(), flags);
if (pushedColor)
ImGui::PopStyleColor();
}
// ── Scroll-to-selection (now reliable: SpanAllColumns gives correct row rect) ──
// ── Scroll-to-selection (SpanAllColumns gives correct full-row rect) ────
if (m_scrollToSelected && primStr == m_primarySelectedPath) {
ImGui::SetScrollHereY(0.5f);
m_scrollToSelected = false;
}
// Selection on click (not on toggle arrow).
if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen())
SetSelectedPathFromClick(primStr);
if (!isRenaming) {
// Selection on click (not on toggle arrow).
if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen())
SetSelectedPathFromClick(primStr);
// Tooltip.
if (ImGui::IsItemHovered()) {
std::string tip = "Type: " + (typeName.empty() ? "(unknown)" : typeName) +
"\nPath: " + primStr +
"\nActive: " + (isActive ? "Yes" : "No");
if (isImageable)
tip += std::string("\nVisibility: ") + (isInvisible ? "Invisible" : "Visible");
if (hasRefs)
tip += "\nHas references";
if (m_selectedPaths.size() > 1)
tip += "\n\n" + std::to_string(m_selectedPaths.size()) + " prims selected";
ImGui::SetTooltip("%s", tip.c_str());
// Double-click to start inline rename.
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
m_renamingPath = primPath;
strncpy(m_renameBuf, displayName.c_str(), sizeof(m_renameBuf) - 1);
m_renameBuf[sizeof(m_renameBuf) - 1] = '\0';
m_renameJustStarted = true;
}
// Tooltip.
if (ImGui::IsItemHovered()) {
std::string tip = "Type: " + (typeName.empty() ? "(unknown)" : typeName) +
"\nPath: " + primStr +
"\nActive: " + (isActive ? "Yes" : "No");
if (isImageable)
tip += std::string("\nVisibility: ") + (isInvisible ? "Invisible" : "Visible");
if (hasRefs)
tip += "\nHas references";
if (m_selectedPaths.size() > 1)
tip += "\n\n" + std::to_string(m_selectedPaths.size()) + " prims selected";
ImGui::SetTooltip("%s", tip.c_str());
}
// ── Drag-and-drop source ─────────────────────────────────────────
if (ImGui::BeginDragDropSource()) {
ImGui::SetDragDropPayload("USD_PRIM_PATH",
primStr.c_str(), primStr.size() + 1,
ImGuiCond_Once);
ImGui::Text("Move: %s", displayName.c_str());
ImGui::EndDragDropSource();
}
// ── Drag-and-drop target ─────────────────────────────────────────
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("USD_PRIM_PATH")) {
SdfPath src(static_cast<const char*>(payload->Data));
// Guard: don't reparent onto self or onto a descendant.
if (src != primPath && !primPath.HasPrefix(src) && m_commandHistory) {
std::string name = FindUniqueChildPath(primPath, src.GetName()).GetName();
m_commandHistory->Push(std::make_unique<ReparentPrimCommand>(
m_stage, src, primPath, name));
SetSelectedPathFromClick(primPath.AppendChild(TfToken(name)).GetString());
}
}
ImGui::EndDragDropTarget();
}
// Context menu (must follow the last widget = the tree node).
RenderContextMenu(prim);
}
// Context menu (must follow the last widget = the tree node).
RenderContextMenu(prim);
// ── Col 1: Prim-type icon ───────────────────────────────────────────────
ImGui::TableNextColumn();
{
@@ -653,6 +817,43 @@ void SceneHierarchyPanel::RenderContextMenu(const UsdPrim& prim) {
ImGui::Separator();
// ── Rename / Group ────────────────────────────────────────────────────
if (ImGui::MenuItem("Rename", "F2")) {
m_renamingPath = prim.GetPath();
std::string name = prim.GetName().GetString();
strncpy(m_renameBuf, name.c_str(), sizeof(m_renameBuf) - 1);
m_renameBuf[sizeof(m_renameBuf) - 1] = '\0';
m_renameJustStarted = true;
m_scrollToSelected = true;
}
if (ImGui::MenuItem("Group", "Ctrl+G")) {
std::vector<SdfPath> srcs;
if (m_selectedPaths.count(prim.GetPath().GetString()))
for (const auto& s : m_selectedPaths) srcs.emplace_back(s);
else
srcs.push_back(prim.GetPath());
if (!srcs.empty() && m_commandHistory && m_stage) {
// Filter descendants
std::vector<SdfPath> filtered;
for (const SdfPath& p : srcs) {
bool hasAncestor = false;
for (const SdfPath& other : srcs)
if (other != p && p.HasPrefix(other)) { hasAncestor = true; break; }
if (!hasAncestor) filtered.push_back(p);
}
if (!filtered.empty()) {
SdfPath commonParent = filtered[0].GetParentPath();
for (const SdfPath& p : filtered)
while (!p.HasPrefix(commonParent)) commonParent = commonParent.GetParentPath();
SdfPath groupPath = FindUniqueChildPath(commonParent, "group1");
m_commandHistory->Push(std::make_unique<GroupPrimsCommand>(m_stage, filtered, groupPath));
SetSelectedPathFromClick(groupPath.GetString());
}
}
}
ImGui::Separator();
bool hasChildren = !prim.GetChildren().empty();
if (ImGui::MenuItem("Expand Children", nullptr, false, hasChildren)) {
ImGui::GetStateStorage()->SetInt(ImGui::GetID(prim.GetPath().GetText()), 1);
+8
View File
@@ -114,6 +114,14 @@ private:
std::string m_arcModalVarSetName;
char m_arcModalBuf[512] = {};
bool m_openArcModal = false;
/// Inline rename state.
SdfPath m_renamingPath; ///< non-empty while renaming
char m_renameBuf[256] = {};
bool m_renameJustStarted = false;
void HandleKeyboardShortcuts();
SdfPath FindUniqueChildPath(const SdfPath& parent, const std::string& baseName);
};
} // namespace UsdLayerManager