#include "MaterialEditorPanel.h"
#include "../core/commands/CreatePrimCommand.h"
#include "../core/commands/CreateShaderNodeCommand.h"
#include "../core/commands/DeletePrimCommand.h"
#include "../core/commands/ConnectShaderAttrsCommand.h"
#include "../core/commands/DisconnectShaderAttrCommand.h"
#include "../core/commands/AttributeSetCommand.h"
#include "../core/commands/RenamePrimCommand.h"
#include "../utils/Logger.h"
#include "../utils/FileDialog.h"
#include "../utils/OcioConfigParser.h"
#include <pxr/usd/usd/editContext.h>
#include <pxr/usd/usdShade/shader.h>
#include <pxr/usd/usdShade/material.h>
#include <pxr/usd/usdShade/materialBindingAPI.h>
#include <pxr/usd/sdr/registry.h>
#include <pxr/usd/sdr/shaderNode.h>
#include <pxr/usd/sdr/shaderProperty.h>
#include <pxr/usd/sdf/types.h>
#include <pxr/usd/sdf/assetPath.h>
#include <pxr/base/gf/vec2f.h>
#include <pxr/base/gf/vec3f.h>
#include <pxr/base/gf/vec4f.h>
#include <pxr/base/gf/half.h>
#include <pxr/base/tf/token.h>
#include <pxr/base/tf/stringUtils.h>
#include <pxr/base/vt/value.h>
#include <ax/Widgets.h>
#include <algorithm>
#include <functional>
#include <set>
#include <cctype>
#include <cstdio>

// UsdLayerManager local patch hook in the vendored node editor (see
// imgui_node_editor.cpp): when set, BuildControl logs its click-time
// hit-test state through it. Temporary debugging aid.
extern void (*g_AxNodeEditorDebugLog)(const char*);

namespace UsdLayerManager {

namespace NE = ax::NodeEditor;

namespace {

uintptr_t HashId(const std::string& s) {
    return static_cast<uintptr_t>(std::hash<std::string>{}(s));
}

// Draws the 3-dot pin-display-mode icon (filled-dot count = 3 - mode, so it
// visually "funnels down" as pins collapse further), centered within
// [min, max]. Shared by the per-node toggle in RenderNodeGraphCanvas (which
// sizes its button around the default dot geometry) and the toolbar's
// "set all nodes" button in RenderToolbar (which instead fits the dots into
// its fixed, already-square icon-button size via smaller dotRadius/dotSpacing).
void DrawPinDisplayModeIcon(ImDrawList* dl, const ImVec2& min, const ImVec2& max,
                            PinDisplayMode mode, ImU32 filledColor, ImU32 hollowColor,
                            float dotRadius = 3.0f, float dotSpacing = 9.0f) {
    const int   dotCount   = 3;
    const int   filledDots = 3 - static_cast<int>(mode);
    const float iconWidth  = dotSpacing * (dotCount - 1) + dotRadius * 2.0f;
    ImVec2 dotCenter(min.x + (max.x - min.x - iconWidth) * 0.5f + dotRadius,
                     min.y + (max.y - min.y) * 0.5f);
    for (int i = 0; i < dotCount; ++i) {
        if (i < filledDots)
            dl->AddCircleFilled(dotCenter, dotRadius, filledColor);
        else
            dl->AddCircle(dotCenter, dotRadius, hollowColor, 0, 1.2f);
        dotCenter.x += dotSpacing;
    }
}

// Mirrors the file-local sanitizers in Application.cpp/SceneHierarchyPanel.cpp.
std::string SanitizeUsdName(const std::string& raw) {
    std::string result;
    result.reserve(raw.size());
    for (char c : raw) {
        if (std::isalnum(static_cast<unsigned char>(c)) || c == '_')
            result += c;
        else
            result += '_';
    }
    if (result.empty() || std::isdigit(static_cast<unsigned char>(result[0])))
        result = "_" + result;
    return result;
}

// Color-codes a pin by its USD value type, roughly following the
// Houdini/Maya convention of one hue per data "shape" (color vs. scalar vs.
// vector vs. string-like vs. asset) so a network's data flow reads at a
// glance, independent of exact type (float vs double, float3 vs color3f...).
ImU32 GetPinColor(const pxr::SdfValueTypeName& typeName) {
    const std::string name = typeName.GetAsToken().GetString();
    if (name.find("color") != std::string::npos)
        return IM_COL32(230, 180, 60, 255);   // color-like: warm yellow
    if (name.find("asset") != std::string::npos)
        return IM_COL32(80, 150, 230, 255);   // asset/file reference: blue
    if (name == "bool")
        return IM_COL32(220, 70, 70, 255);    // bool: red
    if (name == "token" || name == "string")
        return IM_COL32(170, 90, 200, 255);   // string-like: purple
    if (name.find("matrix") != std::string::npos)
        return IM_COL32(220, 100, 170, 255);  // matrix: pink
    if (name.find("int") != std::string::npos)
        return IM_COL32(70, 190, 160, 255);   // int: teal
    if (name.find("point") != std::string::npos || name.find("vector") != std::string::npos ||
        name.find("normal") != std::string::npos || name.find("float3") != std::string::npos ||
        name.find("double3") != std::string::npos)
        return IM_COL32(140, 210, 90, 255);   // vector-like: green
    if (name.find("float") != std::string::npos || name.find("double") != std::string::npos ||
        name.find("half") != std::string::npos)
        return IM_COL32(150, 220, 130, 255);  // scalar: light green
    return IM_COL32(200, 200, 200, 255);      // fallback: light gray
}

// Deterministic per-node-type header color so repeated shader types are
// visually distinguishable at a glance without needing a curated table for
// every identifier the Sdr registry might report.
ImU32 GetHeaderColor(const std::string& key) {
    uint32_t hash = static_cast<uint32_t>(std::hash<std::string>{}(key));
    float hue = (hash % 360) / 360.0f;
    float r, g, b;
    ImGui::ColorConvertHSVtoRGB(hue, 0.45f, 0.55f, r, g, b);
    return IM_COL32(static_cast<int>(r * 255), static_cast<int>(g * 255), static_cast<int>(b * 255), 255);
}

// Cheap stand-in for "did anything the shader-ball cares about change" so
// MaterialPreviewRenderer can skip re-copying/re-rendering on frames where
// the graph is unchanged (real cost with a path-traced delegate selected).
// Node position isn't included — dragging a node shouldn't dirty the swatch.
size_t ComputeGraphRevision(const pxr::UsdStageRefPtr& stage, const ShaderGraphSnapshot& graph) {
    size_t h = graph.nodes.size() * 31 + graph.links.size();
    for (const auto& node : graph.nodes) {
        h = h * 31 + std::hash<std::string>{}(node.path.GetString());
        h = h * 31 + std::hash<std::string>{}(node.shaderId);
    }
    for (const auto& link : graph.links) {
        h = h * 31 + std::hash<std::string>{}(
            link.destNode.GetString() + link.destInput + link.sourceNode.GetString() + link.sourceOutput);
    }
    // Fold in authored input values so property edits — and undo/redo of
    // them, which never pass through the editor widgets — re-render the
    // swatch. Stringify is cheap at material-network scale.
    for (const auto& node : graph.nodes) {
        pxr::UsdShadeShader shader(stage ? stage->GetPrimAtPath(node.path) : pxr::UsdPrim());
        if (!shader) continue;
        for (const auto& input : shader.GetInputs()) {
            pxr::VtValue value;
            if (input.GetAttr().Get(&value))
                h = h * 31 + std::hash<std::string>{}(
                    input.GetBaseName().GetString() + pxr::TfStringify(value));
        }
    }
    return h;
}

// Case-insensitive substring filter over the Sdr node-type list for the
// searchable create-node UIs (TAB popup, browser list); matches label or
// identifier, and collapses duplicate (source, label) pairs — parser
// variants within one source group — to a single entry. The same label under
// different sources (e.g. an "add" in both MaterialX and Arnold) stays.
std::vector<const ShaderNodeTypeInfo*> FilterShaderNodeTypes(
    const std::vector<ShaderNodeTypeInfo>& nodeTypes, const char* rawQuery) {
    auto toLower = [](std::string s) {
        std::transform(s.begin(), s.end(), s.begin(),
                       [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
        return s;
    };
    const std::string query = toLower(rawQuery);

    std::vector<const ShaderNodeTypeInfo*> matches;
    std::set<std::string> seenLabels;
    for (const auto& info : nodeTypes) {
        if (!query.empty() &&
            toLower(info.label).find(query) == std::string::npos &&
            toLower(info.identifier).find(query) == std::string::npos)
            continue;
        if (!seenLabels.insert(info.source + "|" + info.label).second)
            continue;
        matches.push_back(&info);
    }
    return matches;
}

// Clamp range shared by the column splitters and SetColumnWidths (persisted
// preferences may carry hand-edited or stale values).
constexpr float kBrowserMinWidth = 140.0f, kBrowserMaxWidth = 500.0f;
constexpr float kPreviewMinWidth = 220.0f, kPreviewMaxWidth = 640.0f;

// Vertical drag-splitter between the material editor's columns. Adjusts
// *width by the mouse drag (negated for a right-hand column, which grows
// when dragged left); colours match the viewport's split dividers.
void VerticalSplitter(const char* id, float* width, float minWidth, float maxWidth,
                      bool rightSideColumn) {
    ImGui::SameLine(0.0f, 0.0f);
    const float height = std::max(1.0f, ImGui::GetContentRegionAvail().y);
    ImGui::InvisibleButton(id, ImVec2(6.0f, height));
    const bool hovered = ImGui::IsItemHovered();
    const bool active  = ImGui::IsItemActive();
    if (hovered || active)
        ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW);
    if (active) {
        const float delta = ImGui::GetIO().MouseDelta.x * (rightSideColumn ? -1.0f : 1.0f);
        *width = std::clamp(*width + delta, minWidth, maxWidth);
    }
    ImVec2 rectMin = ImGui::GetItemRectMin();
    ImVec2 rectMax = ImGui::GetItemRectMax();
    ImGui::GetWindowDrawList()->AddRectFilled(
        ImVec2(rectMin.x + 2.0f, rectMin.y), ImVec2(rectMax.x - 2.0f, rectMax.y),
        (hovered || active) ? IM_COL32(250, 150, 66, 200) : IM_COL32(80, 80, 80, 180));
    ImGui::SameLine(0.0f, 0.0f);
}

// Horizontal drag-splitter stacked between two vertically-arranged panels.
// Adjusts *ratio (fraction of totalHeight given to the panel above) by the
// mouse drag; colours match VerticalSplitter.
void HorizontalSplitter(const char* id, float* ratio, float totalHeight,
                        float minRatio, float maxRatio) {
    const float width = std::max(1.0f, ImGui::GetContentRegionAvail().x);
    ImGui::InvisibleButton(id, ImVec2(width, 6.0f));
    const bool hovered = ImGui::IsItemHovered();
    const bool active  = ImGui::IsItemActive();
    if (hovered || active)
        ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS);
    if (active && totalHeight > 1.0f)
        *ratio = std::clamp(*ratio + ImGui::GetIO().MouseDelta.y / totalHeight, minRatio, maxRatio);
    ImVec2 rectMin = ImGui::GetItemRectMin();
    ImVec2 rectMax = ImGui::GetItemRectMax();
    ImGui::GetWindowDrawList()->AddRectFilled(
        ImVec2(rectMin.x, rectMin.y + 2.0f), ImVec2(rectMax.x, rectMax.y - 2.0f),
        (hovered || active) ? IM_COL32(250, 150, 66, 200) : IM_COL32(80, 80, 80, 180));
}

// Small glyph for a create-node list entry, keyed by the derived category
// (see MaterialManager::DeriveCategory: Material/Texture/Geometry/Light/Utility).
Icon IconForNodeCategory(const std::string& category) {
    if (category == "Material") return Icon::Swatchbook;
    if (category == "Texture")  return Icon::Grid;
    if (category == "Geometry") return Icon::Cube;
    if (category == "Light")    return Icon::Lightbulb;
    if (category == "Utility")  return Icon::Code;
    return Icon::CircleDot;
}

// Coarse connectability class for lenient shader-pin type matching (0 = only
// matches its own exact type; 1 = scalar; 2/3/4 = 2/3/4-component vector).
// Mirrors the leniency USD/MaterialX shading networks already tolerate.
int PinTypeClass(const pxr::SdfValueTypeName& t) {
    const auto& tn = pxr::SdfValueTypeNames;
    if (t == tn->Float || t == tn->Half || t == tn->Double || t == tn->Int) return 1;
    if (t == tn->Float2) return 2;
    if (t == tn->Float3 || t == tn->Color3f || t == tn->Vector3f ||
        t == tn->Normal3f || t == tn->Point3f) return 3;
    if (t == tn->Float4 || t == tn->Color4f) return 4;
    return 0;
}
bool PinTypesConnectable(const pxr::SdfValueTypeName& a, const pxr::SdfValueTypeName& b) {
    if (a == b) return true;
    const int ca = PinTypeClass(a);
    return ca != 0 && ca == PinTypeClass(b);
}

// Best pin of the requested direction on shaderId to receive a connection from
// a pin of type wantType: first exact type match, else first type-connectable
// pin. Returns false when the node has no connectable pin of that direction.
bool BestConnectablePin(const std::string& shaderId, bool wantOutput,
                        const pxr::SdfValueTypeName& wantType,
                        std::string& outName, pxr::SdfValueTypeName& outType) {
    pxr::SdrShaderNodeConstPtr node =
        pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(pxr::TfToken(shaderId));
    if (!node) return false;
    bool haveConnectable = false;
    for (const pxr::TfToken& n : wantOutput ? node->GetShaderOutputNames()
                                            : node->GetShaderInputNames()) {
        const auto* prop = wantOutput ? node->GetShaderOutput(n) : node->GetShaderInput(n);
        if (!prop) continue;
        const pxr::SdfValueTypeName t = prop->GetTypeAsSdfType().GetSdfType();
        if (t == wantType) { outName = n.GetString(); outType = t; return true; }
        if (!haveConnectable && PinTypesConnectable(wantType, t)) {
            outName = n.GetString();
            outType = t;
            haveConnectable = true;
        }
    }
    return haveConnectable;
}

// Networks referenced from .mtlx (or authored elsewhere) carry no uiPosition
// custom data, so every node would seed at (0,0) in a pile. Give those nodes
// layered left-to-right positions instead: each node sits one column left of
// its farthest-downstream consumer, stacked vertically within its column
// (column height advances by an estimate of the node's rendered height, since
// pin count varies wildly — e.g. standard_surface). Deterministic, so the
// per-frame re-sync always computes the same layout.
void ApplyFallbackLayout(ShaderGraphSnapshot& graph) {
    bool anyMissing = false;
    for (const auto& node : graph.nodes)
        if (!node.hasAuthoredPosition) { anyMissing = true; break; }
    if (!anyMissing) return;

    std::unordered_map<std::string, int> depth; // 0 = rightmost (surface) column
    for (const auto& node : graph.nodes)
        depth[node.path.GetString()] = 0;
    // Longest-path relaxation; iteration cap guards against connection cycles.
    for (size_t i = 0; i < graph.nodes.size(); ++i) {
        bool changed = false;
        for (const auto& link : graph.links) {
            auto src = depth.find(link.sourceNode.GetString());
            auto dst = depth.find(link.destNode.GetString());
            if (src == depth.end() || dst == depth.end()) continue;
            if (src->second < dst->second + 1) {
                src->second = dst->second + 1;
                changed = true;
            }
        }
        if (!changed) break;
    }

    std::unordered_map<int, float> columnY;
    for (auto& node : graph.nodes) {
        if (node.hasAuthoredPosition) continue;
        int col = depth[node.path.GetString()];
        float& y = columnY[col];
        node.uiPosition = pxr::GfVec2f(col * -340.0f, y);
        y += (node.inputs.size() + node.outputs.size()) * 24.0f + 80.0f;
    }
}

} // namespace

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 when diagnosing canvas interaction issues.
    // g_AxNodeEditorDebugLog = [](const char* msg) { LOG_INFO(std::string(msg)); };

