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
+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));
}));
}