Drag-to-create-and-connect nodes; double-click to rename materials

Drag a link off a pin onto empty canvas to open a create-node menu at
the drop point (the editor's QueryNewNode path), filtered to nodes with a
type-connectable pin; the chosen node is created and auto-wired to the
source pin. Direction is handled both ways — drag from an output makes a
downstream consumer, from an input an upstream producer. Type matching is
lenient (exact, or same scalar/2-/3-/4-vector class), mirroring what USD/
MaterialX networks already tolerate; the target pin is the first exact
match else the first connectable. RenderNodeSearchMenu gained an optional
linkSource arg so the TAB popup and this share one implementation, and
CreateShaderNode now returns the created path. Create and connect are two
undo steps.

Materials list: double-click a row starts an inline rename (mirrors
SceneHierarchyPanel — Enter commits, focus-loss/Esc cancels) through
RenamePrimCommand, which fixes up bindings/arcs via UsdNamespaceEditor.
Selection and the open graph follow the rename. This repurposes the row's
double-click, which previously opened the graph; opening stays on the
Show Graph button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 22:13:36 +08:00
parent 2ddeeed061
commit 7d214821f5
2 changed files with 189 additions and 22 deletions
+167 -18
View File
@@ -5,11 +5,14 @@
#include "../core/commands/ConnectShaderAttrsCommand.h"
#include "../core/commands/DisconnectShaderAttrCommand.h"
#include "../core/commands/AttributeSetCommand.h"
#include "../core/commands/RenamePrimCommand.h"
#include "../utils/Logger.h"
#include <pxr/usd/usdShade/shader.h>
#include <pxr/usd/usdShade/material.h>
#include <pxr/usd/usdShade/materialBindingAPI.h>
#include <pxr/usd/sdr/registry.h>
#include <pxr/usd/sdr/shaderNode.h>
#include <pxr/usd/sdr/shaderProperty.h>
#include <pxr/usd/sdf/types.h>
#include <pxr/usd/sdf/assetPath.h>
#include <pxr/base/gf/vec2f.h>
@@ -213,6 +216,49 @@ Icon IconForNodeCategory(const std::string& category) {
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
@@ -748,22 +794,68 @@ void MaterialEditorPanel::RenderMaterialBrowser() {
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);
if (isOpen)
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 210, 90, 255));
// Name for the label (Hypershade lists swatch names); ## suffix keeps
// the ImGui ID unique for same-named materials in different scopes.
std::string label = path.GetName() + "##" + path.GetString();
if (ImGui::Selectable(label.c_str(), isSelected, ImGuiSelectableFlags_AllowDoubleClick)) {
m_browserSelection = path;
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))
OpenOrCreateMaterial(path.GetString());
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());
}
if (isOpen)
ImGui::PopStyleColor();
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", path.GetText());
ImGui::PopID();
}
ImGui::EndChild();
@@ -1270,6 +1362,14 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
RenderNodeSearchMenu(NE::ScreenToCanvas(m_pendingCreateNodePos));
ImGui::EndPopup();
}
if (m_openLinkDragMenu) {
ImGui::OpenPopup("LinkDragCreateNode");
m_openLinkDragMenu = false;
}
if (ImGui::BeginPopup("LinkDragCreateNode")) {
RenderNodeSearchMenu(NE::ScreenToCanvas(m_pendingCreateNodePos), &m_linkDragSourcePin);
ImGui::EndPopup();
}
NE::Resume();
NE::End();
@@ -1470,6 +1570,21 @@ void MaterialEditorPanel::HandleCreateAndDelete() {
NE::RejectNewItem();
}
}
// Nuke-style drag-to-create: a link dragged onto empty canvas (rather
// than another pin) surfaces here. On release, remember the source pin
// and pop the create-node menu (deferred to the suspended block below,
// since OpenPopup must not run mid-editor); the chosen node is wired to
// this pin.
NE::PinId newNodePinId;
if (NE::QueryNewNode(&newNodePinId)) {
auto it = m_pinIdToInfo.find(newNodePinId.Get());
if (it != m_pinIdToInfo.end() && NE::AcceptNewItem()) {
m_linkDragSourcePin = it->second;
m_openLinkDragMenu = true;
m_pendingCreateNodePos = ImGui::GetMousePos();
}
}
NE::EndCreate();
}
@@ -1505,12 +1620,20 @@ bool MaterialEditorPanel::IsPinLinked(const pxr::SdfPath& nodePath, const std::s
return false;
}
void MaterialEditorPanel::RenderNodeSearchMenu(const ImVec2& canvasPos) {
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;
@@ -1522,8 +1645,18 @@ void MaterialEditorPanel::RenderNodeSearchMenu(const ImVec2& canvasPos) {
if (ImGui::IsItemEdited())
m_nodeSearchSelected = 0;
const auto matches =
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);
@@ -1554,7 +1687,22 @@ void MaterialEditorPanel::RenderNodeSearchMenu(const ImVec2& canvasPos) {
ImGui::EndChild();
if (chosen) {
CreateShaderNode(chosen->identifier, canvasPos);
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();
}
}
@@ -1607,8 +1755,8 @@ ImVec2 MaterialEditorPanel::FindFreeCanvasSpot(ImVec2 desired, const std::string
return desired;
}
void MaterialEditorPanel::CreateShaderNode(const std::string& shaderId, const ImVec2& canvasPos) {
if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return;
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";
@@ -1624,6 +1772,7 @@ void MaterialEditorPanel::CreateShaderNode(const std::string& shaderId, const Im
m_commandHistory->Push(std::make_unique<CreateShaderNodeCommand>(m_stage, path, shaderId, pos));
SyncFromUsd();
return path;
}
void MaterialEditorPanel::CreateConnection(const PinInfo& destInput, const PinInfo& sourceOutput) {