    NE::Config config;
    config.SettingsFile = nullptr; // layout persists as USD uiPosition custom data, not an on-disk .json
    config.UserPointer = this;
    config.SaveNodeSettings = &MaterialEditorPanel::SaveNodeSettingsCallback;
    m_editorContext = NE::CreateEditor(&config);
}

MaterialEditorPanel::~MaterialEditorPanel() {
    if (m_editorContext) {
        NE::DestroyEditor(m_editorContext);
        m_editorContext = nullptr;
    }
}

void MaterialEditorPanel::SetStage(pxr::UsdStageRefPtr stage) {
    m_stage = stage;
    m_nodeIdToPath.clear();
    m_graph = ShaderGraphSnapshot();
    m_materialPath = pxr::SdfPath();
    m_browserSelection = pxr::SdfPath();
    m_thumbnails.Clear();
}

void MaterialEditorPanel::SetColumnWidths(float browserWidth, float previewWidth) {
    m_browserWidth = std::clamp(browserWidth, kBrowserMinWidth, kBrowserMaxWidth);
    m_previewWidth = std::clamp(previewWidth, kPreviewMinWidth, kPreviewMaxWidth);
}

void MaterialEditorPanel::SetColorCorrectionFromPrefs(int ccMode, const std::string& ocioDisplay,
                                                      const std::string& ocioView,
                                                      const std::string& ocioColorSpace,
                                                      const std::string& ocioLook) {
    m_preview.SetColorCorrection(ccMode, ocioDisplay, ocioView, ocioColorSpace, ocioLook);
    // Cached for the per-frame NodeThumbnailCache::SyncRenderSettings() call
    // in RenderNodeGraphCanvas, which keeps in-canvas node thumbnails
    // matching this same color correction (see its declaration).
    m_ccMode         = ccMode;
    m_ocioDisplay    = ocioDisplay;
    m_ocioView       = ocioView;
    m_ocioColorSpace = ocioColorSpace;
    m_ocioLook       = ocioLook;
}

void MaterialEditorPanel::Render() {
    RenderToolbar();
    // Re-sync every frame so undo/redo and edits from other panels are
    // reflected live; cheap for material-sized graphs, and the "already
    // seeded" check in the render loop below stops this from fighting an
    // in-progress drag.
    if (!m_materialPath.IsEmpty())
        SyncFromUsd();

    // Hypershade-style three-column layout: material browser on the left,
    // node-graph work area in the middle, shader-ball viewer on the right.
    // The outer columns are user-resizable via the splitters between them.
    ImGui::BeginChild("MaterialBrowserRegion", ImVec2(m_browserWidth, 0.0f), true);
    RenderMaterialBrowser();
    ImGui::EndChild();

    VerticalSplitter("##MaterialSplitL", &m_browserWidth, kBrowserMinWidth, kBrowserMaxWidth,
                     /*rightSideColumn=*/false);

    ImGui::BeginChild("MaterialCanvasRegion", ImVec2(-(m_previewWidth + 6.0f), 0.0f), false);
    RenderNodeGraphCanvas();
    ImGui::EndChild();

    VerticalSplitter("##MaterialSplitR", &m_previewWidth, kPreviewMinWidth, kPreviewMaxWidth,
                     /*rightSideColumn=*/true);

    ImGui::BeginChild("MaterialPreviewRegion", ImVec2(0.0f, 0.0f), true);
    RenderPreviewPanel();
    ImGui::EndChild();
}

void MaterialEditorPanel::RenderPreviewPanel() {
    // Renderer switch and HDR environment side by side above the preview.
    const float halfWidth =
        (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
    m_preview.RenderRendererDropdown(halfWidth);
    ImGui::SameLine();
    m_preview.RenderLightingDropdown(halfWidth);
    m_preview.RenderShapeDropdown(-1.0f);

    if (m_stage && !m_materialPath.IsEmpty()) {
        // Hypershade-style: with a node selected, the ball previews that
        // node's first output instead of the material's surface.
        pxr::SdfPath previewNode;
        std::string  previewOutput;
        bool         outputIsTerminal = false;
        if (!m_selectedNodePath.IsEmpty()) {
            for (const auto& node : m_graph.nodes) {
                if (node.path != m_selectedNodePath)
                    continue;
                // Pick a previewable output: a terminal (surface) or a
                // 3/4-component value the wrapper can plug into diffuseColor.
                // Scalar outputs (e.g. UsdUVTexture's Sdr-first output "r")
                // make Storm's codegen swizzle .xyz off a float and fail to
                // compile — nodes with only scalar outputs keep the
                // whole-material preview instead.
                const auto& tn = pxr::SdfValueTypeNames;
                for (const auto& out : node.outputs) {
                    const pxr::SdfValueTypeName& t = out.typeName;
                    const bool terminal = (t == tn->Token);
                    const bool colorLike =
                        t == tn->Color3f || t == tn->Float3 || t == tn->Vector3f ||
                        t == tn->Normal3f || t == tn->Point3f ||
                        t == tn->Color4f || t == tn->Float4;
                    if (terminal || colorLike) {
                        previewNode      = node.path;
                        previewOutput    = out.name;
                        outputIsTerminal = terminal;
                        break;
                    }
                }
                break;
            }
        }
        m_preview.SetMaterial(m_stage, m_materialPath, ComputeGraphRevision(m_stage, m_graph),
                              previewNode, previewOutput, outputIsTerminal);
    }

    const float size = 200.0f;
    uint32_t texId = m_preview.Render(static_cast<int>(size), static_cast<int>(size));
    ImGui::SetCursorPosX(std::max(0.0f, (ImGui::GetContentRegionAvail().x - size) * 0.5f)
                         + ImGui::GetCursorPosX());
    if (texId != 0) {
        ImGui::Image(ImTextureID(static_cast<ImU64>(texId)), ImVec2(size, size), ImVec2(0, 1), ImVec2(1, 0));
        if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
            ImVec2 delta = ImGui::GetIO().MouseDelta;
            m_preview.OrbitDrag(delta.x, delta.y);
        }
    } else {
        ImGui::Dummy(ImVec2(size, size));
    }

    ImGui::Separator();
    ImGui::TextUnformatted("Node Properties");
    ImGui::Separator();
    RenderSelectedNodeProperties();
}

// Authored value if present, else the Sdr-registered default, so unauthored
// inputs still show something sensible to start editing from.
static pxr::VtValue ReadInputValue(const pxr::UsdPrim& prim, const std::string& shaderId,
                                   const ShaderPinInfo& input, bool* authoredOut) {
    pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + input.name));
    pxr::VtValue value;
    if (attr && attr.Get(&value)) {
        *authoredOut = true;
        return value;
    }
    *authoredOut = false;
    pxr::SdrShaderNodeConstPtr sdrNode =
        pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(pxr::TfToken(shaderId));
    if (sdrNode) {
        if (const auto* prop = sdrNode->GetShaderInput(pxr::TfToken(input.name)))
            return prop->GetDefaultValueAsSdfType();
    }
    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");
        return;
    }
    const ShaderGraphNode* node = nullptr;
    for (const auto& n : m_graph.nodes)
        if (n.path == m_selectedNodePath) { node = &n; break; }
    pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_selectedNodePath);
    if (!node || !prim) {
        ImGui::TextDisabled("No node selected");
        return;
    }

    ImGui::TextUnformatted(node->path.GetName().c_str());
    if (!node->shaderId.empty())
        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 (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();
        }

        if (input.typeName == pxr::SdfValueTypeNames->Asset) {
            ImGui::TableNextRow(ImGuiTableRowFlags_None, rowH);
            ImGui::TableSetColumnIndex(1);
            ImGui::AlignTextToFramePadding();
            ImGui::TextUnformatted("Color Space");
            ImGui::TableSetColumnIndex(2);
            ImGui::PushItemWidth(-FLT_MIN);
            RenderColorSpaceWidget(*node, input, prim);
            ImGui::PopItemWidth();
        }

        ImGui::PopID();
    }
    ImGui::EndTable();
}

