c7edd145df
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>
2265 lines
104 KiB
C++
2265 lines
104 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 "../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 = [¤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) {
|
||
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
|