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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user