7d214821f5
Drag a link off a pin onto empty canvas to open a create-node menu at the drop point (the editor's QueryNewNode path), filtered to nodes with a type-connectable pin; the chosen node is created and auto-wired to the source pin. Direction is handled both ways — drag from an output makes a downstream consumer, from an input an upstream producer. Type matching is lenient (exact, or same scalar/2-/3-/4-vector class), mirroring what USD/ MaterialX networks already tolerate; the target pin is the first exact match else the first connectable. RenderNodeSearchMenu gained an optional linkSource arg so the TAB popup and this share one implementation, and CreateShaderNode now returns the created path. Create and connect are two undo steps. Materials list: double-click a row starts an inline rename (mirrors SceneHierarchyPanel — Enter commits, focus-loss/Esc cancels) through RenamePrimCommand, which fixes up bindings/arcs via UsdNamespaceEditor. Selection and the open graph follow the rename. This repurposes the row's double-click, which previously opened the graph; opening stays on the Show Graph button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1867 lines
85 KiB
C++
1867 lines
85 KiB
C++
#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 <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 while diagnosing the drag regression.
|
||
// 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();
|
||
}
|
||
|
||
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 = [¤t]() -> 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 || type == tn->Asset) {
|
||
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();
|
||
else if (current.IsHolding<pxr::SdfAssetPath>()) s = current.UncheckedGet<pxr::SdfAssetPath>().GetAssetPath();
|
||
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 if (type == tn->Token) newValue = pxr::TfToken(buf);
|
||
else 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();
|
||
}
|
||
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},
|
||
{"normalTexture", "rgb", pxr::SdfValueTypeNames->Float3,
|
||
"normal", pxr::SdfValueTypeNames->Normal3f, 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));
|
||
}
|
||
// Normal maps need the [0,1] texture range remapped to [-1,1].
|
||
pxr::UsdShadeShader normalTex(
|
||
stage->GetPrimAtPath(matPath.AppendChild(pxr::TfToken("normalTexture"))));
|
||
normalTex.CreateInput(pxr::TfToken("scale"), pxr::SdfValueTypeNames->Float4)
|
||
.Set(pxr::GfVec4f(2.f, 2.f, 2.f, 1.f));
|
||
normalTex.CreateInput(pxr::TfToken("bias"), pxr::SdfValueTypeNames->Float4)
|
||
.Set(pxr::GfVec4f(-1.f, -1.f, -1.f, 0.f));
|
||
|
||
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", "normalTexture"})
|
||
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::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
|
||
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);
|
||
}
|
||
|
||
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);
|
||
|
||
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);
|
||
|
||
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);
|
||
|
||
// 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.
|
||
const float rowSpacing = ImGui::GetStyle().ItemSpacing.x;
|
||
float contentWidth = ImGui::CalcTextSize(title.c_str()).x;
|
||
for (const auto& input : node.inputs)
|
||
contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(input.name.c_str()).x);
|
||
for (const auto& output : node.outputs)
|
||
contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(output.name.c_str()).x);
|
||
|
||
NE::BeginNode(nodeId);
|
||
ImVec2 headerTop = ImGui::GetCursorScreenPos();
|
||
ImGui::TextUnformatted(title.c_str());
|
||
ImVec2 headerBottom = ImGui::GetItemRectMax();
|
||
ImGui::Dummy(ImVec2(0.0f, 4.0f)); // breathing room between title and pins
|
||
|
||
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);
|
||
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);
|
||
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();
|
||
}
|
||
|
||
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();
|
||
}
|
||
NE::Resume();
|
||
|
||
NE::End();
|
||
|
||
// --- 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;
|
||
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.
|
||
NE::PinId newNodePinId;
|
||
if (NE::QueryNewNode(&newNodePinId)) {
|
||
auto it = m_pinIdToInfo.find(newNodePinId.Get());
|
||
if (it != m_pinIdToInfo.end() && 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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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));
|
||
}));
|
||
}
|
||
|
||
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
|