void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node,
                                                 const ShaderPinInfo& input,
                                                 const pxr::UsdPrim& prim) {
    const auto& tn = pxr::SdfValueTypeNames;
    const pxr::SdfValueTypeName& type = input.typeName;
    const std::string widgetId = "##" + input.name;

    bool authored = false;
    pxr::VtValue current = ReadInputValue(prim, node.shaderId, input, &authored);

    // PropertyPanel-style table around draggable controls: Drag* widgets
    // (Ctrl+click to type exact values) apply live every frame so the shader
    // ball and viewport track the drag, with this panel's undo convention —
    // pre-edit state stashed on activation, one undoable command pushed when
    // the edit ends (IsItemDeactivatedAfterEdit).
    pxr::VtValue newValue;   // applied live as it changes (drags, colors, checkbox)
    pxr::VtValue finalValue; // applied + committed on deactivate (text fields)

    auto scalarAsFloat = [&current]() -> float {
        if (current.IsHolding<float>())      return current.UncheckedGet<float>();
        if (current.IsHolding<double>())     return static_cast<float>(current.UncheckedGet<double>());
        if (current.IsHolding<pxr::GfHalf>()) return static_cast<float>(current.UncheckedGet<pxr::GfHalf>());
        if (current.IsHolding<int>())        return static_cast<float>(current.UncheckedGet<int>());
        return 0.0f;
    };

    if (type == tn->Color3f) {
        pxr::GfVec3f v = current.IsHolding<pxr::GfVec3f>() ? current.UncheckedGet<pxr::GfVec3f>()
                                                           : pxr::GfVec3f(0.0f);
        if (ImGui::ColorEdit3(widgetId.c_str(), v.data()))
            newValue = v;
    } else if (type == tn->Color4f) {
        pxr::GfVec4f v = current.IsHolding<pxr::GfVec4f>() ? current.UncheckedGet<pxr::GfVec4f>()
                                                           : 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<pxr::GfVec3f>() ? current.UncheckedGet<pxr::GfVec3f>()
                                                           : pxr::GfVec3f(0.0f);
        if (ImGui::DragFloat3(widgetId.c_str(), v.data(), 0.01f, 0.f, 0.f, "%.4f"))
            newValue = v;
    } else if (type == tn->Float2 || type == tn->TexCoord2f) {
        pxr::GfVec2f v = current.IsHolding<pxr::GfVec2f>() ? current.UncheckedGet<pxr::GfVec2f>()
                                                           : pxr::GfVec2f(0.0f);
        if (ImGui::DragFloat2(widgetId.c_str(), v.data(), 0.01f, 0.f, 0.f, "%.4f"))
            newValue = v;
    } else if (type == tn->Float4) {
        pxr::GfVec4f v = current.IsHolding<pxr::GfVec4f>() ? current.UncheckedGet<pxr::GfVec4f>()
                                                           : pxr::GfVec4f(0.0f);
        if (ImGui::DragFloat4(widgetId.c_str(), v.data(), 0.01f, 0.f, 0.f, "%.4f"))
            newValue = v;
    } else if (type == tn->Float || type == tn->Double || type == tn->Half) {
        float f = scalarAsFloat();
        if (ImGui::DragFloat(widgetId.c_str(), &f, 0.01f, 0.f, 0.f, "%.4f")) {
            if (type == tn->Double)    newValue = static_cast<double>(f);
            else if (type == tn->Half) newValue = pxr::GfHalf(f);
            else                       newValue = f;
        }
    } else if (type == tn->Int) {
        int v = current.IsHolding<int>() ? current.UncheckedGet<int>() : 0;
        if (ImGui::DragInt(widgetId.c_str(), &v))
            newValue = v;
    } else if (type == tn->Bool) {
        bool v = current.IsHolding<bool>() && current.UncheckedGet<bool>();
        if (ImGui::Checkbox(widgetId.c_str(), &v))
            newValue = v;
    } else if (type == tn->String || type == tn->Token) {
        std::string s;
        if (current.IsHolding<std::string>())           s = current.UncheckedGet<std::string>();
        else if (current.IsHolding<pxr::TfToken>())     s = current.UncheckedGet<pxr::TfToken>().GetString();

        if (!input.allowedValues.empty()) {
            // Sdr-declared enum (e.g. UsdUVTexture's sourceColorSpace:
            // raw/sRGB/auto) — a dropdown of exactly the legal values instead
            // of free text, which could author anything the renderer ignores.
            // A selection is one atomic edit (no drag-in-progress concept for
            // a combo), so it's committed inline here — like the Asset browse
            // button below — instead of through the drag/type commit
            // machinery at the end of this function.
            if (ImGui::BeginCombo(widgetId.c_str(), s.c_str())) {
                for (const std::string& option : input.allowedValues) {
                    bool selected = (option == s);
                    if (ImGui::Selectable(option.c_str(), selected) && !selected) {
                        m_preEditValue       = current;
                        m_preEditWasAuthored = authored;
                        pxr::UsdShadeShader shader(prim);
                        if (shader)
                            shader.CreateInput(pxr::TfToken(input.name), input.typeName)
                                  .Set(type == tn->String ? pxr::VtValue(option) : pxr::VtValue(pxr::TfToken(option)));
                        CommitInputEdit(node, input);
                    }
                    if (selected) ImGui::SetItemDefaultFocus();
                }
                ImGui::EndCombo();
            }
            return;
        } else {
            char buf[512];
            std::snprintf(buf, sizeof(buf), "%s", s.c_str());
            if (ImGui::InputText(widgetId.c_str(), buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
                if (type == tn->String)     newValue = std::string(buf);
                else                        newValue = pxr::TfToken(buf);
            }
        }
    } else if (type == tn->Asset) {
        std::string s;
        if (current.IsHolding<pxr::SdfAssetPath>()) s = current.UncheckedGet<pxr::SdfAssetPath>().GetAssetPath();
        else if (current.IsHolding<std::string>())  s = current.UncheckedGet<std::string>();

        // Browse button first so the text field stays the "last item" the
        // shared activate/commit logic below inspects. The button authors +
        // commits its own undoable edit inline.
        bool browseClicked;
        if (m_iconManager)
            browseClicked = ImGui::ImageButton("##browseTex",
                                               ImTextureRef(m_iconManager->Get(Icon::FolderOpen)),
                                               ImVec2(14.f, 14.f));
        else
            browseClicked = ImGui::SmallButton("...");
        if (ImGui::IsItemHovered())
            ImGui::SetTooltip("Browse for a texture file");
        ImGui::SameLine();

        if (browseClicked) {
            static const char* kTexFilter =
                "Images (*.png;*.jpg;*.jpeg;*.exr;*.tif;*.tiff;*.hdr;*.tga;*.bmp;*.tx)\0"
                    "*.png;*.jpg;*.jpeg;*.exr;*.tif;*.tiff;*.hdr;*.tga;*.bmp;*.tx\0"
                "All Files (*.*)\0*.*\0";
            std::string picked = FileDialog::OpenFile(kTexFilter, "Select Texture File");
            if (!picked.empty() && picked != s) {
                m_preEditValue       = current;
                m_preEditWasAuthored = authored;
                pxr::UsdShadeShader shader(prim);
                if (shader)
                    shader.CreateInput(pxr::TfToken(input.name), input.typeName)
                          .Set(pxr::VtValue(pxr::SdfAssetPath(picked)));
                CommitInputEdit(node, input);
            }
        }

        char buf[512];
        std::snprintf(buf, sizeof(buf), "%s", s.c_str());
        if (ImGui::InputText(widgetId.c_str(), buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue))
            newValue = pxr::SdfAssetPath(buf);
    } else {
        ImGui::AlignTextToFramePadding();
        ImGui::TextDisabled("%s", type.GetAsToken().GetText());
        return;
    }

    if (ImGui::IsItemActivated()) {
        m_preEditValue       = current;
        m_preEditWasAuthored = authored;
    }

    auto setValue = [&](const pxr::VtValue& v) {
        pxr::UsdShadeShader shader(prim);
        if (shader)
            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);
}

void MaterialEditorPanel::RenderColorSpaceWidget(const ShaderGraphNode& node, const ShaderPinInfo& input,
                                                 const pxr::UsdPrim& prim) {
    if (!m_stage || !m_commandHistory) return;
    pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + input.name));
    if (!attr) return;

    const std::string current = attr.HasColorSpace() ? attr.GetColorSpace().GetString() : std::string();

    // Listed from the active OCIO config ($OCIO — the same one driving
    // viewport/preview color correction) rather than a fixed raw/sRGB/auto
    // set: those are only meaningful to UsdUVTexture's own sourceColorSpace
    // input, while this authors the renderer-agnostic colorSpace attribute
    // metadata, so the choices should span whatever color spaces are
    // actually defined and thus consistently interpretable across every
    // render delegate (Storm, Arnold, Cycles, ...) rather than being tied to
    // one shading backend's built-in vocabulary.
    const OcioConfig& ocfg = GetCurrentOcioConfig();
    static const std::vector<std::string> kFallback = {"raw", "sRGB"};
    const std::vector<std::string>& colorSpaces = ocfg.valid && !ocfg.colorSpaces.empty()
        ? ocfg.colorSpaces : kFallback;

    auto commit = [&](const std::string& newValue) {
        pxr::UsdStageRefPtr stage = m_stage;
        pxr::SdfPath nodePath = node.path;
        pxr::TfToken attrName = attr.GetName();
        std::string oldValue = current;
        m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
            "Set color space on " + node.path.GetName() + "." + input.name,
            [stage, nodePath, attrName, newValue]() {
                pxr::UsdAttribute a = stage->GetPrimAtPath(nodePath).GetAttribute(attrName);
                if (!a) return;
                if (newValue.empty()) a.ClearColorSpace();
                else                  a.SetColorSpace(pxr::TfToken(newValue));
            },
            [stage, nodePath, attrName, oldValue]() {
                pxr::UsdAttribute a = stage->GetPrimAtPath(nodePath).GetAttribute(attrName);
                if (!a) return;
                if (oldValue.empty()) a.ClearColorSpace();
                else                  a.SetColorSpace(pxr::TfToken(oldValue));
            }));
    };

    if (ImGui::BeginCombo("##colorSpace", current.empty() ? "(unset)" : current.c_str())) {
        if (ImGui::Selectable("(unset)", current.empty()) && !current.empty())
            commit(std::string());
        if (current.empty()) ImGui::SetItemDefaultFocus();
        for (const std::string& cs : colorSpaces) {
            const bool selected = (cs == current);
            if (ImGui::Selectable(cs.c_str(), selected) && !selected)
                commit(cs);
            if (selected) ImGui::SetItemDefaultFocus();
        }
        ImGui::EndCombo();
    }
    if (ImGui::IsItemHovered() && ocfg.valid)
        ImGui::SetTooltip("Color spaces from the active OCIO config");
}

void MaterialEditorPanel::CommitInputEdit(const ShaderGraphNode& node, const ShaderPinInfo& input) {
    if (!m_stage || !m_commandHistory) return;

    // The live edits already authored the final value; read it back so the
    // command's redo closure carries exactly what's on the stage now.
    bool nowAuthored = false;
    pxr::UsdPrim prim = m_stage->GetPrimAtPath(node.path);
    if (!prim) return;
    pxr::VtValue newValue = ReadInputValue(prim, node.shaderId, input, &nowAuthored);
    if (nowAuthored == m_preEditWasAuthored && newValue == m_preEditValue)
        return; // e.g. text field deactivated without Enter — nothing changed

    pxr::UsdStageRefPtr stage       = m_stage;
    pxr::SdfPath        nodePath    = node.path;
    pxr::TfToken        nameTok(input.name);
    pxr::SdfValueTypeName typeName  = input.typeName;
    pxr::VtValue        oldValue    = m_preEditValue;
    bool                wasAuthored = m_preEditWasAuthored;

    m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
        "Set " + input.name,
        [stage, nodePath, nameTok, typeName, newValue]() {
            pxr::UsdShadeShader shader(stage->GetPrimAtPath(nodePath));
            if (shader) shader.CreateInput(nameTok, typeName).Set(newValue);
        },
        [stage, nodePath, nameTok, typeName, oldValue, wasAuthored]() {
            pxr::UsdPrim p = stage->GetPrimAtPath(nodePath);
            if (!p) return;
            if (wasAuthored) {
                pxr::UsdShadeShader shader(p);
                if (shader) shader.CreateInput(nameTok, typeName).Set(oldValue);
            } else {
                p.RemoveProperty(pxr::TfToken("inputs:" + nameTok.GetString()));
            }
        }));
}

