Add Collapsed pin display mode with a 3-dot state icon

Cycle each node through All -> Connected -> Collapsed -> All via a
custom-drawn icon (filled-dot count encodes how much is shown: 3/2/1).
Collapsed hides every pin on a side, linked or not, behind one
connector nub; existing links now redirect to that nub too
(EffectivePinId), so they visually converge like Hypershade's collapse
view instead of only hiding unconnected pins. Also moved the connector
nubs off the title row onto the node's bottom row. Persisted mode
switches from a bool (uiShowAllPins) to an int (uiPinDisplayMode).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 01:52:52 +08:00
parent c7edd145df
commit bff42082a5
4 changed files with 149 additions and 78 deletions
+7 -4
View File
@@ -138,7 +138,7 @@ ShaderGraphSnapshot MaterialManager::GetShaderGraph(const pxr::SdfPath& material
if (!materialPrim.IsValid()) return snapshot;
static const pxr::TfToken kUiPositionKey("uiPosition");
static const pxr::TfToken kUiShowAllPinsKey("uiShowAllPins");
static const pxr::TfToken kUiPinDisplayModeKey("uiPinDisplayMode");
// Seed with every shader under the material, recursing into nested node
// graphs (usdMtlx nests a material's nodes inside UsdShadeNodeGraph
@@ -180,9 +180,12 @@ ShaderGraphSnapshot MaterialManager::GetShaderGraph(const pxr::SdfPath& material
node.hasAuthoredPosition = true;
}
pxr::VtValue showAllValue = child.GetCustomDataByKey(kUiShowAllPinsKey);
if (showAllValue.IsHolding<bool>())
node.showAllPins = showAllValue.UncheckedGet<bool>();
pxr::VtValue pinModeValue = child.GetCustomDataByKey(kUiPinDisplayModeKey);
if (pinModeValue.IsHolding<int>()) {
int raw = pinModeValue.UncheckedGet<int>();
if (raw >= static_cast<int>(PinDisplayMode::All) && raw <= static_cast<int>(PinDisplayMode::Collapsed))
node.pinDisplayMode = static_cast<PinDisplayMode>(raw);
}
// Prefer the full Sdr-defined pin set (so unauthored pins can still be
// dragged to create a connection); fall back to authored attributes
+14 -6
View File
@@ -33,6 +33,17 @@ struct ShaderPinInfo {
pxr::SdfValueTypeName typeName;
};
/// Hypershade-style pin display mode for a node in the graph canvas.
/// All: every Sdr-defined pin shown (today's default). Connected: only
/// linked pins shown, the rest collapsed behind a single connectable nub per
/// side. Collapsed: every pin — linked or not — collapses behind that one
/// nub per side, so existing connections visually converge on it too.
enum class PinDisplayMode {
All = 0,
Connected = 1,
Collapsed = 2,
};
/// One UsdShadeShader prim read back from an existing material network.
/// inputs/outputs list the full Sdr-defined pin set (not just authored
/// attributes) so unauthored pins can still be dragged to create a connection.
@@ -48,12 +59,9 @@ struct ShaderGraphNode {
/// False when no uiPosition custom data is authored (typical for networks
/// referenced from .mtlx) — the editor auto-lays such nodes out instead.
bool hasAuthoredPosition = false;
/// Hypershade-style pin display mode, read from customData when the
/// "keep graph node view settings in USD" preference authored it; true
/// (show every pin) when unauthored. False hides unconnected pins,
/// collapsing them into a single connectable "more pins" nub per side
/// (see MaterialEditorPanel) until a new connection reveals one by name.
bool showAllPins = true;
/// Pin display mode, read from customData when the "keep graph node view
/// settings in USD" preference authored it; All when unauthored.
PinDisplayMode pinDisplayMode = PinDisplayMode::All;
std::vector<ShaderPinInfo> inputs;
std::vector<ShaderPinInfo> outputs;
};
+110 -58
View File
@@ -1319,7 +1319,7 @@ void MaterialEditorPanel::SeedPinDisplayOverrides() {
const std::string key = node.path.GetString();
if (m_pinDisplayOverrides.find(key) != m_pinDisplayOverrides.end())
continue;
m_pinDisplayOverrides.emplace(key, PinDisplayOverride{node.showAllPins});
m_pinDisplayOverrides.emplace(key, PinDisplayOverride{node.pinDisplayMode});
}
}
@@ -1432,15 +1432,21 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
// 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.
// Connected mode collapses every unconnected pin on a side into one
// connectable "more pins" nub (Maya Hypershade style), present only
// while that side actually has something hidden. Collapsed mode
// collapses every pin on a side — linked or not — into that nub
// whenever the side has any pins at all (existing links redirect to
// it too; see EffectivePinId()).
bool hasHiddenInput = false, hasHiddenOutput = false;
if (!pinDisplay.showAllPins) {
if (pinDisplay.mode == PinDisplayMode::Connected) {
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; }
} else if (pinDisplay.mode == PinDisplayMode::Collapsed) {
hasHiddenInput = !node.inputs.empty();
hasHiddenOutput = !node.outputs.empty();
}
const float rowSpacing = ImGui::GetStyle().ItemSpacing.x;
@@ -1458,35 +1464,8 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
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
@@ -1499,12 +1478,18 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
// 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);
// 3-dot icon: filled-dot count encodes how much is visible —
// All shows all 3 dots filled, Connected 2, Collapsed 1 — so the
// icon visually "funnels down" as the node collapses further.
const float dotRadius = 3.0f;
const float dotSpacing = 9.0f;
const int dotCount = 3;
const int filledDots = 3 - static_cast<int>(pinDisplay.mode);
const ImVec2 iconSize(dotSpacing * (dotCount - 1) + dotRadius * 2.0f, dotRadius * 2.0f);
const ImVec2 btnMin = ImGui::GetCursorScreenPos();
const ImVec2 btnMax(btnMin.x + labelSize.x + style.FramePadding.x * 2.0f,
btnMin.y + labelSize.y);
const ImVec2 btnMax(btnMin.x + iconSize.x + style.FramePadding.x * 2.0f,
btnMin.y + iconSize.y + style.FramePadding.y * 2.0f);
ImGui::Dummy(ImVec2(btnMax.x - btnMin.x, btnMax.y - btnMin.y));
// IsMouseHoveringRect is pure geometry (no hover-id arbitration)
@@ -1516,15 +1501,28 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
: 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);
const ImU32 dotColor = ImGui::GetColorU32(ImGuiCol_Text);
const ImU32 dotHollowColor = ImGui::GetColorU32(ImGuiCol_TextDisabled);
ImVec2 dotCenter(btnMin.x + style.FramePadding.x + dotRadius,
btnMin.y + style.FramePadding.y + dotRadius);
for (int i = 0; i < dotCount; ++i) {
if (i < filledDots)
dl->AddCircleFilled(dotCenter, dotRadius, dotColor);
else
dl->AddCircle(dotCenter, dotRadius, dotHollowColor, 0, 1.2f);
dotCenter.x += dotSpacing;
}
static const char* kTooltips[3] = {
"Showing all pins - click to show connected pins only",
"Showing connected pins only - click to collapse into one pin",
"Collapsed to one pin per side - click to show all pins",
};
if (btnHovered)
pendingTooltip = pinDisplay.showAllPins
? "Showing all pins - click to show connected pins only"
: "Showing connected pins only - click to show all pins";
pendingTooltip = kTooltips[static_cast<int>(pinDisplay.mode)];
if (btnHovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
TogglePinDisplayMode(node.path);
CyclePinDisplayMode(node.path);
}
ImVec2 headerBottom = ImGui::GetItemRectMax();
@@ -1581,6 +1579,38 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
ax::Widgets::Icon(pinIconSize, ax::Widgets::IconType::Circle, linked, ImColor(GetPinColor(output.typeName)));
NE::EndPin();
}
// Collapsed connector nubs on the node's bottom row (Connected or
// Collapsed mode): input nub at the left edge, output nub hugging the
// right edge. Dropping a connection on (or dragging one out of) a
// nub opens RenderPinPickMenu() to choose which pin it wires to.
if (hasHiddenInput || hasHiddenOutput) {
ImGui::Dummy(ImVec2(0.0f, 2.0f));
const float nubRowStartX = ImGui::GetCursorPosX();
if (hasHiddenInput) {
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";
}
if (hasHiddenOutput) {
if (hasHiddenInput) ImGui::SameLine();
ImGui::SetCursorPosX(nubRowStartX + contentWidth - pinIconSize.x);
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";
}
}
ImGui::PopID();
NE::EndNode();
@@ -1601,8 +1631,9 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
for (const auto& link : m_graph.links) {
uintptr_t linkIdValue = HashId(link.destNode.GetString() + ":" + link.destInput);
// The pin's own id, always populated in m_pinIdToInfo regardless of
// visibility (used here purely to color the link by the real type).
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);
@@ -1610,7 +1641,13 @@ void MaterialEditorPanel::RenderNodeGraphCanvas() {
if (pinIt != m_pinIdToInfo.end())
linkColor = GetPinColor(pinIt->second.typeName);
NE::Link(NE::LinkId(linkIdValue), NE::PinId(startPinId), NE::PinId(endPinId), ImColor(linkColor));
// The id actually referenced by the drawn link: redirected to a
// node's collapsed meta nub when that node is in Collapsed mode
// (the real pin isn't drawn there, so its id can't be referenced).
uintptr_t linkStartPinId = EffectivePinId(link.sourceNode, link.sourceOutput, true);
uintptr_t linkEndPinId = EffectivePinId(link.destNode, link.destInput, false);
NE::Link(NE::LinkId(linkIdValue), NE::PinId(linkStartPinId), NE::PinId(linkEndPinId), ImColor(linkColor));
}
HandleCreateAndDelete();
@@ -1915,17 +1952,32 @@ MaterialEditorPanel::PinDisplayOverride& MaterialEditorPanel::GetPinDisplayOverr
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);
PinDisplayMode mode = (it != m_pinDisplayOverrides.end()) ? it->second.mode : PinDisplayMode::All;
switch (mode) {
case PinDisplayMode::All: return true;
case PinDisplayMode::Connected: return IsPinLinked(node.path, pinName, isOutput);
case PinDisplayMode::Collapsed: return false;
}
return true;
}
void MaterialEditorPanel::TogglePinDisplayMode(const pxr::SdfPath& nodePath) {
void MaterialEditorPanel::CyclePinDisplayMode(const pxr::SdfPath& nodePath) {
PinDisplayOverride& ov = GetPinDisplayOverride(nodePath);
ov.showAllPins = !ov.showAllPins;
switch (ov.mode) {
case PinDisplayMode::All: ov.mode = PinDisplayMode::Connected; break;
case PinDisplayMode::Connected: ov.mode = PinDisplayMode::Collapsed; break;
case PinDisplayMode::Collapsed: ov.mode = PinDisplayMode::All; break;
}
PersistPinDisplayState(nodePath);
}
uintptr_t MaterialEditorPanel::EffectivePinId(const pxr::SdfPath& nodePath, const std::string& pinName, bool isOutput) const {
auto it = m_pinDisplayOverrides.find(nodePath.GetString());
if (it != m_pinDisplayOverrides.end() && it->second.mode == PinDisplayMode::Collapsed)
return HashId(nodePath.GetString() + (isOutput ? ":metaOut" : ":metaIn"));
return HashId(nodePath.GetString() + (isOutput ? ":out:" : ":in:") + pinName);
}
void MaterialEditorPanel::RenderPinPickMenu() {
const ShaderGraphNode* node = nullptr;
for (const auto& n : m_graph.nodes)
@@ -2230,26 +2282,26 @@ void MaterialEditorPanel::PersistPinDisplayState(const pxr::SdfPath& nodePath) {
pxr::UsdPrim prim = m_stage->GetPrimAtPath(nodePath);
if (!prim.IsValid()) return;
static const pxr::TfToken kShowAllKey("uiShowAllPins");
static const pxr::TfToken kPinModeKey("uiPinDisplayMode");
const bool newShowAll = GetPinDisplayOverride(nodePath).showAllPins;
const int newMode = static_cast<int>(GetPinDisplayOverride(nodePath).mode);
pxr::VtValue oldShowAllVt = prim.GetCustomDataByKey(kShowAllKey);
const bool oldShowAll = oldShowAllVt.IsHolding<bool>() ? oldShowAllVt.UncheckedGet<bool>() : true;
pxr::VtValue oldModeVt = prim.GetCustomDataByKey(kPinModeKey);
const int oldMode = oldModeVt.IsHolding<int>() ? oldModeVt.UncheckedGet<int>() : static_cast<int>(PinDisplayMode::All);
if (oldShowAll == newShowAll)
if (oldMode == newMode)
return;
pxr::UsdStageRefPtr stage = m_stage;
m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
"Change pin display for " + nodePath.GetName(),
[stage, nodePath, newShowAll]() {
[stage, nodePath, newMode]() {
pxr::UsdPrim p = stage->GetPrimAtPath(nodePath);
if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiShowAllPins"), pxr::VtValue(newShowAll));
if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPinDisplayMode"), pxr::VtValue(newMode));
},
[stage, nodePath, oldShowAll]() {
[stage, nodePath, oldMode]() {
pxr::UsdPrim p = stage->GetPrimAtPath(nodePath);
if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiShowAllPins"), pxr::VtValue(oldShowAll));
if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPinDisplayMode"), pxr::VtValue(oldMode));
}));
}
+18 -10
View File
@@ -87,7 +87,7 @@ private:
/// customData when m_keepGraphNodeViewSettingsInUsd is set. See
/// GetPinDisplayOverride()/IsPinVisible().
struct PinDisplayOverride {
bool showAllPins = true;
PinDisplayMode mode = PinDisplayMode::All;
};
/// A connection dragged onto/from a collapsed meta pin, awaiting the user
/// picking which actual pin on nodePath/isOutput to wire up. otherPin is
@@ -154,22 +154,30 @@ private:
/// Seeds m_pinDisplayOverrides for any node path not already present
/// (i.e. not touched yet this session) from the just-synced m_graph's
/// showAllPins (customData-backed). Never overwrites an existing entry,
/// so in-session toggles survive resyncs.
/// pinDisplayMode (customData-backed). Never overwrites an existing
/// entry, so in-session toggles survive resyncs.
void SeedPinDisplayOverrides();
/// Creates the entry (defaulted to "show all") on first access.
/// Creates the entry (defaulted to All) on first access.
PinDisplayOverride& GetPinDisplayOverride(const pxr::SdfPath& nodePath);
/// True if pin should be drawn this frame: node is in "show all" mode, or
/// the pin is linked (linked pins are never hidden — Link() unconditionally
/// references pin IDs that must have been drawn). A hidden pin becomes
/// visible the moment it's connected, via RenderPinPickMenu().
/// True if pin should be drawn this frame: All mode shows everything;
/// Connected mode shows only linked pins (linked pins are never hidden —
/// Link() unconditionally references pin IDs that must have been drawn
/// so a hidden pin becomes visible the moment it's connected, via
/// RenderPinPickMenu()); Collapsed mode hides every real pin, linked or
/// not, in favor of the single connector nub per side (see
/// EffectivePinId(), which redirects existing links to that nub).
bool IsPinVisible(const ShaderGraphNode& node, const std::string& pinName, bool isOutput) const;
/// Flips a node between "show all" and "show connected only".
void TogglePinDisplayMode(const pxr::SdfPath& nodePath);
/// Cycles a node's display mode: All -> Connected -> Collapsed -> All.
void CyclePinDisplayMode(const pxr::SdfPath& nodePath);
/// Authors the node's current PinDisplayOverride into USD customData via
/// an undoable command, mirroring PersistNodePosition. No-op unless
/// m_keepGraphNodeViewSettingsInUsd is set.
void PersistPinDisplayState(const pxr::SdfPath& nodePath);
/// PinId to use when drawing a link touching (nodePath, pinName,
/// isOutput): the pin's own id normally, or that node's collapsed meta
/// nub id when the node is in Collapsed mode (so existing links visually
/// converge on the nub instead of referencing an undrawn real pin).
uintptr_t EffectivePinId(const pxr::SdfPath& nodePath, const std::string& pinName, bool isOutput) const;
/// Renders the deferred "choose pin" popup for m_pendingPinPick (Maya
/// Hypershade style): a connection was dragged onto/from a collapsed meta
/// pin, and the user now picks which actual attribute it wires to. Must