#include "MaterialEditorPanel.h" #include "../core/commands/CreatePrimCommand.h" #include "../core/commands/CreateShaderNodeCommand.h" #include "../core/commands/DeletePrimCommand.h" #include "../core/commands/ConnectShaderAttrsCommand.h" #include "../core/commands/DisconnectShaderAttrCommand.h" #include "../core/commands/AttributeSetCommand.h" #include "../core/commands/RenamePrimCommand.h" #include "../utils/Logger.h" #include "../utils/FileDialog.h" #include "../utils/OcioConfigParser.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // UsdLayerManager local patch hook in the vendored node editor (see // imgui_node_editor.cpp): when set, BuildControl logs its click-time // hit-test state through it. Temporary debugging aid. extern void (*g_AxNodeEditorDebugLog)(const char*); namespace UsdLayerManager { namespace NE = ax::NodeEditor; namespace { uintptr_t HashId(const std::string& s) { return static_cast(std::hash{}(s)); } // Draws the 3-dot pin-display-mode icon (filled-dot count = 3 - mode, so it // visually "funnels down" as pins collapse further), centered within // [min, max]. Shared by the per-node toggle in RenderNodeGraphCanvas (which // sizes its button around the default dot geometry) and the toolbar's // "set all nodes" button in RenderToolbar (which instead fits the dots into // its fixed, already-square icon-button size via smaller dotRadius/dotSpacing). void DrawPinDisplayModeIcon(ImDrawList* dl, const ImVec2& min, const ImVec2& max, PinDisplayMode mode, ImU32 filledColor, ImU32 hollowColor, float dotRadius = 3.0f, float dotSpacing = 9.0f) { const int dotCount = 3; const int filledDots = 3 - static_cast(mode); const float iconWidth = dotSpacing * (dotCount - 1) + dotRadius * 2.0f; ImVec2 dotCenter(min.x + (max.x - min.x - iconWidth) * 0.5f + dotRadius, min.y + (max.y - min.y) * 0.5f); for (int i = 0; i < dotCount; ++i) { if (i < filledDots) dl->AddCircleFilled(dotCenter, dotRadius, filledColor); else dl->AddCircle(dotCenter, dotRadius, hollowColor, 0, 1.2f); dotCenter.x += dotSpacing; } } // Mirrors the file-local sanitizers in Application.cpp/SceneHierarchyPanel.cpp. std::string SanitizeUsdName(const std::string& raw) { std::string result; result.reserve(raw.size()); for (char c : raw) { if (std::isalnum(static_cast(c)) || c == '_') result += c; else result += '_'; } if (result.empty() || std::isdigit(static_cast(result[0]))) result = "_" + result; return result; } // Color-codes a pin by its USD value type, roughly following the // Houdini/Maya convention of one hue per data "shape" (color vs. scalar vs. // vector vs. string-like vs. asset) so a network's data flow reads at a // glance, independent of exact type (float vs double, float3 vs color3f...). ImU32 GetPinColor(const pxr::SdfValueTypeName& typeName) { const std::string name = typeName.GetAsToken().GetString(); if (name.find("color") != std::string::npos) return IM_COL32(230, 180, 60, 255); // color-like: warm yellow if (name.find("asset") != std::string::npos) return IM_COL32(80, 150, 230, 255); // asset/file reference: blue if (name == "bool") return IM_COL32(220, 70, 70, 255); // bool: red if (name == "token" || name == "string") return IM_COL32(170, 90, 200, 255); // string-like: purple if (name.find("matrix") != std::string::npos) return IM_COL32(220, 100, 170, 255); // matrix: pink if (name.find("int") != std::string::npos) return IM_COL32(70, 190, 160, 255); // int: teal if (name.find("point") != std::string::npos || name.find("vector") != std::string::npos || name.find("normal") != std::string::npos || name.find("float3") != std::string::npos || name.find("double3") != std::string::npos) return IM_COL32(140, 210, 90, 255); // vector-like: green if (name.find("float") != std::string::npos || name.find("double") != std::string::npos || name.find("half") != std::string::npos) return IM_COL32(150, 220, 130, 255); // scalar: light green return IM_COL32(200, 200, 200, 255); // fallback: light gray } // Deterministic per-node-type header color so repeated shader types are // visually distinguishable at a glance without needing a curated table for // every identifier the Sdr registry might report. ImU32 GetHeaderColor(const std::string& key) { uint32_t hash = static_cast(std::hash{}(key)); float hue = (hash % 360) / 360.0f; float r, g, b; ImGui::ColorConvertHSVtoRGB(hue, 0.45f, 0.55f, r, g, b); return IM_COL32(static_cast(r * 255), static_cast(g * 255), static_cast(b * 255), 255); } // Cheap stand-in for "did anything the shader-ball cares about change" so // MaterialPreviewRenderer can skip re-copying/re-rendering on frames where // the graph is unchanged (real cost with a path-traced delegate selected). // Node position isn't included — dragging a node shouldn't dirty the swatch. size_t ComputeGraphRevision(const pxr::UsdStageRefPtr& stage, const ShaderGraphSnapshot& graph) { size_t h = graph.nodes.size() * 31 + graph.links.size(); for (const auto& node : graph.nodes) { h = h * 31 + std::hash{}(node.path.GetString()); h = h * 31 + std::hash{}(node.shaderId); } for (const auto& link : graph.links) { h = h * 31 + std::hash{}( link.destNode.GetString() + link.destInput + link.sourceNode.GetString() + link.sourceOutput); } // Fold in authored input values so property edits — and undo/redo of // them, which never pass through the editor widgets — re-render the // swatch. Stringify is cheap at material-network scale. for (const auto& node : graph.nodes) { pxr::UsdShadeShader shader(stage ? stage->GetPrimAtPath(node.path) : pxr::UsdPrim()); if (!shader) continue; for (const auto& input : shader.GetInputs()) { pxr::VtValue value; if (input.GetAttr().Get(&value)) h = h * 31 + std::hash{}( input.GetBaseName().GetString() + pxr::TfStringify(value)); } } return h; } // 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 (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) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return s; }; const std::string query = toLower(rawQuery); std::vector matches; std::set seenLabels; for (const auto& info : nodeTypes) { if (!query.empty() && toLower(info.label).find(query) == std::string::npos && toLower(info.identifier).find(query) == std::string::npos) continue; if (!seenLabels.insert(info.source + "|" + info.label).second) continue; matches.push_back(&info); } return matches; } // Clamp range shared by the column splitters and SetColumnWidths (persisted // preferences may carry hand-edited or stale values). constexpr float kBrowserMinWidth = 140.0f, kBrowserMaxWidth = 500.0f; constexpr float kPreviewMinWidth = 220.0f, kPreviewMaxWidth = 640.0f; // 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); } // Horizontal drag-splitter stacked between two vertically-arranged panels. // Adjusts *ratio (fraction of totalHeight given to the panel above) by the // mouse drag; colours match VerticalSplitter. void HorizontalSplitter(const char* id, float* ratio, float totalHeight, float minRatio, float maxRatio) { const float width = std::max(1.0f, ImGui::GetContentRegionAvail().x); ImGui::InvisibleButton(id, ImVec2(width, 6.0f)); const bool hovered = ImGui::IsItemHovered(); const bool active = ImGui::IsItemActive(); if (hovered || active) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); if (active && totalHeight > 1.0f) *ratio = std::clamp(*ratio + ImGui::GetIO().MouseDelta.y / totalHeight, minRatio, maxRatio); ImVec2 rectMin = ImGui::GetItemRectMin(); ImVec2 rectMax = ImGui::GetItemRectMax(); ImGui::GetWindowDrawList()->AddRectFilled( ImVec2(rectMin.x, rectMin.y + 2.0f), ImVec2(rectMax.x, rectMax.y - 2.0f), (hovered || active) ? IM_COL32(250, 150, 66, 200) : IM_COL32(80, 80, 80, 180)); } // Small glyph for a create-node list entry, keyed by the derived category // (see MaterialManager::DeriveCategory: Material/Texture/Geometry/Light/Utility). Icon IconForNodeCategory(const std::string& category) { if (category == "Material") return Icon::Swatchbook; if (category == "Texture") return Icon::Grid; if (category == "Geometry") return Icon::Cube; if (category == "Light") return Icon::Lightbulb; if (category == "Utility") return Icon::Code; return Icon::CircleDot; } // Coarse connectability class for lenient shader-pin type matching (0 = only // matches its own exact type; 1 = scalar; 2/3/4 = 2/3/4-component vector). // Mirrors the leniency USD/MaterialX shading networks already tolerate. int PinTypeClass(const pxr::SdfValueTypeName& t) { const auto& tn = pxr::SdfValueTypeNames; if (t == tn->Float || t == tn->Half || t == tn->Double || t == tn->Int) return 1; if (t == tn->Float2) return 2; if (t == tn->Float3 || t == tn->Color3f || t == tn->Vector3f || t == tn->Normal3f || t == tn->Point3f) return 3; if (t == tn->Float4 || t == tn->Color4f) return 4; return 0; } bool PinTypesConnectable(const pxr::SdfValueTypeName& a, const pxr::SdfValueTypeName& b) { if (a == b) return true; const int ca = PinTypeClass(a); return ca != 0 && ca == PinTypeClass(b); } // Best pin of the requested direction on shaderId to receive a connection from // a pin of type wantType: first exact type match, else first type-connectable // pin. Returns false when the node has no connectable pin of that direction. bool BestConnectablePin(const std::string& shaderId, bool wantOutput, const pxr::SdfValueTypeName& wantType, std::string& outName, pxr::SdfValueTypeName& outType) { pxr::SdrShaderNodeConstPtr node = pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(pxr::TfToken(shaderId)); if (!node) return false; bool haveConnectable = false; for (const pxr::TfToken& n : wantOutput ? node->GetShaderOutputNames() : node->GetShaderInputNames()) { const auto* prop = wantOutput ? node->GetShaderOutput(n) : node->GetShaderInput(n); if (!prop) continue; const pxr::SdfValueTypeName t = prop->GetTypeAsSdfType().GetSdfType(); if (t == wantType) { outName = n.GetString(); outType = t; return true; } if (!haveConnectable && PinTypesConnectable(wantType, t)) { outName = n.GetString(); outType = t; haveConnectable = true; } } return haveConnectable; } // 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 // its farthest-downstream consumer, stacked vertically within its column // (column height advances by an estimate of the node's rendered height, since // pin count varies wildly — e.g. standard_surface). Deterministic, so the // per-frame re-sync always computes the same layout. void ApplyFallbackLayout(ShaderGraphSnapshot& graph) { bool anyMissing = false; for (const auto& node : graph.nodes) if (!node.hasAuthoredPosition) { anyMissing = true; break; } if (!anyMissing) return; std::unordered_map depth; // 0 = rightmost (surface) column for (const auto& node : graph.nodes) depth[node.path.GetString()] = 0; // Longest-path relaxation; iteration cap guards against connection cycles. for (size_t i = 0; i < graph.nodes.size(); ++i) { bool changed = false; for (const auto& link : graph.links) { auto src = depth.find(link.sourceNode.GetString()); auto dst = depth.find(link.destNode.GetString()); if (src == depth.end() || dst == depth.end()) continue; if (src->second < dst->second + 1) { src->second = dst->second + 1; changed = true; } } if (!changed) break; } std::unordered_map columnY; for (auto& node : graph.nodes) { if (node.hasAuthoredPosition) continue; int col = depth[node.path.GetString()]; float& y = columnY[col]; node.uiPosition = pxr::GfVec2f(col * -340.0f, y); y += (node.inputs.size() + node.outputs.size()) * 24.0f + 80.0f; } } } // namespace MaterialEditorPanel::MaterialEditorPanel() { // Route the vendored editor's click-time hit-test dump into our log so // it interleaves with the [NodeGraph] event trace. Disabled: uncomment to // re-enable the [ed] trace when diagnosing canvas interaction issues. // g_AxNodeEditorDebugLog = [](const char* msg) { LOG_INFO(std::string(msg)); }; NE::Config config; config.SettingsFile = nullptr; // layout persists as USD uiPosition custom data, not an on-disk .json config.UserPointer = this; config.SaveNodeSettings = &MaterialEditorPanel::SaveNodeSettingsCallback; m_editorContext = NE::CreateEditor(&config); } MaterialEditorPanel::~MaterialEditorPanel() { if (m_editorContext) { NE::DestroyEditor(m_editorContext); m_editorContext = nullptr; } } void MaterialEditorPanel::SetStage(pxr::UsdStageRefPtr stage) { m_stage = stage; m_nodeIdToPath.clear(); m_graph = ShaderGraphSnapshot(); m_materialPath = pxr::SdfPath(); m_browserSelection = pxr::SdfPath(); m_thumbnails.Clear(); } void MaterialEditorPanel::SetColumnWidths(float browserWidth, float previewWidth) { m_browserWidth = std::clamp(browserWidth, kBrowserMinWidth, kBrowserMaxWidth); m_previewWidth = std::clamp(previewWidth, kPreviewMinWidth, kPreviewMaxWidth); } void MaterialEditorPanel::SetColorCorrectionFromPrefs(int ccMode, const std::string& ocioDisplay, const std::string& ocioView, const std::string& ocioColorSpace, const std::string& ocioLook) { m_preview.SetColorCorrection(ccMode, ocioDisplay, ocioView, ocioColorSpace, ocioLook); // Cached for the per-frame NodeThumbnailCache::SyncRenderSettings() call // in RenderNodeGraphCanvas, which keeps in-canvas node thumbnails // matching this same color correction (see its declaration). m_ccMode = ccMode; m_ocioDisplay = ocioDisplay; m_ocioView = ocioView; m_ocioColorSpace = ocioColorSpace; m_ocioLook = ocioLook; } void MaterialEditorPanel::Render() { RenderToolbar(); // Re-sync every frame so undo/redo and edits from other panels are // reflected live; cheap for material-sized graphs, and the "already // seeded" check in the render loop below stops this from fighting an // in-progress drag. if (!m_materialPath.IsEmpty()) SyncFromUsd(); // Hypershade-style three-column layout: material browser on the left, // node-graph work area in the middle, shader-ball viewer on the right. // The outer columns are user-resizable via the splitters between them. ImGui::BeginChild("MaterialBrowserRegion", ImVec2(m_browserWidth, 0.0f), true); RenderMaterialBrowser(); ImGui::EndChild(); VerticalSplitter("##MaterialSplitL", &m_browserWidth, kBrowserMinWidth, kBrowserMaxWidth, /*rightSideColumn=*/false); ImGui::BeginChild("MaterialCanvasRegion", ImVec2(-(m_previewWidth + 6.0f), 0.0f), false); RenderNodeGraphCanvas(); ImGui::EndChild(); VerticalSplitter("##MaterialSplitR", &m_previewWidth, kPreviewMinWidth, kPreviewMaxWidth, /*rightSideColumn=*/true); ImGui::BeginChild("MaterialPreviewRegion", ImVec2(0.0f, 0.0f), true); RenderPreviewPanel(); ImGui::EndChild(); } void MaterialEditorPanel::RenderPreviewPanel() { // Renderer switch and HDR environment side by side above the preview. const float halfWidth = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; m_preview.RenderRendererDropdown(halfWidth); ImGui::SameLine(); m_preview.RenderLightingDropdown(halfWidth); m_preview.RenderShapeDropdown(-1.0f); if (m_stage && !m_materialPath.IsEmpty()) { // Hypershade-style: with a node selected, the ball previews that // node's first output instead of the material's surface. pxr::SdfPath previewNode; std::string previewOutput; bool outputIsTerminal = false; if (!m_selectedNodePath.IsEmpty()) { for (const auto& node : m_graph.nodes) { if (node.path != m_selectedNodePath) continue; // Pick a previewable output: a terminal (surface) or a // 3/4-component value the wrapper can plug into diffuseColor. // Scalar outputs (e.g. UsdUVTexture's Sdr-first output "r") // make Storm's codegen swizzle .xyz off a float and fail to // compile — nodes with only scalar outputs keep the // whole-material preview instead. const auto& tn = pxr::SdfValueTypeNames; for (const auto& out : node.outputs) { const pxr::SdfValueTypeName& t = out.typeName; const bool terminal = (t == tn->Token); const bool colorLike = t == tn->Color3f || t == tn->Float3 || t == tn->Vector3f || t == tn->Normal3f || t == tn->Point3f || t == tn->Color4f || t == tn->Float4; if (terminal || colorLike) { previewNode = node.path; previewOutput = out.name; outputIsTerminal = terminal; break; } } break; } } m_preview.SetMaterial(m_stage, m_materialPath, ComputeGraphRevision(m_stage, m_graph), previewNode, previewOutput, outputIsTerminal); } const float size = 200.0f; uint32_t texId = m_preview.Render(static_cast(size), static_cast(size)); ImGui::SetCursorPosX(std::max(0.0f, (ImGui::GetContentRegionAvail().x - size) * 0.5f) + ImGui::GetCursorPosX()); if (texId != 0) { ImGui::Image(ImTextureID(static_cast(texId)), ImVec2(size, size), ImVec2(0, 1), ImVec2(1, 0)); if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { ImVec2 delta = ImGui::GetIO().MouseDelta; m_preview.OrbitDrag(delta.x, delta.y); } } else { ImGui::Dummy(ImVec2(size, size)); } ImGui::Separator(); ImGui::TextUnformatted("Node Properties"); ImGui::Separator(); RenderSelectedNodeProperties(); } // Authored value if present, else the Sdr-registered default, so unauthored // inputs still show something sensible to start editing from. static pxr::VtValue ReadInputValue(const pxr::UsdPrim& prim, const std::string& shaderId, const ShaderPinInfo& input, bool* authoredOut) { pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + input.name)); pxr::VtValue value; if (attr && attr.Get(&value)) { *authoredOut = true; return value; } *authoredOut = false; pxr::SdrShaderNodeConstPtr sdrNode = pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(pxr::TfToken(shaderId)); if (sdrNode) { if (const auto* prop = sdrNode->GetShaderInput(pxr::TfToken(input.name))) return prop->GetDefaultValueAsSdfType(); } return value; } // Mirrors PropertyPanel's file-local mini-button (usdtweak-style "(a)" dot): // coloured yellow when authored, grey otherwise; tooltip shows path + type, // left-click offers "Copy path". static void DrawInputMiniButton(const char* label, bool authored, const char* pathForCopy, const char* typeHint) { ImVec4 btnColor = authored ? ImVec4(1.f, 0.85f, 0.4f, 1.f) // yellow – authored : ImVec4(0.5f, 0.5f, 0.5f, 1.f); // grey – Sdr default / no opinion ImGui::PushStyleColor(ImGuiCol_Text, btnColor); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.f, 0.f, 0.f, 0.f)); ImGui::AlignTextToFramePadding(); ImGui::SmallButton(label); ImGui::PopStyleColor(2); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s\n%s", pathForCopy, typeHint); if (ImGui::BeginPopupContextItem(nullptr, ImGuiPopupFlags_MouseButtonLeft)) { if (ImGui::MenuItem("Copy path")) ImGui::SetClipboardText(pathForCopy); ImGui::EndPopup(); } } void MaterialEditorPanel::RenderSelectedNodeProperties() { if (!m_stage || m_selectedNodePath.IsEmpty()) { ImGui::TextDisabled("No node selected"); return; } const ShaderGraphNode* node = nullptr; for (const auto& n : m_graph.nodes) if (n.path == m_selectedNodePath) { node = &n; break; } pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_selectedNodePath); if (!node || !prim) { ImGui::TextDisabled("No node selected"); return; } ImGui::TextUnformatted(node->path.GetName().c_str()); if (!node->shaderId.empty()) ImGui::TextDisabled("%s", node->shaderId.c_str()); ImGui::Spacing(); // Same table anatomy as PropertyPanel's RenderAttrRelSubTable: authored // dot / fixed name column / stretch value column, with row striping. constexpr ImGuiTableFlags kFlags = ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg; if (!ImGui::BeginTable("##nodePropTable", 3, kFlags)) return; ImGui::TableSetupColumn("##dot", ImGuiTableColumnFlags_WidthFixed, 20.f); ImGui::TableSetupColumn("##name", ImGuiTableColumnFlags_WidthFixed, 110.f); ImGui::TableSetupColumn("##val", ImGuiTableColumnFlags_WidthStretch); const float rowH = ImGui::GetFrameHeight(); for (const auto& input : node->inputs) { ImGui::TableNextRow(ImGuiTableRowFlags_None, rowH); ImGui::PushID(input.name.c_str()); pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + input.name)); ImGui::TableSetColumnIndex(0); DrawInputMiniButton("(a)", attr && attr.IsAuthored(), (node->path.GetString() + ".inputs:" + input.name).c_str(), input.typeName.GetAsToken().GetText()); ImGui::TableSetColumnIndex(1); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(input.name.c_str()); if (ImGui::IsItemHovered()) // 110px column truncates long mtlx names ImGui::SetTooltip("%s", input.name.c_str()); ImGui::TableSetColumnIndex(2); const ShaderGraphLink* link = nullptr; for (const auto& l : m_graph.links) if (l.destNode == node->path && l.destInput == input.name) { link = &l; break; } if (link) { // Connected input: source display, like PropertyPanel's "-> path". ImGui::AlignTextToFramePadding(); std::string src = link->sourceNode.GetName() + "." + link->sourceOutput; ImGui::TextDisabled("-> %s", src.c_str()); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s.outputs:%s", link->sourceNode.GetText(), link->sourceOutput.c_str()); } else { ImGui::PushItemWidth(-FLT_MIN); RenderInputValueWidget(*node, input, prim); ImGui::PopItemWidth(); } if (input.typeName == pxr::SdfValueTypeNames->Asset) { ImGui::TableNextRow(ImGuiTableRowFlags_None, rowH); ImGui::TableSetColumnIndex(1); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Color Space"); ImGui::TableSetColumnIndex(2); ImGui::PushItemWidth(-FLT_MIN); RenderColorSpaceWidget(*node, input, prim); ImGui::PopItemWidth(); } ImGui::PopID(); } ImGui::EndTable(); } void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node, const ShaderPinInfo& input, const pxr::UsdPrim& prim) { const auto& tn = pxr::SdfValueTypeNames; const pxr::SdfValueTypeName& type = input.typeName; const std::string widgetId = "##" + input.name; bool authored = false; pxr::VtValue current = ReadInputValue(prim, node.shaderId, input, &authored); // PropertyPanel-style table around draggable controls: Drag* widgets // (Ctrl+click to type exact values) apply live every frame so the shader // ball and viewport track the drag, with this panel's undo convention — // pre-edit state stashed on activation, one undoable command pushed when // the edit ends (IsItemDeactivatedAfterEdit). pxr::VtValue newValue; // applied live as it changes (drags, colors, checkbox) pxr::VtValue finalValue; // applied + committed on deactivate (text fields) auto scalarAsFloat = [¤t]() -> float { if (current.IsHolding()) return current.UncheckedGet(); if (current.IsHolding()) return static_cast(current.UncheckedGet()); if (current.IsHolding()) return static_cast(current.UncheckedGet()); if (current.IsHolding()) return static_cast(current.UncheckedGet()); return 0.0f; }; if (type == tn->Color3f) { pxr::GfVec3f v = current.IsHolding() ? current.UncheckedGet() : pxr::GfVec3f(0.0f); if (ImGui::ColorEdit3(widgetId.c_str(), v.data())) newValue = v; } else if (type == tn->Color4f) { pxr::GfVec4f v = current.IsHolding() ? current.UncheckedGet() : pxr::GfVec4f(0.0f); if (ImGui::ColorEdit4(widgetId.c_str(), v.data())) newValue = v; } else if (type == tn->Float3 || type == tn->Vector3f || type == tn->Normal3f || type == tn->Point3f) { pxr::GfVec3f v = current.IsHolding() ? current.UncheckedGet() : pxr::GfVec3f(0.0f); if (ImGui::DragFloat3(widgetId.c_str(), v.data(), 0.01f, 0.f, 0.f, "%.4f")) newValue = v; } else if (type == tn->Float2 || type == tn->TexCoord2f) { pxr::GfVec2f v = current.IsHolding() ? current.UncheckedGet() : pxr::GfVec2f(0.0f); if (ImGui::DragFloat2(widgetId.c_str(), v.data(), 0.01f, 0.f, 0.f, "%.4f")) newValue = v; } else if (type == tn->Float4) { pxr::GfVec4f v = current.IsHolding() ? current.UncheckedGet() : pxr::GfVec4f(0.0f); if (ImGui::DragFloat4(widgetId.c_str(), v.data(), 0.01f, 0.f, 0.f, "%.4f")) newValue = v; } else if (type == tn->Float || type == tn->Double || type == tn->Half) { float f = scalarAsFloat(); if (ImGui::DragFloat(widgetId.c_str(), &f, 0.01f, 0.f, 0.f, "%.4f")) { if (type == tn->Double) newValue = static_cast(f); else if (type == tn->Half) newValue = pxr::GfHalf(f); else newValue = f; } } else if (type == tn->Int) { int v = current.IsHolding() ? current.UncheckedGet() : 0; if (ImGui::DragInt(widgetId.c_str(), &v)) newValue = v; } else if (type == tn->Bool) { bool v = current.IsHolding() && current.UncheckedGet(); if (ImGui::Checkbox(widgetId.c_str(), &v)) newValue = v; } else if (type == tn->String || type == tn->Token) { std::string s; if (current.IsHolding()) s = current.UncheckedGet(); else if (current.IsHolding()) s = current.UncheckedGet().GetString(); if (!input.allowedValues.empty()) { // Sdr-declared enum (e.g. UsdUVTexture's sourceColorSpace: // raw/sRGB/auto) — a dropdown of exactly the legal values instead // of free text, which could author anything the renderer ignores. // A selection is one atomic edit (no drag-in-progress concept for // a combo), so it's committed inline here — like the Asset browse // button below — instead of through the drag/type commit // machinery at the end of this function. if (ImGui::BeginCombo(widgetId.c_str(), s.c_str())) { for (const std::string& option : input.allowedValues) { bool selected = (option == s); if (ImGui::Selectable(option.c_str(), selected) && !selected) { m_preEditValue = current; m_preEditWasAuthored = authored; pxr::UsdShadeShader shader(prim); if (shader) shader.CreateInput(pxr::TfToken(input.name), input.typeName) .Set(type == tn->String ? pxr::VtValue(option) : pxr::VtValue(pxr::TfToken(option))); CommitInputEdit(node, input); } if (selected) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } return; } else { char buf[512]; std::snprintf(buf, sizeof(buf), "%s", s.c_str()); if (ImGui::InputText(widgetId.c_str(), buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) { if (type == tn->String) newValue = std::string(buf); else newValue = pxr::TfToken(buf); } } } else if (type == tn->Asset) { std::string s; if (current.IsHolding()) s = current.UncheckedGet().GetAssetPath(); else if (current.IsHolding()) s = current.UncheckedGet(); // Browse button first so the text field stays the "last item" the // shared activate/commit logic below inspects. The button authors + // commits its own undoable edit inline. bool browseClicked; if (m_iconManager) browseClicked = ImGui::ImageButton("##browseTex", ImTextureRef(m_iconManager->Get(Icon::FolderOpen)), ImVec2(14.f, 14.f)); else browseClicked = ImGui::SmallButton("..."); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Browse for a texture file"); ImGui::SameLine(); if (browseClicked) { static const char* kTexFilter = "Images (*.png;*.jpg;*.jpeg;*.exr;*.tif;*.tiff;*.hdr;*.tga;*.bmp;*.tx)\0" "*.png;*.jpg;*.jpeg;*.exr;*.tif;*.tiff;*.hdr;*.tga;*.bmp;*.tx\0" "All Files (*.*)\0*.*\0"; std::string picked = FileDialog::OpenFile(kTexFilter, "Select Texture File"); if (!picked.empty() && picked != s) { m_preEditValue = current; m_preEditWasAuthored = authored; pxr::UsdShadeShader shader(prim); if (shader) shader.CreateInput(pxr::TfToken(input.name), input.typeName) .Set(pxr::VtValue(pxr::SdfAssetPath(picked))); CommitInputEdit(node, input); } } char buf[512]; std::snprintf(buf, sizeof(buf), "%s", s.c_str()); if (ImGui::InputText(widgetId.c_str(), buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) newValue = pxr::SdfAssetPath(buf); } else { ImGui::AlignTextToFramePadding(); ImGui::TextDisabled("%s", type.GetAsToken().GetText()); return; } if (ImGui::IsItemActivated()) { m_preEditValue = current; m_preEditWasAuthored = authored; } auto setValue = [&](const pxr::VtValue& v) { pxr::UsdShadeShader shader(prim); if (shader) shader.CreateInput(pxr::TfToken(input.name), input.typeName).Set(v); }; if (!newValue.IsEmpty()) setValue(newValue); if (!finalValue.IsEmpty()) setValue(finalValue); if (ImGui::IsItemDeactivatedAfterEdit()) CommitInputEdit(node, input); } void MaterialEditorPanel::RenderColorSpaceWidget(const ShaderGraphNode& node, const ShaderPinInfo& input, const pxr::UsdPrim& prim) { if (!m_stage || !m_commandHistory) return; pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + input.name)); if (!attr) return; const std::string current = attr.HasColorSpace() ? attr.GetColorSpace().GetString() : std::string(); // Listed from the active OCIO config ($OCIO — the same one driving // viewport/preview color correction) rather than a fixed raw/sRGB/auto // set: those are only meaningful to UsdUVTexture's own sourceColorSpace // input, while this authors the renderer-agnostic colorSpace attribute // metadata, so the choices should span whatever color spaces are // actually defined and thus consistently interpretable across every // render delegate (Storm, Arnold, Cycles, ...) rather than being tied to // one shading backend's built-in vocabulary. const OcioConfig& ocfg = GetCurrentOcioConfig(); static const std::vector kFallback = {"raw", "sRGB"}; const std::vector& colorSpaces = ocfg.valid && !ocfg.colorSpaces.empty() ? ocfg.colorSpaces : kFallback; auto commit = [&](const std::string& newValue) { pxr::UsdStageRefPtr stage = m_stage; pxr::SdfPath nodePath = node.path; pxr::TfToken attrName = attr.GetName(); std::string oldValue = current; m_commandHistory->Push(std::make_unique( "Set color space on " + node.path.GetName() + "." + input.name, [stage, nodePath, attrName, newValue]() { pxr::UsdAttribute a = stage->GetPrimAtPath(nodePath).GetAttribute(attrName); if (!a) return; if (newValue.empty()) a.ClearColorSpace(); else a.SetColorSpace(pxr::TfToken(newValue)); }, [stage, nodePath, attrName, oldValue]() { pxr::UsdAttribute a = stage->GetPrimAtPath(nodePath).GetAttribute(attrName); if (!a) return; if (oldValue.empty()) a.ClearColorSpace(); else a.SetColorSpace(pxr::TfToken(oldValue)); })); }; if (ImGui::BeginCombo("##colorSpace", current.empty() ? "(unset)" : current.c_str())) { if (ImGui::Selectable("(unset)", current.empty()) && !current.empty()) commit(std::string()); if (current.empty()) ImGui::SetItemDefaultFocus(); for (const std::string& cs : colorSpaces) { const bool selected = (cs == current); if (ImGui::Selectable(cs.c_str(), selected) && !selected) commit(cs); if (selected) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } if (ImGui::IsItemHovered() && ocfg.valid) ImGui::SetTooltip("Color spaces from the active OCIO config"); } void MaterialEditorPanel::CommitInputEdit(const ShaderGraphNode& node, const ShaderPinInfo& input) { if (!m_stage || !m_commandHistory) return; // The live edits already authored the final value; read it back so the // command's redo closure carries exactly what's on the stage now. bool nowAuthored = false; pxr::UsdPrim prim = m_stage->GetPrimAtPath(node.path); if (!prim) return; pxr::VtValue newValue = ReadInputValue(prim, node.shaderId, input, &nowAuthored); if (nowAuthored == m_preEditWasAuthored && newValue == m_preEditValue) return; // e.g. text field deactivated without Enter — nothing changed pxr::UsdStageRefPtr stage = m_stage; pxr::SdfPath nodePath = node.path; pxr::TfToken nameTok(input.name); pxr::SdfValueTypeName typeName = input.typeName; pxr::VtValue oldValue = m_preEditValue; bool wasAuthored = m_preEditWasAuthored; m_commandHistory->Push(std::make_unique( "Set " + input.name, [stage, nodePath, nameTok, typeName, newValue]() { pxr::UsdShadeShader shader(stage->GetPrimAtPath(nodePath)); if (shader) shader.CreateInput(nameTok, typeName).Set(newValue); }, [stage, nodePath, nameTok, typeName, oldValue, wasAuthored]() { pxr::UsdPrim p = stage->GetPrimAtPath(nodePath); if (!p) return; if (wasAuthored) { pxr::UsdShadeShader shader(p); if (shader) shader.CreateInput(nameTok, typeName).Set(oldValue); } else { p.RemoveProperty(pxr::TfToken("inputs:" + nameTok.GetString())); } })); } void MaterialEditorPanel::RenderToolbar() { // Every toolbar action is an icon button; the tooltip carries the label. // The icon fills the full button height (standard frame height, zero frame // padding) so the buttons stay a uniform, standard-row-height square. Falls // back to a text button only when no icon set is loaded. const float btnH = ImGui::GetFrameHeight(); auto iconBtn = [&](const char* id, Icon icon, const char* fallback, const char* tip) -> bool { bool clicked; if (m_iconManager) { ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.f, 0.f)); clicked = ImGui::ImageButton(id, ImTextureRef(m_iconManager->Get(icon)), ImVec2(btnH, btnH)); ImGui::PopStyleVar(); } else { clicked = ImGui::Button(fallback); } if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); return clicked; }; if (iconBtn("##createMaterial", Icon::FilePlus, "Create Material", "Create Material")) CreateNewMaterial(); // Presets menu: entries appear per node source when the required shader // definitions are registered, and need an open material to apply to. ImGui::SameLine(); ImGui::BeginDisabled(m_materialPath.IsEmpty()); if (iconBtn("##presets", Icon::Swatchbook, "Presets", "Presets")) ImGui::OpenPopup("MaterialPresets"); ImGui::EndDisabled(); if (ImGui::BeginPopup("MaterialPresets")) { auto& sdr = pxr::SdrRegistry::GetInstance(); if (ImGui::BeginMenu("USD")) { ImGui::BeginDisabled(!sdr.GetShaderNodeByIdentifier(pxr::TfToken("UsdPreviewSurface"))); if (ImGui::MenuItem("Default Shader Graph")) CreateUsdPresetGraph(); ImGui::EndDisabled(); ImGui::EndMenu(); } if (ImGui::BeginMenu("MaterialX")) { ImGui::BeginDisabled( !sdr.GetShaderNodeByIdentifier(pxr::TfToken("ND_standard_surface_surfaceshader"))); if (ImGui::MenuItem("Standard Surface Graph")) CreateMaterialXPresetGraph(); ImGui::EndDisabled(); ImGui::EndMenu(); } if (ImGui::BeginMenu("Arnold")) { ImGui::BeginDisabled( !sdr.GetShaderNodeByIdentifier(pxr::TfToken("arnold:standard_surface"))); if (ImGui::MenuItem("Standard Surface Graph")) CreateArnoldPresetGraph(); ImGui::EndDisabled(); ImGui::EndMenu(); } ImGui::EndPopup(); } ImGui::SameLine(); if (iconBtn("##toggleThumbs", m_showNodeThumbnails ? Icon::Eye : Icon::EyeSlash, m_showNodeThumbnails ? "Hide Thumbnails" : "Show Thumbnails", m_showNodeThumbnails ? "Hide node thumbnails" : "Show node thumbnails")) m_showNodeThumbnails = !m_showNodeThumbnails; // "Set all nodes" pin-display broadcast: same 3-dot icon language as the // per-node toggle (DrawPinDisplayModeIcon), but hit-tested the normal // ImGui way — this button lives in the toolbar window, not inside the // node canvas, so none of RenderNodeGraphCanvas's geometric hit-test // workarounds are needed here. ImGui::SameLine(); ImGui::BeginDisabled(m_materialPath.IsEmpty()); { ImGui::PushID("##allPinMode"); const ImVec2 pos = ImGui::GetCursorScreenPos(); const ImVec2 posMax(pos.x + btnH, pos.y + btnH); const bool clicked = ImGui::InvisibleButton("##btn", ImVec2(btnH, btnH)); const bool hovered = ImGui::IsItemHovered(); ImDrawList* dl = ImGui::GetWindowDrawList(); const ImU32 bg = ImGui::GetColorU32( hovered ? (ImGui::IsItemActive() ? ImGuiCol_ButtonActive : ImGuiCol_ButtonHovered) : ImGuiCol_Button); dl->AddRectFilled(pos, posMax, bg, ImGui::GetStyle().FrameRounding); // Dot geometry scaled to btnH (unlike the per-node toggle's fixed // size) so the icon fits inside this fixed, already-square button // instead of overflowing it. DrawPinDisplayModeIcon(dl, pos, posMax, m_toolbarPinDisplayMode, ImGui::GetColorU32(ImGuiCol_Text), ImGui::GetColorU32(ImGuiCol_TextDisabled), btnH * 0.09f, btnH * 0.30f); static const char* kTips[3] = { "All nodes: showing all pins - click to show connected pins only on every node", "All nodes: showing connected pins only - click to collapse every node to one pin", "All nodes: collapsed to one pin per side - click to show all pins on every node", }; if (hovered) ImGui::SetTooltip("%s", kTips[static_cast(m_toolbarPinDisplayMode)]); if (clicked) { m_toolbarPinDisplayMode = static_cast((static_cast(m_toolbarPinDisplayMode) + 1) % 3); ApplyPinDisplayModeToAllNodes(m_toolbarPinDisplayMode); } ImGui::PopID(); } ImGui::EndDisabled(); if (!m_targetPrimPath.IsEmpty()) { ImGui::SameLine(); ImGui::Text("Selected: %s", m_targetPrimPath.GetText()); ImGui::SameLine(); if (iconBtn("##createBind", Icon::ObjectGroup, "Create + Bind", "Create + Bind Material to Selected")) CreateAndBindMaterialForTarget(); ImGui::SameLine(); ImGui::BeginDisabled(m_materialPath.IsEmpty()); if (iconBtn("##bindCurrent", Icon::Link, "Bind Current", "Bind Current Material to Selected")) BindMaterialToTarget(m_materialPath, m_targetPrimPath); ImGui::EndDisabled(); } ImGui::Separator(); } void MaterialEditorPanel::RenderMaterialBrowser() { ImGui::TextUnformatted("Materials"); ImGui::SameLine(ImGui::GetContentRegionAvail().x + ImGui::GetCursorPosX() - ImGui::CalcTextSize("Show Graph").x - ImGui::GetStyle().FramePadding.x * 2.0f); ImGui::BeginDisabled(m_browserSelection.IsEmpty()); if (ImGui::Button("Show Graph")) OpenOrCreateMaterial(m_browserSelection.GetString()); ImGui::EndDisabled(); ImGui::Separator(); if (!m_materialManager || !m_stage) { ImGui::TextDisabled("No stage loaded"); return; } // Re-enumerated every frame so materials created/deleted anywhere (other // panels, undo/redo) show up live; cheap relative to the graph re-sync // Render() already does per frame. const std::vector materials = m_materialManager->GetAllMaterials(); if (materials.empty()) { ImGui::TextDisabled("No materials in scene"); return; } // Keep the browser selection valid if the selected material was deleted. if (!m_browserSelection.IsEmpty() && std::find(materials.begin(), materials.end(), m_browserSelection) == materials.end()) m_browserSelection = pxr::SdfPath(); // Materials list (top) and the persistent searchable create-node list // (bottom, Hypershade's create bar with Nuke-style filtering), split by a // draggable horizontal splitter; m_browserListRatio is the list's share. const float browserAvail = ImGui::GetContentRegionAvail().y; const float listHeight = std::max(1.0f, browserAvail * m_browserListRatio); ImGui::BeginChild("MaterialBrowserList", ImVec2(0.0f, listHeight), false); for (const pxr::SdfPath& path : materials) { // PushID(full path) keeps IDs unique for same-named materials in // different scopes without an explicit ## suffix on the label. ImGui::PushID(path.GetText()); const bool isSelected = (path == m_browserSelection); const bool isOpen = (path == m_materialPath); const bool isRenaming = (path == m_renamingMaterial); if (isRenaming) { // Inline rename (mirrors SceneHierarchyPanel): commit on Enter, // cancel on focus loss. firstFrame guards against IsItemDeactivated // firing spuriously on the frame SetKeyboardFocusHere activates it. ImGui::SetNextItemWidth(-FLT_MIN); const bool firstFrame = m_materialRenameJustStarted; if (m_materialRenameJustStarted) { ImGui::SetKeyboardFocusHere(); m_materialRenameJustStarted = false; } const bool commit = ImGui::InputText("##matrename", m_materialRenameBuf, sizeof(m_materialRenameBuf), ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll); const bool canceled = !firstFrame && ImGui::IsItemDeactivated() && !commit; if (commit && m_materialRenameBuf[0] != '\0') { const std::string newName = SanitizeUsdName(m_materialRenameBuf); m_renamingMaterial = pxr::SdfPath(); if (!newName.empty() && newName != path.GetName() && m_commandHistory) { const pxr::SdfPath newPath = path.GetParentPath().AppendChild(pxr::TfToken(newName)); m_commandHistory->Push(std::make_unique( m_stage, path, newName)); // Follow the rename with the selection and (if it was the // open one) the loaded graph. UsdNamespaceEditor fixes up // bindings/arcs, so bound prims keep pointing at it. if (m_browserSelection == path) m_browserSelection = newPath; if (m_materialPath == path) OpenOrCreateMaterial(newPath.GetString()); ImGui::PopID(); break; // 'materials' now holds stale paths — rebuilt next frame } } else if (canceled) { m_renamingMaterial = pxr::SdfPath(); } } else { if (isOpen) ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 210, 90, 255)); if (ImGui::Selectable(path.GetName().c_str(), isSelected, ImGuiSelectableFlags_AllowDoubleClick)) { m_browserSelection = path; // Double-click starts an inline rename (single-click selects; // "Show Graph" opens the material into the canvas). if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { m_renamingMaterial = path; std::snprintf(m_materialRenameBuf, sizeof(m_materialRenameBuf), "%s", path.GetName().c_str()); m_materialRenameJustStarted = true; } } if (isOpen) ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", path.GetText()); } ImGui::PopID(); } ImGui::EndChild(); HorizontalSplitter("##BrowserListSplitter", &m_browserListRatio, browserAvail, 0.15f, 0.85f); ImGui::TextUnformatted("Create Node"); ImGui::SetNextItemWidth(-FLT_MIN); ImGui::InputTextWithHint("##BrowserNodeSearch", "search...", m_browserNodeSearchBuf, sizeof(m_browserNodeSearchBuf)); ImGui::BeginChild("BrowserNodeList", ImVec2(0.0f, 0.0f), false); 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]; 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; if (m_iconManager) { const float h = ImGui::GetTextLineHeight(); ImGui::Image(ImTextureRef(m_iconManager->Get(IconForNodeCategory(info->category))), ImVec2(h, h)); ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); } 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(); } void MaterialEditorPanel::CreateNewMaterial() { if (!m_stage) return; pxr::SdfPath materialsScope("/Materials"); std::string finalName = "material1"; int suffix = 1; while (m_stage->GetPrimAtPath(materialsScope.AppendChild(pxr::TfToken(finalName))).IsValid()) finalName = "material" + std::to_string(++suffix); OpenOrCreateMaterial(materialsScope.AppendChild(pxr::TfToken(finalName)).GetString()); } namespace { // Shared by the preset builders below. pxr::UsdShadeShader DefinePresetShader(const pxr::UsdStageRefPtr& stage, const pxr::SdfPath& materialPath, const char* name, const char* shaderId, const pxr::GfVec2f& uiPos) { pxr::UsdShadeShader shader = pxr::UsdShadeShader::Define(stage, materialPath.AppendChild(pxr::TfToken(name))); shader.CreateIdAttr(pxr::VtValue(pxr::TfToken(shaderId))); shader.GetPrim().SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(uiPos)); return shader; } } // namespace void MaterialEditorPanel::CreateUsdPresetGraph() { if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return; // Re-applying onto an existing preset graph is allowed: the build is // idempotent (same prims/connections re-authored, user-set values like // texture file paths survive) and re-normalizes the node layout. pxr::UsdStageRefPtr stage = m_stage; pxr::SdfPath matPath = m_materialPath; auto build = [stage, matPath]() { // Column/row spacing must exceed the rendered node sizes (full Sdr // pin sets make these ~400px tall): overlapping nodes steal each // other's clicks in the editor's hit test and become undraggable. pxr::UsdShadeShader surf = DefinePresetShader( stage, matPath, "UsdPreviewSurface", "UsdPreviewSurface", pxr::GfVec2f(0.f, 400.f)); pxr::UsdShadeShader st = DefinePresetShader( stage, matPath, "stReader", "UsdPrimvarReader_float2", pxr::GfVec2f(-840.f, 600.f)); st.CreateInput(pxr::TfToken("varname"), pxr::SdfValueTypeNames->Token) .Set(pxr::TfToken("st")); struct TexPreset { const char* name; const char* texOutput; pxr::SdfValueTypeName outType; const char* destInput; pxr::SdfValueTypeName destType; bool rawColorSpace; }; const TexPreset textures[] = { {"diffuseTexture", "rgb", pxr::SdfValueTypeNames->Float3, "diffuseColor", pxr::SdfValueTypeNames->Color3f, false}, {"metallicTexture", "r", pxr::SdfValueTypeNames->Float, "metallic", pxr::SdfValueTypeNames->Float, true}, {"roughnessTexture", "r", pxr::SdfValueTypeNames->Float, "roughness", pxr::SdfValueTypeNames->Float, true}, }; float y = 0.f; for (const TexPreset& t : textures) { pxr::UsdShadeShader tex = DefinePresetShader( stage, matPath, t.name, "UsdUVTexture", pxr::GfVec2f(-420.f, y)); y += 440.f; tex.CreateInput(pxr::TfToken("st"), pxr::SdfValueTypeNames->Float2) .ConnectToSource(st.ConnectableAPI(), pxr::TfToken("result")); if (t.rawColorSpace) tex.CreateInput(pxr::TfToken("sourceColorSpace"), pxr::SdfValueTypeNames->Token) .Set(pxr::TfToken("raw")); surf.CreateInput(pxr::TfToken(t.destInput), t.destType) .ConnectToSource(tex.ConnectableAPI(), pxr::TfToken(t.texOutput)); } pxr::UsdShadeMaterial material(stage->GetPrimAtPath(matPath)); if (material) material.CreateSurfaceOutput().ConnectToSource( surf.ConnectableAPI(), pxr::TfToken("surface")); }; auto remove = [stage, matPath]() { for (const char* name : {"UsdPreviewSurface", "stReader", "diffuseTexture", "metallicTexture", "roughnessTexture"}) stage->RemovePrim(matPath.AppendChild(pxr::TfToken(name))); if (pxr::UsdPrim mat = stage->GetPrimAtPath(matPath)) mat.RemoveProperty(pxr::TfToken("outputs:surface")); }; m_commandHistory->Push(std::make_unique( "Create USD Preset Graph", build, remove)); // Force a position reseed: on a re-apply the nodes are already known to // the editor at their old canvas positions. m_nodeIdToPath.clear(); SyncFromUsd(); } void MaterialEditorPanel::CreateMaterialXPresetGraph() { if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return; // Re-applying is allowed; see CreateUsdPresetGraph. pxr::UsdStageRefPtr stage = m_stage; pxr::SdfPath matPath = m_materialPath; auto build = [stage, matPath]() { // See the USD preset: spacing must exceed rendered node sizes or // overlapping nodes steal each other's drag hit-tests. pxr::UsdShadeShader surf = DefinePresetShader( stage, matPath, "standard_surface", "ND_standard_surface_surfaceshader", pxr::GfVec2f(0.f, 300.f)); pxr::UsdShadeShader texcoord = DefinePresetShader( stage, matPath, "texcoord", "ND_texcoord_vector2", pxr::GfVec2f(-840.f, 400.f)); struct ImagePreset { const char* name; const char* shaderId; pxr::SdfValueTypeName outType; const char* destInput; pxr::SdfValueTypeName destType; }; const ImagePreset images[] = { {"base_color_image", "ND_image_color3", pxr::SdfValueTypeNames->Color3f, "base_color", pxr::SdfValueTypeNames->Color3f}, {"specular_roughness_image", "ND_image_float", pxr::SdfValueTypeNames->Float, "specular_roughness", pxr::SdfValueTypeNames->Float}, {"metalness_image", "ND_image_float", pxr::SdfValueTypeNames->Float, "metalness", pxr::SdfValueTypeNames->Float}, }; float y = 0.f; for (const ImagePreset& img : images) { pxr::UsdShadeShader tex = DefinePresetShader( stage, matPath, img.name, img.shaderId, pxr::GfVec2f(-420.f, y)); y += 400.f; tex.CreateInput(pxr::TfToken("texcoord"), pxr::SdfValueTypeNames->Float2) .ConnectToSource(texcoord.ConnectableAPI(), pxr::TfToken("out")); surf.CreateInput(pxr::TfToken(img.destInput), img.destType) .ConnectToSource(tex.ConnectableAPI(), pxr::TfToken("out")); } // MaterialX networks terminate on the mtlx render-context output // (matches usdMtlx-imported materials). pxr::UsdShadeMaterial material(stage->GetPrimAtPath(matPath)); if (material) material.CreateSurfaceOutput(pxr::TfToken("mtlx")).ConnectToSource( surf.ConnectableAPI(), pxr::TfToken("out")); }; auto remove = [stage, matPath]() { for (const char* name : {"standard_surface", "texcoord", "base_color_image", "specular_roughness_image", "metalness_image"}) stage->RemovePrim(matPath.AppendChild(pxr::TfToken(name))); if (pxr::UsdPrim mat = stage->GetPrimAtPath(matPath)) mat.RemoveProperty(pxr::TfToken("outputs:mtlx:surface")); }; m_commandHistory->Push(std::make_unique( "Create MaterialX Preset Graph", build, remove)); m_nodeIdToPath.clear(); // reseed positions (see CreateUsdPresetGraph) SyncFromUsd(); } void MaterialEditorPanel::CreateArnoldPresetGraph() { if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return; // Re-applying is allowed; see CreateUsdPresetGraph. pxr::UsdStageRefPtr stage = m_stage; pxr::SdfPath matPath = m_materialPath; auto build = [stage, matPath]() { // See the USD preset: spacing must exceed rendered node sizes or // overlapping nodes steal each other's drag hit-tests. pxr::UsdShadeShader surf = DefinePresetShader( stage, matPath, "standard_surface", "arnold:standard_surface", pxr::GfVec2f(0.f, 300.f)); struct ImagePreset { const char* name; const char* destInput; pxr::SdfValueTypeName destType; bool rawColorSpace; }; const ImagePreset images[] = { {"base_color_image", "base_color", pxr::SdfValueTypeNames->Color3f, false}, {"specular_roughness_image", "specular_roughness", pxr::SdfValueTypeNames->Float, true}, {"metalness_image", "metalness", pxr::SdfValueTypeNames->Float, true}, }; // Arnold's image node samples the mesh's default UV set when uvcoords is // left unconnected, so no explicit texcoord reader node is needed. float y = 0.f; for (const ImagePreset& img : images) { pxr::UsdShadeShader tex = DefinePresetShader( stage, matPath, img.name, "arnold:image", pxr::GfVec2f(-420.f, y)); y += 400.f; if (img.rawColorSpace) tex.CreateInput(pxr::TfToken("color_space"), pxr::SdfValueTypeNames->String) .Set(std::string("raw")); surf.CreateInput(pxr::TfToken(img.destInput), img.destType) .ConnectToSource(tex.ConnectableAPI(), pxr::TfToken("out")); } // Arnold materials terminate on the arnold render-context surface output // (matches hdArnold-authored materials). pxr::UsdShadeMaterial material(stage->GetPrimAtPath(matPath)); if (material) material.CreateSurfaceOutput(pxr::TfToken("arnold")).ConnectToSource( surf.ConnectableAPI(), pxr::TfToken("out")); }; auto remove = [stage, matPath]() { for (const char* name : {"standard_surface", "base_color_image", "specular_roughness_image", "metalness_image"}) stage->RemovePrim(matPath.AppendChild(pxr::TfToken(name))); if (pxr::UsdPrim mat = stage->GetPrimAtPath(matPath)) mat.RemoveProperty(pxr::TfToken("outputs:arnold:surface")); }; m_commandHistory->Push(std::make_unique( "Create Arnold Preset Graph", build, remove)); m_nodeIdToPath.clear(); // reseed positions (see CreateUsdPresetGraph) SyncFromUsd(); } void MaterialEditorPanel::OpenOrCreateMaterial(const std::string& pathStr) { if (!m_stage) return; if (!pxr::SdfPath::IsValidPathString(pathStr, nullptr)) { LOG_WARNING("Material Editor: invalid material path '" + pathStr + "'"); return; } pxr::SdfPath path(pathStr); if (!m_stage->GetPrimAtPath(path).IsValid()) { if (m_commandHistory) m_commandHistory->Push(std::make_unique(m_stage, path, pxr::TfToken("Material"))); else m_stage->DefinePrim(path, pxr::TfToken("Material")); } m_materialPath = path; m_browserSelection = path; m_nodeIdToPath.clear(); // force position reseed for the (possibly different) material now open m_thumbnails.Clear(); // drop the previous material's node thumbnails SyncFromUsd(); } void MaterialEditorPanel::SetTargetPrimPath(const std::string& path) { m_targetPrimPath = pxr::SdfPath(); if (path.empty() || !pxr::SdfPath::IsValidPathString(path, nullptr)) return; pxr::SdfPath primPath(path); if (!primPath.IsPrimPath()) return; m_targetPrimPath = primPath; if (!m_stage) return; pxr::UsdPrim prim = m_stage->GetPrimAtPath(primPath); if (!prim || !prim.HasAPI()) return; pxr::UsdShadeMaterial resolved = pxr::UsdShadeMaterialBindingAPI(prim).ComputeBoundMaterial(); if (!resolved) return; pxr::SdfPath matPath = resolved.GetPrim().GetPath(); if (matPath == m_materialPath) return; m_materialPath = matPath; m_browserSelection = matPath; m_nodeIdToPath.clear(); SyncFromUsd(); } void MaterialEditorPanel::BindMaterialToTarget(const pxr::SdfPath& materialPath, const pxr::SdfPath& targetPath) { if (!m_stage || materialPath.IsEmpty() || targetPath.IsEmpty()) return; pxr::UsdPrim targetPrim = m_stage->GetPrimAtPath(targetPath); if (!targetPrim) return; pxr::SdfPathVector priorTargets; bool hadPriorRel = false; { pxr::UsdShadeMaterialBindingAPI existingBindAPI(targetPrim); pxr::UsdRelationship priorRel = existingBindAPI.GetDirectBindingRel(); hadPriorRel = priorRel && priorRel.IsAuthored(); if (hadPriorRel) priorRel.GetTargets(&priorTargets); } pxr::UsdStageRefPtr stage = m_stage; // Captured at construction time so Undo() lands in the same layer as // Execute() even if the ambient edit target changes in between (e.g. a // render-layer switch) — mirrors ConnectShaderAttrsCommand's pattern. pxr::SdfLayerHandle editLayer = m_stage->GetEditTarget().GetLayer(); auto doBind = [stage, targetPath, materialPath, editLayer]() { pxr::UsdPrim target = stage->GetPrimAtPath(targetPath); pxr::UsdShadeMaterial material(stage->GetPrimAtPath(materialPath)); if (!target || !material) return; if (editLayer) { pxr::UsdEditContext ec(stage, editLayer); pxr::UsdShadeMaterialBindingAPI::Apply(target).Bind(material); } else { pxr::UsdShadeMaterialBindingAPI::Apply(target).Bind(material); } }; if (m_commandHistory) { m_commandHistory->Push(std::make_unique( "Bind Material", doBind, [stage, targetPath, hadPriorRel, priorTargets, editLayer]() { pxr::UsdPrim target = stage->GetPrimAtPath(targetPath); if (!target) return; auto doUnbind = [&]() { pxr::UsdShadeMaterialBindingAPI bindAPI(target); if (hadPriorRel && !priorTargets.empty()) bindAPI.GetDirectBindingRel().SetTargets(priorTargets); else bindAPI.UnbindDirectBinding(); }; if (editLayer) { pxr::UsdEditContext ec(stage, editLayer); doUnbind(); } else { doUnbind(); } } )); } else { doBind(); } } void MaterialEditorPanel::CreateAndBindMaterialForTarget() { if (!m_stage || m_targetPrimPath.IsEmpty()) return; std::string baseName = SanitizeUsdName(m_targetPrimPath.GetName()) + "Material"; std::string finalName = baseName; int suffix = 1; pxr::SdfPath materialsScope("/Materials"); while (m_stage->GetPrimAtPath(materialsScope.AppendChild(pxr::TfToken(finalName))).IsValid()) finalName = baseName + "_" + std::to_string(suffix++); pxr::SdfPath materialPath = materialsScope.AppendChild(pxr::TfToken(finalName)); if (m_commandHistory) m_commandHistory->Push(std::make_unique(m_stage, materialPath, pxr::TfToken("Material"))); else m_stage->DefinePrim(materialPath, pxr::TfToken("Material")); BindMaterialToTarget(materialPath, m_targetPrimPath); m_materialPath = materialPath; m_browserSelection = materialPath; m_nodeIdToPath.clear(); SyncFromUsd(); } void MaterialEditorPanel::SyncFromUsd() { if (!m_materialManager || m_materialPath.IsEmpty()) { m_graph = ShaderGraphSnapshot(); return; } m_graph = m_materialManager->GetShaderGraph(m_materialPath); ApplyFallbackLayout(m_graph); SeedPinDisplayOverrides(); } void MaterialEditorPanel::SeedPinDisplayOverrides() { for (const auto& node : m_graph.nodes) { const std::string key = node.path.GetString(); if (m_pinDisplayOverrides.find(key) != m_pinDisplayOverrides.end()) continue; m_pinDisplayOverrides.emplace(key, PinDisplayOverride{node.pinDisplayMode}); } } void MaterialEditorPanel::RenderNodeGraphCanvas() { // Viewport-style navigation: Alt+RMB drag zooms. The node editor only // zooms on discrete mouse-wheel steps, so convert drag distance into // synthetic wheel steps before it processes input; drag right/down zooms // in, matching ViewportTile's Alt+RMB dolly. (Alt+MMB pan is the // library's own scroll button — c_ScrollButtonIndex, patched to MMB.) ImGuiIO& io = ImGui::GetIO(); const bool canvasHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows); if (io.KeyAlt && canvasHovered && ImGui::IsMouseDragging(ImGuiMouseButton_Right, 0.0f)) { m_zoomDragAccum += io.MouseDelta.x + io.MouseDelta.y; const float pixelsPerStep = 50.0f; while (m_zoomDragAccum >= pixelsPerStep) { io.MouseWheel += 1.0f; m_zoomDragAccum -= pixelsPerStep; } while (m_zoomDragAccum <= -pixelsPerStep) { io.MouseWheel -= 1.0f; m_zoomDragAccum += pixelsPerStep; } } else { m_zoomDragAccum = 0.0f; } // Nuke-style TAB: searchable create-node popup at the cursor. const bool openNodeSearch = canvasHovered && ImGui::IsKeyPressed(ImGuiKey_Tab, false); // Screen center of the canvas region, captured while it's still the // current window — where browser-list creations land (they have no // meaningful mouse position). const ImVec2 regionPos = ImGui::GetCursorScreenPos(); const ImVec2 regionSize = ImGui::GetContentRegionAvail(); const ImVec2 viewCenterScreen(regionPos.x + regionSize.x * 0.5f, regionPos.y + regionSize.y * 0.5f); // Keep node thumbnails' renderer/color-correction matching the big // preview swatch (cheap no-op unless something actually changed) before // pumping, so a change this frame invalidates stale entries in time to // be picked up by the pump below. m_thumbnails.SyncRenderSettings(m_preview.GetRendererPlugin(), m_ccMode, m_ocioDisplay, m_ocioView, m_ocioColorSpace, m_ocioLook); // Drain finished texture decodes (GL upload), render one stale material // thumbnail, and prune dead entries — GL/FBO work kept outside the // node-editor's Begin/End so it never touches mid-draw ImGui state. m_thumbnails.PumpMainThread(); NE::SetCurrentEditor(m_editorContext); NE::Begin("MaterialEditorCanvas", ImVec2(0.0f, 0.0f)); if (!m_pendingCreateShaderId.empty()) { // Browser-list creations land at the view center; CreateShaderNode // nudges the spot clear of existing nodes. CreateShaderNode(m_pendingCreateShaderId, NE::ScreenToCanvas(viewCenterScreen)); m_pendingCreateShaderId.clear(); } m_pinIdToInfo.clear(); m_linkIdToInfo.clear(); const float nodeRounding = NE::GetStyle().NodeRounding; const ImVec2 pinIconSize(16.0f, 16.0f); // One whole-graph revision hash drives every material thumbnail this frame. const size_t graphRevision = ComputeGraphRevision(m_stage, m_graph); // Tooltip requested by a hovered in-canvas widget this frame. Shown after // NE::End(): inside the canvas io.MousePos is in canvas space, so a // tooltip opened there is positioned at canvas coordinates misread as // screen coordinates — deferring restores correct placement at the cursor. const char* pendingTooltip = nullptr; for (const auto& node : m_graph.nodes) { uintptr_t nodeIdValue = HashId(node.path.GetString()); NE::NodeId nodeId(nodeIdValue); if (m_nodeIdToPath.find(nodeIdValue) == m_nodeIdToPath.end()) { NE::SetNodePosition(nodeId, ImVec2(node.uiPosition[0], node.uiPosition[1])); m_nodeIdToPath[nodeIdValue] = node.path; } const std::string& title = node.shaderId.empty() ? node.path.GetName() : node.shaderId; const ImU32 headerColor = GetHeaderColor(title); // Thumbnail below the title: the source image for Texture nodes, a // shader-ball render for Material nodes. thumb is 0 until ready; the // space is still reserved so the node doesn't jump when it appears. // Toggled off entirely (no reserved space, no decode/render work) via // the toolbar's thumbnail eye icon. ImTextureID thumb = 0; bool wantsThumb = false; if (m_showNodeThumbnails && node.category == "Texture") { // Any texture node that exposes a file/asset input reserves a // thumbnail slot. It stays black until a valid, on-disk image is // decoded, so an unset or unresolvable path reads as a black chip. bool hasFileInput = false; for (const auto& in : node.inputs) if (in.typeName == pxr::SdfValueTypeNames->Asset) { hasFileInput = true; break; } if (hasFileInput) { wantsThumb = true; const std::string file = ResolveTextureFilePath(node); if (!file.empty()) thumb = m_thumbnails.GetTextureThumbnail(node.path, file); } } else if (m_showNodeThumbnails && node.category == "Material") { wantsThumb = true; // Preview the terminal (token-typed) output as the surface; fall // back to the first output routed into diffuseColor. std::string previewOut; bool terminal = false; for (const auto& o : node.outputs) if (o.typeName == pxr::SdfValueTypeNames->Token) { previewOut = o.name; terminal = true; break; } if (previewOut.empty() && !node.outputs.empty()) previewOut = node.outputs.front().name; thumb = m_thumbnails.GetMaterialThumbnail(m_stage, m_materialPath, node.path, previewOut, terminal, graphRevision); } // Node width is auto-fit to content with no flex-layout available (the // library's Spring()/BeginHorizontal() column layout needs a custom // ImGui fork this project doesn't use) — approximate the classic // "outputs hug the right edge" look by right-aligning each output row // within the widest row measured across the whole node. PinDisplayOverride& pinDisplay = GetPinDisplayOverride(node.path); // Connected mode collapses every unconnected pin on a side into one // connectable "more pins" nub (Maya Hypershade style), present only // while that side actually has something hidden. Collapsed mode // collapses every pin on a side — linked or not — into that nub // whenever the side has any pins at all (existing links redirect to // it too; see EffectivePinId()). bool hasHiddenInput = false, hasHiddenOutput = false; if (pinDisplay.mode == PinDisplayMode::Connected) { for (const auto& input : node.inputs) if (!IsPinLinked(node.path, input.name, false)) { hasHiddenInput = true; break; } for (const auto& output : node.outputs) if (!IsPinLinked(node.path, output.name, true)) { hasHiddenOutput = true; break; } } else if (pinDisplay.mode == PinDisplayMode::Collapsed) { hasHiddenInput = !node.inputs.empty(); hasHiddenOutput = !node.outputs.empty(); } const float rowSpacing = ImGui::GetStyle().ItemSpacing.x; float contentWidth = ImGui::CalcTextSize(title.c_str()).x; for (const auto& input : node.inputs) if (IsPinVisible(node, input.name, false)) contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(input.name.c_str()).x); for (const auto& output : node.outputs) if (IsPinVisible(node, output.name, true)) contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(output.name.c_str()).x); if (wantsThumb) contentWidth = std::max(contentWidth, static_cast(NodeThumbnailCache::kThumbPx)); NE::BeginNode(nodeId); ImGui::PushID(node.path.GetText()); ImVec2 headerTop = ImGui::GetCursorScreenPos(); ImGui::TextUnformatted(title.c_str()); // Mode toggle, drawn and hit-tested manually. ImGui's own hover // attribution is unreliable for widgets inside the canvas (the same // mis-attribution the vendored editor's FindPinAt()/m_PressedNode // patches work around for pins and node drags — the hover race is // lost to the editor's background item), so an ImGui::SmallButton // here never highlights or clicks on most nodes. Hit-test the rect // geometrically instead, like those patches do. io.MousePos is in // canvas space inside NE::Begin/End, the same space as the cursor // position, so the comparison is direct; canvasHovered (screen-space, // captured before NE::Begin) gates out clicks landing on other panels // or popups, since the canvas-space transform is unclamped. { const ImGuiStyle& style = ImGui::GetStyle(); // Icon area sized to match the 3-dot icon's fixed geometry (see // DrawPinDisplayModeIcon); filled-dot count visually "funnels // down" as the node collapses further (All=3, Connected=2, // Collapsed=1). const ImVec2 iconSize(9.0f * 2.0f + 3.0f * 2.0f, 3.0f * 2.0f); const ImVec2 btnMin = ImGui::GetCursorScreenPos(); const ImVec2 btnMax(btnMin.x + iconSize.x + style.FramePadding.x * 2.0f, btnMin.y + iconSize.y + style.FramePadding.y * 2.0f); ImGui::Dummy(ImVec2(btnMax.x - btnMin.x, btnMax.y - btnMin.y)); // IsMouseHoveringRect is pure geometry (no hover-id arbitration) // and clips against the canvas view, so off-view parts don't hit. const bool btnHovered = canvasHovered && ImGui::IsMouseHoveringRect(btnMin, btnMax); const ImU32 frameColor = ImGui::GetColorU32( btnHovered ? (ImGui::IsMouseDown(ImGuiMouseButton_Left) ? ImGuiCol_ButtonActive : ImGuiCol_ButtonHovered) : ImGuiCol_Button); ImDrawList* dl = ImGui::GetWindowDrawList(); dl->AddRectFilled(btnMin, btnMax, frameColor, style.FrameRounding); DrawPinDisplayModeIcon(dl, btnMin, btnMax, pinDisplay.mode, ImGui::GetColorU32(ImGuiCol_Text), ImGui::GetColorU32(ImGuiCol_TextDisabled)); static const char* kTooltips[3] = { "Showing all pins - click to show connected pins only", "Showing connected pins only - click to collapse into one pin", "Collapsed to one pin per side - click to show all pins", }; if (btnHovered) pendingTooltip = kTooltips[static_cast(pinDisplay.mode)]; if (btnHovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) CyclePinDisplayMode(node.path); } ImVec2 headerBottom = ImGui::GetItemRectMax(); ImGui::Dummy(ImVec2(0.0f, 4.0f)); // breathing room between title and pins if (wantsThumb) { const ImVec2 thumbSize(static_cast(NodeThumbnailCache::kThumbPx), static_cast(NodeThumbnailCache::kThumbPx)); if (thumb) { ImGui::Image(thumb, thumbSize); } else if (node.category == "Texture") { // No resolvable image (unset path / file not found / still // decoding): draw a solid black chip in the reserved slot. const ImVec2 p = ImGui::GetCursorScreenPos(); ImGui::Dummy(thumbSize); ImGui::GetWindowDrawList()->AddRectFilled( p, ImVec2(p.x + thumbSize.x, p.y + thumbSize.y), IM_COL32(0, 0, 0, 255)); } else { ImGui::Dummy(thumbSize); // material thumbnail still rendering } ImGui::Dummy(ImVec2(0.0f, 4.0f)); } for (const auto& input : node.inputs) { uintptr_t pinIdValue = HashId(node.path.GetString() + ":in:" + input.name); m_pinIdToInfo[pinIdValue] = PinInfo{node.path, input.name, false, input.typeName}; bool linked = IsPinLinked(node.path, input.name, false); if (!IsPinVisible(node, input.name, false)) continue; NE::BeginPin(NE::PinId(pinIdValue), NE::PinKind::Input); // Default pivot is the center of the whole icon+text row, which // makes links land mid-label instead of at the icon. Pin it to // the icon's own edge instead (icon is the first element here). NE::PinPivotAlignment(ImVec2(0.0f, 0.5f)); NE::PinPivotSize(ImVec2(0.0f, 0.0f)); ax::Widgets::Icon(pinIconSize, ax::Widgets::IconType::Circle, linked, ImColor(GetPinColor(input.typeName))); ImGui::SameLine(); ImGui::TextUnformatted(input.name.c_str()); NE::EndPin(); } for (const auto& output : node.outputs) { uintptr_t pinIdValue = HashId(node.path.GetString() + ":out:" + output.name); m_pinIdToInfo[pinIdValue] = PinInfo{node.path, output.name, true, output.typeName}; bool linked = IsPinLinked(node.path, output.name, true); if (!IsPinVisible(node, output.name, true)) continue; float rowWidth = ImGui::CalcTextSize(output.name.c_str()).x + rowSpacing + pinIconSize.x; if (contentWidth > rowWidth) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (contentWidth - rowWidth)); NE::BeginPin(NE::PinId(pinIdValue), NE::PinKind::Output); // Icon is the last element in an output row — pin to its edge. NE::PinPivotAlignment(ImVec2(1.0f, 0.5f)); NE::PinPivotSize(ImVec2(0.0f, 0.0f)); ImGui::TextUnformatted(output.name.c_str()); ImGui::SameLine(); ax::Widgets::Icon(pinIconSize, ax::Widgets::IconType::Circle, linked, ImColor(GetPinColor(output.typeName))); NE::EndPin(); } // Collapsed connector nubs on the node's bottom row (Connected or // Collapsed mode): input nub at the left edge, output nub hugging the // right edge. Dropping a connection on (or dragging one out of) a // nub opens RenderPinPickMenu() to choose which pin it wires to. if (hasHiddenInput || hasHiddenOutput) { ImGui::Dummy(ImVec2(0.0f, 2.0f)); const float nubRowStartX = ImGui::GetCursorPosX(); if (hasHiddenInput) { uintptr_t metaInId = HashId(node.path.GetString() + ":metaIn"); m_pinIdToInfo[metaInId] = PinInfo{node.path, "", false, pxr::SdfValueTypeName(), true}; NE::BeginPin(NE::PinId(metaInId), NE::PinKind::Input); NE::PinPivotAlignment(ImVec2(0.0f, 0.5f)); NE::PinPivotSize(ImVec2(0.0f, 0.0f)); ax::Widgets::Icon(pinIconSize, ax::Widgets::IconType::Circle, false, ImColor(180, 180, 180, 255)); NE::EndPin(); if (ImGui::IsItemHovered()) pendingTooltip = "Connect to a hidden input"; } if (hasHiddenOutput) { if (hasHiddenInput) ImGui::SameLine(); ImGui::SetCursorPosX(nubRowStartX + contentWidth - pinIconSize.x); uintptr_t metaOutId = HashId(node.path.GetString() + ":metaOut"); m_pinIdToInfo[metaOutId] = PinInfo{node.path, "", true, pxr::SdfValueTypeName(), true}; NE::BeginPin(NE::PinId(metaOutId), NE::PinKind::Output); NE::PinPivotAlignment(ImVec2(1.0f, 0.5f)); NE::PinPivotSize(ImVec2(0.0f, 0.0f)); ax::Widgets::Icon(pinIconSize, ax::Widgets::IconType::Circle, false, ImColor(180, 180, 180, 255)); NE::EndPin(); if (ImGui::IsItemHovered()) pendingTooltip = "Connect a hidden output"; } } ImGui::PopID(); NE::EndNode(); // Colored header strip drawn on the node's background draw list, // spanning the node's full width behind the title (same technique // BlueprintNodeBuilder uses, without needing its Spring/BeginHorizontal // layout dependency). if (ImGui::IsItemVisible()) { ImVec2 nodeMin = ImGui::GetItemRectMin(); ImVec2 nodeMax = ImGui::GetItemRectMax(); ImDrawList* bgDrawList = NE::GetNodeBackgroundDrawList(nodeId); bgDrawList->AddRectFilled( nodeMin, ImVec2(nodeMax.x, headerBottom.y + 6.0f), headerColor, nodeRounding, ImDrawFlags_RoundCornersTop); (void)headerTop; } } for (const auto& link : m_graph.links) { uintptr_t linkIdValue = HashId(link.destNode.GetString() + ":" + link.destInput); // The pin's own id, always populated in m_pinIdToInfo regardless of // visibility (used here purely to color the link by the real type). uintptr_t startPinId = HashId(link.sourceNode.GetString() + ":out:" + link.sourceOutput); m_linkIdToInfo[linkIdValue] = LinkInfo{link.destNode, link.destInput}; ImU32 linkColor = IM_COL32(200, 200, 200, 255); auto pinIt = m_pinIdToInfo.find(startPinId); if (pinIt != m_pinIdToInfo.end()) linkColor = GetPinColor(pinIt->second.typeName); // The id actually referenced by the drawn link: redirected to a // node's collapsed meta nub when that node is in Collapsed mode // (the real pin isn't drawn there, so its id can't be referenced). uintptr_t linkStartPinId = EffectivePinId(link.sourceNode, link.sourceOutput, true); uintptr_t linkEndPinId = EffectivePinId(link.destNode, link.destInput, false); NE::Link(NE::LinkId(linkIdValue), NE::PinId(linkStartPinId), NE::PinId(linkEndPinId), ImColor(linkColor)); } HandleCreateAndDelete(); NE::Suspend(); if (openNodeSearch) { m_pendingCreateNodePos = ImGui::GetMousePos(); ImGui::OpenPopup("CreateNodeSearch"); } if (ImGui::BeginPopup("CreateNodeSearch")) { RenderNodeSearchMenu(NE::ScreenToCanvas(m_pendingCreateNodePos)); ImGui::EndPopup(); } if (m_openLinkDragMenu) { ImGui::OpenPopup("LinkDragCreateNode"); m_openLinkDragMenu = false; } if (ImGui::BeginPopup("LinkDragCreateNode")) { RenderNodeSearchMenu(NE::ScreenToCanvas(m_pendingCreateNodePos), &m_linkDragSourcePin); ImGui::EndPopup(); } if (m_openPinPickMenu) { ImGui::OpenPopup("PinPickMenu"); m_openPinPickMenu = false; } if (ImGui::BeginPopup("PinPickMenu")) { RenderPinPickMenu(); ImGui::EndPopup(); } NE::Resume(); NE::End(); // Deferred from the node loop — see pendingTooltip's declaration. if (pendingTooltip) ImGui::SetTooltip("%s", pendingTooltip); // --- Debug tracing of node interaction events. Edge-triggered so each // event logs once, not per frame. Event order in the editor is: // LMB press -> node becomes active (and is brought to front) -> drag // moves it -> release -> "click" -> selection updates -> position save. // Disabled: flip to #if 1 to re-enable the [NodeGraph] trace while // diagnosing the node-editor drag regression. #if 0 { // Logs the mouse position and every node's editor rect with a hit // verdict, and returns the hit paths. Topmost is unknown to us, but // the full table makes overlap fights directly visible in the log. // hitIds, when given, collects the editor ids of the hit nodes. auto nodeRectsAtMouse = [this](std::vector* hitIds) { std::string hits; const ImVec2 screen = ImGui::GetMousePos(); const ImVec2 mouse = NE::ScreenToCanvas(screen); char buf[512]; std::snprintf(buf, sizeof(buf), "[NodeGraph] hit test: mouse screen (%.1f, %.1f) canvas (%.1f, %.1f), %zu node(s)", screen.x, screen.y, mouse.x, mouse.y, m_graph.nodes.size()); LOG_INFO(std::string(buf)); for (const auto& node : m_graph.nodes) { NE::NodeId nodeId(HashId(node.path.GetString())); const ImVec2 pos = NE::GetNodePosition(nodeId); const ImVec2 size = NE::GetNodeSize(nodeId); if (pos.x == FLT_MAX || size.x <= 0.0f) { LOG_INFO("[NodeGraph] " + node.path.GetString() + ": no editor rect yet (not rendered)"); continue; } const bool hit = mouse.x >= pos.x && mouse.x <= pos.x + size.x && mouse.y >= pos.y && mouse.y <= pos.y + size.y; std::snprintf(buf, sizeof(buf), "[NodeGraph] %s: rect min (%.1f, %.1f) size (%.1f x %.1f)%s", node.path.GetText(), pos.x, pos.y, size.x, size.y, hit ? " <-- HIT" : ""); LOG_INFO(std::string(buf)); if (hit) { hits += (hits.empty() ? "" : ", ") + node.path.GetString(); if (hitIds) hitIds->push_back(nodeId.Get()); } } return hits; }; // Editor NodeIds of the currently selected nodes. auto selectedNodeIds = []() { std::vector ids; const int count = NE::GetSelectedObjectCount(); if (count > 0) { std::vector sel(static_cast(count)); const int nodeCount = NE::GetSelectedNodes(sel.data(), count); ids.reserve(static_cast(nodeCount)); for (int i = 0; i < nodeCount; ++i) ids.push_back(sel[static_cast(i)].Get()); } return ids; }; auto nodeNames = [this](const std::vector& ids) { std::string names; for (uintptr_t idValue : ids) { auto it = m_nodeIdToPath.find(idValue); names += (names.empty() ? "" : ", "); names += (it != m_nodeIdToPath.end()) ? it->second.GetName() : ""; } return names; }; auto containsAnyOf = [](const std::vector& haystack, const std::vector& needles) { for (uintptr_t needle : needles) if (std::find(haystack.begin(), haystack.end(), needle) != haystack.end()) return true; return false; }; if (canvasHovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { m_debugDragNodeIds.clear(); const std::string hits = nodeRectsAtMouse(&m_debugDragNodeIds); m_debugPressOnNode = !hits.empty(); m_debugDragLogged = false; if (m_debugPressOnNode) { // Pressing an already-selected node keeps the selection (no // clear, no rect-select) so a drag moves the whole group — // the editor only updates selection on release. const std::vector selected = selectedNodeIds(); std::string msg = "[NodeGraph] LMB press over node rect(s): " + hits; if (selected.size() > 1 && containsAnyOf(selected, m_debugDragNodeIds)) msg += " [in current selection of " + std::to_string(selected.size()) + " -> drag moves all selected]"; LOG_INFO(msg); } else { LOG_INFO("[NodeGraph] LMB press on empty canvas (rect select / clear selection on release)"); } } if (m_debugPressOnNode && !m_debugDragLogged && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { m_debugDragLogged = true; // Mirror DragAction::Accept: dragging a selected node moves the // whole selection, so trace every group member from here on. const std::vector selected = selectedNodeIds(); if (selected.size() > 1 && containsAnyOf(selected, m_debugDragNodeIds)) { m_debugDragNodeIds = selected; LOG_INFO("[NodeGraph] drag start: group move of " + std::to_string(selected.size()) + " selected node(s): " + nodeNames(m_debugDragNodeIds)); } else { LOG_INFO("[NodeGraph] drag start (ready to move), cursor over: " + nodeRectsAtMouse(nullptr)); } } // Per-move trace while a drag is live, only on frames the mouse // actually moved: mouse position, accumulated drag delta, and the // live editor position of every tracked node — the whole selection // for a group drag, else the node(s) hit at press time (a node that // stops tracking the delta shows up immediately). if (m_debugDragLogged && ImGui::IsMouseDragging(ImGuiMouseButton_Left) && (io.MouseDelta.x != 0.0f || io.MouseDelta.y != 0.0f)) { const ImVec2 screen = ImGui::GetMousePos(); const ImVec2 canvas = NE::ScreenToCanvas(screen); const ImVec2 delta = ImGui::GetMouseDragDelta(ImGuiMouseButton_Left, 0.0f); char buf[512]; std::snprintf(buf, sizeof(buf), "[NodeGraph] drag move: mouse screen (%.1f, %.1f) canvas (%.1f, %.1f) dragDelta (%.1f, %.1f)", screen.x, screen.y, canvas.x, canvas.y, delta.x, delta.y); std::string msg = buf; for (uintptr_t idValue : m_debugDragNodeIds) { const ImVec2 nodePos = NE::GetNodePosition(NE::NodeId(idValue)); auto it = m_nodeIdToPath.find(idValue); std::snprintf(buf, sizeof(buf), "; %s at (%.1f, %.1f)", it != m_nodeIdToPath.end() ? it->second.GetName().c_str() : "", nodePos.x, nodePos.y); msg += buf; } LOG_INFO(msg); } if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) && (m_debugPressOnNode || m_debugDragLogged)) { LOG_INFO(std::string("[NodeGraph] LMB release") + (m_debugDragLogged ? " (ends drag; position save should follow)" : " (click, no drag)")); m_debugPressOnNode = false; m_debugDragLogged = false; m_debugDragNodeIds.clear(); } // Covers single-click selection and rubber-band rect multi-select // alike; fires on the frame the editor's selection list changes. if (NE::HasSelectionChanged()) { const int count = NE::GetSelectedObjectCount(); std::vector selected(static_cast(std::max(count, 1))); const int nodeCount = NE::GetSelectedNodes(selected.data(), count); std::string msg = "[NodeGraph] selection changed: " + std::to_string(nodeCount) + " node(s)"; for (int i = 0; i < nodeCount; ++i) { auto it = m_nodeIdToPath.find(selected[static_cast(i)].Get()); msg += (i == 0 ? ": " : ", "); msg += (it != m_nodeIdToPath.end()) ? it->second.GetString() : ""; } LOG_INFO(msg); } } #endif // Mirror the editor's node selection (first selected node) for the // properties section under the preview. m_selectedNodePath = pxr::SdfPath(); NE::NodeId selectedNode; if (NE::GetSelectedNodes(&selectedNode, 1) >= 1) { auto it = m_nodeIdToPath.find(selectedNode.Get()); if (it != m_nodeIdToPath.end()) m_selectedNodePath = it->second; } NE::SetCurrentEditor(nullptr); } void MaterialEditorPanel::HandleCreateAndDelete() { if (NE::BeginCreate()) { NE::PinId startId, endId; if (NE::QueryNewLink(&startId, &endId) && startId && endId) { auto startIt = m_pinIdToInfo.find(startId.Get()); auto endIt = m_pinIdToInfo.find(endId.Get()); bool valid = startIt != m_pinIdToInfo.end() && endIt != m_pinIdToInfo.end() && startIt->second.isOutput != endIt->second.isOutput && startIt->second.nodePath != endIt->second.nodePath; if (valid) { if (NE::AcceptNewItem()) { const PinInfo& a = startIt->second; const PinInfo& b = endIt->second; const PinInfo& outPin = a.isOutput ? a : b; const PinInfo& inPin = a.isOutput ? b : a; if (outPin.isMeta || inPin.isMeta) { // Dropped on (or dragged from) a collapsed "more pins" // nub: defer to RenderPinPickMenu() to choose which // actual pin it wires to, instead of connecting now. const PinInfo& metaSide = outPin.isMeta ? outPin : inPin; const PinInfo& otherSide = outPin.isMeta ? inPin : outPin; m_pendingPinPick.nodePath = metaSide.nodePath; m_pendingPinPick.isOutput = metaSide.isOutput; m_pendingPinPick.otherPin = otherSide; m_openPinPickMenu = true; } else { CreateConnection(inPin, outPin); } } } else { NE::RejectNewItem(); } } // Nuke-style drag-to-create: a link dragged onto empty canvas (rather // than another pin) surfaces here. On release, remember the source pin // and pop the create-node menu (deferred to the suspended block below, // since OpenPopup must not run mid-editor); the chosen node is wired to // this pin. // Dragging from a collapsed meta pin onto empty canvas is not // supported (there's no single concrete pin/type to auto-wire the // new node to) — left un-accepted here so the library cancels it. NE::PinId newNodePinId; if (NE::QueryNewNode(&newNodePinId)) { auto it = m_pinIdToInfo.find(newNodePinId.Get()); if (it != m_pinIdToInfo.end() && !it->second.isMeta && NE::AcceptNewItem()) { m_linkDragSourcePin = it->second; m_openLinkDragMenu = true; m_pendingCreateNodePos = ImGui::GetMousePos(); } } NE::EndCreate(); } if (NE::BeginDelete()) { NE::NodeId nodeId; while (NE::QueryDeletedNode(&nodeId)) { if (NE::AcceptDeletedItem()) { auto it = m_nodeIdToPath.find(nodeId.Get()); if (it != m_nodeIdToPath.end()) DeleteNode(it->second); } } NE::LinkId linkId; while (NE::QueryDeletedLink(&linkId)) { if (NE::AcceptDeletedItem()) { auto it = m_linkIdToInfo.find(linkId.Get()); if (it != m_linkIdToInfo.end()) DisconnectAttr(it->second.destNode, it->second.destInput); } } NE::EndDelete(); } } bool MaterialEditorPanel::IsPinLinked(const pxr::SdfPath& nodePath, const std::string& pinName, bool isOutput) const { for (const auto& link : m_graph.links) { if (isOutput) { if (link.sourceNode == nodePath && link.sourceOutput == pinName) return true; } else { if (link.destNode == nodePath && link.destInput == pinName) return true; } } return false; } MaterialEditorPanel::PinDisplayOverride& MaterialEditorPanel::GetPinDisplayOverride(const pxr::SdfPath& nodePath) { return m_pinDisplayOverrides[nodePath.GetString()]; } bool MaterialEditorPanel::IsPinVisible(const ShaderGraphNode& node, const std::string& pinName, bool isOutput) const { auto it = m_pinDisplayOverrides.find(node.path.GetString()); PinDisplayMode mode = (it != m_pinDisplayOverrides.end()) ? it->second.mode : PinDisplayMode::All; switch (mode) { case PinDisplayMode::All: return true; case PinDisplayMode::Connected: return IsPinLinked(node.path, pinName, isOutput); case PinDisplayMode::Collapsed: return false; } return true; } void MaterialEditorPanel::CyclePinDisplayMode(const pxr::SdfPath& nodePath) { PinDisplayOverride& ov = GetPinDisplayOverride(nodePath); switch (ov.mode) { case PinDisplayMode::All: ov.mode = PinDisplayMode::Connected; break; case PinDisplayMode::Connected: ov.mode = PinDisplayMode::Collapsed; break; case PinDisplayMode::Collapsed: ov.mode = PinDisplayMode::All; break; } PersistPinDisplayState(nodePath); } void MaterialEditorPanel::ApplyPinDisplayModeToAllNodes(PinDisplayMode mode) { for (const auto& node : m_graph.nodes) { PinDisplayOverride& ov = GetPinDisplayOverride(node.path); if (ov.mode != mode) { ov.mode = mode; PersistPinDisplayState(node.path); } } } uintptr_t MaterialEditorPanel::EffectivePinId(const pxr::SdfPath& nodePath, const std::string& pinName, bool isOutput) const { auto it = m_pinDisplayOverrides.find(nodePath.GetString()); if (it != m_pinDisplayOverrides.end() && it->second.mode == PinDisplayMode::Collapsed) return HashId(nodePath.GetString() + (isOutput ? ":metaOut" : ":metaIn")); return HashId(nodePath.GetString() + (isOutput ? ":out:" : ":in:") + pinName); } void MaterialEditorPanel::RenderPinPickMenu() { const ShaderGraphNode* node = nullptr; for (const auto& n : m_graph.nodes) if (n.path == m_pendingPinPick.nodePath) { node = &n; break; } if (!node) return; const bool isOutput = m_pendingPinPick.isOutput; const std::vector& pins = isOutput ? node->outputs : node->inputs; ImGui::TextDisabled(isOutput ? "Connect output" : "Connect input"); ImGui::Separator(); for (const auto& pin : pins) { if (IsPinLinked(node->path, pin.name, isOutput)) continue; // already wired elsewhere if (ImGui::MenuItem(pin.name.c_str())) { CompletePinPick(PinInfo{node->path, pin.name, isOutput, pin.typeName, false}); ImGui::CloseCurrentPopup(); break; } } } void MaterialEditorPanel::CompletePinPick(const PinInfo& chosen) { if (m_pendingPinPick.otherPin.isMeta) { // Dragged directly between two collapsed nubs: this pick resolved // one side to a real pin, now chain into a second pick for the far // side (deferred, since we're inside the already-open popup). PendingPinPick next; next.nodePath = m_pendingPinPick.otherPin.nodePath; next.isOutput = m_pendingPinPick.otherPin.isOutput; next.otherPin = chosen; m_pendingPinPick = next; m_openPinPickMenu = true; return; } const PinInfo& outPin = chosen.isOutput ? chosen : m_pendingPinPick.otherPin; const PinInfo& inPin = chosen.isOutput ? m_pendingPinPick.otherPin : chosen; CreateConnection(inPin, outPin); } void MaterialEditorPanel::RenderNodeSearchMenu(const ImVec2& canvasPos, const PinInfo* linkSource) { if (!m_materialManager || !m_stage || m_materialPath.IsEmpty()) { ImGui::TextDisabled("Open or create a material first"); return; } // The new node must expose a pin of the opposite direction to the pin the // link was dragged from. const bool wantOutput = linkSource && !linkSource->isOutput; if (linkSource) { ImGui::TextDisabled("Connect %s %s to a new node:", linkSource->isOutput ? "output" : "input", linkSource->name.c_str()); } if (ImGui::IsWindowAppearing()) { m_nodeSearchBuf[0] = '\0'; m_nodeSearchSelected = 0; ImGui::SetKeyboardFocusHere(); } ImGui::SetNextItemWidth(300.0f); const bool commit = ImGui::InputText("##NodeSearchQuery", m_nodeSearchBuf, sizeof(m_nodeSearchBuf), ImGuiInputTextFlags_EnterReturnsTrue); if (ImGui::IsItemEdited()) m_nodeSearchSelected = 0; auto matches = FilterShaderNodeTypes(m_materialManager->GetAvailableShaderNodes(), m_nodeSearchBuf); if (linkSource) { // Keep only nodes with a pin type-connectable to the dragged pin. std::string pn; pxr::SdfValueTypeName pt; matches.erase(std::remove_if(matches.begin(), matches.end(), [&](const ShaderNodeTypeInfo* info) { return !BestConnectablePin(info->identifier, wantOutput, linkSource->typeName, pn, pt); }), matches.end()); } const bool movedDown = ImGui::IsKeyPressed(ImGuiKey_DownArrow); const bool movedUp = ImGui::IsKeyPressed(ImGuiKey_UpArrow); if (movedDown) ++m_nodeSearchSelected; if (movedUp) --m_nodeSearchSelected; if (matches.empty()) m_nodeSearchSelected = 0; else m_nodeSearchSelected = std::clamp(m_nodeSearchSelected, 0, static_cast(matches.size()) - 1); const ShaderNodeTypeInfo* chosen = nullptr; if (commit && !matches.empty()) chosen = matches[m_nodeSearchSelected]; ImGui::BeginChild("NodeSearchResults", ImVec2(300.0f, 260.0f), false); if (matches.empty()) 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 + " [" + info->source + "/" + info->category + "]##" + std::to_string(i); const bool selected = (i == m_nodeSearchSelected); if (ImGui::Selectable(itemLabel.c_str(), selected)) chosen = info; if (selected && (movedDown || movedUp)) ImGui::SetScrollHereY(); } ImGui::EndChild(); if (chosen) { if (linkSource) { const pxr::SdfPath newPath = CreateShaderNode(chosen->identifier, canvasPos); std::string pinName; pxr::SdfValueTypeName pinType; if (!newPath.IsEmpty() && BestConnectablePin(chosen->identifier, wantOutput, linkSource->typeName, pinName, pinType)) { const PinInfo newPin{newPath, pinName, wantOutput, pinType}; // dest is the input pin, source the output pin. if (linkSource->isOutput) CreateConnection(newPin, *linkSource); else CreateConnection(*linkSource, newPin); } } else { CreateShaderNode(chosen->identifier, canvasPos); } ImGui::CloseCurrentPopup(); } } ImVec2 MaterialEditorPanel::FindFreeCanvasSpot(ImVec2 desired, const std::string& shaderId) const { // Estimated footprint of the not-yet-rendered node: same height model as // ApplyFallbackLayout, width covering typical icon+label pin rows. auto estimateSize = [](size_t pinCount) { return ImVec2(340.0f, static_cast(pinCount) * 24.0f + 80.0f); }; size_t newPinCount = 6; if (pxr::SdrShaderNodeConstPtr sdrNode = pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(pxr::TfToken(shaderId))) newPinCount = sdrNode->GetShaderInputNames().size() + sdrNode->GetShaderOutputNames().size(); const ImVec2 newSize = estimateSize(newPinCount); struct Rect { ImVec2 pos, size; }; std::vector rects; rects.reserve(m_graph.nodes.size()); for (const auto& node : m_graph.nodes) { Rect r{ImVec2(node.uiPosition[0], node.uiPosition[1]), estimateSize(node.inputs.size() + node.outputs.size())}; // Nodes the editor has already rendered report their real rectangle. NE::NodeId nodeId(HashId(node.path.GetString())); const ImVec2 livePos = NE::GetNodePosition(nodeId); const ImVec2 liveSize = NE::GetNodeSize(nodeId); if (livePos.x != FLT_MAX && liveSize.x > 0.0f) { r.pos = livePos; r.size = liveSize; } rects.push_back(r); } const float margin = 24.0f; bool moved = true; while (moved) { moved = false; for (const Rect& r : rects) { const bool overlaps = desired.x < r.pos.x + r.size.x + margin && r.pos.x < desired.x + newSize.x + margin && desired.y < r.pos.y + r.size.y + margin && r.pos.y < desired.y + newSize.y + margin; if (overlaps) { // March right past the blocker; x only grows, so the scan // terminates once it clears the rightmost overlapping node. desired.x = r.pos.x + r.size.x + margin; moved = true; } } } return desired; } pxr::SdfPath MaterialEditorPanel::CreateShaderNode(const std::string& shaderId, const ImVec2& canvasPos) { if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return pxr::SdfPath(); std::string baseName = SanitizeUsdName(shaderId); if (baseName.empty()) baseName = "Shader"; std::string finalName = baseName; int suffix = 1; while (m_stage->GetPrimAtPath(m_materialPath.AppendChild(pxr::TfToken(finalName))).IsValid()) finalName = baseName + "_" + std::to_string(++suffix); pxr::SdfPath path = m_materialPath.AppendChild(pxr::TfToken(finalName)); const ImVec2 freePos = FindFreeCanvasSpot(canvasPos, shaderId); pxr::GfVec2f pos(freePos.x, freePos.y); m_commandHistory->Push(std::make_unique(m_stage, path, shaderId, pos)); SyncFromUsd(); return path; } void MaterialEditorPanel::CreateConnection(const PinInfo& destInput, const PinInfo& sourceOutput) { if (!m_stage || !m_commandHistory) return; // Captured now so Undo() (which may run after the ambient edit target // has since changed, e.g. a render-layer switch) still targets the same // layer this connection was authored into. pxr::SdfLayerHandle editLayer = m_stage->GetEditTarget().GetLayer(); m_commandHistory->Push(std::make_unique( m_stage, destInput.nodePath, destInput.name, destInput.typeName, sourceOutput.nodePath, sourceOutput.name, sourceOutput.typeName, editLayer)); SyncFromUsd(); } void MaterialEditorPanel::DeleteNode(const pxr::SdfPath& nodePath) { if (!m_stage || !m_commandHistory) return; m_commandHistory->Push(std::make_unique(m_stage, nodePath)); SyncFromUsd(); } void MaterialEditorPanel::DisconnectAttr(const pxr::SdfPath& destNode, const std::string& destInput) { if (!m_stage || !m_commandHistory) return; pxr::SdfLayerHandle editLayer = m_stage->GetEditTarget().GetLayer(); m_commandHistory->Push(std::make_unique(m_stage, destNode, destInput, editLayer)); SyncFromUsd(); } std::string MaterialEditorPanel::ResolveTextureFilePath(const ShaderGraphNode& node) const { if (!m_stage) return {}; // UsdUVTexture and MaterialX image/tiledimage all expose the source as an // Asset-typed `file` input; prefer that name, else any Asset input. std::string inputName; for (const auto& in : node.inputs) { if (in.typeName == pxr::SdfValueTypeNames->Asset) { inputName = in.name; if (in.name == "file") break; } } if (inputName.empty()) return {}; pxr::UsdPrim prim = m_stage->GetPrimAtPath(node.path); if (!prim) return {}; pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + inputName)); pxr::VtValue v; if (!attr || !attr.Get(&v) || !v.IsHolding()) return {}; const pxr::SdfAssetPath ap = v.UncheckedGet(); // Resolved path for on-disk load; fall back to the authored path (may // already be absolute) when the resolver returns nothing. return ap.GetResolvedPath().empty() ? ap.GetAssetPath() : ap.GetResolvedPath(); } void MaterialEditorPanel::PersistNodePosition(NE::NodeId nodeId) { if (!m_stage || !m_commandHistory) return; auto it = m_nodeIdToPath.find(nodeId.Get()); if (it == m_nodeIdToPath.end()) return; pxr::SdfPath path = it->second; pxr::UsdPrim prim = m_stage->GetPrimAtPath(path); if (!prim.IsValid()) return; static const pxr::TfToken kUiPositionKey("uiPosition"); ImVec2 newPos = NE::GetNodePosition(nodeId); pxr::GfVec2f newValue(newPos.x, newPos.y); // A Save echoed by our own seeding is a no-op: the editor position still // matches what the current snapshot prescribes (authored or fallback // auto-layout). Only a real drag diverges from it and should author. for (const auto& node : m_graph.nodes) { if (node.path == path) { if (node.uiPosition == newValue) { // LOG_INFO("[NodeGraph] position save for " + path.GetString() + // " ignored (echo of snapshot seed)"); return; } break; } } pxr::VtValue oldValueVt = prim.GetCustomDataByKey(kUiPositionKey); pxr::GfVec2f oldValue = oldValueVt.IsHolding() ? oldValueVt.UncheckedGet() : pxr::GfVec2f(0.0f, 0.0f); if (oldValue == newValue) { // LOG_INFO("[NodeGraph] position save for " + path.GetString() + // " ignored (customData already matches)"); return; } // char moveMsg[256]; // std::snprintf(moveMsg, sizeof(moveMsg), // "[NodeGraph] move committed: %s (%.1f, %.1f) -> (%.1f, %.1f)", // path.GetText(), oldValue[0], oldValue[1], newValue[0], newValue[1]); // LOG_INFO(std::string(moveMsg)); pxr::UsdStageRefPtr stage = m_stage; m_commandHistory->Push(std::make_unique( "Move " + path.GetName(), [stage, path, newValue]() { pxr::UsdPrim p = stage->GetPrimAtPath(path); if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(newValue)); }, [stage, path, oldValue]() { pxr::UsdPrim p = stage->GetPrimAtPath(path); if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(oldValue)); })); } void MaterialEditorPanel::PersistPinDisplayState(const pxr::SdfPath& nodePath) { if (!m_keepGraphNodeViewSettingsInUsd || !m_stage || !m_commandHistory) return; pxr::UsdPrim prim = m_stage->GetPrimAtPath(nodePath); if (!prim.IsValid()) return; static const pxr::TfToken kPinModeKey("uiPinDisplayMode"); const int newMode = static_cast(GetPinDisplayOverride(nodePath).mode); pxr::VtValue oldModeVt = prim.GetCustomDataByKey(kPinModeKey); const int oldMode = oldModeVt.IsHolding() ? oldModeVt.UncheckedGet() : static_cast(PinDisplayMode::All); if (oldMode == newMode) return; pxr::UsdStageRefPtr stage = m_stage; m_commandHistory->Push(std::make_unique( "Change pin display for " + nodePath.GetName(), [stage, nodePath, newMode]() { pxr::UsdPrim p = stage->GetPrimAtPath(nodePath); if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPinDisplayMode"), pxr::VtValue(newMode)); }, [stage, nodePath, oldMode]() { pxr::UsdPrim p = stage->GetPrimAtPath(nodePath); if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPinDisplayMode"), pxr::VtValue(oldMode)); })); } bool MaterialEditorPanel::SaveNodeSettingsCallback(NE::NodeId nodeId, const char* /*data*/, size_t /*size*/, NE::SaveReasonFlags reason, void* userPointer) { auto* self = static_cast(userPointer); if (self && (reason & NE::SaveReasonFlags::Position) != NE::SaveReasonFlags::None) self->PersistNodePosition(nodeId); return true; } } // namespace UsdLayerManager