void MaterialEditorPanel::RenderToolbar() {
    // Every toolbar action is an icon button; the tooltip carries the label.
    // The icon fills the full button height (standard frame height, zero frame
    // padding) so the buttons stay a uniform, standard-row-height square. Falls
    // back to a text button only when no icon set is loaded.
    const float btnH = ImGui::GetFrameHeight();
    auto iconBtn = [&](const char* id, Icon icon, const char* fallback, const char* tip) -> bool {
        bool clicked;
        if (m_iconManager) {
            ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.f, 0.f));
            clicked = ImGui::ImageButton(id, ImTextureRef(m_iconManager->Get(icon)),
                                         ImVec2(btnH, btnH));
            ImGui::PopStyleVar();
        } else {
            clicked = ImGui::Button(fallback);
        }
        if (ImGui::IsItemHovered())
            ImGui::SetTooltip("%s", tip);
        return clicked;
    };

    if (iconBtn("##createMaterial", Icon::FilePlus, "Create Material", "Create Material"))
        CreateNewMaterial();

    // Presets menu: entries appear per node source when the required shader
    // definitions are registered, and need an open material to apply to.
    ImGui::SameLine();
    ImGui::BeginDisabled(m_materialPath.IsEmpty());
    if (iconBtn("##presets", Icon::Swatchbook, "Presets", "Presets"))
        ImGui::OpenPopup("MaterialPresets");
    ImGui::EndDisabled();
    if (ImGui::BeginPopup("MaterialPresets")) {
        auto& sdr = pxr::SdrRegistry::GetInstance();
        if (ImGui::BeginMenu("USD")) {
            ImGui::BeginDisabled(!sdr.GetShaderNodeByIdentifier(pxr::TfToken("UsdPreviewSurface")));
            if (ImGui::MenuItem("Default Shader Graph"))
                CreateUsdPresetGraph();
            ImGui::EndDisabled();
            ImGui::EndMenu();
        }
        if (ImGui::BeginMenu("MaterialX")) {
            ImGui::BeginDisabled(
                !sdr.GetShaderNodeByIdentifier(pxr::TfToken("ND_standard_surface_surfaceshader")));
            if (ImGui::MenuItem("Standard Surface Graph"))
                CreateMaterialXPresetGraph();
            ImGui::EndDisabled();
            ImGui::EndMenu();
        }
        if (ImGui::BeginMenu("Arnold")) {
            ImGui::BeginDisabled(
                !sdr.GetShaderNodeByIdentifier(pxr::TfToken("arnold:standard_surface")));
            if (ImGui::MenuItem("Standard Surface Graph"))
                CreateArnoldPresetGraph();
            ImGui::EndDisabled();
            ImGui::EndMenu();
        }
        ImGui::EndPopup();
    }

    ImGui::SameLine();
    if (iconBtn("##toggleThumbs", m_showNodeThumbnails ? Icon::Eye : Icon::EyeSlash,
                m_showNodeThumbnails ? "Hide Thumbnails" : "Show Thumbnails",
                m_showNodeThumbnails ? "Hide node thumbnails" : "Show node thumbnails"))
        m_showNodeThumbnails = !m_showNodeThumbnails;

    // "Set all nodes" pin-display broadcast: same 3-dot icon language as the
    // per-node toggle (DrawPinDisplayModeIcon), but hit-tested the normal
    // ImGui way — this button lives in the toolbar window, not inside the
    // node canvas, so none of RenderNodeGraphCanvas's geometric hit-test
    // workarounds are needed here.
    ImGui::SameLine();
    ImGui::BeginDisabled(m_materialPath.IsEmpty());
    {
        ImGui::PushID("##allPinMode");
        const ImVec2 pos = ImGui::GetCursorScreenPos();
        const ImVec2 posMax(pos.x + btnH, pos.y + btnH);
        const bool clicked = ImGui::InvisibleButton("##btn", ImVec2(btnH, btnH));
        const bool hovered = ImGui::IsItemHovered();
        ImDrawList* dl = ImGui::GetWindowDrawList();
        const ImU32 bg = ImGui::GetColorU32(
            hovered ? (ImGui::IsItemActive() ? ImGuiCol_ButtonActive : ImGuiCol_ButtonHovered)
                    : ImGuiCol_Button);
        dl->AddRectFilled(pos, posMax, bg, ImGui::GetStyle().FrameRounding);
        // Dot geometry scaled to btnH (unlike the per-node toggle's fixed
        // size) so the icon fits inside this fixed, already-square button
        // instead of overflowing it.
        DrawPinDisplayModeIcon(dl, pos, posMax, m_toolbarPinDisplayMode,
                               ImGui::GetColorU32(ImGuiCol_Text), ImGui::GetColorU32(ImGuiCol_TextDisabled),
                               btnH * 0.09f, btnH * 0.30f);
        static const char* kTips[3] = {
            "All nodes: showing all pins - click to show connected pins only on every node",
            "All nodes: showing connected pins only - click to collapse every node to one pin",
            "All nodes: collapsed to one pin per side - click to show all pins on every node",
        };
        if (hovered) ImGui::SetTooltip("%s", kTips[static_cast<int>(m_toolbarPinDisplayMode)]);
        if (clicked) {
            m_toolbarPinDisplayMode = static_cast<PinDisplayMode>((static_cast<int>(m_toolbarPinDisplayMode) + 1) % 3);
            ApplyPinDisplayModeToAllNodes(m_toolbarPinDisplayMode);
        }
        ImGui::PopID();
    }
    ImGui::EndDisabled();

    if (!m_targetPrimPath.IsEmpty()) {
        ImGui::SameLine();
        ImGui::Text("Selected: %s", m_targetPrimPath.GetText());
        ImGui::SameLine();
        if (iconBtn("##createBind", Icon::ObjectGroup, "Create + Bind",
                    "Create + Bind Material to Selected"))
            CreateAndBindMaterialForTarget();
        ImGui::SameLine();
        ImGui::BeginDisabled(m_materialPath.IsEmpty());
        if (iconBtn("##bindCurrent", Icon::Link, "Bind Current",
                    "Bind Current Material to Selected"))
            BindMaterialToTarget(m_materialPath, m_targetPrimPath);
        ImGui::EndDisabled();
    }

    ImGui::Separator();
}

void MaterialEditorPanel::RenderMaterialBrowser() {
    ImGui::TextUnformatted("Materials");
    ImGui::SameLine(ImGui::GetContentRegionAvail().x + ImGui::GetCursorPosX()
                    - ImGui::CalcTextSize("Show Graph").x - ImGui::GetStyle().FramePadding.x * 2.0f);
    ImGui::BeginDisabled(m_browserSelection.IsEmpty());
    if (ImGui::Button("Show Graph"))
        OpenOrCreateMaterial(m_browserSelection.GetString());
    ImGui::EndDisabled();
    ImGui::Separator();

    if (!m_materialManager || !m_stage) {
        ImGui::TextDisabled("No stage loaded");
        return;
    }

    // Re-enumerated every frame so materials created/deleted anywhere (other
    // panels, undo/redo) show up live; cheap relative to the graph re-sync
    // Render() already does per frame.
    const std::vector<pxr::SdfPath> materials = m_materialManager->GetAllMaterials();
    if (materials.empty()) {
        ImGui::TextDisabled("No materials in scene");
        return;
    }

    // Keep the browser selection valid if the selected material was deleted.
    if (!m_browserSelection.IsEmpty() &&
        std::find(materials.begin(), materials.end(), m_browserSelection) == materials.end())
        m_browserSelection = pxr::SdfPath();

    // Materials list (top) and the persistent searchable create-node list
    // (bottom, Hypershade's create bar with Nuke-style filtering), split by a
    // draggable horizontal splitter; m_browserListRatio is the list's share.
    const float browserAvail = ImGui::GetContentRegionAvail().y;
    const float listHeight = std::max(1.0f, browserAvail * m_browserListRatio);
    ImGui::BeginChild("MaterialBrowserList", ImVec2(0.0f, listHeight), false);
    for (const pxr::SdfPath& path : materials) {
        // PushID(full path) keeps IDs unique for same-named materials in
        // different scopes without an explicit ## suffix on the label.
        ImGui::PushID(path.GetText());
        const bool isSelected = (path == m_browserSelection);
        const bool isOpen     = (path == m_materialPath);
        const bool isRenaming = (path == m_renamingMaterial);

        if (isRenaming) {
            // Inline rename (mirrors SceneHierarchyPanel): commit on Enter,
            // cancel on focus loss. firstFrame guards against IsItemDeactivated
            // firing spuriously on the frame SetKeyboardFocusHere activates it.
            ImGui::SetNextItemWidth(-FLT_MIN);
            const bool firstFrame = m_materialRenameJustStarted;
            if (m_materialRenameJustStarted) {
                ImGui::SetKeyboardFocusHere();
                m_materialRenameJustStarted = false;
            }
            const bool commit = ImGui::InputText("##matrename", m_materialRenameBuf,
                                                 sizeof(m_materialRenameBuf),
                                                 ImGuiInputTextFlags_EnterReturnsTrue |
                                                 ImGuiInputTextFlags_AutoSelectAll);
            const bool canceled = !firstFrame && ImGui::IsItemDeactivated() && !commit;
            if (commit && m_materialRenameBuf[0] != '\0') {
                const std::string newName = SanitizeUsdName(m_materialRenameBuf);
                m_renamingMaterial = pxr::SdfPath();
                if (!newName.empty() && newName != path.GetName() && m_commandHistory) {
                    const pxr::SdfPath newPath =
                        path.GetParentPath().AppendChild(pxr::TfToken(newName));
                    m_commandHistory->Push(std::make_unique<RenamePrimCommand>(
                        m_stage, path, newName));
                    // Follow the rename with the selection and (if it was the
                    // open one) the loaded graph. UsdNamespaceEditor fixes up
                    // bindings/arcs, so bound prims keep pointing at it.
                    if (m_browserSelection == path) m_browserSelection = newPath;
                    if (m_materialPath == path)     OpenOrCreateMaterial(newPath.GetString());
                    ImGui::PopID();
                    break; // 'materials' now holds stale paths — rebuilt next frame
                }
            } else if (canceled) {
                m_renamingMaterial = pxr::SdfPath();
            }
        } else {
            if (isOpen)
                ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 210, 90, 255));
            if (ImGui::Selectable(path.GetName().c_str(), isSelected,
                                  ImGuiSelectableFlags_AllowDoubleClick)) {
                m_browserSelection = path;
                // Double-click starts an inline rename (single-click selects;
                // "Show Graph" opens the material into the canvas).
                if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
                    m_renamingMaterial = path;
                    std::snprintf(m_materialRenameBuf, sizeof(m_materialRenameBuf), "%s",
                                  path.GetName().c_str());
                    m_materialRenameJustStarted = true;
                }
            }
            if (isOpen)
                ImGui::PopStyleColor();
            if (ImGui::IsItemHovered())
                ImGui::SetTooltip("%s", path.GetText());
        }
        ImGui::PopID();
    }
    ImGui::EndChild();

    HorizontalSplitter("##BrowserListSplitter", &m_browserListRatio, browserAvail, 0.15f, 0.85f);

    ImGui::TextUnformatted("Create Node");
    ImGui::SetNextItemWidth(-FLT_MIN);
    ImGui::InputTextWithHint("##BrowserNodeSearch", "search...",
                             m_browserNodeSearchBuf, sizeof(m_browserNodeSearchBuf));

    ImGui::BeginChild("BrowserNodeList", ImVec2(0.0f, 0.0f), false);
    if (m_materialPath.IsEmpty()) {
        ImGui::TextDisabled("Open a material first");
    } else {
        const bool searching = (m_browserNodeSearchBuf[0] != '\0');
        const auto matches =
            FilterShaderNodeTypes(m_materialManager->GetAvailableShaderNodes(), m_browserNodeSearchBuf);
        if (matches.empty())
            ImGui::TextDisabled("No matching nodes");
        // Matches arrive sorted by (source, category, label) — a collapsing
        // header per source (USD / MaterialX / Arnold / ...) with a category
        // sub-tree (Material / Texture / Geometry / Utility ...) inside,
        // Hypershade-style. While searching, everything is forced open so a
        // collapsed section can never hide a hit.
        std::string currentSource;
        std::string currentCategory;
        bool sourceOpen   = false;
        bool categoryOpen = false;
        auto closeCategory = [&]() {
            if (categoryOpen) ImGui::TreePop();
            categoryOpen = false;
        };
        for (int i = 0; i < static_cast<int>(matches.size()); ++i) {
            const ShaderNodeTypeInfo* info = matches[i];
            if (info->source != currentSource) {
                closeCategory();
                currentSource   = info->source;
                currentCategory = std::string();
                if (searching)
                    ImGui::SetNextItemOpen(true);
                sourceOpen = ImGui::CollapsingHeader(currentSource.c_str(),
                                                     ImGuiTreeNodeFlags_DefaultOpen);
            }
            if (!sourceOpen)
                continue;
            if (info->category != currentCategory) {
                closeCategory();
                currentCategory = info->category;
                if (searching)
                    ImGui::SetNextItemOpen(true);
                // "##source" keeps IDs unique — category names repeat per source.
                std::string catLabel = currentCategory + "##" + currentSource;
                categoryOpen = ImGui::TreeNodeEx(catLabel.c_str(), ImGuiTreeNodeFlags_DefaultOpen);
            }
            if (!categoryOpen)
                continue;
            if (m_iconManager) {
                const float h = ImGui::GetTextLineHeight();
                ImGui::Image(ImTextureRef(m_iconManager->Get(IconForNodeCategory(info->category))),
                             ImVec2(h, h));
                ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x);
            }
            std::string itemLabel = info->label + "##" + std::to_string(i);
            if (ImGui::Selectable(itemLabel.c_str(), false))
                m_pendingCreateShaderId = info->identifier; // created at view center next canvas pass
            if (ImGui::IsItemHovered())
                ImGui::SetTooltip("%s", info->identifier.c_str());
        }
        closeCategory();
    }
    ImGui::EndChild();
}

