Files
UsdLayerManager/src/ui/MaterialEditorPanel.cpp
T
indigo b5c1e50438 Add Maya-style Render Layer panel
Render layers built on the existing USD sublayer architecture: each layer is a
file-backed SdfLayer mounted as a sublayer and tagged via custom layer data,
with membership stored in a UsdCollectionAPI on a hidden /RenderLayerData prim.
Activating a layer applies its overrides and isolates it to its members.
"Default" is virtual -- it means all render-layer sublayers muted.

See docs/adr/0002-render-layer.md for the design and rationale.

- RenderLayerManager: create/delete/rename/activate, membership, mute-based
  isolation. Sdf-level APIs are used while a layer is muted (Usd-level APIs
  can't see muted layers) and Usd-level APIs when active.
- Commands: create/delete/rename/activate and add/remove members, all undoable.
- RenderLayerPanel: layer list with one-click activation and a membership
  editor for the selected prims.
- StageEditorPanel / SceneHierarchyPanel: badge render-layer-owned sublayers,
  disable their mute/remove controls (they're managed from the Render Layer
  panel so the two can't desync), and lock the edit target while a non-Default
  layer is active.
- ConnectShaderAttrs/DisconnectShaderAttr: take an explicit editLayer captured
  at construction, so undo targets the same layer as execute even if the
  ambient edit target changed in between (e.g. a render-layer switch).

Note: SceneHierarchyPanel.h also carries declarations for the sublayers
section added in the following commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:02:33 +08:00

2520 lines
118 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 <pxr/usd/usd/editContext.h>
#include <pxr/usd/usdShade/shader.h>
#include <pxr/usd/usdShade/material.h>
#include <pxr/usd/usdShade/materialBindingAPI.h>
#include <pxr/usd/sdr/registry.h>
#include <pxr/usd/sdr/shaderNode.h>
#include <pxr/usd/sdr/shaderProperty.h>
#include <pxr/usd/sdf/types.h>
#include <pxr/usd/sdf/assetPath.h>
#include <pxr/base/gf/vec2f.h>
#include <pxr/base/gf/vec3f.h>
#include <pxr/base/gf/vec4f.h>
#include <pxr/base/gf/half.h>
#include <pxr/base/tf/token.h>
#include <pxr/base/tf/stringUtils.h>
#include <pxr/base/vt/value.h>
#include <ax/Widgets.h>
#include <algorithm>
#include <functional>
#include <set>
#include <cctype>
#include <cstdio>
// 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<uintptr_t>(std::hash<std::string>{}(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<int>(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<unsigned char>(c)) || c == '_')
result += c;
else
result += '_';
}
if (result.empty() || std::isdigit(static_cast<unsigned char>(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<uint32_t>(std::hash<std::string>{}(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<int>(r * 255), static_cast<int>(g * 255), static_cast<int>(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<std::string>{}(node.path.GetString());
h = h * 31 + std::hash<std::string>{}(node.shaderId);
}
for (const auto& link : graph.links) {
h = h * 31 + std::hash<std::string>{}(
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<std::string>{}(
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<const ShaderNodeTypeInfo*> FilterShaderNodeTypes(
const std::vector<ShaderNodeTypeInfo>& nodeTypes, const char* rawQuery) {
auto toLower = [](std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
};
const std::string query = toLower(rawQuery);
std::vector<const ShaderNodeTypeInfo*> matches;
std::set<std::string> 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<std::string, int> 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<int, float> 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<int>(size), static_cast<int>(size));
ImGui::SetCursorPosX(std::max(0.0f, (ImGui::GetContentRegionAvail().x - size) * 0.5f)
+ ImGui::GetCursorPosX());
if (texId != 0) {
ImGui::Image(ImTextureID(static_cast<ImU64>(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 = [&current]() -> float {
if (current.IsHolding<float>()) return current.UncheckedGet<float>();
if (current.IsHolding<double>()) return static_cast<float>(current.UncheckedGet<double>());
if (current.IsHolding<pxr::GfHalf>()) return static_cast<float>(current.UncheckedGet<pxr::GfHalf>());
if (current.IsHolding<int>()) return static_cast<float>(current.UncheckedGet<int>());
return 0.0f;
};
if (type == tn->Color3f) {
pxr::GfVec3f v = current.IsHolding<pxr::GfVec3f>() ? current.UncheckedGet<pxr::GfVec3f>()
: pxr::GfVec3f(0.0f);
if (ImGui::ColorEdit3(widgetId.c_str(), v.data()))
newValue = v;
} else if (type == tn->Color4f) {
pxr::GfVec4f v = current.IsHolding<pxr::GfVec4f>() ? current.UncheckedGet<pxr::GfVec4f>()
: 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<pxr::GfVec3f>() ? current.UncheckedGet<pxr::GfVec3f>()
: 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<pxr::GfVec2f>() ? current.UncheckedGet<pxr::GfVec2f>()
: 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<pxr::GfVec4f>() ? current.UncheckedGet<pxr::GfVec4f>()
: 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<double>(f);
else if (type == tn->Half) newValue = pxr::GfHalf(f);
else newValue = f;
}
} else if (type == tn->Int) {
int v = current.IsHolding<int>() ? current.UncheckedGet<int>() : 0;
if (ImGui::DragInt(widgetId.c_str(), &v))
newValue = v;
} else if (type == tn->Bool) {
bool v = current.IsHolding<bool>() && current.UncheckedGet<bool>();
if (ImGui::Checkbox(widgetId.c_str(), &v))
newValue = v;
} else if (type == tn->String || type == tn->Token) {
std::string s;
if (current.IsHolding<std::string>()) s = current.UncheckedGet<std::string>();
else if (current.IsHolding<pxr::TfToken>()) s = current.UncheckedGet<pxr::TfToken>().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<pxr::SdfAssetPath>()) s = current.UncheckedGet<pxr::SdfAssetPath>().GetAssetPath();
else if (current.IsHolding<std::string>()) s = current.UncheckedGet<std::string>();
// 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<std::string> kFallback = {"raw", "sRGB"};
const std::vector<std::string>& 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<AttributeSetCommand>(
"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<AttributeSetCommand>(
"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<int>(m_toolbarPinDisplayMode)]);
if (clicked) {
m_toolbarPinDisplayMode = static_cast<PinDisplayMode>((static_cast<int>(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<pxr::SdfPath> 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<RenamePrimCommand>(
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<int>(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<AttributeSetCommand>(
"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<AttributeSetCommand>(
"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<AttributeSetCommand>(
"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<CreatePrimCommand>(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<pxr::UsdShadeMaterialBindingAPI>())
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<AttributeSetCommand>(
"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<CreatePrimCommand>(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<float>(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<int>(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<float>(NodeThumbnailCache::kThumbPx),
static_cast<float>(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<uintptr_t>* 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<uintptr_t> ids;
const int count = NE::GetSelectedObjectCount();
if (count > 0) {
std::vector<NE::NodeId> sel(static_cast<size_t>(count));
const int nodeCount = NE::GetSelectedNodes(sel.data(), count);
ids.reserve(static_cast<size_t>(nodeCount));
for (int i = 0; i < nodeCount; ++i)
ids.push_back(sel[static_cast<size_t>(i)].Get());
}
return ids;
};
auto nodeNames = [this](const std::vector<uintptr_t>& 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() : "<unknown>";
}
return names;
};
auto containsAnyOf = [](const std::vector<uintptr_t>& haystack,
const std::vector<uintptr_t>& 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<uintptr_t> 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<uintptr_t> 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() : "<unknown>",
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<NE::NodeId> selected(static_cast<size_t>(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<size_t>(i)].Get());
msg += (i == 0 ? ": " : ", ");
msg += (it != m_nodeIdToPath.end()) ? it->second.GetString() : "<unknown>";
}
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<ShaderPinInfo>& 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<int>(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<int>(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<float>(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<Rect> 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<CreateShaderNodeCommand>(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<ConnectShaderAttrsCommand>(
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<DeletePrimCommand>(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<DisconnectShaderAttrCommand>(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<pxr::SdfAssetPath>()) return {};
const pxr::SdfAssetPath ap = v.UncheckedGet<pxr::SdfAssetPath>();
// 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<pxr::GfVec2f>()
? oldValueVt.UncheckedGet<pxr::GfVec2f>()
: 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<AttributeSetCommand>(
"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<int>(GetPinDisplayOverride(nodePath).mode);
pxr::VtValue oldModeVt = prim.GetCustomDataByKey(kPinModeKey);
const int oldMode = oldModeVt.IsHolding<int>() ? oldModeVt.UncheckedGet<int>() : static_cast<int>(PinDisplayMode::All);
if (oldMode == newMode)
return;
pxr::UsdStageRefPtr stage = m_stage;
m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
"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<MaterialEditorPanel*>(userPointer);
if (self && (reason & NE::SaveReasonFlags::Position) != NE::SaveReasonFlags::None)
self->PersistNodePosition(nodeId);
return true;
}
} // namespace UsdLayerManager