Hypershade-style pin display modes for material graph nodes

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 23:02:56 +08:00
parent 22406fbe63
commit c7edd145df
6 changed files with 331 additions and 7 deletions
+221 -6
View File
@@ -306,7 +306,7 @@ void ApplyFallbackLayout(ShaderGraphSnapshot& graph) {
MaterialEditorPanel::MaterialEditorPanel() {
// Route the vendored editor's click-time hit-test dump into our log so
// it interleaves with the [NodeGraph] event trace. Disabled: uncomment to
// re-enable the [ed] trace while diagnosing the drag regression.
// re-enable the [ed] trace when diagnosing canvas interaction issues.
// g_AxNodeEditorDebugLog = [](const char* msg) { LOG_INFO(std::string(msg)); };
NE::Config config;
@@ -1311,6 +1311,16 @@ void MaterialEditorPanel::SyncFromUsd() {
}
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() {
@@ -1365,6 +1375,12 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
// 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);
@@ -1414,18 +1430,103 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
// 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)
contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(input.name.c_str()).x);
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)
contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(output.name.c_str()).x);
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
@@ -1451,6 +1552,7 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
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
@@ -1466,6 +1568,7 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
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));
@@ -1478,7 +1581,7 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
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,
@@ -1529,10 +1632,22 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
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
@@ -1723,7 +1838,19 @@ void MaterialEditorPanel::HandleCreateAndDelete() {
const PinInfo& b = endIt->second;
const PinInfo& outPin = a.isOutput ? a : b;
const PinInfo& inPin = a.isOutput ? b : a;
CreateConnection(inPin, outPin);
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();
@@ -1735,10 +1862,13 @@ void MaterialEditorPanel::HandleCreateAndDelete() {
// 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() && NE::AcceptNewItem()) {
if (it != m_pinIdToInfo.end() && !it->second.isMeta && NE::AcceptNewItem()) {
m_linkDragSourcePin = it->second;
m_openLinkDragMenu = true;
m_pendingCreateNodePos = ImGui::GetMousePos();
@@ -1779,6 +1909,62 @@ bool MaterialEditorPanel::IsPinLinked(const pxr::SdfPath& nodePath, const std::s
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");
@@ -2038,6 +2224,35 @@ void MaterialEditorPanel::PersistNodePosition(NE::NodeId nodeId) {
}));
}
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);