void MaterialEditorPanel::CreateNewMaterial() {
    if (!m_stage) return;

    pxr::SdfPath materialsScope("/Materials");
    std::string finalName = "material1";
    int suffix = 1;
    while (m_stage->GetPrimAtPath(materialsScope.AppendChild(pxr::TfToken(finalName))).IsValid())
        finalName = "material" + std::to_string(++suffix);

    OpenOrCreateMaterial(materialsScope.AppendChild(pxr::TfToken(finalName)).GetString());
}

namespace {
// Shared by the preset builders below.
pxr::UsdShadeShader DefinePresetShader(const pxr::UsdStageRefPtr& stage,
                                       const pxr::SdfPath& materialPath,
                                       const char* name, const char* shaderId,
                                       const pxr::GfVec2f& uiPos) {
    pxr::UsdShadeShader shader =
        pxr::UsdShadeShader::Define(stage, materialPath.AppendChild(pxr::TfToken(name)));
    shader.CreateIdAttr(pxr::VtValue(pxr::TfToken(shaderId)));
    shader.GetPrim().SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(uiPos));
    return shader;
}
} // namespace

void MaterialEditorPanel::CreateUsdPresetGraph() {
    if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return;
    // Re-applying onto an existing preset graph is allowed: the build is
    // idempotent (same prims/connections re-authored, user-set values like
    // texture file paths survive) and re-normalizes the node layout.

    pxr::UsdStageRefPtr stage = m_stage;
    pxr::SdfPath matPath = m_materialPath;

    auto build = [stage, matPath]() {
        // Column/row spacing must exceed the rendered node sizes (full Sdr
        // pin sets make these ~400px tall): overlapping nodes steal each
        // other's clicks in the editor's hit test and become undraggable.
        pxr::UsdShadeShader surf = DefinePresetShader(
            stage, matPath, "UsdPreviewSurface", "UsdPreviewSurface", pxr::GfVec2f(0.f, 400.f));
        pxr::UsdShadeShader st = DefinePresetShader(
            stage, matPath, "stReader", "UsdPrimvarReader_float2", pxr::GfVec2f(-840.f, 600.f));
        st.CreateInput(pxr::TfToken("varname"), pxr::SdfValueTypeNames->Token)
            .Set(pxr::TfToken("st"));

        struct TexPreset {
            const char* name;
            const char* texOutput;   pxr::SdfValueTypeName outType;
            const char* destInput;   pxr::SdfValueTypeName destType;
            bool        rawColorSpace;
        };
        const TexPreset textures[] = {
            {"diffuseTexture",   "rgb", pxr::SdfValueTypeNames->Float3,
                                 "diffuseColor", pxr::SdfValueTypeNames->Color3f,  false},
            {"metallicTexture",  "r",   pxr::SdfValueTypeNames->Float,
                                 "metallic",     pxr::SdfValueTypeNames->Float,    true},
            {"roughnessTexture", "r",   pxr::SdfValueTypeNames->Float,
                                 "roughness",    pxr::SdfValueTypeNames->Float,    true},
        };
        float y = 0.f;
        for (const TexPreset& t : textures) {
            pxr::UsdShadeShader tex = DefinePresetShader(
                stage, matPath, t.name, "UsdUVTexture", pxr::GfVec2f(-420.f, y));
            y += 440.f;
            tex.CreateInput(pxr::TfToken("st"), pxr::SdfValueTypeNames->Float2)
                .ConnectToSource(st.ConnectableAPI(), pxr::TfToken("result"));
            if (t.rawColorSpace)
                tex.CreateInput(pxr::TfToken("sourceColorSpace"), pxr::SdfValueTypeNames->Token)
                    .Set(pxr::TfToken("raw"));
            surf.CreateInput(pxr::TfToken(t.destInput), t.destType)
                .ConnectToSource(tex.ConnectableAPI(), pxr::TfToken(t.texOutput));
        }

        pxr::UsdShadeMaterial material(stage->GetPrimAtPath(matPath));
        if (material)
            material.CreateSurfaceOutput().ConnectToSource(
                surf.ConnectableAPI(), pxr::TfToken("surface"));
    };
    auto remove = [stage, matPath]() {
        for (const char* name : {"UsdPreviewSurface", "stReader", "diffuseTexture",
                                 "metallicTexture", "roughnessTexture"})
            stage->RemovePrim(matPath.AppendChild(pxr::TfToken(name)));
        if (pxr::UsdPrim mat = stage->GetPrimAtPath(matPath))
            mat.RemoveProperty(pxr::TfToken("outputs:surface"));
    };

    m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
        "Create USD Preset Graph", build, remove));
    // Force a position reseed: on a re-apply the nodes are already known to
    // the editor at their old canvas positions.
    m_nodeIdToPath.clear();
    SyncFromUsd();
}

void MaterialEditorPanel::CreateMaterialXPresetGraph() {
    if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return;
    // Re-applying is allowed; see CreateUsdPresetGraph.

    pxr::UsdStageRefPtr stage = m_stage;
    pxr::SdfPath matPath = m_materialPath;

    auto build = [stage, matPath]() {
        // See the USD preset: spacing must exceed rendered node sizes or
        // overlapping nodes steal each other's drag hit-tests.
        pxr::UsdShadeShader surf = DefinePresetShader(
            stage, matPath, "standard_surface", "ND_standard_surface_surfaceshader",
            pxr::GfVec2f(0.f, 300.f));
        pxr::UsdShadeShader texcoord = DefinePresetShader(
            stage, matPath, "texcoord", "ND_texcoord_vector2", pxr::GfVec2f(-840.f, 400.f));

        struct ImagePreset {
            const char* name;
            const char* shaderId;    pxr::SdfValueTypeName outType;
            const char* destInput;   pxr::SdfValueTypeName destType;
        };
        const ImagePreset images[] = {
            {"base_color_image",         "ND_image_color3", pxr::SdfValueTypeNames->Color3f,
                                         "base_color",         pxr::SdfValueTypeNames->Color3f},
            {"specular_roughness_image", "ND_image_float",  pxr::SdfValueTypeNames->Float,
                                         "specular_roughness", pxr::SdfValueTypeNames->Float},
            {"metalness_image",          "ND_image_float",  pxr::SdfValueTypeNames->Float,
                                         "metalness",          pxr::SdfValueTypeNames->Float},
        };
        float y = 0.f;
        for (const ImagePreset& img : images) {
            pxr::UsdShadeShader tex = DefinePresetShader(
                stage, matPath, img.name, img.shaderId, pxr::GfVec2f(-420.f, y));
            y += 400.f;
            tex.CreateInput(pxr::TfToken("texcoord"), pxr::SdfValueTypeNames->Float2)
                .ConnectToSource(texcoord.ConnectableAPI(), pxr::TfToken("out"));
            surf.CreateInput(pxr::TfToken(img.destInput), img.destType)
                .ConnectToSource(tex.ConnectableAPI(), pxr::TfToken("out"));
        }

        // MaterialX networks terminate on the mtlx render-context output
        // (matches usdMtlx-imported materials).
        pxr::UsdShadeMaterial material(stage->GetPrimAtPath(matPath));
        if (material)
            material.CreateSurfaceOutput(pxr::TfToken("mtlx")).ConnectToSource(
                surf.ConnectableAPI(), pxr::TfToken("out"));
    };
    auto remove = [stage, matPath]() {
        for (const char* name : {"standard_surface", "texcoord", "base_color_image",
                                 "specular_roughness_image", "metalness_image"})
            stage->RemovePrim(matPath.AppendChild(pxr::TfToken(name)));
        if (pxr::UsdPrim mat = stage->GetPrimAtPath(matPath))
            mat.RemoveProperty(pxr::TfToken("outputs:mtlx:surface"));
    };

    m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
        "Create MaterialX Preset Graph", build, remove));
    m_nodeIdToPath.clear(); // reseed positions (see CreateUsdPresetGraph)
    SyncFromUsd();
}

void MaterialEditorPanel::CreateArnoldPresetGraph() {
    if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return;
    // Re-applying is allowed; see CreateUsdPresetGraph.

    pxr::UsdStageRefPtr stage = m_stage;
    pxr::SdfPath matPath = m_materialPath;

    auto build = [stage, matPath]() {
        // See the USD preset: spacing must exceed rendered node sizes or
        // overlapping nodes steal each other's drag hit-tests.
        pxr::UsdShadeShader surf = DefinePresetShader(
            stage, matPath, "standard_surface", "arnold:standard_surface",
            pxr::GfVec2f(0.f, 300.f));

        struct ImagePreset {
            const char* name;
            const char* destInput;   pxr::SdfValueTypeName destType;
            bool        rawColorSpace;
        };
        const ImagePreset images[] = {
            {"base_color_image",         "base_color",         pxr::SdfValueTypeNames->Color3f, false},
            {"specular_roughness_image", "specular_roughness", pxr::SdfValueTypeNames->Float,   true},
            {"metalness_image",          "metalness",          pxr::SdfValueTypeNames->Float,   true},
        };
        // Arnold's image node samples the mesh's default UV set when uvcoords is
        // left unconnected, so no explicit texcoord reader node is needed.
        float y = 0.f;
        for (const ImagePreset& img : images) {
            pxr::UsdShadeShader tex = DefinePresetShader(
                stage, matPath, img.name, "arnold:image", pxr::GfVec2f(-420.f, y));
            y += 400.f;
            if (img.rawColorSpace)
                tex.CreateInput(pxr::TfToken("color_space"), pxr::SdfValueTypeNames->String)
                    .Set(std::string("raw"));
            surf.CreateInput(pxr::TfToken(img.destInput), img.destType)
                .ConnectToSource(tex.ConnectableAPI(), pxr::TfToken("out"));
        }

        // Arnold materials terminate on the arnold render-context surface output
        // (matches hdArnold-authored materials).
        pxr::UsdShadeMaterial material(stage->GetPrimAtPath(matPath));
        if (material)
            material.CreateSurfaceOutput(pxr::TfToken("arnold")).ConnectToSource(
                surf.ConnectableAPI(), pxr::TfToken("out"));
    };
    auto remove = [stage, matPath]() {
        for (const char* name : {"standard_surface", "base_color_image",
                                 "specular_roughness_image", "metalness_image"})
            stage->RemovePrim(matPath.AppendChild(pxr::TfToken(name)));
        if (pxr::UsdPrim mat = stage->GetPrimAtPath(matPath))
            mat.RemoveProperty(pxr::TfToken("outputs:arnold:surface"));
    };

    m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
        "Create Arnold Preset Graph", build, remove));
    m_nodeIdToPath.clear(); // reseed positions (see CreateUsdPresetGraph)
    SyncFromUsd();
}

void MaterialEditorPanel::OpenOrCreateMaterial(const std::string& pathStr) {
    if (!m_stage) return;
    if (!pxr::SdfPath::IsValidPathString(pathStr, nullptr)) {
        LOG_WARNING("Material Editor: invalid material path '" + pathStr + "'");
        return;
    }

    pxr::SdfPath path(pathStr);
    if (!m_stage->GetPrimAtPath(path).IsValid()) {
        if (m_commandHistory)
            m_commandHistory->Push(std::make_unique<CreatePrimCommand>(m_stage, path, pxr::TfToken("Material")));
        else
            m_stage->DefinePrim(path, pxr::TfToken("Material"));
    }

    m_materialPath = path;
    m_browserSelection = path;
    m_nodeIdToPath.clear(); // force position reseed for the (possibly different) material now open
    m_thumbnails.Clear();   // drop the previous material's node thumbnails
    SyncFromUsd();
}

void MaterialEditorPanel::SetTargetPrimPath(const std::string& path) {
    m_targetPrimPath = pxr::SdfPath();
    if (path.empty() || !pxr::SdfPath::IsValidPathString(path, nullptr))
        return;
    pxr::SdfPath primPath(path);
    if (!primPath.IsPrimPath())
        return;
    m_targetPrimPath = primPath;

    if (!m_stage) return;
    pxr::UsdPrim prim = m_stage->GetPrimAtPath(primPath);
    if (!prim || !prim.HasAPI<pxr::UsdShadeMaterialBindingAPI>())
        return;

    pxr::UsdShadeMaterial resolved = pxr::UsdShadeMaterialBindingAPI(prim).ComputeBoundMaterial();
    if (!resolved) return;

    pxr::SdfPath matPath = resolved.GetPrim().GetPath();
    if (matPath == m_materialPath) return;
    m_materialPath = matPath;
    m_browserSelection = matPath;
    m_nodeIdToPath.clear();
    SyncFromUsd();
}

