From 42fd355c53a17617673cbef760fe6ef28a19f9ed Mon Sep 17 00:00:00 2001 From: indigo Date: Sun, 5 Jul 2026 06:47:12 +0800 Subject: [PATCH] Group create-node list by source and category; make editor columns resizable The create-node list is now a two-level tree: a header per node source (USD, MaterialX, Arnold, Cycles - from the Sdr sourceType, with glslfx and OSL merged into USD so core nodes don't appear twice) containing Hypershade-style category sub-trees (Material / Texture / Geometry / Utility / Light) derived from Sdr context, terminal output types, role, and name keywords. Duplicate collapsing is per (source, label) so same-named nodes from different providers both stay; searching forces all sections open. The TAB popup shows [Source/Category] per entry. The browser and preview columns get drag splitters (viewport-divider styling, clamped widths); the canvas absorbs the remainder. Co-Authored-By: Claude Fable 5 --- src/core/MaterialManager.cpp | 85 ++++++++++++++++++++++++++++++- src/core/MaterialManager.h | 10 +++- src/ui/MaterialEditorPanel.cpp | 92 +++++++++++++++++++++++++++------- src/ui/MaterialEditorPanel.h | 5 ++ 4 files changed, 173 insertions(+), 19 deletions(-) diff --git a/src/core/MaterialManager.cpp b/src/core/MaterialManager.cpp index 4e8db94..12bbcc4 100644 --- a/src/core/MaterialManager.cpp +++ b/src/core/MaterialManager.cpp @@ -1,5 +1,7 @@ #include "MaterialManager.h" #include +#include +#include #include #include #include @@ -9,10 +11,84 @@ #include #include #include +#include #include namespace UsdLayerManager { +namespace { + +// Coarse Hypershade-style bucket for the create-node list. Sdr metadata is +// parser-dependent (glslfx vs MaterialX vs Arnold vs Cycles fill different +// fields), so classify from the strongest signal available: context first, +// then terminal output types, then role, then name keywords. +std::string DeriveCategory(pxr::SdrShaderNodeConstPtr node) { + const std::string context = pxr::TfStringify(node->GetContext()); + if (context == "surface" || context == "volume" || context == "displacement") + return "Material"; + if (context == "light" || context == "lightFilter") + return "Light"; + + // MaterialX/Arnold terminals often lack a context but type their outputs + // as surfaceshader/volumeshader/displacementshader/closure. + for (const pxr::TfToken& outName : node->GetShaderOutputNames()) { + if (const auto* prop = node->GetShaderOutput(outName)) { + const std::string outType = pxr::TfStringify(prop->GetType()); + if (outType.find("shader") != std::string::npos || + outType == "terminal" || outType == "closure") + return "Material"; + } + } + + const std::string role = pxr::TfStringify(node->GetRole()); + if (role == "texture") return "Texture"; + if (role == "primvar") return "Geometry"; + if (role == "math") return "Utility"; + + const std::string name = pxr::TfStringToLower( + node->GetIdentifier().GetString() + " " + node->GetName()); + for (const char* kw : {"texture", "image", "checker", "noise", "ramp", + "fractal", "worley", "voronoi", "triplanar"}) + if (name.find(kw) != std::string::npos) return "Texture"; + for (const char* kw : {"primvar", "texcoord", "geomprop", "position", + "normal", "tangent", "bitangent", "uv_"}) + if (name.find(kw) != std::string::npos) return "Geometry"; + + return "Utility"; +} + +// Fixed display order for the known buckets; anything unexpected sorts after. +int CategoryRank(const std::string& category) { + if (category == "Material") return 0; + if (category == "Texture") return 1; + if (category == "Geometry") return 2; + if (category == "Utility") return 3; + if (category == "Light") return 4; + return 5; +} + +// Top-level group from the Sdr source type. glslfx and OSL collapse into one +// "USD" group — the core usd* nodes register under both parsers and would +// otherwise show up twice under different headers. +std::string DeriveSource(pxr::SdrShaderNodeConstPtr node) { + std::string st = pxr::TfStringToLower(pxr::TfStringify(node->GetSourceType())); + if (st == "glslfx" || st == "osl") return "USD"; + if (st == "mtlx") return "MaterialX"; + if (st == "arnold") return "Arnold"; + if (st == "cycles") return "Cycles"; + if (st.empty()) return "Other"; + st[0] = static_cast(std::toupper(static_cast(st[0]))); + return st; +} + +int SourceRank(const std::string& source) { + if (source == "USD") return 0; + if (source == "MaterialX") return 1; + return 2; // renderers and anything else, alphabetical among themselves +} + +} // namespace + const std::vector& MaterialManager::GetAvailableShaderNodes() { if (m_shaderNodeCacheBuilt) return m_shaderNodeCache; @@ -24,12 +100,19 @@ const std::vector& MaterialManager::GetAvailableShaderNodes( info.identifier = node->GetIdentifier().GetString(); info.label = !node->GetLabel().IsEmpty() ? node->GetLabel().GetString() : node->GetName(); info.family = node->GetFamily().GetString(); + info.category = DeriveCategory(node); + info.source = DeriveSource(node); m_shaderNodeCache.push_back(std::move(info)); } std::sort(m_shaderNodeCache.begin(), m_shaderNodeCache.end(), [](const ShaderNodeTypeInfo& a, const ShaderNodeTypeInfo& b) { - if (a.family != b.family) return a.family < b.family; + const int sa = SourceRank(a.source), sb = SourceRank(b.source); + if (sa != sb) return sa < sb; + if (a.source != b.source) return a.source < b.source; + const int ra = CategoryRank(a.category), rb = CategoryRank(b.category); + if (ra != rb) return ra < rb; + if (a.category != b.category) return a.category < b.category; return a.label < b.label; }); diff --git a/src/core/MaterialManager.h b/src/core/MaterialManager.h index 7a695a1..367460d 100644 --- a/src/core/MaterialManager.h +++ b/src/core/MaterialManager.h @@ -15,7 +15,15 @@ namespace UsdLayerManager { struct ShaderNodeTypeInfo { std::string identifier; ///< Sdr identifier, authored as info:id std::string label; ///< display label for the create-node menu - std::string family; ///< grouping for the create-node menu (may be empty) + std::string family; ///< Sdr family (parser-provided, may be empty) + /// Coarse Hypershade-style bucket ("Material", "Texture", "Geometry", + /// "Utility", "Light") derived from Sdr context/role/output types; the + /// create-node list groups by it. Never empty. + std::string category; + /// Top-level create-list group derived from the Sdr source type: + /// "USD" (glslfx/OSL core nodes), "MaterialX", "Arnold", "Cycles", or a + /// capitalized raw source type. Never empty. + std::string source; }; /// One input or output pin on a shader node, with its USD value type so diff --git a/src/ui/MaterialEditorPanel.cpp b/src/ui/MaterialEditorPanel.cpp index a0bffc8..7d1d25b 100644 --- a/src/ui/MaterialEditorPanel.cpp +++ b/src/ui/MaterialEditorPanel.cpp @@ -122,8 +122,9 @@ size_t ComputeGraphRevision(const pxr::UsdStageRefPtr& stage, const ShaderGraphS // Case-insensitive substring filter over the Sdr node-type list for the // searchable create-node UIs (TAB popup, browser list); matches label or -// identifier, and collapses duplicate labels (one per source-type parser -// variant) to a single entry. +// identifier, and collapses duplicate (source, label) pairs — parser +// variants within one source group — to a single entry. The same label under +// different sources (e.g. an "add" in both MaterialX and Arnold) stays. std::vector FilterShaderNodeTypes( const std::vector& nodeTypes, const char* rawQuery) { auto toLower = [](std::string s) { @@ -140,13 +141,37 @@ std::vector FilterShaderNodeTypes( toLower(info.label).find(query) == std::string::npos && toLower(info.identifier).find(query) == std::string::npos) continue; - if (!seenLabels.insert(info.label).second) + if (!seenLabels.insert(info.source + "|" + info.label).second) continue; matches.push_back(&info); } return matches; } +// Vertical drag-splitter between the material editor's columns. Adjusts +// *width by the mouse drag (negated for a right-hand column, which grows +// when dragged left); colours match the viewport's split dividers. +void VerticalSplitter(const char* id, float* width, float minWidth, float maxWidth, + bool rightSideColumn) { + ImGui::SameLine(0.0f, 0.0f); + const float height = std::max(1.0f, ImGui::GetContentRegionAvail().y); + ImGui::InvisibleButton(id, ImVec2(6.0f, height)); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + if (hovered || active) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + if (active) { + const float delta = ImGui::GetIO().MouseDelta.x * (rightSideColumn ? -1.0f : 1.0f); + *width = std::clamp(*width + delta, minWidth, maxWidth); + } + ImVec2 rectMin = ImGui::GetItemRectMin(); + ImVec2 rectMax = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddRectFilled( + ImVec2(rectMin.x + 2.0f, rectMin.y), ImVec2(rectMax.x - 2.0f, rectMax.y), + (hovered || active) ? IM_COL32(250, 150, 66, 200) : IM_COL32(80, 80, 80, 180)); + ImGui::SameLine(0.0f, 0.0f); +} + // Networks referenced from .mtlx (or authored elsewhere) carry no uiPosition // custom data, so every node would seed at (0,0) in a pile. Give those nodes // layered left-to-right positions instead: each node sits one column left of @@ -231,19 +256,20 @@ void MaterialEditorPanel::Render() { // Hypershade-style three-column layout: material browser on the left, // node-graph work area in the middle, shader-ball viewer on the right. - ImGui::BeginChild("MaterialBrowserRegion", ImVec2(220.0f, 0.0f), true); + // The outer columns are user-resizable via the splitters between them. + ImGui::BeginChild("MaterialBrowserRegion", ImVec2(m_browserWidth, 0.0f), true); RenderMaterialBrowser(); ImGui::EndChild(); - ImGui::SameLine(); + VerticalSplitter("##MaterialSplitL", &m_browserWidth, 140.0f, 500.0f, + /*rightSideColumn=*/false); - // Preview column is 320 wide so the PropertyPanel-style attribute table - // (dot + name + value columns) fits below the shader ball. - ImGui::BeginChild("MaterialCanvasRegion", ImVec2(-320.0f, 0.0f), false); + ImGui::BeginChild("MaterialCanvasRegion", ImVec2(-(m_previewWidth + 6.0f), 0.0f), false); RenderNodeGraphCanvas(); ImGui::EndChild(); - ImGui::SameLine(); + VerticalSplitter("##MaterialSplitR", &m_previewWidth, 220.0f, 640.0f, + /*rightSideColumn=*/true); ImGui::BeginChild("MaterialPreviewRegion", ImVec2(0.0f, 0.0f), true); RenderPreviewPanel(); @@ -633,21 +659,55 @@ void MaterialEditorPanel::RenderMaterialBrowser() { if (m_materialPath.IsEmpty()) { ImGui::TextDisabled("Open a material first"); } else { + const bool searching = (m_browserNodeSearchBuf[0] != '\0'); const auto matches = FilterShaderNodeTypes(m_materialManager->GetAvailableShaderNodes(), m_browserNodeSearchBuf); if (matches.empty()) ImGui::TextDisabled("No matching nodes"); + // Matches arrive sorted by (source, category, label) — a collapsing + // header per source (USD / MaterialX / Arnold / ...) with a category + // sub-tree (Material / Texture / Geometry / Utility ...) inside, + // Hypershade-style. While searching, everything is forced open so a + // collapsed section can never hide a hit. + std::string currentSource; + std::string currentCategory; + bool sourceOpen = false; + bool categoryOpen = false; + auto closeCategory = [&]() { + if (categoryOpen) ImGui::TreePop(); + categoryOpen = false; + }; for (int i = 0; i < static_cast(matches.size()); ++i) { const ShaderNodeTypeInfo* info = matches[i]; - std::string itemLabel = info->label; - if (!info->family.empty()) - itemLabel += " [" + info->family + "]"; - itemLabel += "##" + std::to_string(i); + if (info->source != currentSource) { + closeCategory(); + currentSource = info->source; + currentCategory = std::string(); + if (searching) + ImGui::SetNextItemOpen(true); + sourceOpen = ImGui::CollapsingHeader(currentSource.c_str(), + ImGuiTreeNodeFlags_DefaultOpen); + } + if (!sourceOpen) + continue; + if (info->category != currentCategory) { + closeCategory(); + currentCategory = info->category; + if (searching) + ImGui::SetNextItemOpen(true); + // "##source" keeps IDs unique — category names repeat per source. + std::string catLabel = currentCategory + "##" + currentSource; + categoryOpen = ImGui::TreeNodeEx(catLabel.c_str(), ImGuiTreeNodeFlags_DefaultOpen); + } + if (!categoryOpen) + continue; + std::string itemLabel = info->label + "##" + std::to_string(i); if (ImGui::Selectable(itemLabel.c_str(), false)) m_pendingCreateShaderId = info->identifier; // created at view center next canvas pass if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", info->identifier.c_str()); } + closeCategory(); } ImGui::EndChild(); } @@ -1041,10 +1101,8 @@ void MaterialEditorPanel::RenderNodeSearchMenu(const ImVec2& canvasPos) { ImGui::TextDisabled("No matching nodes"); for (int i = 0; i < static_cast(matches.size()); ++i) { const ShaderNodeTypeInfo* info = matches[i]; - std::string itemLabel = info->label; - if (!info->family.empty()) - itemLabel += " [" + info->family + "]"; - itemLabel += "##" + std::to_string(i); + std::string itemLabel = + info->label + " [" + info->source + "/" + info->category + "]##" + std::to_string(i); const bool selected = (i == m_nodeSearchSelected); if (ImGui::Selectable(itemLabel.c_str(), selected)) chosen = info; diff --git a/src/ui/MaterialEditorPanel.h b/src/ui/MaterialEditorPanel.h index 6bb643b..d0cf63c 100644 --- a/src/ui/MaterialEditorPanel.h +++ b/src/ui/MaterialEditorPanel.h @@ -133,6 +133,11 @@ private: char m_nodeSearchBuf[128] = ""; int m_nodeSearchSelected = 0; + /// Column widths of the browser (left) and preview (right) sections, + /// user-adjustable via the splitters between them and the canvas. + float m_browserWidth = 220.0f; + float m_previewWidth = 320.0f; + /// Search text of the browser's persistent create-node list. char m_browserNodeSearchBuf[128] = ""; /// Shader id clicked in the browser's create-node list; created at the