Files
UsdLayerManager/src/ui/MaterialEditorPanel.cpp
T
indigo c7edd145df Hypershade-style pin display modes for material graph nodes
Each node gets an All/Conn toggle: connected-only mode hides
unconnected pins behind collapsed connector nubs on the title row;
dragging a link onto (or from) a nub opens a menu to pick which hidden
pin to wire, and the pin appears once connected. The toggle is drawn
and hit-tested geometrically, and its tooltip is deferred to after
NE::End() so it lands at the cursor. A new Preferences > Material
checkbox opts into persisting the per-node mode as USD customData
(uiShowAllPins); otherwise it stays session-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:02:56 +08:00

2265 lines
104 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 <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));
}
// 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);
}
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();
}
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();
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::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();
}
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;
auto doBind = [stage, targetPath, materialPath]() {
pxr::UsdPrim target = stage->GetPrimAtPath(targetPath);
pxr::UsdShadeMaterial material(stage->GetPrimAtPath(materialPath));
if (target && material)
pxr::UsdShadeMaterialBindingAPI::Apply(target).Bind(material);
};
if (m_commandHistory) {
m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
"Bind Material",
doBind,
[stage, targetPath, hadPriorRel, priorTargets]() {
pxr::UsdPrim target = stage->GetPrimAtPath(targetPath);
if (!target) return;
pxr::UsdShadeMaterialBindingAPI bindAPI(target);
if (hadPriorRel && !priorTargets.empty())
bindAPI.GetDirectBindingRel().SetTargets(priorTargets);
else
bindAPI.UnbindDirectBinding();
}
));
} 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.showAllPins});
}
}
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);
// 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.
ImTextureID thumb = 0;
bool wantsThumb = false;
if (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 (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-only mode collapses every unconnected pin on a side into
// one connectable "more pins" nub on the title row (Maya Hypershade
// style) — present only while that side actually has something hidden.
bool hasHiddenInput = false, hasHiddenOutput = false;
if (!pinDisplay.showAllPins) {
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; }
}
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();
if (hasHiddenInput) {
// Collapsed input nub: dropping a connection here (or dragging
// one out of it) opens RenderPinPickMenu() to choose which
// hidden input it actually wires to.
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";
ImGui::SameLine();
}
ImGui::TextUnformatted(title.c_str());
if (hasHiddenOutput) {
ImGui::SameLine();
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";
}
// 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 char* label = pinDisplay.showAllPins ? "All" : "Conn";
const ImGuiStyle& style = ImGui::GetStyle();
const ImVec2 labelSize = ImGui::CalcTextSize(label);
const ImVec2 btnMin = ImGui::GetCursorScreenPos();
const ImVec2 btnMax(btnMin.x + labelSize.x + style.FramePadding.x * 2.0f,
btnMin.y + labelSize.y);
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);
dl->AddText(ImVec2(btnMin.x + style.FramePadding.x, btnMin.y),
ImGui::GetColorU32(ImGuiCol_Text), label);
if (btnHovered)
pendingTooltip = pinDisplay.showAllPins
? "Showing all pins - click to show connected pins only"
: "Showing connected pins only - click to show all pins";
if (btnHovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
TogglePinDisplayMode(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();
}
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);
uintptr_t startPinId = HashId(link.sourceNode.GetString() + ":out:" + link.sourceOutput);
uintptr_t endPinId = HashId(link.destNode.GetString() + ":in:" + link.destInput);
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);
NE::Link(NE::LinkId(linkIdValue), NE::PinId(startPinId), NE::PinId(endPinId), 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());
if (it == m_pinDisplayOverrides.end() || it->second.showAllPins)
return true;
return IsPinLinked(node.path, pinName, isOutput);
}
void MaterialEditorPanel::TogglePinDisplayMode(const pxr::SdfPath& nodePath) {
PinDisplayOverride& ov = GetPinDisplayOverride(nodePath);
ov.showAllPins = !ov.showAllPins;
PersistPinDisplayState(nodePath);
}
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;
m_commandHistory->Push(std::make_unique<ConnectShaderAttrsCommand>(
m_stage, destInput.nodePath, destInput.name, destInput.typeName,
sourceOutput.nodePath, sourceOutput.name, sourceOutput.typeName));
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;
m_commandHistory->Push(std::make_unique<DisconnectShaderAttrCommand>(m_stage, destNode, destInput));
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 kShowAllKey("uiShowAllPins");
const bool newShowAll = GetPinDisplayOverride(nodePath).showAllPins;
pxr::VtValue oldShowAllVt = prim.GetCustomDataByKey(kShowAllKey);
const bool oldShowAll = oldShowAllVt.IsHolding<bool>() ? oldShowAllVt.UncheckedGet<bool>() : true;
if (oldShowAll == newShowAll)
return;
pxr::UsdStageRefPtr stage = m_stage;
m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
"Change pin display for " + nodePath.GetName(),
[stage, nodePath, newShowAll]() {
pxr::UsdPrim p = stage->GetPrimAtPath(nodePath);
if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiShowAllPins"), pxr::VtValue(newShowAll));
},
[stage, nodePath, oldShowAll]() {
pxr::UsdPrim p = stage->GetPrimAtPath(nodePath);
if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiShowAllPins"), pxr::VtValue(oldShowAll));
}));
}
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