From b08bb6b233fab516e593abea5f6e811884645afd Mon Sep 17 00:00:00 2001 From: indigo Date: Sat, 4 Jul 2026 23:09:05 +0800 Subject: [PATCH] Style material editor node properties like PropertyPanel Same table anatomy as the attribute inspector: authored-state (a) mini-button with path/type tooltip and copy-path popup, fixed name column, stretch value column with row striping. Widgets switch to the same vocabulary (InputFloat/InputScalarN %.4f, InputInt, default ColorEdit swatches) committing on deactivation, and connected inputs render as '-> node.output'. Preview column widens to 320 to fit; the shader ball centers in it. Per-edit undo commands are kept. Co-Authored-By: Claude Fable 5 --- src/ui/MaterialEditorPanel.cpp | 142 +++++++++++++++++++++++++-------- 1 file changed, 110 insertions(+), 32 deletions(-) diff --git a/src/ui/MaterialEditorPanel.cpp b/src/ui/MaterialEditorPanel.cpp index b38a12f..a0bffc8 100644 --- a/src/ui/MaterialEditorPanel.cpp +++ b/src/ui/MaterialEditorPanel.cpp @@ -237,7 +237,9 @@ void MaterialEditorPanel::Render() { ImGui::SameLine(); - ImGui::BeginChild("MaterialCanvasRegion", ImVec2(-220.0f, 0.0f), false); + // Preview column is 320 wide so the PropertyPanel-style attribute table + // (dot + name + value columns) fits below the shader ball. + ImGui::BeginChild("MaterialCanvasRegion", ImVec2(-320.0f, 0.0f), false); RenderNodeGraphCanvas(); ImGui::EndChild(); @@ -274,6 +276,8 @@ void MaterialEditorPanel::RenderPreviewPanel() { const float size = 200.0f; uint32_t texId = m_preview.Render(static_cast(size), static_cast(size)); + ImGui::SetCursorPosX(std::max(0.0f, (ImGui::GetContentRegionAvail().x - size) * 0.5f) + + ImGui::GetCursorPosX()); if (texId != 0) { ImGui::Image(ImTextureID(static_cast(texId)), ImVec2(size, size), ImVec2(0, 1), ImVec2(1, 0)); if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { @@ -314,6 +318,30 @@ static pxr::VtValue ReadInputValue(const pxr::UsdPrim& prim, const std::string& return value; } +// Mirrors PropertyPanel's file-local mini-button (usdtweak-style "(a)" dot): +// coloured yellow when authored, grey otherwise; tooltip shows path + type, +// left-click offers "Copy path". +static void DrawInputMiniButton(const char* label, bool authored, + const char* pathForCopy, const char* typeHint) { + ImVec4 btnColor = authored + ? ImVec4(1.f, 0.85f, 0.4f, 1.f) // yellow – authored + : ImVec4(0.5f, 0.5f, 0.5f, 1.f); // grey – Sdr default / no opinion + + ImGui::PushStyleColor(ImGuiCol_Text, btnColor); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.f, 0.f, 0.f, 0.f)); + ImGui::AlignTextToFramePadding(); + ImGui::SmallButton(label); + ImGui::PopStyleColor(2); + + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s\n%s", pathForCopy, typeHint); + if (ImGui::BeginPopupContextItem(nullptr, ImGuiPopupFlags_MouseButtonLeft)) { + if (ImGui::MenuItem("Copy path")) + ImGui::SetClipboardText(pathForCopy); + ImGui::EndPopup(); + } +} + void MaterialEditorPanel::RenderSelectedNodeProperties() { if (!m_stage || m_selectedNodePath.IsEmpty()) { ImGui::TextDisabled("No node selected"); @@ -333,15 +361,51 @@ void MaterialEditorPanel::RenderSelectedNodeProperties() { ImGui::TextDisabled("%s", node->shaderId.c_str()); ImGui::Spacing(); + // Same table anatomy as PropertyPanel's RenderAttrRelSubTable: authored + // dot / fixed name column / stretch value column, with row striping. + constexpr ImGuiTableFlags kFlags = ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg; + if (!ImGui::BeginTable("##nodePropTable", 3, kFlags)) return; + ImGui::TableSetupColumn("##dot", ImGuiTableColumnFlags_WidthFixed, 20.f); + ImGui::TableSetupColumn("##name", ImGuiTableColumnFlags_WidthFixed, 110.f); + ImGui::TableSetupColumn("##val", ImGuiTableColumnFlags_WidthStretch); + + const float rowH = ImGui::GetFrameHeight(); for (const auto& input : node->inputs) { + ImGui::TableNextRow(ImGuiTableRowFlags_None, rowH); + ImGui::PushID(input.name.c_str()); + + pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + input.name)); + + ImGui::TableSetColumnIndex(0); + DrawInputMiniButton("(a)", attr && attr.IsAuthored(), + (node->path.GetString() + ".inputs:" + input.name).c_str(), + input.typeName.GetAsToken().GetText()); + + ImGui::TableSetColumnIndex(1); + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(input.name.c_str()); - if (IsPinLinked(node->path, input.name, false)) { - ImGui::TextDisabled(" (connected)"); - continue; + if (ImGui::IsItemHovered()) // 110px column truncates long mtlx names + ImGui::SetTooltip("%s", input.name.c_str()); + + ImGui::TableSetColumnIndex(2); + const ShaderGraphLink* link = nullptr; + for (const auto& l : m_graph.links) + if (l.destNode == node->path && l.destInput == input.name) { link = &l; break; } + if (link) { + // Connected input: source display, like PropertyPanel's "-> path". + ImGui::AlignTextToFramePadding(); + std::string src = link->sourceNode.GetName() + "." + link->sourceOutput; + ImGui::TextDisabled("-> %s", src.c_str()); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s.outputs:%s", link->sourceNode.GetText(), link->sourceOutput.c_str()); + } else { + ImGui::PushItemWidth(-FLT_MIN); + RenderInputValueWidget(*node, input, prim); + ImGui::PopItemWidth(); } - ImGui::SetNextItemWidth(-FLT_MIN); - RenderInputValueWidget(*node, input, prim); + ImGui::PopID(); } + ImGui::EndTable(); } void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node, @@ -354,11 +418,12 @@ void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node, bool authored = false; pxr::VtValue current = ReadInputValue(prim, node.shaderId, input, &authored); - // Convention for every widget below: apply the value live while the - // widget is being edited (no command — a drag would flood undo), stash - // the pre-edit state on activation, push one undoable command when the - // edit ends (IsItemDeactivatedAfterEdit). - pxr::VtValue newValue; + // Widget vocabulary mirrors PropertyPanel's DrawVtValueWidget (Input* + // fields with %.4f, applied on IsItemDeactivatedAfterEdit; ColorEdit + // applied live) — plus this panel's undo convention: pre-edit state is + // stashed on activation and one undoable command is pushed per edit. + pxr::VtValue newValue; // applied live (colors, checkbox) as it changes + pxr::VtValue finalValue; // applied + committed on deactivate (Input* fields) auto scalarAsFloat = [¤t]() -> float { if (current.IsHolding()) return current.UncheckedGet(); @@ -371,37 +436,44 @@ void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node, if (type == tn->Color3f) { pxr::GfVec3f v = current.IsHolding() ? current.UncheckedGet() : pxr::GfVec3f(0.0f); - if (ImGui::ColorEdit3(widgetId.c_str(), v.data(), ImGuiColorEditFlags_Float)) + if (ImGui::ColorEdit3(widgetId.c_str(), v.data())) + newValue = v; + } else if (type == tn->Color4f) { + pxr::GfVec4f v = current.IsHolding() ? current.UncheckedGet() + : pxr::GfVec4f(0.0f); + if (ImGui::ColorEdit4(widgetId.c_str(), v.data())) newValue = v; } else if (type == tn->Float3 || type == tn->Vector3f || type == tn->Normal3f || type == tn->Point3f) { pxr::GfVec3f v = current.IsHolding() ? current.UncheckedGet() : pxr::GfVec3f(0.0f); - if (ImGui::DragFloat3(widgetId.c_str(), v.data(), 0.01f)) - newValue = v; + ImGui::InputScalarN(widgetId.c_str(), ImGuiDataType_Float, v.data(), 3, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) + finalValue = v; } else if (type == tn->Float2 || type == tn->TexCoord2f) { pxr::GfVec2f v = current.IsHolding() ? current.UncheckedGet() : pxr::GfVec2f(0.0f); - if (ImGui::DragFloat2(widgetId.c_str(), v.data(), 0.01f)) - newValue = v; - } else if (type == tn->Float4 || type == tn->Color4f) { + ImGui::InputScalarN(widgetId.c_str(), ImGuiDataType_Float, v.data(), 2, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) + finalValue = v; + } else if (type == tn->Float4) { pxr::GfVec4f v = current.IsHolding() ? current.UncheckedGet() : pxr::GfVec4f(0.0f); - bool changed = (type == tn->Color4f) - ? ImGui::ColorEdit4(widgetId.c_str(), v.data(), ImGuiColorEditFlags_Float) - : ImGui::DragFloat4(widgetId.c_str(), v.data(), 0.01f); - if (changed) - newValue = v; + ImGui::InputScalarN(widgetId.c_str(), ImGuiDataType_Float, v.data(), 4, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) + finalValue = v; } else if (type == tn->Float || type == tn->Double || type == tn->Half) { float f = scalarAsFloat(); - if (ImGui::DragFloat(widgetId.c_str(), &f, 0.01f)) { - if (type == tn->Double) newValue = static_cast(f); - else if (type == tn->Half) newValue = pxr::GfHalf(f); - else newValue = f; + ImGui::InputFloat(widgetId.c_str(), &f, 0.f, 0.f, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (type == tn->Double) finalValue = static_cast(f); + else if (type == tn->Half) finalValue = pxr::GfHalf(f); + else finalValue = f; } } else if (type == tn->Int) { int v = current.IsHolding() ? current.UncheckedGet() : 0; - if (ImGui::DragInt(widgetId.c_str(), &v)) - newValue = v; + ImGui::InputInt(widgetId.c_str(), &v); + if (ImGui::IsItemDeactivatedAfterEdit()) + finalValue = v; } else if (type == tn->Bool) { bool v = current.IsHolding() && current.UncheckedGet(); if (ImGui::Checkbox(widgetId.c_str(), &v)) @@ -419,7 +491,8 @@ void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node, else newValue = pxr::SdfAssetPath(buf); } } else { - ImGui::TextDisabled(" %s (unsupported)", type.GetAsToken().GetText()); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", type.GetAsToken().GetText()); return; } @@ -427,11 +500,16 @@ void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node, m_preEditValue = current; m_preEditWasAuthored = authored; } - if (!newValue.IsEmpty()) { + + auto setValue = [&](const pxr::VtValue& v) { pxr::UsdShadeShader shader(prim); if (shader) - shader.CreateInput(pxr::TfToken(input.name), input.typeName).Set(newValue); - } + shader.CreateInput(pxr::TfToken(input.name), input.typeName).Set(v); + }; + if (!newValue.IsEmpty()) + setValue(newValue); + if (!finalValue.IsEmpty()) + setValue(finalValue); if (ImGui::IsItemDeactivatedAfterEdit()) CommitInputEdit(node, input); }