void MaterialEditorPanel::BindMaterialToTarget(const pxr::SdfPath& materialPath, const pxr::SdfPath& targetPath) {
    if (!m_stage || materialPath.IsEmpty() || targetPath.IsEmpty()) return;
    pxr::UsdPrim targetPrim = m_stage->GetPrimAtPath(targetPath);
    if (!targetPrim) return;

    pxr::SdfPathVector priorTargets;
    bool hadPriorRel = false;
    {
        pxr::UsdShadeMaterialBindingAPI existingBindAPI(targetPrim);
        pxr::UsdRelationship priorRel = existingBindAPI.GetDirectBindingRel();
        hadPriorRel = priorRel && priorRel.IsAuthored();
        if (hadPriorRel) priorRel.GetTargets(&priorTargets);
    }

    pxr::UsdStageRefPtr stage = m_stage;
    // Captured at construction time so Undo() lands in the same layer as
    // Execute() even if the ambient edit target changes in between (e.g. a
    // render-layer switch) — mirrors ConnectShaderAttrsCommand's pattern.
    pxr::SdfLayerHandle editLayer = m_stage->GetEditTarget().GetLayer();
    auto doBind = [stage, targetPath, materialPath, editLayer]() {
        pxr::UsdPrim target = stage->GetPrimAtPath(targetPath);
        pxr::UsdShadeMaterial material(stage->GetPrimAtPath(materialPath));
        if (!target || !material) return;
        if (editLayer) {
            pxr::UsdEditContext ec(stage, editLayer);
            pxr::UsdShadeMaterialBindingAPI::Apply(target).Bind(material);
        } else {
            pxr::UsdShadeMaterialBindingAPI::Apply(target).Bind(material);
        }
    };

    if (m_commandHistory) {
        m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
            "Bind Material",
            doBind,
            [stage, targetPath, hadPriorRel, priorTargets, editLayer]() {
                pxr::UsdPrim target = stage->GetPrimAtPath(targetPath);
                if (!target) return;
                auto doUnbind = [&]() {
                    pxr::UsdShadeMaterialBindingAPI bindAPI(target);
                    if (hadPriorRel && !priorTargets.empty())
                        bindAPI.GetDirectBindingRel().SetTargets(priorTargets);
                    else
                        bindAPI.UnbindDirectBinding();
                };
                if (editLayer) {
                    pxr::UsdEditContext ec(stage, editLayer);
                    doUnbind();
                } else {
                    doUnbind();
                }
            }
        ));
    } else {
        doBind();
    }
}

void MaterialEditorPanel::CreateAndBindMaterialForTarget() {
    if (!m_stage || m_targetPrimPath.IsEmpty()) return;

    std::string baseName = SanitizeUsdName(m_targetPrimPath.GetName()) + "Material";
    std::string finalName = baseName;
    int suffix = 1;
    pxr::SdfPath materialsScope("/Materials");
    while (m_stage->GetPrimAtPath(materialsScope.AppendChild(pxr::TfToken(finalName))).IsValid())
        finalName = baseName + "_" + std::to_string(suffix++);
    pxr::SdfPath materialPath = materialsScope.AppendChild(pxr::TfToken(finalName));

    if (m_commandHistory)
        m_commandHistory->Push(std::make_unique<CreatePrimCommand>(m_stage, materialPath, pxr::TfToken("Material")));
    else
        m_stage->DefinePrim(materialPath, pxr::TfToken("Material"));

    BindMaterialToTarget(materialPath, m_targetPrimPath);

    m_materialPath = materialPath;
    m_browserSelection = materialPath;
    m_nodeIdToPath.clear();
    SyncFromUsd();
}

void MaterialEditorPanel::SyncFromUsd() {
    if (!m_materialManager || m_materialPath.IsEmpty()) {
        m_graph = ShaderGraphSnapshot();
        return;
    }
    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.pinDisplayMode});
    }
}

void MaterialEditorPanel::RenderNodeGraphCanvas() {
    // Viewport-style navigation: Alt+RMB drag zooms. The node editor only
    // zooms on discrete mouse-wheel steps, so convert drag distance into
    // synthetic wheel steps before it processes input; drag right/down zooms
    // in, matching ViewportTile's Alt+RMB dolly. (Alt+MMB pan is the
    // library's own scroll button — c_ScrollButtonIndex, patched to MMB.)
    ImGuiIO& io = ImGui::GetIO();
    const bool canvasHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows);
    if (io.KeyAlt && canvasHovered && ImGui::IsMouseDragging(ImGuiMouseButton_Right, 0.0f)) {
        m_zoomDragAccum += io.MouseDelta.x + io.MouseDelta.y;
        const float pixelsPerStep = 50.0f;
        while (m_zoomDragAccum >= pixelsPerStep) { io.MouseWheel += 1.0f; m_zoomDragAccum -= pixelsPerStep; }
        while (m_zoomDragAccum <= -pixelsPerStep) { io.MouseWheel -= 1.0f; m_zoomDragAccum += pixelsPerStep; }
    } else {
        m_zoomDragAccum = 0.0f;
    }

    // Nuke-style TAB: searchable create-node popup at the cursor.
    const bool openNodeSearch = canvasHovered && ImGui::IsKeyPressed(ImGuiKey_Tab, false);

    // Screen center of the canvas region, captured while it's still the
    // current window — where browser-list creations land (they have no
    // meaningful mouse position).
    const ImVec2 regionPos  = ImGui::GetCursorScreenPos();
    const ImVec2 regionSize = ImGui::GetContentRegionAvail();
    const ImVec2 viewCenterScreen(regionPos.x + regionSize.x * 0.5f,
                                  regionPos.y + regionSize.y * 0.5f);

    // Keep node thumbnails' renderer/color-correction matching the big
    // preview swatch (cheap no-op unless something actually changed) before
    // pumping, so a change this frame invalidates stale entries in time to
    // be picked up by the pump below.
    m_thumbnails.SyncRenderSettings(m_preview.GetRendererPlugin(), m_ccMode,
                                    m_ocioDisplay, m_ocioView, m_ocioColorSpace, m_ocioLook);

    // Drain finished texture decodes (GL upload), render one stale material
    // thumbnail, and prune dead entries — GL/FBO work kept outside the
    // node-editor's Begin/End so it never touches mid-draw ImGui state.
    m_thumbnails.PumpMainThread();

    NE::SetCurrentEditor(m_editorContext);
    NE::Begin("MaterialEditorCanvas", ImVec2(0.0f, 0.0f));

    if (!m_pendingCreateShaderId.empty()) {
        // Browser-list creations land at the view center; CreateShaderNode
        // nudges the spot clear of existing nodes.
        CreateShaderNode(m_pendingCreateShaderId, NE::ScreenToCanvas(viewCenterScreen));
        m_pendingCreateShaderId.clear();
    }

    m_pinIdToInfo.clear();
    m_linkIdToInfo.clear();

    const float nodeRounding = NE::GetStyle().NodeRounding;
    const ImVec2 pinIconSize(16.0f, 16.0f);

    // 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);

        if (m_nodeIdToPath.find(nodeIdValue) == m_nodeIdToPath.end()) {
            NE::SetNodePosition(nodeId, ImVec2(node.uiPosition[0], node.uiPosition[1]));
            m_nodeIdToPath[nodeIdValue] = node.path;
        }

        const std::string& title = node.shaderId.empty() ? node.path.GetName() : node.shaderId;
        const ImU32 headerColor = GetHeaderColor(title);

        // Thumbnail below the title: the source image for Texture nodes, a
        // shader-ball render for Material nodes. thumb is 0 until ready; the
        // space is still reserved so the node doesn't jump when it appears.
        // Toggled off entirely (no reserved space, no decode/render work) via
        // the toolbar's thumbnail eye icon.
        ImTextureID thumb = 0;
        bool wantsThumb = false;
        if (m_showNodeThumbnails && node.category == "Texture") {
            // Any texture node that exposes a file/asset input reserves a
            // thumbnail slot. It stays black until a valid, on-disk image is
            // decoded, so an unset or unresolvable path reads as a black chip.
            bool hasFileInput = false;
            for (const auto& in : node.inputs)
                if (in.typeName == pxr::SdfValueTypeNames->Asset) { hasFileInput = true; break; }
            if (hasFileInput) {
                wantsThumb = true;
                const std::string file = ResolveTextureFilePath(node);
                if (!file.empty())
                    thumb = m_thumbnails.GetTextureThumbnail(node.path, file);
            }
        } else if (m_showNodeThumbnails && node.category == "Material") {
            wantsThumb = true;
            // Preview the terminal (token-typed) output as the surface; fall
            // back to the first output routed into diffuseColor.
            std::string previewOut;
            bool terminal = false;
            for (const auto& o : node.outputs)
                if (o.typeName == pxr::SdfValueTypeNames->Token) { previewOut = o.name; terminal = true; break; }
            if (previewOut.empty() && !node.outputs.empty())
                previewOut = node.outputs.front().name;
            thumb = m_thumbnails.GetMaterialThumbnail(m_stage, m_materialPath, node.path,
                                                      previewOut, terminal, graphRevision);
        }

        // Node width is auto-fit to content with no flex-layout available (the
        // library's Spring()/BeginHorizontal() column layout needs a custom
        // 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 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.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;
        float contentWidth = ImGui::CalcTextSize(title.c_str()).x;
        for (const auto& input : node.inputs)
            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)
            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();

        ImGui::TextUnformatted(title.c_str());

        // 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 ImGuiStyle& style = ImGui::GetStyle();
            // Icon area sized to match the 3-dot icon's fixed geometry (see
            // DrawPinDisplayModeIcon); filled-dot count visually "funnels
            // down" as the node collapses further (All=3, Connected=2,
            // Collapsed=1).
            const ImVec2 iconSize(9.0f * 2.0f + 3.0f * 2.0f, 3.0f * 2.0f);
            const ImVec2 btnMin = ImGui::GetCursorScreenPos();
            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)
            // 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);
            DrawPinDisplayModeIcon(dl, btnMin, btnMax, pinDisplay.mode,
                                   ImGui::GetColorU32(ImGuiCol_Text), ImGui::GetColorU32(ImGuiCol_TextDisabled));

            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 = kTooltips[static_cast<int>(pinDisplay.mode)];
            if (btnHovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
                CyclePinDisplayMode(node.path);
        }

        ImVec2 headerBottom = ImGui::GetItemRectMax();
        ImGui::Dummy(ImVec2(0.0f, 4.0f)); // breathing room between title and pins

        if (wantsThumb) {
            const ImVec2 thumbSize(static_cast<float>(NodeThumbnailCache::kThumbPx),
                                   static_cast<float>(NodeThumbnailCache::kThumbPx));
            if (thumb) {
                ImGui::Image(thumb, thumbSize);
            } else if (node.category == "Texture") {
                // No resolvable image (unset path / file not found / still
                // decoding): draw a solid black chip in the reserved slot.
                const ImVec2 p = ImGui::GetCursorScreenPos();
                ImGui::Dummy(thumbSize);
                ImGui::GetWindowDrawList()->AddRectFilled(
                    p, ImVec2(p.x + thumbSize.x, p.y + thumbSize.y), IM_COL32(0, 0, 0, 255));
            } else {
                ImGui::Dummy(thumbSize); // material thumbnail still rendering
            }
            ImGui::Dummy(ImVec2(0.0f, 4.0f));
        }

        for (const auto& input : node.inputs) {
            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
            // the icon's own edge instead (icon is the first element here).
            NE::PinPivotAlignment(ImVec2(0.0f, 0.5f));
            NE::PinPivotSize(ImVec2(0.0f, 0.0f));
            ax::Widgets::Icon(pinIconSize, ax::Widgets::IconType::Circle, linked, ImColor(GetPinColor(input.typeName)));
            ImGui::SameLine();
            ImGui::TextUnformatted(input.name.c_str());
            NE::EndPin();
        }
        for (const auto& output : node.outputs) {
            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));
            NE::BeginPin(NE::PinId(pinIdValue), NE::PinKind::Output);
            // Icon is the last element in an output row — pin to its edge.
            NE::PinPivotAlignment(ImVec2(1.0f, 0.5f));
            NE::PinPivotSize(ImVec2(0.0f, 0.0f));
            ImGui::TextUnformatted(output.name.c_str());
            ImGui::SameLine();
            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();

        // Colored header strip drawn on the node's background draw list,
        // spanning the node's full width behind the title (same technique
        // BlueprintNodeBuilder uses, without needing its Spring/BeginHorizontal
        // layout dependency).
        if (ImGui::IsItemVisible()) {
            ImVec2 nodeMin = ImGui::GetItemRectMin();
            ImVec2 nodeMax = ImGui::GetItemRectMax();
            ImDrawList* bgDrawList = NE::GetNodeBackgroundDrawList(nodeId);
            bgDrawList->AddRectFilled(
                nodeMin, ImVec2(nodeMax.x, headerBottom.y + 6.0f),
                headerColor, nodeRounding, ImDrawFlags_RoundCornersTop);
            (void)headerTop;
        }
    }

    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);
        m_linkIdToInfo[linkIdValue] = LinkInfo{link.destNode, link.destInput};

        ImU32 linkColor = IM_COL32(200, 200, 200, 255);
        auto pinIt = m_pinIdToInfo.find(startPinId);
        if (pinIt != m_pinIdToInfo.end())
            linkColor = GetPinColor(pinIt->second.typeName);

        // 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();

    NE::Suspend();
    if (openNodeSearch) {
        m_pendingCreateNodePos = ImGui::GetMousePos();
        ImGui::OpenPopup("CreateNodeSearch");
    }
    if (ImGui::BeginPopup("CreateNodeSearch")) {
        RenderNodeSearchMenu(NE::ScreenToCanvas(m_pendingCreateNodePos));
        ImGui::EndPopup();
    }
    if (m_openLinkDragMenu) {
        ImGui::OpenPopup("LinkDragCreateNode");
        m_openLinkDragMenu = false;
    }
    if (ImGui::BeginPopup("LinkDragCreateNode")) {
        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
    // moves it -> release -> "click" -> selection updates -> position save.
    // Disabled: flip to #if 1 to re-enable the [NodeGraph] trace while
    // diagnosing the node-editor drag regression.
#if 0
    {
        // Logs the mouse position and every node's editor rect with a hit
        // verdict, and returns the hit paths. Topmost is unknown to us, but
        // the full table makes overlap fights directly visible in the log.
        // hitIds, when given, collects the editor ids of the hit nodes.
        auto nodeRectsAtMouse = [this](std::vector<uintptr_t>* hitIds) {
            std::string hits;
            const ImVec2 screen = ImGui::GetMousePos();
            const ImVec2 mouse  = NE::ScreenToCanvas(screen);
            char buf[512];
            std::snprintf(buf, sizeof(buf),
                          "[NodeGraph] hit test: mouse screen (%.1f, %.1f) canvas (%.1f, %.1f), %zu node(s)",
                          screen.x, screen.y, mouse.x, mouse.y, m_graph.nodes.size());
            LOG_INFO(std::string(buf));
            for (const auto& node : m_graph.nodes) {
                NE::NodeId nodeId(HashId(node.path.GetString()));
                const ImVec2 pos  = NE::GetNodePosition(nodeId);
                const ImVec2 size = NE::GetNodeSize(nodeId);
                if (pos.x == FLT_MAX || size.x <= 0.0f) {
                    LOG_INFO("[NodeGraph]   " + node.path.GetString() +
                             ": no editor rect yet (not rendered)");
                    continue;
                }
                const bool hit = mouse.x >= pos.x && mouse.x <= pos.x + size.x &&
                                 mouse.y >= pos.y && mouse.y <= pos.y + size.y;
                std::snprintf(buf, sizeof(buf),
                              "[NodeGraph]   %s: rect min (%.1f, %.1f) size (%.1f x %.1f)%s",
                              node.path.GetText(), pos.x, pos.y, size.x, size.y,
                              hit ? "  <-- HIT" : "");
                LOG_INFO(std::string(buf));
                if (hit) {
                    hits += (hits.empty() ? "" : ", ") + node.path.GetString();
                    if (hitIds)
                        hitIds->push_back(nodeId.Get());
                }
            }
            return hits;
        };

        // Editor NodeIds of the currently selected nodes.
        auto selectedNodeIds = []() {
            std::vector<uintptr_t> ids;
            const int count = NE::GetSelectedObjectCount();
            if (count > 0) {
                std::vector<NE::NodeId> sel(static_cast<size_t>(count));
                const int nodeCount = NE::GetSelectedNodes(sel.data(), count);
                ids.reserve(static_cast<size_t>(nodeCount));
                for (int i = 0; i < nodeCount; ++i)
                    ids.push_back(sel[static_cast<size_t>(i)].Get());
            }
            return ids;
        };
        auto nodeNames = [this](const std::vector<uintptr_t>& ids) {
            std::string names;
            for (uintptr_t idValue : ids) {
                auto it = m_nodeIdToPath.find(idValue);
                names += (names.empty() ? "" : ", ");
                names += (it != m_nodeIdToPath.end()) ? it->second.GetName() : "<unknown>";
            }
            return names;
        };
        auto containsAnyOf = [](const std::vector<uintptr_t>& haystack,
                                const std::vector<uintptr_t>& needles) {
            for (uintptr_t needle : needles)
                if (std::find(haystack.begin(), haystack.end(), needle) != haystack.end())
                    return true;
            return false;
        };

        if (canvasHovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
            m_debugDragNodeIds.clear();
            const std::string hits = nodeRectsAtMouse(&m_debugDragNodeIds);
            m_debugPressOnNode = !hits.empty();
            m_debugDragLogged  = false;
            if (m_debugPressOnNode) {
                // Pressing an already-selected node keeps the selection (no
                // clear, no rect-select) so a drag moves the whole group —
                // the editor only updates selection on release.
                const std::vector<uintptr_t> selected = selectedNodeIds();
                std::string msg = "[NodeGraph] LMB press over node rect(s): " + hits;
                if (selected.size() > 1 && containsAnyOf(selected, m_debugDragNodeIds))
                    msg += " [in current selection of " + std::to_string(selected.size()) +
                           " -> drag moves all selected]";
                LOG_INFO(msg);
            } else {
                LOG_INFO("[NodeGraph] LMB press on empty canvas (rect select / clear selection on release)");
            }
        }
        if (m_debugPressOnNode && !m_debugDragLogged &&
            ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
            m_debugDragLogged = true;
            // Mirror DragAction::Accept: dragging a selected node moves the
            // whole selection, so trace every group member from here on.
            const std::vector<uintptr_t> selected = selectedNodeIds();
            if (selected.size() > 1 && containsAnyOf(selected, m_debugDragNodeIds)) {
                m_debugDragNodeIds = selected;
                LOG_INFO("[NodeGraph] drag start: group move of " +
                         std::to_string(selected.size()) + " selected node(s): " +
                         nodeNames(m_debugDragNodeIds));
            } else {
                LOG_INFO("[NodeGraph] drag start (ready to move), cursor over: " + nodeRectsAtMouse(nullptr));
            }
        }
        // Per-move trace while a drag is live, only on frames the mouse
        // actually moved: mouse position, accumulated drag delta, and the
        // live editor position of every tracked node — the whole selection
        // for a group drag, else the node(s) hit at press time (a node that
        // stops tracking the delta shows up immediately).
        if (m_debugDragLogged && ImGui::IsMouseDragging(ImGuiMouseButton_Left) &&
            (io.MouseDelta.x != 0.0f || io.MouseDelta.y != 0.0f)) {
            const ImVec2 screen = ImGui::GetMousePos();
            const ImVec2 canvas = NE::ScreenToCanvas(screen);
            const ImVec2 delta  = ImGui::GetMouseDragDelta(ImGuiMouseButton_Left, 0.0f);
            char buf[512];
            std::snprintf(buf, sizeof(buf),
                          "[NodeGraph] drag move: mouse screen (%.1f, %.1f) canvas (%.1f, %.1f) dragDelta (%.1f, %.1f)",
                          screen.x, screen.y, canvas.x, canvas.y, delta.x, delta.y);
            std::string msg = buf;
            for (uintptr_t idValue : m_debugDragNodeIds) {
                const ImVec2 nodePos = NE::GetNodePosition(NE::NodeId(idValue));
                auto it = m_nodeIdToPath.find(idValue);
                std::snprintf(buf, sizeof(buf), "; %s at (%.1f, %.1f)",
                              it != m_nodeIdToPath.end() ? it->second.GetName().c_str() : "<unknown>",
                              nodePos.x, nodePos.y);
                msg += buf;
            }
            LOG_INFO(msg);
        }
        if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) &&
            (m_debugPressOnNode || m_debugDragLogged)) {
            LOG_INFO(std::string("[NodeGraph] LMB release") +
                     (m_debugDragLogged ? " (ends drag; position save should follow)" : " (click, no drag)"));
            m_debugPressOnNode = false;
            m_debugDragLogged  = false;
            m_debugDragNodeIds.clear();
        }

        // Covers single-click selection and rubber-band rect multi-select
        // alike; fires on the frame the editor's selection list changes.
        if (NE::HasSelectionChanged())
        {
            const int count = NE::GetSelectedObjectCount();
            std::vector<NE::NodeId> selected(static_cast<size_t>(std::max(count, 1)));
            const int nodeCount = NE::GetSelectedNodes(selected.data(), count);
            std::string msg = "[NodeGraph] selection changed: " + std::to_string(nodeCount) + " node(s)";
            for (int i = 0; i < nodeCount; ++i) {
                auto it = m_nodeIdToPath.find(selected[static_cast<size_t>(i)].Get());
                msg += (i == 0 ? ": " : ", ");
                msg += (it != m_nodeIdToPath.end()) ? it->second.GetString() : "<unknown>";
            }
            LOG_INFO(msg);
        }
    }
