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 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 06:47:12 +08:00
parent b95b567b75
commit 42fd355c53
4 changed files with 173 additions and 19 deletions
+75 -17
View File
@@ -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<const ShaderNodeTypeInfo*> FilterShaderNodeTypes(
const std::vector<ShaderNodeTypeInfo>& nodeTypes, const char* rawQuery) {
auto toLower = [](std::string s) {
@@ -140,13 +141,37 @@ std::vector<const ShaderNodeTypeInfo*> 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<int>(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<int>(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;
+5
View File
@@ -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