#endif

    // Mirror the editor's node selection (first selected node) for the
    // properties section under the preview.
    m_selectedNodePath = pxr::SdfPath();
    NE::NodeId selectedNode;
    if (NE::GetSelectedNodes(&selectedNode, 1) >= 1) {
        auto it = m_nodeIdToPath.find(selectedNode.Get());
        if (it != m_nodeIdToPath.end())
            m_selectedNodePath = it->second;
    }

    NE::SetCurrentEditor(nullptr);
}

void MaterialEditorPanel::HandleCreateAndDelete() {
    if (NE::BeginCreate()) {
        NE::PinId startId, endId;
        if (NE::QueryNewLink(&startId, &endId) && startId && endId) {
            auto startIt = m_pinIdToInfo.find(startId.Get());
            auto endIt   = m_pinIdToInfo.find(endId.Get());
            bool valid = startIt != m_pinIdToInfo.end() && endIt != m_pinIdToInfo.end() &&
                         startIt->second.isOutput != endIt->second.isOutput &&
                         startIt->second.nodePath != endIt->second.nodePath;
            if (valid) {
                if (NE::AcceptNewItem()) {
                    const PinInfo& a = startIt->second;
                    const PinInfo& b = endIt->second;
                    const PinInfo& outPin = a.isOutput ? a : b;
                    const PinInfo& inPin  = a.isOutput ? b : a;
                    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();
            }
        }

        // Nuke-style drag-to-create: a link dragged onto empty canvas (rather
        // than another pin) surfaces here. On release, remember the source pin
        // 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() && !it->second.isMeta && NE::AcceptNewItem()) {
                m_linkDragSourcePin    = it->second;
                m_openLinkDragMenu     = true;
                m_pendingCreateNodePos = ImGui::GetMousePos();
            }
        }
        NE::EndCreate();
    }

    if (NE::BeginDelete()) {
        NE::NodeId nodeId;
        while (NE::QueryDeletedNode(&nodeId)) {
            if (NE::AcceptDeletedItem()) {
                auto it = m_nodeIdToPath.find(nodeId.Get());
                if (it != m_nodeIdToPath.end())
                    DeleteNode(it->second);
            }
        }
        NE::LinkId linkId;
        while (NE::QueryDeletedLink(&linkId)) {
            if (NE::AcceptDeletedItem()) {
                auto it = m_linkIdToInfo.find(linkId.Get());
                if (it != m_linkIdToInfo.end())
                    DisconnectAttr(it->second.destNode, it->second.destInput);
            }
        }
        NE::EndDelete();
    }
}

bool MaterialEditorPanel::IsPinLinked(const pxr::SdfPath& nodePath, const std::string& pinName, bool isOutput) const {
    for (const auto& link : m_graph.links) {
        if (isOutput) {
            if (link.sourceNode == nodePath && link.sourceOutput == pinName) return true;
        } else {
            if (link.destNode == nodePath && link.destInput == pinName) return true;
        }
    }
    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());
    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::CyclePinDisplayMode(const pxr::SdfPath& nodePath) {
    PinDisplayOverride& ov = GetPinDisplayOverride(nodePath);
    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);
}

void MaterialEditorPanel::ApplyPinDisplayModeToAllNodes(PinDisplayMode mode) {
    for (const auto& node : m_graph.nodes) {
        PinDisplayOverride& ov = GetPinDisplayOverride(node.path);
        if (ov.mode != mode) {
            ov.mode = mode;
            PersistPinDisplayState(node.path);
        }
    }
}

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)
        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");
        return;
    }

    // The new node must expose a pin of the opposite direction to the pin the
    // link was dragged from.
    const bool wantOutput = linkSource && !linkSource->isOutput;
    if (linkSource) {
        ImGui::TextDisabled("Connect %s %s to a new node:",
                            linkSource->isOutput ? "output" : "input", linkSource->name.c_str());
    }

    if (ImGui::IsWindowAppearing()) {
        m_nodeSearchBuf[0]   = '\0';
        m_nodeSearchSelected = 0;
        ImGui::SetKeyboardFocusHere();
    }
    ImGui::SetNextItemWidth(300.0f);
    const bool commit = ImGui::InputText("##NodeSearchQuery", m_nodeSearchBuf, sizeof(m_nodeSearchBuf),
                                         ImGuiInputTextFlags_EnterReturnsTrue);
    if (ImGui::IsItemEdited())
        m_nodeSearchSelected = 0;

    auto matches =
        FilterShaderNodeTypes(m_materialManager->GetAvailableShaderNodes(), m_nodeSearchBuf);
    if (linkSource) {
        // Keep only nodes with a pin type-connectable to the dragged pin.
        std::string pn; pxr::SdfValueTypeName pt;
        matches.erase(std::remove_if(matches.begin(), matches.end(),
                          [&](const ShaderNodeTypeInfo* info) {
                              return !BestConnectablePin(info->identifier, wantOutput,
                                                         linkSource->typeName, pn, pt);
                          }),
                      matches.end());
    }

    const bool movedDown = ImGui::IsKeyPressed(ImGuiKey_DownArrow);
    const bool movedUp   = ImGui::IsKeyPressed(ImGuiKey_UpArrow);
    if (movedDown) ++m_nodeSearchSelected;
    if (movedUp)   --m_nodeSearchSelected;
    if (matches.empty())
        m_nodeSearchSelected = 0;
    else
        m_nodeSearchSelected = std::clamp(m_nodeSearchSelected, 0, static_cast<int>(matches.size()) - 1);

    const ShaderNodeTypeInfo* chosen = nullptr;
    if (commit && !matches.empty())
        chosen = matches[m_nodeSearchSelected];

    ImGui::BeginChild("NodeSearchResults", ImVec2(300.0f, 260.0f), false);
    if (matches.empty())
        ImGui::TextDisabled("No matching nodes");
    for (int i = 0; i < static_cast<int>(matches.size()); ++i) {
        const ShaderNodeTypeInfo* info = matches[i];
        std::string itemLabel =
            info->label + "  [" + info->source + "/" + info->category + "]##" + std::to_string(i);
        const bool selected = (i == m_nodeSearchSelected);
        if (ImGui::Selectable(itemLabel.c_str(), selected))
            chosen = info;
        if (selected && (movedDown || movedUp))
            ImGui::SetScrollHereY();
    }
    ImGui::EndChild();

    if (chosen) {
        if (linkSource) {
            const pxr::SdfPath newPath = CreateShaderNode(chosen->identifier, canvasPos);
            std::string pinName; pxr::SdfValueTypeName pinType;
            if (!newPath.IsEmpty() &&
                BestConnectablePin(chosen->identifier, wantOutput, linkSource->typeName,
                                   pinName, pinType)) {
                const PinInfo newPin{newPath, pinName, wantOutput, pinType};
                // dest is the input pin, source the output pin.
                if (linkSource->isOutput)
                    CreateConnection(newPin, *linkSource);
                else
                    CreateConnection(*linkSource, newPin);
            }
        } else {
            CreateShaderNode(chosen->identifier, canvasPos);
        }
        ImGui::CloseCurrentPopup();
    }
}

ImVec2 MaterialEditorPanel::FindFreeCanvasSpot(ImVec2 desired, const std::string& shaderId) const {
    // Estimated footprint of the not-yet-rendered node: same height model as
    // ApplyFallbackLayout, width covering typical icon+label pin rows.
    auto estimateSize = [](size_t pinCount) {
        return ImVec2(340.0f, static_cast<float>(pinCount) * 24.0f + 80.0f);
    };
    size_t newPinCount = 6;
    if (pxr::SdrShaderNodeConstPtr sdrNode =
            pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(pxr::TfToken(shaderId)))
        newPinCount = sdrNode->GetShaderInputNames().size() + sdrNode->GetShaderOutputNames().size();
    const ImVec2 newSize = estimateSize(newPinCount);

    struct Rect { ImVec2 pos, size; };
    std::vector<Rect> rects;
    rects.reserve(m_graph.nodes.size());
    for (const auto& node : m_graph.nodes) {
        Rect r{ImVec2(node.uiPosition[0], node.uiPosition[1]),
               estimateSize(node.inputs.size() + node.outputs.size())};
        // Nodes the editor has already rendered report their real rectangle.
        NE::NodeId nodeId(HashId(node.path.GetString()));
        const ImVec2 livePos  = NE::GetNodePosition(nodeId);
        const ImVec2 liveSize = NE::GetNodeSize(nodeId);
        if (livePos.x != FLT_MAX && liveSize.x > 0.0f) {
            r.pos  = livePos;
            r.size = liveSize;
        }
        rects.push_back(r);
    }

    const float margin = 24.0f;
    bool moved = true;
    while (moved) {
        moved = false;
        for (const Rect& r : rects) {
            const bool overlaps =
                desired.x < r.pos.x + r.size.x + margin && r.pos.x < desired.x + newSize.x + margin &&
                desired.y < r.pos.y + r.size.y + margin && r.pos.y < desired.y + newSize.y + margin;
            if (overlaps) {
                // March right past the blocker; x only grows, so the scan
                // terminates once it clears the rightmost overlapping node.
                desired.x = r.pos.x + r.size.x + margin;
                moved = true;
            }
        }
    }
    return desired;
}

pxr::SdfPath MaterialEditorPanel::CreateShaderNode(const std::string& shaderId, const ImVec2& canvasPos) {
    if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return pxr::SdfPath();

    std::string baseName = SanitizeUsdName(shaderId);
    if (baseName.empty()) baseName = "Shader";

    std::string finalName = baseName;
    int suffix = 1;
    while (m_stage->GetPrimAtPath(m_materialPath.AppendChild(pxr::TfToken(finalName))).IsValid())
        finalName = baseName + "_" + std::to_string(++suffix);

    pxr::SdfPath path = m_materialPath.AppendChild(pxr::TfToken(finalName));
    const ImVec2 freePos = FindFreeCanvasSpot(canvasPos, shaderId);
    pxr::GfVec2f pos(freePos.x, freePos.y);

    m_commandHistory->Push(std::make_unique<CreateShaderNodeCommand>(m_stage, path, shaderId, pos));
    SyncFromUsd();
    return path;
}

void MaterialEditorPanel::CreateConnection(const PinInfo& destInput, const PinInfo& sourceOutput) {
    if (!m_stage || !m_commandHistory) return;

    // Captured now so Undo() (which may run after the ambient edit target
    // has since changed, e.g. a render-layer switch) still targets the same
    // layer this connection was authored into.
    pxr::SdfLayerHandle editLayer = m_stage->GetEditTarget().GetLayer();
    m_commandHistory->Push(std::make_unique<ConnectShaderAttrsCommand>(
        m_stage, destInput.nodePath, destInput.name, destInput.typeName,
        sourceOutput.nodePath, sourceOutput.name, sourceOutput.typeName, editLayer));
    SyncFromUsd();
}

void MaterialEditorPanel::DeleteNode(const pxr::SdfPath& nodePath) {
    if (!m_stage || !m_commandHistory) return;
    m_commandHistory->Push(std::make_unique<DeletePrimCommand>(m_stage, nodePath));
    SyncFromUsd();
}

void MaterialEditorPanel::DisconnectAttr(const pxr::SdfPath& destNode, const std::string& destInput) {
    if (!m_stage || !m_commandHistory) return;
    pxr::SdfLayerHandle editLayer = m_stage->GetEditTarget().GetLayer();
    m_commandHistory->Push(std::make_unique<DisconnectShaderAttrCommand>(m_stage, destNode, destInput, editLayer));
    SyncFromUsd();
}

std::string MaterialEditorPanel::ResolveTextureFilePath(const ShaderGraphNode& node) const {
    if (!m_stage) return {};
    // UsdUVTexture and MaterialX image/tiledimage all expose the source as an
    // Asset-typed `file` input; prefer that name, else any Asset input.
    std::string inputName;
    for (const auto& in : node.inputs) {
        if (in.typeName == pxr::SdfValueTypeNames->Asset) {
            inputName = in.name;
            if (in.name == "file") break;
        }
    }
    if (inputName.empty()) return {};

    pxr::UsdPrim prim = m_stage->GetPrimAtPath(node.path);
    if (!prim) return {};
    pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + inputName));
    pxr::VtValue v;
    if (!attr || !attr.Get(&v) || !v.IsHolding<pxr::SdfAssetPath>()) return {};
    const pxr::SdfAssetPath ap = v.UncheckedGet<pxr::SdfAssetPath>();
    // Resolved path for on-disk load; fall back to the authored path (may
    // already be absolute) when the resolver returns nothing.
    return ap.GetResolvedPath().empty() ? ap.GetAssetPath() : ap.GetResolvedPath();
}

void MaterialEditorPanel::PersistNodePosition(NE::NodeId nodeId) {
    if (!m_stage || !m_commandHistory) return;

    auto it = m_nodeIdToPath.find(nodeId.Get());
    if (it == m_nodeIdToPath.end()) return;

    pxr::SdfPath path = it->second;
    pxr::UsdPrim prim = m_stage->GetPrimAtPath(path);
    if (!prim.IsValid()) return;

    static const pxr::TfToken kUiPositionKey("uiPosition");

    ImVec2 newPos = NE::GetNodePosition(nodeId);
    pxr::GfVec2f newValue(newPos.x, newPos.y);

    // A Save echoed by our own seeding is a no-op: the editor position still
    // matches what the current snapshot prescribes (authored or fallback
    // auto-layout). Only a real drag diverges from it and should author.
    for (const auto& node : m_graph.nodes) {
        if (node.path == path) {
            if (node.uiPosition == newValue) {
                // LOG_INFO("[NodeGraph] position save for " + path.GetString() +
                //          " ignored (echo of snapshot seed)");
                return;
            }
            break;
        }
    }

    pxr::VtValue oldValueVt = prim.GetCustomDataByKey(kUiPositionKey);
    pxr::GfVec2f oldValue = oldValueVt.IsHolding<pxr::GfVec2f>()
        ? oldValueVt.UncheckedGet<pxr::GfVec2f>()
        : pxr::GfVec2f(0.0f, 0.0f);

    if (oldValue == newValue) {
        // LOG_INFO("[NodeGraph] position save for " + path.GetString() +
        //          " ignored (customData already matches)");
        return;
    }

    // char moveMsg[256];
    // std::snprintf(moveMsg, sizeof(moveMsg),
    //               "[NodeGraph] move committed: %s (%.1f, %.1f) -> (%.1f, %.1f)",
    //               path.GetText(), oldValue[0], oldValue[1], newValue[0], newValue[1]);
    // LOG_INFO(std::string(moveMsg));

    pxr::UsdStageRefPtr stage = m_stage;
    m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
        "Move " + path.GetName(),
        [stage, path, newValue]() {
            pxr::UsdPrim p = stage->GetPrimAtPath(path);
            if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(newValue));
        },
        [stage, path, oldValue]() {
            pxr::UsdPrim p = stage->GetPrimAtPath(path);
            if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(oldValue));
        }));
}

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 kPinModeKey("uiPinDisplayMode");

    const int newMode = static_cast<int>(GetPinDisplayOverride(nodePath).mode);

    pxr::VtValue oldModeVt = prim.GetCustomDataByKey(kPinModeKey);
    const int oldMode = oldModeVt.IsHolding<int>() ? oldModeVt.UncheckedGet<int>() : static_cast<int>(PinDisplayMode::All);

    if (oldMode == newMode)
        return;

    pxr::UsdStageRefPtr stage = m_stage;
    m_commandHistory->Push(std::make_unique<AttributeSetCommand>(
        "Change pin display for " + nodePath.GetName(),
        [stage, nodePath, newMode]() {
            pxr::UsdPrim p = stage->GetPrimAtPath(nodePath);
            if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPinDisplayMode"), pxr::VtValue(newMode));
        },
        [stage, nodePath, oldMode]() {
            pxr::UsdPrim p = stage->GetPrimAtPath(nodePath);
            if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPinDisplayMode"), pxr::VtValue(oldMode));
        }));
}

bool MaterialEditorPanel::SaveNodeSettingsCallback(NE::NodeId nodeId, const char* /*data*/, size_t /*size*/,
                                                   NE::SaveReasonFlags reason, void* userPointer) {
    auto* self = static_cast<MaterialEditorPanel*>(userPointer);
    if (self && (reason & NE::SaveReasonFlags::Position) != NE::SaveReasonFlags::None)
        self->PersistNodePosition(nodeId);
    return true;
}

} // namespace UsdLayerManager
