#include "panels/CanvasPanel.h"
#include "MainFrame.h"
#include "io/CatalogLoader.h"
#include "io/SceneryCatalog.h"
#include "panels/PropertiesPanel.h"
#include "model/HelixVariant.h"
#include <wx/dcbuffer.h>
#include <wx/spinctrl.h>
#include <wx/statline.h>
#include <wx/clrpicker.h>
#include <cmath>
#include <set>
#include <unordered_set>
#include <queue>
#include <unordered_map>
#include <functional>

// ── Rubber-band geometric selection helpers ────────────────────────────────────
// Tests whether a line segment intersects (or is contained within) an
// axis-aligned rectangle. Standard Cohen-Sutherland style outcode clipping:
// each endpoint gets a 4-bit outcode (left/right/top/bottom of the rect),
// trivial accept if either endpoint is inside, trivial reject if both
// endpoints share an outside region, otherwise clip iteratively.
static bool SegmentIntersectsRect(wxPoint a, wxPoint b,
                                   int rx1, int ry1, int rx2, int ry2)
{
    auto outcode = [&](wxPoint p) {
        int code = 0;
        if (p.x < rx1) code |= 1;
        else if (p.x > rx2) code |= 2;
        if (p.y < ry1) code |= 4;
        else if (p.y > ry2) code |= 8;
        return code;
    };

    double x0 = a.x, y0 = a.y, x1 = b.x, y1 = b.y;
    int oc0 = outcode({(int)x0, (int)y0});
    int oc1 = outcode({(int)x1, (int)y1});

    while (true) {
        if (!(oc0 | oc1)) return true;           // both inside
        if (oc0 & oc1)    return false;           // both outside same side

        int ocOut = oc0 ? oc0 : oc1;
        double x, y;
        if (ocOut & 8) {       // below
            x = x0 + (x1 - x0) * (ry2 - y0) / (y1 - y0);
            y = ry2;
        } else if (ocOut & 4) { // above
            x = x0 + (x1 - x0) * (ry1 - y0) / (y1 - y0);
            y = ry1;
        } else if (ocOut & 2) { // right
            y = y0 + (y1 - y0) * (rx2 - x0) / (x1 - x0);
            x = rx2;
        } else {                // left
            y = y0 + (y1 - y0) * (rx1 - x0) / (x1 - x0);
            x = rx1;
        }

        if (ocOut == oc0) { x0 = x; y0 = y; oc0 = outcode({(int)x0, (int)y0}); }
        else               { x1 = x; y1 = y; oc1 = outcode({(int)x1, (int)y1}); }
    }
}

// Builds the world-space centerline points that visually represent a placed
// piece, mirroring the geometry the Renderer actually draws (see
// Renderer.cpp StraightPoly/CurvePoly). Straights get 2 points; curves are
// sampled into several segments along the arc centerline so an angled or
// curved piece is tested by its true drawn shape, not a single anchor point
// or its much-larger axis-aligned bounding box. Turnouts contribute both
// the straight and diverging routes since both are visually drawn.
static void BuildPiecePathPoints(const MC::PlacedPiece& p, const MC::TrackPiece& def,
                                  std::vector<std::vector<MC::Vec2>>& outRoutes)
{
    double rad = p.transform.heading * M_PI / 180.0;
    MC::Vec2 dir{ std::cos(rad), std::sin(rad) };
    MC::Vec2 entry = p.transform.pos;

    // Route 0: straight/main run from entry, length = def.lengthMm.
    // For curved pieces (radiusMm > 0), sample the arc centerline instead
    // of a straight chord, matching CurvePoly's worldArcPt geometry.
    if (def.radiusMm > 0 && def.angleDeg != 0) {
        double angleRad = def.angleDeg * M_PI / 180.0;
        double centreY  = (def.angleDeg >= 0) ? def.radiusMm : -def.radiusMm;
        double startA   = (def.angleDeg >= 0) ? -M_PI/2.0 : M_PI/2.0;
        int segments = std::max(6, (int)(std::abs(def.angleDeg) / 5.0));

        std::vector<MC::Vec2> route;
        for (int i = 0; i <= segments; ++i) {
            double t = (double)i / segments;
            double a = startA + t * angleRad;
            // Local point relative to piece origin (entry at 0,0), matching
            // Renderer.cpp's worldArcPt exactly: local = centre + radius*(cos,sin).
            // (Earlier version incorrectly subtracted centreY a second time,
            // which shifted the whole arc off by one full radius -- that bug
            // made 24224 and similar pieces fail every rubber-band test
            // since their sampled points landed far outside the piece.)
            MC::Vec2 local{ def.radiusMm * std::cos(a), centreY + def.radiusMm * std::sin(a) };
            double wx = entry.x + local.x * std::cos(rad) - local.y * std::sin(rad);
            double wy = entry.y + local.x * std::sin(rad) + local.y * std::cos(rad);
            route.push_back({wx, wy});
        }
        outRoutes.push_back(std::move(route));
    } else {
        MC::Vec2 exit{ entry.x + dir.x * def.lengthMm, entry.y + dir.y * def.lengthMm };
        outRoutes.push_back({ entry, exit });
    }

    // Turnouts: a third connection point (conn[2], the diverging route)
    // means there's a second visually-drawn branch from the same entry.
    // Approximate it as a straight chord to the branch connector's world
    // position -- close enough for a hit test even though the actual
    // branch may have its own curvature, since we just need "does the
    // rectangle touch the drawn branch."
    if (def.connections.size() > 2) {
        MC::ConnectionPoint branchConn = p.worldConnection(def, 2);
        outRoutes.push_back({ entry, branchConn.pos });
    }
}

// Returns true if the user OK'd the deletion (or no prompt was needed).
// Prompts only when the current selection includes at least one piece with
// a non-zero grade (i.e. a member of an elevation chain). The pieces that
// would be expanded into the selection by pair-partner logic are NOT
// considered here — by the time a partner gets pulled in, the user has
// already explicitly chosen something graded.
static bool ConfirmGradedDeletion(wxWindow* parent, const MC::CanvasState& state)
{
    bool anyGraded = false;
    for (const auto& p : state.pieces) {
        if (p.selected && std::abs(p.gradeDeg) > 0.001) { anyGraded = true; break; }
    }
    if (!anyGraded) return true;

    int reply = wxMessageBox(
        "Selection includes graded pieces. Delete?",
        "Delete",
        wxYES_NO | wxICON_QUESTION,
        parent);
    return reply == wxYES;
}

// Warn the user (once per session) that moving graded pieces will break
// joints and leave chain elevation inconsistent. Returns true to allow
// the move, false to cancel. After the first Yes, the flag latches and
// subsequent calls return true without prompting again.
static bool ConfirmGradedMoveOnce(wxWindow* parent, const MC::CanvasState& state)
{
    static bool s_sessionAcknowledged = false;
    if (s_sessionAcknowledged) return true;

    bool anyGraded = false;
    for (const auto& p : state.pieces) {
        if (p.selected && std::abs(p.gradeDeg) > 0.001) { anyGraded = true; break; }
    }
    if (!anyGraded) return true;

    int reply = wxMessageBox(
        "Selection includes graded pieces. Moving will break joints "
        "and leave the chain's elevation inconsistent.\n\n"
        "Move anyway? (This warning will not appear again this session.)",
        "Move",
        wxYES_NO | wxICON_QUESTION | wxNO_DEFAULT,
        parent);
    if (reply == wxYES) {
        s_sessionAcknowledged = true;
        return true;
    }
    return false;
}

// Returns true if any selected piece is part of a Tunnel or Helix, or any
// selected scenery is a tunnel portal.
static bool SelectionTouchesTunnel(const MC::CanvasState& state)
{
    for (const auto& p : state.pieces) {
        if (p.selected) {
            if (p.helixId != -1) return true;
            if (state.IsPieceInTunnel(p.id)) return true;
        }
    }
    for (const auto& s : state.scenery) {
        if (s.selected && state.IsPortalScenery(s.id)) return true;
    }
    return false;
}

// Pops a warning when the user tries to modify tunnel pieces, portals, or
// helix-owned pieces. Returns true if the operation may proceed.
static bool WarnIfTunneled(wxWindow* parent, const MC::CanvasState& state,
                            const char* verb)
{
    if (!SelectionTouchesTunnel(state)) return true;
    bool helixHit = false;
    for (const auto& p : state.pieces)
        if (p.selected && p.helixId != -1) { helixHit = true; break; }
    wxString msg;
    if (helixHit) {
        msg = wxString::Format(
            "Selection includes track that is part of a helix. "
            "Helix track cannot be %sed individually.", verb);
    } else {
        msg = wxString::Format(
            "Selection includes tunnel track or portals. "
            "Remove the tunnel first, then %s.", verb);
    }
    wxMessageBox(msg, helixHit ? "Helix track is locked" : "Tunnel is locked",
                  wxOK | wxICON_INFORMATION, parent);
    return false;
}

// If the given piece is part of a helix, select ALL pieces in that helix.
// Returns true if a helix selection was performed.
static bool SelectHelixSiblings(MC::CanvasState& state, int hitPieceId)
{
    MC::PlacedPiece* hit = state.FindById(hitPieceId);
    if (!hit || hit->helixId == -1) return false;
    int hxId = hit->helixId;
    for (auto& p : state.pieces)
        if (p.helixId == hxId) p.selected = true;
    return true;
}

// If the clicked piece is a 74861 turntable, also select every piece
// joined to one of its spoke connectors (the 6 included 74871 stubs and
// any extras the user added). Moving the turntable then carries the
// stubs with it instead of breaking the joints. Returns true if a
// turntable was matched.
static bool SelectTurntableStubs(MC::CanvasState& state, int hitPieceId)
{
    MC::PlacedPiece* hit = state.FindById(hitPieceId);
    if (!hit || hit->catalogNumber != "74861") return false;
    hit->selected = true;
    for (const auto& j : hit->joints) {
        if (j.pieceId < 0) continue;
        MC::PlacedPiece* nb = state.FindById(j.pieceId);
        if (nb) nb->selected = true;
    }
    return true;
}

// If the current selection is exactly one complete helix (all selected
// pieces share the same non-empty helixId and no scenery is selected),
// returns the helix id; otherwise returns -1.
static int SelectionSingleHelixId(const MC::CanvasState& state)
{
    for (const auto& s : state.scenery)
        if (s.selected) return -1;
    int hxId = -1;
    bool anyPiece = false;
    for (const auto& p : state.pieces) {
        if (!p.selected) continue;
        anyPiece = true;
        if (p.helixId == -1) return -1;
        if (hxId == -1) hxId = p.helixId;
        else if (hxId != p.helixId) return -1;
    }
    return anyPiece ? hxId : -1;
}

// A helix must always be selected as a complete unit. If any of its 4
// stubs is currently selected, promote the other 3 to selected too.
// Returns true if the selection set was expanded.
static bool PromotePartialHelixSelections(MC::CanvasState& state)
{
    // Collect helixIds that have at least one selected stub.
    std::set<int> touchedHelices;
    for (const auto& p : state.pieces)
        if (p.selected && p.helixId != -1)
            touchedHelices.insert(p.helixId);
    if (touchedHelices.empty()) return false;
    bool changed = false;
    for (auto& p : state.pieces) {
        if (p.helixId == -1) continue;
        if (touchedHelices.count(p.helixId) && !p.selected) {
            p.selected = true;
            changed = true;
        }
    }
    return changed;
}

wxBEGIN_EVENT_TABLE(CanvasPanel, wxPanel)
    EVT_PAINT(CanvasPanel::OnPaint)
    EVT_SIZE(CanvasPanel::OnSize)
    EVT_MOTION(CanvasPanel::OnMouseMove)
    EVT_LEFT_DCLICK(CanvasPanel::OnLeftDblClick)
    EVT_LEFT_DOWN(CanvasPanel::OnLeftDown)
    EVT_LEFT_UP(CanvasPanel::OnLeftUp)
    EVT_RIGHT_DOWN(CanvasPanel::OnRightDown)
    EVT_RIGHT_UP(CanvasPanel::OnRightUp)
    EVT_MIDDLE_DOWN(CanvasPanel::OnMiddleDown)
    EVT_MIDDLE_UP(CanvasPanel::OnMiddleUp)
    EVT_MOUSEWHEEL(CanvasPanel::OnMouseWheel)
    EVT_KEY_DOWN(CanvasPanel::OnKeyDown)
    EVT_TIMER(wxID_ANY, CanvasPanel::OnRotSnapTimer)
    EVT_LEAVE_WINDOW(CanvasPanel::OnMouseLeave)
wxEND_EVENT_TABLE()

CanvasPanel::CanvasPanel(wxWindow* parent, MainFrame* frame)
    : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize,
              wxWANTS_CHARS | wxFULL_REPAINT_ON_RESIZE)
    , m_frame(frame)
{
    SetBackgroundStyle(wxBG_STYLE_PAINT);
    ResetView();

    // Accelerator table so Delete works even without perfect focus
    wxAcceleratorEntry entries[2];
    entries[0].Set(wxACCEL_NORMAL, WXK_DELETE, wxID_DELETE);
    entries[1].Set(wxACCEL_NORMAL, WXK_BACK,   wxID_DELETE);
    SetAcceleratorTable(wxAcceleratorTable(2, entries));
    Bind(wxEVT_MENU, [this](wxCommandEvent&) {
        auto& state = m_frame->State();
        bool hadSel = state.HasSelection();
        bool hadLabel = false;
        for (const auto& l : state.labels) if (l.selected) { hadLabel = true; break; }
        bool hadMeasure = false;
        for (const auto& m : state.measurements) if (m.selected) { hadMeasure = true; break; }
        bool hadShape = false;
        for (const auto& s : state.shapes) if (s.selected) { hadShape = true; break; }
        if (hadSel || hadLabel || hadMeasure || hadShape) {
            // A full-helix selection bypasses the guard so the user can
            // delete the helix as one unit.
            if (SelectionSingleHelixId(state) == -1) {
                if (!WarnIfTunneled(this, state, "delete")) return;
            }
            if (!ConfirmGradedDeletion(this, state)) return;
            // Expand selection to include pair partners
            for (auto& p : state.pieces) {
                if (!p.selected) continue;
                const MC::TrackPiece* def = MC::GetCatalog().Find(p.catalogNumber);
                if (!def || !def->pairPiece) continue;
                for (auto& j : p.joints) {
                    if (j.pieceId < 0) continue;
                    MC::PlacedPiece* partner = state.FindById(j.pieceId);
                    if (partner) {
                        const MC::TrackPiece* pd = MC::GetCatalog().Find(partner->catalogNumber);
                        if (pd && pd->pairPiece) partner->selected = true;
                    }
                }
            }
            // Use DeleteSelected which properly cleans up joint refs in remaining pieces
            state.DeleteSelected();
            m_frame->OnSelectionChanged();
            m_frame->MarkDirty();
            Refresh();
        }
    }, wxID_DELETE);
}

void CanvasPanel::ResetView() {
    m_view.scale = 0.4;
    m_view.panX  = 60.0;  // leave room for Y ruler
    m_view.panY  = 60.0;  // leave room for X ruler
    Refresh();
}

void CanvasPanel::ZoomBy(double factor) {
    wxSize sz = GetClientSize();
    m_view.ZoomAt(factor, { sz.x / 2, sz.y / 2 });
    Refresh();
}

void CanvasPanel::SetZoomForZ() {
    const int margin = 60;
    m_view.scale = 1.0;
    m_view.panX  = margin;
    m_view.panY  = margin;
    Refresh();
}

void CanvasPanel::ZoomFit(bool contentOnly) {
    auto& state = m_frame->State();
    auto& pieces = state.pieces;
    auto& scenery = state.scenery;

    bool anyDeck = false;
    if (!contentOnly) {
        for (const auto& l : state.levels) if (l.shape.size() >= 3) { anyDeck = true; break; }
    }
    bool anyContent = !pieces.empty() || !scenery.empty()
                   || !state.shapes.empty()
                   || !state.helices.empty();
    if (!contentOnly) {
        anyContent = anyContent || anyDeck || state.baseboard.size() >= 3;
    }
    if (!anyContent) { ResetView(); return; }

    auto& catalog = MC::GetCatalog();
    double minX=1e9, minY=1e9, maxX=-1e9, maxY=-1e9;

    auto expand = [&](double x, double y) {
        minX=std::min(minX,x); minY=std::min(minY,y);
        maxX=std::max(maxX,x); maxY=std::max(maxY,y);
    };

    for (const auto& p : pieces) {
        const MC::TrackPiece* def = catalog.Find(p.catalogNumber);
        if (!def) continue;
        double rad = p.transform.heading * M_PI / 180.0;
        MC::Vec2 p0 = p.transform.pos;
        MC::Vec2 p1 = p0 + MC::Vec2{std::cos(rad), std::sin(rad)} * def->lengthMm;
        expand(p0.x, p0.y); expand(p1.x, p1.y);
    }
    for (const auto& s : scenery)
        expand(s.transform.pos.x, s.transform.pos.y);
    // Skip baseboards/levels when content-only: a fresh library load
    // shouldn't be framed against the room outline, only against the
    // pieces that just appeared.
    if (!contentOnly) {
        for (const auto& l : state.levels)
            for (const auto& v : l.shape) expand(v.x, v.y);
        for (const auto& v : state.baseboard) expand(v.x, v.y);
    }
    // Free-form polygon shapes
    for (const auto& s : state.shapes)
        for (const auto& v : s.vertices) expand(v.x, v.y);
    // Helices (approximate by center ± outer radius)
    for (const auto& h : state.helices) {
        const MC::HelixVariant* var = MC::FindHelixVariant(h.variantId);
        double rOuter = var ? var->radiusR2 : 500.0;
        expand(h.center.x - rOuter, h.center.y - rOuter);
        expand(h.center.x + rOuter, h.center.y + rOuter);
    }

    wxSize sz = GetClientSize();
    double margin = 80.0;
    double W = std::max(1.0, maxX - minX);
    double H = std::max(1.0, maxY - minY);
    double scaleX = (sz.x - 2*margin) / W;
    double scaleY = (sz.y - 2*margin) / H;
    m_view.scale = std::max(0.05, std::min(std::min(scaleX, scaleY), 20.0));
    m_view.panX  = margin - minX * m_view.scale + ((sz.x - 2*margin) - W*m_view.scale) * 0.5;
    m_view.panY  = margin - minY * m_view.scale + ((sz.y - 2*margin) - H*m_view.scale) * 0.5;
    Refresh();
}

void CanvasPanel::SetShowGrid(bool show) {
    m_frame->State().showGrid = show;
    Refresh();
}

MC::Vec2 CanvasPanel::ScreenToWorld(wxPoint pt) const { return m_view.ToWorld(pt); }

void CanvasPanel::PlaceAndJoinToSelected(const std::string& catalogNumber) {
    auto& state = m_frame->State();
    auto selected = state.Selected();
    if (selected.empty()) {
        BeginPieceDrag(catalogNumber);
        return;
    }

    // Use most recently placed selected piece as anchor (highest id)
    MC::PlacedPiece* anchor = selected[0];
    for (auto* p : selected)
        if (p->id > anchor->id) anchor = p;
    const MC::TrackPiece* anchorDef = MC::GetCatalog().Find(anchor->catalogNumber);
    const MC::TrackPiece* newDef    = MC::GetCatalog().Find(catalogNumber);
    if (!anchorDef || !newDef) return;

    // Ensure joints vector is fully sized before reading it
    anchor->joints.resize(anchorDef->connections.size(), {-1,-1});

    // Find free connector — prefer conn[1] (forward), then conn[0], then any branch
    int anchorConnIdx = -1;
    if (anchorDef->connections.size() > 1 && anchor->joints[1].pieceId == -1)
        anchorConnIdx = 1;
    else if (anchor->joints[0].pieceId == -1)
        anchorConnIdx = 0;
    // Check all remaining connectors (branches, etc.)
    if (anchorConnIdx == -1) {
        for (int ci = 2; ci < (int)anchorDef->connections.size(); ++ci) {
            if (anchor->joints[ci].pieceId == -1) { anchorConnIdx = ci; break; }
        }
    }

    if (anchorConnIdx == -1) return;  // all connectors occupied — nothing to join

    // The new piece connects its entry (conn[0]) to the anchor's free conn,
    // unless the anchor conn is an entry (0), in which case join new piece's exit (1)
    int newConnIdx = (anchorConnIdx == 0) ? 1 : 0;
    if (newConnIdx >= (int)newDef->connections.size()) newConnIdx = 0;

    MC::ConnectionPoint targetConn = anchor->worldConnection(*anchorDef, anchorConnIdx);
    MC::Transform2D newTransform   = MC::SnapEngine::ComputeSnapTransform(
        *newDef, newConnIdx, targetConn);

    MC::PlacedPiece newPiece;
    newPiece.catalogNumber = catalogNumber;
    newPiece.transform     = newTransform;
    newPiece.joints.resize(newDef->connections.size());
    newPiece.levelId       = anchor->levelId;  // inherit level from anchor
    newPiece.z_mm          = anchor->z_mm;     // ...and its elevation, so a
                                               // chain built on a raised deck
                                               // does not drop to the ground

    int newId = state.AddPiece(newPiece);

    MC::PlacedPiece* np  = state.FindById(newId);
    MC::PlacedPiece* anc = state.FindById(anchor->id);
    if (np && anc) {
        np->joints[newConnIdx]      = { anc->id, anchorConnIdx };
        anc->joints[anchorConnIdx]  = { newId,   newConnIdx    };
        // Select new piece BEFORE ApplyGradeToNewPiece so RefreshProperties shows it
        state.ClearSelection();
        np->selected = true;
        // Apply grade if a run is active
        ApplyGradeToNewPiece(*np, anc->id, anchorConnIdx, newConnIdx);
    }

    m_frame->OnSelectionChanged();

    // Pair pieces (74618, 8976) — immediately place a second copy joined to first
    if (newDef->pairPiece && np) {
        MC::PlacedPiece* firstPiece = state.FindById(newId);
        if (firstPiece && newDef->connections.size() > 1 &&
            firstPiece->joints[1].pieceId == -1)
        {
            MC::ConnectionPoint exitConn = firstPiece->worldConnection(*newDef, 1);
            MC::Transform2D t2 = MC::SnapEngine::ComputeSnapTransform(*newDef, 0, exitConn);

            MC::PlacedPiece pair;
            pair.catalogNumber = catalogNumber;
            pair.transform     = t2;
            pair.joints.resize(newDef->connections.size());
            pair.levelId       = firstPiece->levelId;

            int pairId = state.AddPiece(pair);
            MC::PlacedPiece* pp  = state.FindById(pairId);
            MC::PlacedPiece* fp  = state.FindById(newId);
            if (pp && fp) {
                pp->joints[0] = { fp->id, 1 };
                fp->joints[1] = { pp->id, 0 };
                ApplyGradeToNewPiece(*pp, fp->id, 1, 0);
            }
            state.ClearSelection();
            if (pp) pp->selected = true;
            m_frame->OnSelectionChanged();
        }
    }
    // Do NOT set m_justCloned here — that flag is only for suppressing the
    // OnLeftDown that follows a canvas double-click. Palette placements don't
    // generate a canvas OnLeftDown, so setting it here causes the next canvas
    // click to be incorrectly eaten.
    m_frame->MarkDirty();
    wxString pc; pc << "Pieces: " << (int)state.pieces.size();
    m_frame->SetStatusText(pc, 1);
    Refresh();
}

void CanvasPanel::BeginPieceDrag(const std::string& catalogNumber) {
    if (catalogNumber.empty()) return;
    const MC::TrackPiece* def = MC::GetCatalog().Find(catalogNumber);
    if (!def) return;
    m_draggingPiece  = true;
    m_dragCatalogNum = catalogNumber;
    m_dragTransform.pos     = m_view.ToWorld({ 300, 200 });
    m_dragTransform.heading = 0.0;
    SetFocus();
    if (!HasCapture()) CaptureMouse();
    Refresh();
}

void CanvasPanel::BeginSceneryDrag(const std::string& itemId) {
    if (itemId.empty()) return;
    const MC::SceneryItem* def = MC::GetSceneryCatalog().Find(itemId);
    if (!def) return;
    m_draggingScenery       = true;
    m_draggingPiece         = false;
    m_draggingHelix         = false;
    m_dragSceneryId         = itemId;
    m_dragSceneryTransform.pos     = m_view.ToWorld({ 300, 200 });
    m_dragSceneryTransform.heading = 0.0;
    SetFocus();
    if (!HasCapture()) CaptureMouse();
    Refresh();
}

// Begins a helix drag from the palette. The cursor shows two concentric
// dashed circles (R1 + R2) during drag; clicking on the canvas drops the
// helix centered at that point.
void CanvasPanel::BeginHelixDrag(const std::string& helixVariant) {
    if (helixVariant.empty()) return;
    m_draggingHelix    = true;
    m_draggingPiece    = false;
    m_draggingScenery  = false;
    m_dragHelixVariant = helixVariant;
    m_dragHelixPos     = m_view.ToWorld({ 300, 200 });
    SetFocus();
    if (!HasCapture()) CaptureMouse();
    Refresh();
}

// ── Paint ─────────────────────────────────────────────────────────────────────
void CanvasPanel::OnPaint(wxPaintEvent&) {
    wxAutoBufferedPaintDC dc(this);
    m_renderer.Draw(dc, m_frame->State(), m_view, GetClientSize());

    // Draw rulers
    DrawRulers(dc);

    // Platform resize handles (only when a single platform is selected)
    {
        int platId = GetResizablePlatformId();
        if (platId >= 0) DrawPlatformHandles(dc, platId);
    }

    // Persistent grade-run indicator in status bar
    {
        auto& state = m_frame->State();
        if (!state.gradeRuns.empty()) {
            wxString msg = "[ELEV]";
            for (const auto& r : state.gradeRuns)
                msg += wxString::Format(" %.1f deg", r.gradeDeg);
            m_frame->SetStatusText(msg + "  (right-click piece to stop)", 0);
        } else {
            // Only clear if we own the status (don't overwrite other messages)
            // by checking for the elevation marker character
            wxString cur = m_frame->GetStatusBar()->GetStatusText(0);
            if (cur.StartsWith(wxS("\u25b2")))  // ▲
                m_frame->SetStatusText("Ready", 0);
        }
    }

    // Rubber band
    if (m_rubberBanding) {
        dc.SetBrush(*wxTRANSPARENT_BRUSH);
        dc.SetPen(wxPen(wxColour(0, 120, 215), 1, wxPENSTYLE_DOT));
        int x = std::min(m_rubberStart.x, m_rubberEnd.x);
        int y = std::min(m_rubberStart.y, m_rubberEnd.y);
        int w = std::abs(m_rubberEnd.x - m_rubberStart.x);
        int h = std::abs(m_rubberEnd.y - m_rubberStart.y);
        dc.DrawRectangle(x, y, w, h);
    }

    // Polygon tool — in-progress vertex placement preview: dots at each
    // vertex, polyline between them, rubber-banded segment from last vertex
    // to cursor, and a "close indicator" ring on the first vertex when the
    // cursor is near it (and we have at least 3 vertices).
    if ((m_frame->State().toolMode == MC::ToolMode::Polygon ||
         m_frame->State().toolMode == MC::ToolMode::BaseboardDraw)
        && m_drawingPolygon) {
        std::vector<wxPoint> screenPts;
        screenPts.reserve(m_polygonVerts.size() + 1);
        for (const auto& v : m_polygonVerts) screenPts.push_back(m_view.ToScreen(v));

        // Polyline through placed vertices.
        dc.SetPen(wxPen(wxColour(40, 80, 200), 2));
        for (size_t i = 1; i < screenPts.size(); ++i)
            dc.DrawLine(screenPts[i-1], screenPts[i]);

        // Rubber band: last vertex → cursor. If hovering near the start to
        // close, swap to a dashed line back to the start instead.
        if (!screenPts.empty()) {
            wxPoint cursorScreen = m_view.ToScreen(m_polygonCursorWorld);
            if (m_polygonCloseHover) {
                dc.SetPen(wxPen(wxColour(40, 150, 60), 2, wxPENSTYLE_LONG_DASH));
                dc.DrawLine(screenPts.back(), screenPts.front());
            } else {
                dc.SetPen(wxPen(wxColour(40, 80, 200), 1, wxPENSTYLE_LONG_DASH));
                dc.DrawLine(screenPts.back(), cursorScreen);
            }

            // Live distance readout next to the cursor: length of the
            // edge from the last placed vertex to the cursor (or to the
            // first vertex if we're about to close). Shows in the active
            // units. Helps the user place vertices a known distance apart.
            const MC::Vec2& target =
                m_polygonCloseHover ? m_polygonVerts.front()
                                     : m_polygonCursorWorld;
            double dxw = target.x - m_polygonVerts.back().x;
            double dyw = target.y - m_polygonVerts.back().y;
            double dist = std::sqrt(dxw*dxw + dyw*dyw);
            wxString lbl;
            auto& st = m_frame->State();
            if      (st.units == 1) lbl = wxString::Format("%.1f cm", dist/10.0);
            else if (st.units == 2) lbl = wxString::Format("%.2f\"",  dist/25.4);
            else                    lbl = wxString::Format("%.0f mm", dist);
            dc.SetFont(wxFont(10, wxFONTFAMILY_DEFAULT,
                              wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD));
            dc.SetTextForeground(wxColour(20, 20, 60));
            dc.SetBackgroundMode(wxTRANSPARENT);
            wxSize ts = dc.GetTextExtent(lbl);
            // Offset above-right of cursor; pad with a translucent
            // background so the text reads over the line.
            wxPoint lp { cursorScreen.x + 14, cursorScreen.y - ts.y - 6 };
            dc.SetBrush(wxBrush(wxColour(255, 255, 255, 200)));
            dc.SetPen(wxPen(wxColour(200, 200, 200), 1));
            dc.DrawRoundedRectangle(lp.x - 4, lp.y - 2,
                                    ts.x + 8, ts.y + 4, 3);
            dc.DrawText(lbl, lp.x, lp.y);
        }

        // Vertex dots.
        for (size_t i = 0; i < screenPts.size(); ++i) {
            dc.SetBrush(wxBrush(i == 0 ? wxColour(40, 150, 60) : wxColour(40, 80, 200)));
            dc.SetPen(wxPen(wxColour(20, 20, 20), 1));
            dc.DrawCircle(screenPts[i], i == 0 ? 6 : 4);
        }

        // Closure indicator: green ring around start vertex while hovered.
        if (m_polygonCloseHover && !screenPts.empty()) {
            dc.SetBrush(*wxTRANSPARENT_BRUSH);
            dc.SetPen(wxPen(wxColour(40, 150, 60), 3));
            dc.DrawCircle(screenPts.front(), 12);
            dc.SetFont(wxFont(10, wxFONTFAMILY_DEFAULT,
                              wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD));
            dc.SetTextForeground(wxColour(40, 150, 60));
            dc.SetBackgroundMode(wxTRANSPARENT);
            dc.DrawText("Close", screenPts.front().x + 16, screenPts.front().y - 8);
        }
    }

    // Measuring tape — in-progress preview between first and second click.
    if (m_measuring) {
        wxPoint pa = m_view.ToScreen(m_measureA);
        wxPoint pb = m_view.ToScreen(m_measureB);
        dc.SetPen(wxPen(wxColour(40, 80, 200), 2, wxPENSTYLE_LONG_DASH));
        dc.DrawLine(pa, pb);
        dc.SetBrush(wxBrush(wxColour(40, 80, 200)));
        dc.SetPen(wxPen(wxColour(40, 80, 200), 1));
        dc.DrawCircle(pa, 4);
        dc.DrawCircle(pb, 4);
        // Live length readout near the cursor end.
        double dxw = m_measureB.x - m_measureA.x;
        double dyw = m_measureB.y - m_measureA.y;
        double dist = std::sqrt(dxw*dxw + dyw*dyw);
        wxString lbl;
        auto& st = m_frame->State();
        if      (st.units == 1) lbl = wxString::Format("%.1f cm", dist/10.0);
        else if (st.units == 2) lbl = wxString::Format("%.2f\"",  dist/25.4);
        else                    lbl = wxString::Format("%.0f mm", dist);
        dc.SetFont(wxFont(10, wxFONTFAMILY_DEFAULT,
                          wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD));
        dc.SetBackgroundMode(wxTRANSPARENT);
        wxSize ts = dc.GetTextExtent(lbl);
        dc.SetBrush(wxBrush(wxColour(255, 248, 220, 235)));
        dc.SetPen(wxPen(wxColour(40, 80, 200), 1));
        dc.DrawRoundedRectangle(pb.x + 8, pb.y - ts.y/2 - 2,
                                ts.x + 10, ts.y + 4, 3);
        dc.SetTextForeground(wxColour(20, 20, 20));
        dc.DrawText(lbl, pb.x + 13, pb.y - ts.y/2);
    }

    // Elevation wizard - end-point pick preview line along the heading.
    if (m_elevMode == 2) {
        wxPoint pa = m_view.ToScreen(m_elevAnchorPt.pos);

        // Connector heading is the outward/departure direction.
        double hr = m_elevAnchorPt.heading * M_PI / 180.0;
        double ax = std::cos(hr), ay = std::sin(hr);
        double dx = m_elevCursor.x - m_elevAnchorPt.pos.x;
        double dy = m_elevCursor.y - m_elevAnchorPt.pos.y;
        double dist = dx * ax + dy * ay;
        if (dist < 10.0) dist = 10.0;

        MC::Vec2 endWorld = { m_elevAnchorPt.pos.x + ax * dist,
                              m_elevAnchorPt.pos.y + ay * dist };
        wxPoint pb = m_view.ToScreen(endWorld);

        // Highlighted connector indicator (snap-style)
        dc.SetBrush(wxBrush(wxColour(255, 220, 0)));
        dc.SetPen(wxPen(wxColour(180, 120, 0), 2));
        dc.DrawCircle(pa, 8);

        // Direction arrow on the track piece, pointing from connector
        // inward along the departure direction. Arrow sits just past the
        // connector so it overlays the track.
        {
            // Arrow tip sits ~30 screen pixels out from the connector.
            // Shaft starts at the connector.
            double arrowLen = 30.0;   // screen pixels
            double headLen  = 10.0;
            double headW    =  6.0;

            // Screen-space direction
            double sdx = pb.x - pa.x, sdy = pb.y - pa.y;
            double slen = std::sqrt(sdx*sdx + sdy*sdy);
            if (slen > 1.0) {
                double ux = sdx / slen, uy = sdy / slen;   // unit along run
                double px = -uy,        py =  ux;           // perpendicular

                // Clamp arrow length to not overshoot the end point
                if (arrowLen > slen * 0.5) arrowLen = slen * 0.5;

                double tipX = pa.x + ux * arrowLen;
                double tipY = pa.y + uy * arrowLen;
                double baseX = pa.x + ux * 4.0;   // small gap from connector dot
                double baseY = pa.y + uy * 4.0;

                // Shaft
                dc.SetPen(wxPen(wxColour(220, 80, 40), 3));
                dc.DrawLine((int)baseX, (int)baseY, (int)tipX, (int)tipY);

                // Arrowhead
                wxPoint head[3];
                head[0] = wxPoint((int)tipX, (int)tipY);
                head[1] = wxPoint((int)(tipX - ux*headLen + px*headW),
                                  (int)(tipY - uy*headLen + py*headW));
                head[2] = wxPoint((int)(tipX - ux*headLen - px*headW),
                                  (int)(tipY - uy*headLen - py*headW));
                dc.SetBrush(wxBrush(wxColour(220, 80, 40)));
                dc.SetPen(wxPen(wxColour(220, 80, 40), 1));
                dc.DrawPolygon(3, head);
            }
        }

        // Draw the run line
        dc.SetPen(wxPen(wxColour(220, 80, 40), 2, wxPENSTYLE_LONG_DASH));
        dc.DrawLine(pa, pb);

        // End point dot
        dc.SetBrush(wxBrush(wxColour(220, 80, 40)));
        dc.SetPen(wxPen(wxColour(220, 80, 40), 1));
        dc.DrawCircle(pb, 5);

        // Distance label near the end point
        auto& st2 = m_frame->State();
        wxString distLbl;
        if      (st2.units == 1) distLbl = wxString::Format("%.1f cm", dist / 10.0);
        else if (st2.units == 2) distLbl = wxString::Format("%.2f\"",  dist / 25.4);
        else                     distLbl = wxString::Format("%.0f mm", dist);
        dc.SetFont(wxFont(10, wxFONTFAMILY_DEFAULT,
                          wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD));
        dc.SetBackgroundMode(wxTRANSPARENT);
        wxSize ts2 = dc.GetTextExtent(distLbl);
        dc.SetBrush(wxBrush(wxColour(255, 240, 220, 235)));
        dc.SetPen(wxPen(wxColour(220, 80, 40), 1));
        dc.DrawRoundedRectangle(pb.x + 8, pb.y - ts2.y/2 - 2,
                                ts2.x + 10, ts2.y + 4, 3);
        dc.SetTextForeground(wxColour(20, 20, 20));
        dc.DrawText(distLbl, pb.x + 13, pb.y - ts2.y/2);
    }

    // Snap indicator during piece move
    if (m_movingSelection && m_moveSnap.found) {
        MC::PlacedPiece* target = m_frame->State().FindById(m_moveSnap.targetPieceId);
        if (target) {
            const MC::TrackPiece* tdef = MC::GetCatalog().Find(target->catalogNumber);
            if (tdef) {
                MC::ConnectionPoint wc = target->worldConnection(*tdef, m_moveSnap.targetConnIdx);
                wxPoint pt = m_view.ToScreen(wc.pos);
                dc.SetBrush(wxBrush(wxColour(255, 220, 0)));
                dc.SetPen(wxPen(wxColour(180, 120, 0), 2));
                dc.DrawCircle(pt, 8);
            }
        }
    }

    // Drag ghost + snap indicator
    if (m_draggingPiece) {
        const MC::TrackPiece* def = MC::GetCatalog().Find(m_dragCatalogNum);
        if (def) {
            MC::Transform2D t = m_currentSnap.found
                                ? m_currentSnap.snappedTransform
                                : m_dragTransform;

            // Snap indicator — yellow dot on target connection
            if (m_currentSnap.found) {
                MC::PlacedPiece* target =
                    m_frame->State().FindById(m_currentSnap.targetPieceId);
                if (target) {
                    const MC::TrackPiece* tdef =
                        MC::GetCatalog().Find(target->catalogNumber);
                    if (tdef) {
                        MC::ConnectionPoint wc =
                            target->worldConnection(*tdef, m_currentSnap.targetConnIdx);
                        wxPoint pt = m_view.ToScreen(wc.pos);
                        dc.SetBrush(wxBrush(wxColour(255, 220, 0)));
                        dc.SetPen(wxPen(wxColour(180, 120, 0), 2));
                        dc.DrawCircle(pt, 8);
                    }
                }
            }

            m_renderer.DrawGhost(dc, *def, t, m_view);
        }
    }

    // Scenery drag ghost
    if (m_draggingScenery) {
        const MC::SceneryItem* def = MC::GetSceneryCatalog().Find(m_dragSceneryId);
        if (def)
            m_renderer.DrawSceneryGhost(dc, *def, m_dragSceneryTransform, m_view);
    }

    // Helix drag ghost: two concentric dashed circles (R1 and R2) at cursor.
    if (m_draggingHelix) {
        const MC::HelixVariant* v = MC::FindHelixVariant(m_dragHelixVariant);
        if (!v) v = MC::FindHelixVariant(MC::DefaultHelixVariantId());
        if (v) {
            wxPoint center = m_view.ToScreen(m_dragHelixPos);
            int rPx1 = (int)(v->radiusR1 * m_view.scale);
            int rPx2 = (int)(v->radiusR2 * m_view.scale);
            dc.SetBrush(*wxTRANSPARENT_BRUSH);
            dc.SetPen(wxPen(wxColour(0, 150, 200), 2, wxPENSTYLE_LONG_DASH));
            dc.DrawCircle(center, rPx1);
            dc.DrawCircle(center, rPx2);
            dc.DrawLine(center.x - 8, center.y, center.x + 8, center.y);
            dc.DrawLine(center.x, center.y - 8, center.x, center.y + 8);
        }
    }

    // Free rotation overlay: angle popup + green snap flash
    if (m_freeRotating) {
        // Pivot point on screen
        wxPoint pivotPt = m_view.ToScreen(m_rotPivot);

        // Green flash when snapping to cardinal
        if (m_rotSnapping) {
            dc.SetBrush(wxBrush(wxColour(0, 220, 80, 180)));
            dc.SetPen(wxPen(wxColour(0, 160, 60), 2));
            dc.DrawCircle(pivotPt, 16);
        } else {
            dc.SetBrush(wxBrush(wxColour(255, 180, 0, 160)));
            dc.SetPen(wxPen(wxColour(180, 120, 0), 2));
            dc.DrawCircle(pivotPt, 8);
        }

        // Angle popup near pivot
        wxString label = wxString::Format("%.1f deg", m_rotCurrentDeg);
        dc.SetFont(wxFont(11, wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD));
        wxSize sz = dc.GetTextExtent(label);
        int px = pivotPt.x + 14;
        int py = pivotPt.y - sz.GetHeight() / 2;
        // Background
        dc.SetBrush(wxBrush(wxColour(30, 30, 30, 200)));
        dc.SetPen(*wxTRANSPARENT_PEN);
        dc.DrawRoundedRectangle(px - 4, py - 3, sz.GetWidth() + 8, sz.GetHeight() + 6, 3);
        // Text
        wxColour textCol = m_rotSnapping ? wxColour(0, 255, 100) : wxColour(255, 255, 255);
        dc.SetTextForeground(textCol);
        dc.SetBackgroundMode(wxTRANSPARENT);
        dc.DrawText(label, px, py);
    }
    {
        auto& state = m_frame->State();
        // Draw +/- height labels on all pieces with z_mm != 0 (at zoom >= 0.5)
    }
}

// ── Rulers ────────────────────────────────────────────────────────────────────
void CanvasPanel::DrawRulers(wxDC& dc) {
    const int rulerSize = 20;
    wxSize sz = GetClientSize();
    int units = m_frame->State().units; // 0=mm, 1=cm, 2=inch

    // Background
    dc.SetBrush(wxBrush(wxColour(240, 240, 240)));
    dc.SetPen(wxPen(wxColour(180, 180, 180), 1));
    dc.DrawRectangle(0, 0, sz.x, rulerSize);
    dc.DrawRectangle(0, 0, rulerSize, sz.y);
    dc.DrawRectangle(0, 0, rulerSize, rulerSize);

    // Choose tick intervals based on unit.
    // All internal positions are mm; convert for display.
    double mmPerUnit;
    const char* unitSuffix = "";
    if (units == 2) {           // inches
        mmPerUnit = 25.4;
        unitSuffix = "\"";
    } else if (units == 1) {    // cm
        mmPerUnit = 10.0;
        unitSuffix = "";
    } else {                    // mm
        mmPerUnit = 1.0;
        unitSuffix = "";
    }

    // Pick a tick spacing that gives roughly 60px between ticks
    double targetTickPx = 60.0;
    double unitsPerPx   = 1.0 / (m_view.scale * mmPerUnit);
    double rawUnits     = targetTickPx * unitsPerPx;

    // Candidate intervals in display units
    double intervals_in[]  = {0.25, 0.5, 1, 2, 3, 6, 12, 24, 48, 96, 192};
    double intervals_cm[]  = {0.5,  1,   2, 5, 10, 20, 50, 100, 200};
    double intervals_mm[]  = {1,    2,   5, 10, 20, 50, 100, 200, 500};
    double* ivList = (units==2) ? intervals_in : (units==1) ? intervals_cm : intervals_mm;
    int    ivCount = (units==2) ? 11            : 9;

    double tickUnits = ivList[ivCount-1];
    for (int i = 0; i < ivCount; ++i) {
        if (ivList[i] >= rawUnits) { tickUnits = ivList[i]; break; }
    }
    double tickMm = tickUnits * mmPerUnit;

    dc.SetFont(wxFont(7, wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL));
    dc.SetTextForeground(wxColour(80, 80, 80));
    dc.SetPen(wxPen(wxColour(140, 140, 140), 1));

    // X ruler (top)
    double startMmX = std::floor((-m_view.panX) / (tickMm * m_view.scale)) * tickMm;
    for (double mm = startMmX; mm * m_view.scale + m_view.panX < sz.x; mm += tickMm) {
        int px = (int)(mm * m_view.scale + m_view.panX);
        if (px < rulerSize) continue;
        dc.DrawLine(px, rulerSize - 6, px, rulerSize);
        double val = mm / mmPerUnit;
        wxString label;
        if (units == 2) {
            // Show as whole number if no fraction, else 1 decimal place
            double rounded = std::round(val * 2) / 2.0;  // round to 0.5
            label = (std::fmod(rounded, 1.0) == 0.0)
                ? wxString::Format("%.0f\"", rounded)
                : wxString::Format("%.1f\"", rounded);
        } else {
            label = wxString::Format("%.0f", val);
        }
        dc.DrawText(label, px + 2, 2);
    }

    // Y ruler (left)
    double startMmY = std::floor((-m_view.panY) / (tickMm * m_view.scale)) * tickMm;
    for (double mm = startMmY; mm * m_view.scale + m_view.panY < sz.y; mm += tickMm) {
        int py = (int)(mm * m_view.scale + m_view.panY);
        if (py < rulerSize) continue;
        dc.DrawLine(rulerSize - 6, py, rulerSize, py);
        double val = mm / mmPerUnit;
        wxString label;
        if (units == 2) {
            double rounded = std::round(val * 2) / 2.0;
            label = (std::fmod(rounded, 1.0) == 0.0)
                ? wxString::Format("%.0f\"", rounded)
                : wxString::Format("%.1f\"", rounded);
        } else {
            label = wxString::Format("%.0f", val);
        }
        dc.DrawText(label, 1, py + 1);
    }

    // Origin marker
    dc.SetPen(wxPen(wxColour(200, 80, 80), 1));
    wxPoint o = m_view.ToScreen({0, 0});
    if (o.x >= rulerSize && o.x < sz.x) dc.DrawLine(o.x, 0, o.x, rulerSize);
    if (o.y >= rulerSize && o.y < sz.y) dc.DrawLine(0, o.y, rulerSize, o.y);
}

void CanvasPanel::OnSize(wxSizeEvent& e) { Refresh(); e.Skip(); }

// ── Mouse Move ────────────────────────────────────────────────────────────────
void CanvasPanel::OnMouseMove(wxMouseEvent& e) {
    MC::Vec2 worldPos = m_view.ToWorld(e.GetPosition());
    UpdateStatusBar(worldPos);

    // Elevation wizard end-point pick: track cursor and show distance.
    if (m_elevMode == 2) {
        m_elevCursor = worldPos;

        // Connector heading is the outward direction (departure).
        double hr = m_elevAnchorPt.heading * M_PI / 180.0;
        double ax = std::cos(hr), ay = std::sin(hr);
        double dx = worldPos.x - m_elevAnchorPt.pos.x;
        double dy = worldPos.y - m_elevAnchorPt.pos.y;
        double dist = dx * ax + dy * ay;
        if (dist < 0.0) dist = 0.0;

        auto& st = m_frame->State();
        wxString lbl;
        if      (st.units == 1) lbl = wxString::Format("Distance: %.1f cm", dist / 10.0);
        else if (st.units == 2) lbl = wxString::Format("Distance: %.2f in", dist / 25.4);
        else                    lbl = wxString::Format("Distance: %.0f mm", dist);
        m_frame->SetStatusText(lbl + "  -  click to set end point", 0);

        Refresh();
        return;
    }

    // Measure tool active — rubber-band the second endpoint while in
    // mid-measurement. Set cursor on every move so it sticks reliably on
    // Windows even after the toolbar click that activated the mode.
    if (m_frame->State().toolMode == MC::ToolMode::Measure) {
        SetCursor(wxCursor(wxCURSOR_CROSS));
        if (m_measuring) {
            m_measureB = worldPos;
            Refresh();
        }
        return;
    }

    // Polygon tool active — track cursor for rubber-band line from last
    // vertex and detect proximity to the first vertex (closure indicator).
    if (m_frame->State().toolMode == MC::ToolMode::Polygon ||
        m_frame->State().toolMode == MC::ToolMode::BaseboardDraw) {
        SetCursor(wxCursor(wxCURSOR_CROSS));
        if (m_drawingPolygon) {
            m_polygonCursorWorld = worldPos;
            bool wasHover = m_polygonCloseHover;
            m_polygonCloseHover = false;
            if (m_polygonVerts.size() >= 3) {
                double dx = worldPos.x - m_polygonVerts[0].x;
                double dy = worldPos.y - m_polygonVerts[0].y;
                double tol = 10.0 / std::max(0.0001, m_view.scale);
                if (std::sqrt(dx*dx + dy*dy) <= tol) m_polygonCloseHover = true;
            }
            if (wasHover != m_polygonCloseHover || true) Refresh();
        }
        return;
    }

    // Drag an existing free-form polygon shape — translate all its vertices.
    if (m_draggingShape && e.LeftIsDown()) {
        auto& st = m_frame->State();
        MC::CanvasShape* sp = nullptr;
        for (auto& s : st.shapes) if (s.id == m_dragShapeId) { sp = &s; break; }
        if (sp && sp->vertices.size() == m_shapeOrigVerts.size()) {
            MC::Vec2 delta = worldPos - m_shapeDragStartWorld;
            for (size_t i = 0; i < sp->vertices.size(); ++i)
                sp->vertices[i] = m_shapeOrigVerts[i] + delta;
            m_frame->MarkDirty();
            Refresh();
        }
        return;
    }

    // Drag an existing measurement (label and line are fused — dragging
    // either translates the whole measurement together).
    if (m_measureDragWhat && e.LeftIsDown()) {
        auto& st = m_frame->State();
        MC::Measurement* m = nullptr;
        for (auto& mm : st.measurements) if (mm.id == m_measureDragId) { m = &mm; break; }
        if (m) {
            MC::Vec2 delta = worldPos - m_measureDragStartWorld;
            m->a = m_measureOrigA + delta;
            m->b = m_measureOrigB + delta;
            // labelOffset is kept at 0,0 by design; nothing to update.
            m_frame->MarkDirty();
            Refresh();
        }
        return;
    }

    // Hover cursor over platform resize handles --------------------------
    if (!m_resizingPlatform && !m_panning && !m_freeRotating &&
        !m_draggingPiece && !m_draggingScenery && !e.LeftIsDown())
    {
        int platId = GetResizablePlatformId();
        if (platId >= 0) {
            int h = HitTestPlatformHandle(e.GetPosition(), platId);
            if (h >= 0) {
                // Pick a sensible directional cursor. wxWidgets has
                // SIZENWSE / SIZENESW / SIZENS / SIZEWE.
                wxStockCursor cur;
                switch (h) {
                    case 0: case 4: cur = wxCURSOR_SIZENWSE; break;
                    case 2: case 6: cur = wxCURSOR_SIZENESW; break;
                    case 1: case 5: cur = wxCURSOR_SIZENS;   break;
                    case 3: case 7: cur = wxCURSOR_SIZEWE;   break;
                    default:        cur = wxCURSOR_ARROW;    break;
                }
                SetCursor(wxCursor(cur));
            } else {
                SetCursor(wxNullCursor);
            }
        }
    }

    // Platform resize drag --------------------------------------------------
    if (m_resizingPlatform && e.LeftIsDown()) {        auto& state = m_frame->State();
        MC::PlacedScenery* s = state.FindSceneryById(m_resizeSceneryId);
        const MC::SceneryItem* def = s ? MC::GetSceneryCatalog().Find(s->itemId)
                                       : nullptr;
        if (!s || !def || def->widthMm <= 0 || def->heightMm <= 0) {
            m_resizingPlatform = false;
            if (HasCapture()) ReleaseMouse();
            return;
        }

        // Determine which axes this handle controls.
        //   corners (0,2,4,6): both X and Y
        //   edges (1,5): Y only
        //   edges (3,7): X only
        bool isCorner = (m_resizeHandle == 0 || m_resizeHandle == 2 ||
                         m_resizeHandle == 4 || m_resizeHandle == 6);
        bool affectsX = isCorner || (m_resizeHandle == 3 || m_resizeHandle == 7);
        bool affectsY = isCorner || (m_resizeHandle == 1 || m_resizeHandle == 5);

        // Sign of local X axis movement for this handle:
        //   handles 2/3/4 are on +X side -> dragging outward grows width
        //   handles 0/6/7 are on -X side -> dragging outward also grows width
        //   handles 1/5 don't affect X
        double signX = 0.0, signY = 0.0;
        switch (m_resizeHandle) {
            case 0: signX = -1; signY = -1; break;
            case 1: signX =  0; signY = -1; break;
            case 2: signX = +1; signY = -1; break;
            case 3: signX = +1; signY =  0; break;
            case 4: signX = +1; signY = +1; break;
            case 5: signX =  0; signY = +1; break;
            case 6: signX = -1; signY = +1; break;
            case 7: signX = -1; signY =  0; break;
        }

        // Translate mouse position into the item's local (pre-rotation) frame
        // relative to the anchor. The anchor is the opposite handle's world
        // position, which stays fixed during the resize.
        MC::Vec2 rel = worldPos - m_resizeAnchorWorld;
        double rad = -m_resizeOrigHeading * M_PI / 180.0;
        double c = std::cos(rad), si = std::sin(rad);
        double localDX = rel.x * c - rel.y * si;
        double localDY = rel.x * si + rel.y * c;

        // The handle's distance from the anchor along the relevant axis gives
        // the new full extent (width or height) directly:
        //   handle is +1 side: width  = +localDX (if signX=+1, anchor at -1)
        //                                                 etc.
        // Anchor is always on the opposite side, so:
        //   full width  = |localDX|, full height = |localDY|
        // We use the projection sign to enforce a minimum extent.
        double newWidth  = std::max(5.0, std::abs(localDX));
        double newHeight = std::max(5.0, std::abs(localDY));

        if (affectsX) s->scaleX = newWidth  / def->widthMm;
        if (affectsY) s->scaleY = newHeight / def->heightMm;

        // The platform's transform.pos is at its CENTER. After resize, the
        // center sits halfway between the anchor and the dragged handle.
        // Compute the new center: along each axis we move from the anchor by
        // half of the new full extent in the direction of the dragged handle.
        double halfExtentLocalX = (affectsX ? newWidth  * 0.5 : def->widthMm  * m_resizeOrigScaleX * 0.5);
        double halfExtentLocalY = (affectsY ? newHeight * 0.5 : def->heightMm * m_resizeOrigScaleY * 0.5);
        // The center is offset from the anchor by +halfExtent along whichever
        // local axis points from anchor toward dragged handle.
        double cxLocal = signX * halfExtentLocalX;
        double cyLocal = signY * halfExtentLocalY;
        // Rotate back to world frame.
        double rrad = m_resizeOrigHeading * M_PI / 180.0;
        double cr = std::cos(rrad), sr = std::sin(rrad);
        double cxW = cxLocal * cr - cyLocal * sr;
        double cyW = cxLocal * sr + cyLocal * cr;
        s->transform.pos = MC::Vec2{ m_resizeAnchorWorld.x + cxW,
                                      m_resizeAnchorWorld.y + cyW };

        m_frame->MarkDirty();
        Refresh();
        return;
    }

    if (m_draggingDeck && e.LeftIsDown()) {
        MC::Vec2 cur = m_view.ToWorld(e.GetPosition());
        MC::Vec2 delta = cur - m_deckDragStartWorld;
        auto& state = m_frame->State();
        MC::Level* lvl = state.FindLevel(m_dragDeckLevel);
        if (lvl && m_deckOrigShape.size() == lvl->shape.size()) {
            for (size_t i = 0; i < lvl->shape.size(); ++i)
                lvl->shape[i] = m_deckOrigShape[i] + delta;
        }
        m_frame->MarkDirty();
        Refresh();
        return;
    }

    if (m_panning) {
        int dx = e.GetX() - m_panStart.x;
        int dy = e.GetY() - m_panStart.y;
        if (std::abs(dx) > 3 || std::abs(dy) > 3) {
            SetCursor(wxCursor(wxCURSOR_HAND));
            m_view.panX = m_panStartX + dx;
            m_view.panY = m_panStartY + dy;
            Refresh();
        }
        return;
    }

    // Space+drag panning — start pan on first move with space held
    if (e.LeftIsDown() && wxGetKeyState(WXK_SPACE) && !m_panning
        && !m_draggingPiece && !m_movingSelection) {
        if (!m_spaceWasPanning) {
            m_spaceWasPanning = true;
            m_panStart  = e.GetPosition();
            m_panStartX = m_view.panX;
            m_panStartY = m_view.panY;
            // Cancel rubber band if it started
            m_rubberBanding = false;
            SetCursor(wxCursor(wxCURSOR_HAND));
        }
        m_view.panX = m_panStartX + (e.GetX() - m_panStart.x);
        m_view.panY = m_panStartY + (e.GetY() - m_panStart.y);
        Refresh();
        return;
    }
    if (m_spaceWasPanning && !e.LeftIsDown()) {
        m_spaceWasPanning = false;
        SetCursor(wxNullCursor);
    }

    if (m_draggingLabel && e.LeftIsDown()) {
        MC::Vec2 worldPt = m_view.ToWorld(e.GetPosition());
        auto& state = m_frame->State();
        for (auto& lbl : state.labels) {
            if (lbl.id == m_dragLabelId) {
                lbl.pos = worldPt - m_dragLabelOffset;
                break;
            }
        }
        Refresh();
        return;
    }

    if (m_moveArmed && e.LeftIsDown()) {
        wxPoint cur = e.GetPosition();
        int dx = cur.x - m_moveArmScreen.x;
        int dy = cur.y - m_moveArmScreen.y;
        if (dx*dx + dy*dy >= 25) {  // 5px threshold
            // Refuse to start the move if any selected piece is inside a
            // tunnel, OR if any helix is only PARTIALLY in the selection.
            // A selection that contains a helix's complete set of 4 stubs
            // (whether or not other non-helix pieces are also selected) is
            // fine — Ctrl-A → drag should work.
            auto sel = m_frame->State().Selected();
            auto& state = m_frame->State();
            bool partialHelix = false;
            {
                std::set<int> touched;
                for (auto* p : sel) if (p->helixId != -1) touched.insert(p->helixId);
                for (int hid : touched) {
                    int n = 0;
                    for (auto* p : sel) if (p->helixId == hid) ++n;
                    if (n != 4) { partialHelix = true; break; }
                }
            }
            if (partialHelix) {
                wxMessageBox(
                    "Selection contains part of a helix. Either select the "
                    "whole helix (click any of its stubs) or exclude it from "
                    "the selection before moving.",
                    "Helix partially selected",
                    wxOK | wxICON_INFORMATION, this);
                m_moveArmed = false;
                return;
            }
            // Tunnel check still applies. Skip it when the only "tunnel-like"
            // membership is helix stubs that we've already verified are
            // fully selected.
            bool tunnelHit = false;
            for (const auto& p : state.pieces)
                if (p.selected && p.helixId == -1 && state.IsPieceInTunnel(p.id))
                    { tunnelHit = true; break; }
            if (!tunnelHit) {
                for (const auto& s : state.scenery)
                    if (s.selected && state.IsPortalScenery(s.id))
                        { tunnelHit = true; break; }
            }
            if (tunnelHit) {
                wxMessageBox(
                    "Selection includes tunnel track or portals. "
                    "Remove the tunnel first, then move.",
                    "Tunnel is locked", wxOK | wxICON_INFORMATION, this);
                m_moveArmed = false;
                return;
            }
            m_moveArmed = false;

            // First-time-per-session warning if the selection includes
            // graded pieces. After the user clicks Yes once, the flag
            // latches and no further dialogs appear this session. The
            // first time only, the modal interrupts the drag -- user
            // re-clicks to start moving. Every subsequent move is
            // unimpeded.
            if (!ConfirmGradedMoveOnce(this, m_frame->State())) {
                return;
            }

            m_frame->State().PushUndo();
            m_movingSelection = true;

            // If any selected piece is a 74861 turntable, extend the
            // selection to include every piece joined to its spokes (the
            // included 74871 stubs and any extras the user added). This
            // must happen BEFORE the joint-break pass below; otherwise
            // the turntable-to-stub joints would be classed as
            // "selected-to-unselected" and severed, and the stubs would
            // get left behind when the turntable moves.
            {
                auto& state2 = m_frame->State();
                std::vector<int> stubsToSelect;
                for (const auto& p : state2.pieces) {
                    if (!p.selected) continue;
                    if (p.catalogNumber != "74861") continue;
                    for (const auto& j : p.joints) {
                        if (j.pieceId >= 0)
                            stubsToSelect.push_back(j.pieceId);
                    }
                }
                for (int id : stubsToSelect) {
                    if (auto* nb = state2.FindById(id)) nb->selected = true;
                }
            }

            // Break joints between selected and unselected pieces now —
            // at the moment the move actually starts. Joints within the
            // selection stay intact so the selection moves as a rigid
            // block; joints to outside pieces are cleared symmetrically
            // so the moving section presents clean, unjoined connectors
            // to the snap engine when the user drops it back near other
            // track. Done here rather than at arm-time so Shift-clicking
            // to extend the selection (which arms but doesn't move) does
            // NOT detach anything.
            {
                auto& st = m_frame->State();
                std::set<int> selIds;
                for (auto* p : st.Selected()) selIds.insert(p->id);
                for (auto* p : st.Selected()) {
                    for (size_t ci = 0; ci < p->joints.size(); ++ci) {
                        int otherId = p->joints[ci].pieceId;
                        if (otherId < 0) continue;
                        if (selIds.count(otherId)) continue;
                        int otherConn = p->joints[ci].connIdx;
                        p->joints[ci] = {};
                        MC::PlacedPiece* other = st.FindById(otherId);
                        if (other && otherConn >= 0 &&
                            otherConn < (int)other->joints.size())
                            other->joints[otherConn] = {};
                    }
                }
            }
        }
    }

    if (m_freeRotating && e.LeftIsDown()) {
        auto& state = m_frame->State();
        auto sel  = state.Selected();
        auto selS = state.SelectedScenery();
        auto selP = state.SelectedShapes();
        if (sel.empty() && selS.empty() && selP.empty()) {
            m_freeRotating = false; return;
        }

        // Compute angle from pivot to current mouse position
        wxPoint cur = e.GetPosition();
        wxPoint pivotScreen = m_view.ToScreen(m_rotPivot);
        double dx = cur.x - pivotScreen.x;
        double dy = cur.y - pivotScreen.y;

        double angleCur = std::atan2(dy, dx) * 180.0 / M_PI;
        double rawDeg   = angleCur - m_rotStartHeading;

        // Normalise to -180..180
        while (rawDeg >  180.0) rawDeg -= 360.0;
        while (rawDeg < -180.0) rawDeg += 360.0;

        // Normalise to 0..360 for display -- show absolute heading of first item
        // Shapes have no heading, so a shapes-only selection has an empty
        // m_rotOrigTransforms. Fall back to zero.
        double baseHeading = m_rotOrigTransforms.empty()
                           ? 0.0 : m_rotOrigTransforms[0].heading;
        double absHeading = baseHeading + rawDeg;
        double displayDeg = absHeading;
        while (displayDeg <    0.0) displayDeg += 360.0;
        while (displayDeg >= 360.0) displayDeg -= 360.0;

        // Snap absolute heading to cardinals within 2 degrees
        bool nearSnap = false;
        double snappedDeg = rawDeg;
        double cardinals[] = {0.0, 90.0, 180.0, 270.0, 360.0};
        for (double c : cardinals) {
            if (std::abs(displayDeg - c) < 2.0) {
                double target = (c == 360.0) ? 0.0 : c;
                snappedDeg = target - baseHeading;
                while (snappedDeg >  180.0) snappedDeg -= 360.0;
                while (snappedDeg < -180.0) snappedDeg += 360.0;
                displayDeg = target;
                nearSnap   = true;
                break;
            }
        }
        m_rotCurrentDeg = displayDeg;

        double rotRad = snappedDeg * M_PI / 180.0;
        double cosR = std::cos(rotRad), sinR = std::sin(rotRad);
        // m_rotOrigTransforms is laid out as [all pieces..., all scenery...]
        // matching the push order at Alt-down time.
        for (int i = 0; i < (int)sel.size(); ++i) {
            MC::Vec2 rel = m_rotOrigTransforms[i].pos - m_rotPivot;
            sel[i]->transform.pos = m_rotPivot + MC::Vec2{
                rel.x*cosR - rel.y*sinR,
                rel.x*sinR + rel.y*cosR
            };
            sel[i]->transform.heading = MC::Transform2D::normalizeAngle(
                m_rotOrigTransforms[i].heading + snappedDeg);
        }
        // If a single full helix is being rotated, keep its heading in sync
        // so the rendered interior curves follow the stubs.
        if (m_rotHelixId != -1) {
            for (auto& h : state.helices) {
                if (h.id == m_rotHelixId) {
                    h.heading = MC::Transform2D::normalizeAngle(
                        m_rotOrigHelixHeading + snappedDeg);
                    break;
                }
            }
        }
        for (int j = 0; j < (int)selS.size(); ++j) {
            int idx = (int)sel.size() + j;
            if (idx >= (int)m_rotOrigTransforms.size()) break;
            MC::Vec2 rel = m_rotOrigTransforms[idx].pos - m_rotPivot;
            selS[j]->transform.pos = m_rotPivot + MC::Vec2{
                rel.x*cosR - rel.y*sinR,
                rel.x*sinR + rel.y*cosR
            };
            selS[j]->transform.heading = MC::Transform2D::normalizeAngle(
                m_rotOrigTransforms[idx].heading + snappedDeg);
        }

        // Polygons: rotate every vertex about the pivot. Always taken from the
        // ORIGINAL vertices captured at Alt-down, never from the current ones,
        // so repeated mouse-moves cannot compound rounding error and distort
        // the shape.
        for (int k = 0; k < (int)selP.size(); ++k) {
            if (k >= (int)m_rotOrigShapeVerts.size()) break;
            const auto& orig = m_rotOrigShapeVerts[k];
            auto& verts = selP[k]->vertices;
            if (verts.size() != orig.size()) continue;
            for (size_t vi = 0; vi < orig.size(); ++vi) {
                MC::Vec2 rel = orig[vi] - m_rotPivot;
                verts[vi] = m_rotPivot + MC::Vec2{
                    rel.x*cosR - rel.y*sinR,
                    rel.x*sinR + rel.y*cosR
                };
            }
        }

        if (nearSnap && !m_rotSnapping) {
            m_rotSnapping = true;
            if (!m_rotSnapTimer) m_rotSnapTimer = new wxTimer(this);
            m_rotSnapTimer->StartOnce(400);
        } else if (!nearSnap) {
            m_rotSnapping = false;
            if (m_rotSnapTimer) m_rotSnapTimer->Stop();
        }

        m_frame->MarkDirty();
        Refresh();
        return;
    }

    if (m_draggingPiece) {
        m_dragTransform.pos = worldPos;
        UpdateDragSnap(e.GetPosition());
        Refresh();
        return;
    }

    if (m_draggingScenery) {
        m_dragSceneryTransform.pos = worldPos;
        Refresh();
        return;
    }

    if (m_draggingHelix) {
        m_dragHelixPos = worldPos;
        Refresh();
        return;
    }

    if (m_movingSelection && e.LeftIsDown()) {
        MC::Vec2 delta = worldPos - m_moveStartWorld;
        auto& state = m_frame->State();

        // Move selected track pieces
        auto selected = state.Selected();
        for (size_t i = 0; i < selected.size() && i < m_moveOrigTransforms.size(); ++i)
            selected[i]->transform.pos = m_moveOrigTransforms[i].pos + delta;

        // Move helix centers along with their pieces
        for (auto& kv : m_moveOrigHelixCenters) {
            for (auto& h : state.helices)
                if (h.id == kv.first) { h.center = kv.second + delta; break; }
        }

        // Move selected scenery items
        // (their originals are appended to m_moveOrigTransforms after piece transforms)
        size_t offset = selected.size();
        auto selScenery = state.SelectedScenery();
        for (size_t i = 0; i < selScenery.size(); ++i) {
            size_t idx = offset + i;
            if (idx < m_moveOrigTransforms.size())
                selScenery[i]->transform.pos = m_moveOrigTransforms[idx].pos + delta;
        }

        // Run snap detection for all selected pieces, keep best.
        // Candidates exclude selected pieces (the things being dragged)
        // and pieces on hidden levels (so deselected decks don't pull
        // the drag toward invisible track).
        m_moveSnap = {};
        m_moveSnapPieceId = -1;
        std::vector<MC::PlacedPiece> others;
        for (const auto& p : state.pieces)
            if (!p.selected && state.IsLevelVisible(p.levelId))
                others.push_back(p);
        for (auto* sp : selected) {
            const MC::TrackPiece* def = MC::GetCatalog().Find(sp->catalogNumber);
            if (!def || !state.snapEnabled) continue;
            MC::SnapResult r = MC::SnapEngine::FindSnap(*def, sp->transform, others, MC::GetCatalog());
            if (r.found && (!m_moveSnap.found || r.distanceMm < m_moveSnap.distanceMm)) {
                m_moveSnap = r;
                m_moveSnapPieceId = sp->id;
            }
        }
        Refresh();
        return;
    }

    if (m_rubberBanding && e.LeftIsDown()) {
        m_rubberEnd = e.GetPosition();
        Refresh();
        return;
    }

    e.Skip();
}

// ── Left Double Click — clone piece / resize scenery ─────────────────────────
void CanvasPanel::OnLeftDblClick(wxMouseEvent& e) {
    // Alt+double-click rotates (via OnLeftDown), never clones
    if (e.AltDown()) { e.Skip(); return; }

    // Check if we hit a free-form polygon shape — open its editor.
    // TRACK WINS: same reasoning as in OnLeftDown. Double-clicking a piece of
    // track sitting on a river bank should clone the piece, not open the bank's
    // editor. Double-clicking bare polygon still opens the editor.
    int shapeId = ShapeHitTest(e.GetPosition());
    if (shapeId >= 0 && HitTest(e.GetPosition()) >= 0) shapeId = -1;
    if (shapeId >= 0) {
        ShowShapeEditorDialog(shapeId);
        return;
    }

    // Check if we hit a scenery item first
    int sceneryId = SceneryHitTest(e.GetPosition());
    if (sceneryId >= 0) {
        ShowSceneryResizeDialog(sceneryId);
        return;
    }

    int hitId = HitTest(e.GetPosition());
    if (hitId < 0) { e.Skip(); return; }

    auto& state = m_frame->State();
    MC::PlacedPiece* orig = state.FindById(hitId);
    if (!orig) { e.Skip(); return; }

    // Helix stubs are locked — never clone off them via double-click.
    if (orig->helixId != -1) { e.Skip(); return; }

    const MC::TrackPiece* def = MC::GetCatalog().Find(orig->catalogNumber);
    if (!def || def->connections.empty()) { e.Skip(); return; }

    // Ensure joints vector is fully sized before reading
    orig->joints.resize(def->connections.size(), {-1, -1});

    // Helper: check if a connection index on this piece is truly free.
    // A connection is occupied if either:
    //   (a) orig->joints[i].pieceId != -1  (we recorded a join), OR
    //   (b) another piece has a joint pointing back to this piece at this conn index.
    auto isOccupied = [&](int connIdx) -> bool {
        if (orig->joints[connIdx].pieceId != -1) return true;
        for (const auto& other : state.pieces) {
            if (other.id == orig->id) continue;
            for (const auto& j : other.joints)
                if (j.pieceId == orig->id && j.connIdx == connIdx) return true;
        }
        return false;
    };

    // Find the best free primary connection to clone off.
    // Only consider conn[0] (entry) and conn[1] (straight exit).
    // Prefer conn[1] (forward) over conn[0] (backward).
    int openConnIdx = -1;
    if (def->connections.size() > 1 && !isOccupied(1))
        openConnIdx = 1;
    else if (!isOccupied(0))
        openConnIdx = 0;
    if (openConnIdx == -1) return;  // both primary ends occupied — no clone

    // New piece's joining connection: entry (0) when cloning off an exit,
    // exit (1) when cloning off the entry end
    int newConnIdx = (openConnIdx == 0) ? 1 : 0;
    if (newConnIdx >= (int)def->connections.size()) newConnIdx = 0;

    MC::ConnectionPoint targetConn = orig->worldConnection(*def, openConnIdx);
    MC::Transform2D newTransform =
        MC::SnapEngine::ComputeSnapTransform(*def, newConnIdx, targetConn);

    MC::PlacedPiece newPiece;
    newPiece.catalogNumber = orig->catalogNumber;
    newPiece.transform     = newTransform;
    newPiece.joints.resize(def->connections.size());
    newPiece.levelId       = orig->levelId;  // inherit level from cloned piece
    newPiece.z_mm          = orig->z_mm;     // ...and its elevation

    // AddPiece calls PushUndo — orig pointer may be invalidated
    int newId = state.AddPiece(newPiece);

    // Re-fetch both pointers after AddPiece
    MC::PlacedPiece* origAfter = state.FindById(hitId);
    MC::PlacedPiece* np        = state.FindById(newId);
    if (origAfter && np) {
        np->joints[newConnIdx]         = { hitId, openConnIdx };
        origAfter->joints[openConnIdx] = { newId, newConnIdx  };
        state.ClearSelection();
        np->selected = true;
        ApplyGradeToNewPiece(*np, hitId, openConnIdx, newConnIdx);
    }

    m_justCloned = true;
    m_frame->OnSelectionChanged();
    m_frame->MarkDirty();
    wxString pc; pc << "Pieces: " << (int)state.pieces.size();
    m_frame->SetStatusText(pc, 1);
    Refresh();
}

void CanvasPanel::OnRotSnapTimer(wxTimerEvent&) {
    m_rotSnapping = false;
    Refresh();
}

// ── RotateSelection ───────────────────────────────────────────────────────────
// Rotates all selected pieces (30 deg) and scenery items (15 deg) around the
// pivot of the first selected item. Called from both Alt+Left and Alt+Right.
void CanvasPanel::RotateSelection(bool clockwise)
{
    auto& state = m_frame->State();
    if (!state.HasSelection()) return;

    // Check whether selection is a complete-helix selection (allowed) vs
    // a partial-helix selection (which the tunnel/helix guard will reject).
    auto sel  = state.Selected();
    auto selS = state.SelectedScenery();
    auto selP = state.SelectedShapes();   // polygons rotate too
    int singleHelixId = -1;
    bool allSameHelix = !sel.empty();
    for (auto* p : sel) {
        if (p->helixId == -1) { allSameHelix = false; break; }
        if (singleHelixId == -1) singleHelixId = p->helixId;
        else if (singleHelixId != p->helixId) { allSameHelix = false; break; }
    }
    if (allSameHelix && !selS.empty()) allSameHelix = false;
    if (allSameHelix && !selP.empty()) allSameHelix = false;

    if (!allSameHelix) {
        if (!WarnIfTunneled(this, state, "rotate")) return;
    }
    state.PushUndo();

    double step  = clockwise ? 30.0 : -30.0;   // track step
    double stepS = clockwise ? 15.0 : -15.0;   // non-platform scenery step

    MC::Vec2 pivot;
    if (allSameHelix && singleHelixId != -1) {
        for (auto& h : state.helices) {
            if (h.id == singleHelixId) {
                pivot = h.center;
                h.heading = MC::Transform2D::normalizeAngle(h.heading + step);
                break;
            }
        }
    } else {
        pivot = !sel.empty()  ? sel[0]->transform.pos
              : !selS.empty() ? selS[0]->transform.pos
              : !selP.empty() ? MC::CanvasState::ShapeCentroid(*selP[0])
              : MC::Vec2{0,0};
    }

    double rotRad = step * M_PI / 180.0;
    double cosR = std::cos(rotRad), sinR = std::sin(rotRad);
    for (auto* p : sel) {
        MC::Vec2 rel = p->transform.pos - pivot;
        p->transform.pos = pivot + MC::Vec2{rel.x*cosR - rel.y*sinR,
                                            rel.x*sinR + rel.y*cosR};
        p->transform.heading = MC::Transform2D::normalizeAngle(p->transform.heading + step);
    }

    // Scenery: platforms and signals rotate exactly like track (30 deg step);
    // everything else uses the finer 15 deg step. Each item's per-step rotation
    // runs in its own loop so the pivot transformation matches its step.
    auto& sceneryCat = MC::GetSceneryCatalog();
    auto applyScenery = [&](double sStep) {
        double srad  = sStep * M_PI / 180.0;
        double cosS  = std::cos(srad), sinS = std::sin(srad);
        for (auto* s : selS) {
            const MC::SceneryItem* def = sceneryCat.Find(s->itemId);
            bool isTrackAligned = def && (def->category == MC::SceneryCategory::Platform ||
                                          def->category == MC::SceneryCategory::Signal);
            if ((isTrackAligned && std::abs(sStep - step) > 0.001) ||
                (!isTrackAligned && std::abs(sStep - stepS) > 0.001)) continue;
            MC::Vec2 rel = s->transform.pos - pivot;
            s->transform.pos = pivot + MC::Vec2{rel.x*cosS - rel.y*sinS,
                                                rel.x*sinS + rel.y*cosS};
            s->transform.heading = MC::Transform2D::normalizeAngle(s->transform.heading + sStep);
        }
    };
    applyScenery(step);     // platforms + signals
    applyScenery(stepS);    // everything else

    // Polygons: rotate every vertex about the pivot. Uses the finer 15 deg
    // step, matching non-track scenery -- 30 deg is too coarse for a shape.
    {
        double prad = stepS * M_PI / 180.0;
        double cosP = std::cos(prad), sinP = std::sin(prad);
        for (auto* sh : selP) {
            for (auto& v : sh->vertices) {
                MC::Vec2 rel = v - pivot;
                v = pivot + MC::Vec2{ rel.x*cosP - rel.y*sinP,
                                      rel.x*sinP + rel.y*cosP };
            }
        }
    }

    m_frame->MarkDirty();
    Refresh();
}

// ── Left Down ─────────────────────────────────────────────────────────────────
void CanvasPanel::OnLeftDown(wxMouseEvent& e) {
    SetFocus();

    // ── Dropping something dragged from the palette ───────────────────────
    // A palette drag does not commit on mouse-UP; it commits on the next click
    // here. So while a drag is in flight, this click is a DROP -- not a
    // selection -- and none of the hit-testing below should run.
    //
    // These checks have to come FIRST. They used to sit ~250 lines further
    // down, after the polygon / measurement / scenery hit-tests. So clicking to
    // drop a piece onto a polygon hit the polygon block instead: it selected
    // the polygon and returned, and the piece was left dangling on the cursor.
    // It only worked over blank canvas, where nothing intercepted the click.
    if (m_draggingPiece) {
        CommitDrag(e.GetPosition());
        return;
    }
    if (m_draggingScenery) {
        CommitSceneryDrag();
        return;
    }
    if (m_draggingHelix) {
        MC::Vec2 worldDrop = m_view.ToWorld(e.GetPosition());
        m_frame->PlaceHelix(worldDrop, 0.0, m_dragHelixVariant);
        m_draggingHelix = false;
        m_dragHelixVariant.clear();
        if (HasCapture()) ReleaseMouse();
        Refresh();
        return;
    }

    // ── Elevation wizard pick modes ─────────────────────────────────────────
    if (m_elevMode == 1) {
        // Picking anchor piece
        int hitId = HitTest(e.GetPosition());
        if (hitId < 0) {
            m_frame->SetStatusText("Elevation Wizard: no piece under cursor - try again", 0);
            return;
        }
        auto& st = m_frame->State();
        MC::PlacedPiece* piece = st.FindById(hitId);
        if (!piece) return;
        const MC::TrackPiece* def = MC::GetCatalog().Find(piece->catalogNumber);
        if (!def || def->connections.empty()) return;

        // Find the free connector closest to where the user clicked.
        // If only one is free, use that. If both free, pick the one
        // nearest to the click point so the user controls the direction.
        piece->joints.resize(def->connections.size(), {-1, -1});
        bool free0 = !piece->joints.empty() && piece->joints[0].pieceId == -1;
        bool free1 = piece->joints.size() > 1 && piece->joints[1].pieceId == -1;
        int connIdx = -1;

        if (free0 && free1) {
            // Both free — pick the connector closest to the click.
            MC::Vec2 click = m_view.ToWorld(e.GetPosition());
            MC::ConnectionPoint cp0 = piece->worldConnection(*def, 0);
            MC::ConnectionPoint cp1 = piece->worldConnection(*def, 1);
            double d0 = (click.x - cp0.pos.x) * (click.x - cp0.pos.x)
                      + (click.y - cp0.pos.y) * (click.y - cp0.pos.y);
            double d1 = (click.x - cp1.pos.x) * (click.x - cp1.pos.x)
                      + (click.y - cp1.pos.y) * (click.y - cp1.pos.y);
            connIdx = (d0 <= d1) ? 0 : 1;
        } else if (free1) {
            connIdx = 1;
        } else if (free0) {
            connIdx = 0;
        }

        if (connIdx < 0) {
            wxMessageBox("This piece has no free connectors.\n\n"
                         "Pick a piece at the end of a track run that has "
                         "an open connection.",
                         "Elevation Wizard", wxOK | wxICON_INFORMATION, this);
            return;
        }

        // Highlight the anchor
        st.ClearSelection();
        piece->selected = true;
        m_frame->OnSelectionChanged();

        m_elevMode = 0;
        SetCursor(wxNullCursor);

        if (m_elevMethod == 1) {
            // Method 1: Set Grade — show grade dialog
            DoElevationGrade(hitId, connIdx);
        } else {
            // Method 2: Set Target — next pick the end point
            DoElevationTarget(hitId, connIdx);
        }
        Refresh();
        return;
    }

    if (m_elevMode == 2) {
        // Picking end point for target-height method
        MC::Vec2 worldClick = m_view.ToWorld(e.GetPosition());

        // Connector heading is the outward/departure direction.
        double hr = m_elevAnchorPt.heading * M_PI / 180.0;
        double ax = std::cos(hr), ay = std::sin(hr);
        double dx = worldClick.x - m_elevAnchorPt.pos.x;
        double dy = worldClick.y - m_elevAnchorPt.pos.y;
        double distance = dx * ax + dy * ay;   // projection onto heading axis

        if (distance < 10.0) {
            m_frame->SetStatusText(
                "Elevation Wizard: click further along the track direction", 0);
            return;
        }

        m_elevMode = 0;
        SetCursor(wxNullCursor);

        // Ask for target height
        auto& st = m_frame->State();
        MC::PlacedPiece* anchor = st.FindById(m_elevAnchorId);
        if (!anchor) return;

        double startZ = anchor->z_mm;
        double scale = (st.units == 1) ? 0.1 : (st.units == 2) ? 1.0/25.4 : 1.0;
        const char* unit = (st.units == 1) ? "cm" : (st.units == 2) ? "in" : "mm";

        wxTextEntryDialog hdlg(this,
            wxString::Format(
                "Run distance: %.0f mm\n"
                "Starting height: %.1f %s\n\n"
                "What height should the far end reach?",
                distance, startZ * scale, unit),
            "Elevation Wizard - Target Height",
            wxString::Format("%.1f", (startZ + 50.0) * scale));
        if (hdlg.ShowModal() != wxID_OK) {
            m_frame->SetStatusText("Elevation Wizard cancelled", 0);
            Refresh();
            return;
        }

        double targetShown = 0.0;
        if (!hdlg.GetValue().ToDouble(&targetShown)) {
            wxMessageBox("That is not a number.", "Elevation Wizard",
                         wxOK | wxICON_WARNING, this);
            return;
        }
        double targetZ = targetShown / scale;   // back to mm

        PlaceElevatedRun(m_elevAnchorId, m_elevAnchorConn,
                         m_elevAnchorPt, distance, targetZ);
        return;
    }

    // Measure tool: two clicks place a measurement. First click sets the
    // start; we go into "measuring" state and the second click commits.
    auto& topState = m_frame->State();
    if (topState.toolMode == MC::ToolMode::Measure) {
        MC::Vec2 w = m_view.ToWorld(e.GetPosition());
        if (!m_measuring) {
            m_measuring = true;
            m_measureA  = w;
            m_measureB  = w;       // live-tracked in OnMouseMove
            Refresh();
        } else {
            // Commit
            topState.PushUndo();
            MC::Measurement m;
            m.id = topState.nextId++;
            m.a  = m_measureA;
            m.b  = w;
            // Refuse near-zero-length measurements
            double dx = m.b.x - m.a.x, dy = m.b.y - m.a.y;
            if (std::sqrt(dx*dx + dy*dy) >= 1.0)
                topState.measurements.push_back(m);
            m_measuring = false;

            // Leave Measure mode after placing one measurement; uncheck the
            // toolbar button so the cursor goes back to normal. The user can
            // re-arm it for another measurement with one click on the button.
            topState.toolMode = MC::ToolMode::Select;
            wxToolBar* tb = m_frame->GetToolBar();
            if (tb) {
                tb->ToggleTool(ID_Measure, false);
            }
            SetCursor(wxNullCursor);
            m_frame->MarkDirty();
            Refresh();
        }
        return;
    }

    // Polygon / BaseboardDraw tools: each click drops a vertex. If the
    // click lands within ~10px of the FIRST vertex (and we already have at
    // least 3 vertices), close the polygon and commit. Polygon mode adds a
    // new CanvasShape; BaseboardDraw mode replaces state.baseboard.
    if (topState.toolMode == MC::ToolMode::Polygon ||
        topState.toolMode == MC::ToolMode::BaseboardDraw)
    {
        bool isBaseboard = (topState.toolMode == MC::ToolMode::BaseboardDraw);
        MC::Vec2 w = m_view.ToWorld(e.GetPosition());
        const double closeTolMm = 10.0 / std::max(0.0001, m_view.scale);
        if (m_drawingPolygon && m_polygonVerts.size() >= 3) {
            double dx = w.x - m_polygonVerts[0].x;
            double dy = w.y - m_polygonVerts[0].y;
            if (std::sqrt(dx*dx + dy*dy) <= closeTolMm) {
                topState.PushUndo();
                if (isBaseboard) {
                    topState.baseboard = m_polygonVerts;
                    // Remember that the user chose free-form for next
                    // dialog open. shapeId 5 = SH_FREEFORM in the enum.
                    topState.lastShape.shapeId = 5;
                } else {
                    MC::CanvasShape s;
                    s.id       = topState.nextId++;
                    s.vertices = m_polygonVerts;
                    // Inherit highest visible level
                    int bestId = topState.activeLevelId;
                    double bestH = -1e9;
                    for (const auto& l : topState.levels) {
                        if (!l.visible) continue;
                        if (l.heightMm > bestH) { bestH = l.heightMm; bestId = l.id; }
                    }
                    s.levelId = bestId;
                    topState.shapes.push_back(s);
                }
                m_drawingPolygon = false;
                m_polygonVerts.clear();
                m_polygonCloseHover = false;
                topState.toolMode = MC::ToolMode::Select;
                wxToolBar* tb = m_frame->GetToolBar();
                if (tb && !isBaseboard) tb->ToggleTool(ID_Polygon, false);
                SetCursor(wxNullCursor);
                if (isBaseboard) m_frame->SetStatusText("");
                m_frame->MarkDirty();
                Refresh();
                return;
            }
        }
        // Otherwise append the vertex.
        if (!m_drawingPolygon) m_drawingPolygon = true;
        m_polygonVerts.push_back(w);
        m_polygonCursorWorld = w;
        Refresh();
        return;
    }


    // arm a drag. Done before normal hit-testing so the measurement always
    // wins when the cursor is precisely on its line or label.
    if (!e.AltDown() && !e.ShiftDown() && !e.ControlDown()) {
        int what = 0;
        int mid  = MeasurementHitTest(e.GetPosition(), &what);
        if (mid >= 0) {
            auto& st = m_frame->State();
            st.ClearSelection();
            MC::Measurement* m = nullptr;
            for (auto& mm : st.measurements) if (mm.id == mid) { m = &mm; break; }
            if (m) {
                m->selected = true;
                st.PushUndo();
                m_measureDragWhat        = what;
                m_measureDragId          = mid;
                m_measureDragStartWorld  = m_view.ToWorld(e.GetPosition());
                m_measureOrigA           = m->a;
                m_measureOrigB           = m->b;
                m_measureOrigLabelOffset = m->labelOffset;
                if (!HasCapture()) CaptureMouse();
            }
            Refresh();
            return;
        }

        // Click on an existing free-form polygon shape — select & arm drag.
        //
        // TRACK WINS. A polygon is scenery you place once and rarely touch; the
        // track on top of it is what the user handles constantly. The polygon
        // hit-test runs well before the track one, so without this guard a big
        // shape (a river bank, a field) would swallow every click inside it and
        // the track laid over it became unselectable.
        //
        // So: if a piece of track is under the cursor, ignore the shape and let
        // the click fall through to the track handling below. Clicking a part
        // of the polygon with no track on it still selects the polygon.
        int sid = ShapeHitTest(e.GetPosition());
        if (sid >= 0 && HitTest(e.GetPosition()) >= 0) sid = -1;
        if (sid >= 0) {
            auto& st = m_frame->State();
            st.ClearSelection();
            MC::CanvasShape* sp = nullptr;
            for (auto& s : st.shapes) if (s.id == sid) { sp = &s; break; }
            if (sp) {
                sp->selected = true;
                st.PushUndo();
                m_draggingShape       = true;
                m_dragShapeId         = sid;
                m_shapeDragStartWorld = m_view.ToWorld(e.GetPosition());
                m_shapeOrigVerts      = sp->vertices;
                if (!HasCapture()) CaptureMouse();
                // Tell the Properties panel the selection changed. Without
                // this it never learns a shape was picked, so it shows nothing
                // and its Level dropdown stays greyed out -- even though
                // ShowShapeProperties() is perfectly capable of populating it.
                m_frame->OnSelectionChanged();
            }
            Refresh();
            return;
        }
    }

    // Platform resize: if a single platform is selected and the click landed
    // on one of its 8 handles, start a resize gesture. Checked BEFORE Alt+drag
    // and normal hit-testing so handles always win when they exist.
    if (!e.AltDown()) {
        int platId = GetResizablePlatformId();
        if (platId >= 0) {
            int handle = HitTestPlatformHandle(e.GetPosition(), platId);
            if (handle >= 0) {
                auto& state = m_frame->State();
                MC::PlacedScenery* s = state.FindSceneryById(platId);
                const MC::SceneryItem* def = s ? MC::GetSceneryCatalog().Find(s->itemId)
                                               : nullptr;
                if (s && def) {
                    state.PushUndo();
                    m_resizingPlatform = true;
                    m_resizeSceneryId  = platId;
                    m_resizeHandle     = handle;
                    m_resizeOrigScaleX = s->scaleX;
                    m_resizeOrigScaleY = s->scaleY;
                    m_resizeOrigPos    = s->transform.pos;
                    m_resizeOrigHeading = s->transform.heading;
                    int opp = OppositeHandle(handle);
                    m_resizeAnchorWorld = PlatformHandleWorldPos(*s, *def, opp);
                    if (!HasCapture()) CaptureMouse();
                    return;
                }
            }
        }
    }

    // Alt + left-drag → free rotation around piece center
    if (e.AltDown()) {
        auto& state = m_frame->State();
        auto sel  = state.Selected();
        auto selS = state.SelectedScenery();
        auto selP = state.SelectedShapes();   // polygons rotate too
        if (!sel.empty() || !selS.empty() || !selP.empty()) {
            // A complete-helix selection bypasses the tunnel/helix guard:
            // the helix rotates as one unit, exactly like a single piece.
            int hxId = SelectionSingleHelixId(state);
            if (hxId == -1) {
                if (!WarnIfTunneled(this, state, "rotate")) return;
            }
            state.PushUndo();
            m_freeRotating   = true;
            m_rotStartScreen = e.GetPosition();
            m_rotHelixId         = hxId;
            m_rotOrigHelixHeading = 0.0;
            if (hxId != -1) {
                // Pivot = helix center; record its current heading.
                for (const auto& h : state.helices) {
                    if (h.id == hxId) {
                        m_rotPivot = h.center;
                        m_rotOrigHelixHeading = h.heading;
                        break;
                    }
                }
            } else {
                // Pivot = center of bounding box of selection (pieces + scenery)
                MC::Vec2 sum{0,0};
                int count = 0;
                for (auto* p : sel)  { sum = sum + p->transform.pos; ++count; }
                for (auto* s : selS) { sum = sum + s->transform.pos; ++count; }
                for (auto* sh : selP) {
                    sum = sum + MC::CanvasState::ShapeCentroid(*sh); ++count;
                }
                m_rotPivot = sum * (1.0 / std::max(1, count));
            }
            m_rotOrigTransforms.clear();
            for (auto* p : sel)  m_rotOrigTransforms.push_back(p->transform);
            for (auto* s : selS) m_rotOrigTransforms.push_back(s->transform);
            m_rotOrigShapeVerts.clear();
            for (auto* sh : selP) m_rotOrigShapeVerts.push_back(sh->vertices);
            m_rotCurrentDeg  = 0.0;
            m_rotSnapping    = false;
            // Store start angle from pivot to mouse
            wxPoint pivotScreen = m_view.ToScreen(m_rotPivot);
            double dxs = e.GetPosition().x - pivotScreen.x;
            double dys = e.GetPosition().y - pivotScreen.y;
            m_rotStartHeading = std::atan2(dys, dxs) * 180.0 / M_PI;
            // Store original absolute heading of first item for display
            // A shapes-only selection has neither pieces nor scenery, so guard
            // against indexing an empty vector. Shapes carry no heading of
            // their own -- the readout starts from zero.
            m_rotCurrentDeg = !sel.empty()  ? sel[0]->transform.heading
                            : !selS.empty() ? selS[0]->transform.heading
                            : 0.0;
            // Normalise to 0..360
            while (m_rotCurrentDeg <    0.0) m_rotCurrentDeg += 360.0;
            while (m_rotCurrentDeg >= 360.0) m_rotCurrentDeg -= 360.0;
            if (!HasCapture()) CaptureMouse();
            return;
        }
    }

    // Suppress the OnLeftDown that fires immediately after OnLeftDblClick
    if (m_justCloned) { m_justCloned = false; return; }


    auto& state = m_frame->State();

    // Check for label hit first
    MC::Vec2 worldPt = m_view.ToWorld(e.GetPosition());
    for (auto& lbl : state.labels) {
        // Approximate hit: within 80px screen distance
        wxPoint lblScreen = m_view.ToScreen(lbl.pos);
        int dx = e.GetPosition().x - lblScreen.x;
        int dy = e.GetPosition().y - lblScreen.y;
        if (dx >= -4 && dx <= 120 && dy >= -4 && dy <= 24) {
            // Select this label, deselect others
            for (auto& l2 : state.labels) l2.selected = false;
            lbl.selected = true;
            m_draggingLabel  = true;
            m_dragLabelId    = lbl.id;
            m_dragLabelOffset = worldPt - lbl.pos;
            if (!HasCapture()) CaptureMouse();
            SetFocus();
            Refresh();
            return;
        }
    }
    // Deselect all labels on click elsewhere
    for (auto& lbl : state.labels) lbl.selected = false;

    // Helix curves are rendered ghost-only (not in state.pieces). If the
    // click lands on a perceived R1/R2 ring, promote the hit to the helix's
    // bottom-R1 stub so the rest of the selection/move machinery kicks in
    // and SelectHelixSiblings selects the whole helix as a unit.
    int hitId = HitTest(e.GetPosition());
    if (hitId < 0) {
        int hxId = HelixHitTest(e.GetPosition());
        if (hxId >= 0) {
            for (const auto& h : state.helices) {
                if (h.id == hxId) { hitId = h.bottomStubR1; break; }
            }
        }
    }

    // No track or helix under the cursor — if the user holds SHIFT while
    // clicking on a level's deck outline, drag that deck. Without Shift, a
    // click in empty space falls through to rubber-band selection (so the
    // deck-grab can't accidentally swallow a rubber-band start).
    if (hitId < 0 && e.ShiftDown() && !e.ControlDown()) {
        int deckLevel = DeckHitTest(e.GetPosition());
        if (deckLevel >= 0) {
            MC::Level* lvl = state.FindLevel(deckLevel);
            if (lvl && lvl->shape.size() >= 3) {
                state.PushUndo();
                m_draggingDeck       = true;
                m_dragDeckLevel      = deckLevel;
                m_deckDragStartWorld = m_view.ToWorld(e.GetPosition());
                m_deckOrigShape      = lvl->shape;
                if (!HasCapture()) CaptureMouse();
                SetFocus();
                return;
            }
        }
    }

    if (hitId >= 0) {
        MC::PlacedPiece* hit = state.FindById(hitId);
        bool selectionChanged = false;
        bool multiSelect = e.ShiftDown() || e.ControlDown();
        if (multiSelect && e.ShiftDown()) {
            // Shift+click: select chain from last selected piece to this one
            auto sel = state.Selected();
            int fromId = sel.empty() ? -1 : sel.back()->id;
            if (fromId >= 0 && fromId != hitId) {
                // BFS from fromId to hitId through joints
                std::unordered_map<int,int> parent;
                std::queue<int> q;
                q.push(fromId);
                parent[fromId] = -1;
                bool found = false;
                while (!q.empty() && !found) {
                    int cur = q.front(); q.pop();
                    MC::PlacedPiece* p = state.FindById(cur);
                    if (!p) continue;
                    for (auto& j : p->joints) {
                        if (j.pieceId < 0 || parent.count(j.pieceId)) continue;
                        parent[j.pieceId] = cur;
                        if (j.pieceId == hitId) { found = true; break; }
                        q.push(j.pieceId);
                    }
                }
                if (found) {
                    // Walk path back from hitId to fromId and select all
                    int cur = hitId;
                    while (cur >= 0) {
                        MC::PlacedPiece* p = state.FindById(cur);
                        if (p) p->selected = true;
                        auto it = parent.find(cur);
                        cur = (it != parent.end()) ? it->second : -1;
                    }
                } else {
                    // No path found — just toggle
                    hit->selected = !hit->selected;
                }
            } else {
                hit->selected = !hit->selected;
            }
            selectionChanged = true;
        } else if (!multiSelect) {
            if (!hit->selected) {
                // Clicking unselected piece — clear and select just this one,
                // OR if it's part of a helix, select the whole helix.
                state.ClearSelection();
                if (!SelectHelixSiblings(state, hitId) &&
                    !SelectTurntableStubs(state, hitId))
                {
                    hit->selected = true;
                }
                selectionChanged = true;
            }
            // If already selected, keep selection intact and begin move
        } else {
            // Ctrl+click: toggle single piece
            hit->selected = !hit->selected;
            selectionChanged = true;
        }
        if (selectionChanged) {
            PromotePartialHelixSelections(state);
            m_frame->OnSelectionChanged();
        }

        // Block movement if the hit piece is connected on both primary ends.
        // A fully-connected piece should not be dragged out of its joined position.
        bool fullyConnected = false;
        {
            const MC::TrackPiece* hitDef = MC::GetCatalog().Find(hit->catalogNumber);
            if (hitDef && hitDef->connections.size() >= 2) {
                hit->joints.resize(hitDef->connections.size(), {-1,-1});
                auto occupiedByUnselected = [&](int ci) {
                    if (hit->joints[ci].pieceId != -1) {
                        MC::PlacedPiece* nb = state.FindById(hit->joints[ci].pieceId);
                        if (nb && !nb->selected) return true;
                    }
                    for (const auto& other : state.pieces) {
                        if (other.id == hit->id || other.selected) continue;
                        for (const auto& j : other.joints)
                            if (j.pieceId == hit->id && j.connIdx == ci) return true;
                    }
                    return false;
                };
                fullyConnected = occupiedByUnselected(0) && occupiedByUnselected(1);
            }
        }
        // Fully connected pieces can still be moved — allow movement always

        // Begin move — arm it, wait for threshold in OnMouseMove
        m_moveArmed      = true;
        m_moveArmScreen  = e.GetPosition();
        m_movingSelection = false;
        m_moveStartWorld  = m_view.ToWorld(e.GetPosition());
        m_moveOrigTransforms.clear();
        for (auto* p : state.Selected())
            m_moveOrigTransforms.push_back(p->transform);
        for (auto& s : state.scenery)
            if (s.selected) m_moveOrigTransforms.push_back(s.transform);

        // Capture original helix centers for any helices that have all
        // their pieces selected (so we can update the helix record's center
        // as the selection moves).
        m_moveOrigHelixCenters.clear();
        std::unordered_set<int> hxSeen;
        for (auto* p : state.Selected()) {
            if (p->helixId == -1 || hxSeen.count(p->helixId)) continue;
            hxSeen.insert(p->helixId);
            for (auto& h : state.helices)
                if (h.id == p->helixId) {
                    m_moveOrigHelixCenters.push_back({h.id, h.center});
                    break;
                }
        }
        if (!HasCapture()) CaptureMouse();
    } else {
        // Check scenery hit
        int sceneryId = SceneryHitTest(e.GetPosition());
        if (sceneryId >= 0) {
            MC::PlacedScenery* hit = state.FindSceneryById(sceneryId);
            if (!e.ShiftDown()) {
                if (!hit->selected) state.ClearSelection();
                hit->selected = true;
            } else {
                hit->selected = !hit->selected;
            }
            m_frame->OnSelectionChanged();
            // Begin move for scenery — stored in m_moveStartWorld,
            // but we track scenery separately in OnMouseMove
            m_movingSelection = true;
            m_moveStartWorld  = m_view.ToWorld(e.GetPosition());
            m_moveOrigTransforms.clear();
            // Store scenery transforms as "move originals"
            for (auto& s : state.scenery)
                if (s.selected) m_moveOrigTransforms.push_back(s.transform);
            if (!HasCapture()) CaptureMouse();
        } else {
            // Left click on empty canvas: rubber-band select.
            // Hold Ctrl with left-drag to pan instead. Middle-press also pans.
            state.ClearSelection();
            m_clipboard.clear();
            m_clipboardScenery.clear();
                m_clipboardShapeValid = false;
            m_frame->OnSelectionChanged();
            if (e.ControlDown()) {
                m_panning   = true;
                m_panStart  = e.GetPosition();
                m_panStartX = m_view.panX;
                m_panStartY = m_view.panY;
                m_frame->OnCanvasEmptyClick(e.GetPosition());
            } else {
                m_rubberBanding = true;
                m_rubberStart = m_rubberEnd = e.GetPosition();
            }
            if (!HasCapture()) CaptureMouse();
        }
    }
    SetFocus();
    Refresh();
}

// ── Left Up ───────────────────────────────────────────────────────────────────
void CanvasPanel::OnLeftUp(wxMouseEvent& e) {
    if (HasCapture()) ReleaseMouse();

    if (m_draggingShape) {
        m_draggingShape = false;
        m_dragShapeId   = -1;
        m_shapeOrigVerts.clear();
        m_frame->MarkDirty();
        Refresh();
        return;
    }

    if (m_measureDragWhat) {
        m_measureDragWhat = 0;
        m_measureDragId   = -1;
        m_frame->MarkDirty();
        Refresh();
        return;
    }

    if (m_draggingDeck) {
        m_draggingDeck  = false;
        m_dragDeckLevel = -1;
        m_deckOrigShape.clear();
        m_frame->MarkDirty();
        Refresh();
        return;
    }

    if (m_resizingPlatform) {
        m_resizingPlatform = false;
        m_resizeSceneryId  = -1;
        m_resizeHandle     = -1;
        m_frame->MarkDirty();
        Refresh();
        return;
    }

    if (m_freeRotating) {
        m_freeRotating = false;
        m_rotSnapping  = false;
        m_rotHelixId   = -1;
        if (m_rotSnapTimer) { m_rotSnapTimer->Stop(); }
        m_frame->MarkDirty();
        Refresh();
        return;
    }

    m_moveArmed = false;

    if (m_draggingLabel) {
        m_draggingLabel = false;
        m_dragLabelId = -1;
        m_frame->MarkDirty();
        if (HasCapture()) ReleaseMouse();
        Refresh();
        return;
    }

    if (m_panning) {
        m_panning = false;
        SetCursor(wxNullCursor);
    }
    if (m_spaceWasPanning) {
        m_spaceWasPanning = false;
        SetCursor(wxNullCursor);
    }

    if (m_rubberBanding) {
        m_rubberBanding = false;
        int rx1=std::min(m_rubberStart.x,m_rubberEnd.x), ry1=std::min(m_rubberStart.y,m_rubberEnd.y);
        int rx2=std::max(m_rubberStart.x,m_rubberEnd.x), ry2=std::max(m_rubberStart.y,m_rubberEnd.y);
        if (rx2-rx1 > 4 || ry2-ry1 > 4) {
            auto& state2 = m_frame->State();
            auto& catalog = MC::GetCatalog();
            for (auto& p : state2.pieces) {
                if (!state2.IsLevelVisible(p.levelId)) continue;
                const MC::TrackPiece* def = catalog.Find(p.catalogNumber);
                if (!def) continue;

                // Build the piece's actual drawn centerline(s) and test
                // each segment for true intersection with the rubber-band
                // rectangle. This selects a piece only if the rectangle
                // visually touches its track -- straight, curved, or at
                // any angle -- rather than testing a single anchor point
                // (too strict) or an axis-aligned bounding box (too loose
                // for diagonal pieces).
                std::vector<std::vector<MC::Vec2>> routes;
                BuildPiecePathPoints(p, *def, routes);

                bool hit = false;
                for (const auto& route : routes) {
                    for (size_t i = 0; i + 1 < route.size() && !hit; ++i) {
                        wxPoint sa = m_view.ToScreen(route[i]);
                        wxPoint sb = m_view.ToScreen(route[i+1]);
                        if (SegmentIntersectsRect(sa, sb, rx1, ry1, rx2, ry2))
                            hit = true;
                    }
                    if (hit) break;
                }
                if (hit) p.selected = true;
            }
            for (auto& s : state2.scenery) {
                if (!state2.IsLevelVisible(s.levelId)) continue;
                wxPoint sc = m_view.ToScreen(s.transform.pos);
                if (sc.x>=rx1&&sc.x<=rx2&&sc.y>=ry1&&sc.y<=ry2) s.selected=true;
            }
            // A helix must move as a unit; if the rubber-band grabbed
            // some of its stubs but not all, expand to include them.
            PromotePartialHelixSelections(state2);
            m_clipboard.clear();
            m_clipboardScenery.clear();
                m_clipboardShapeValid = false;
            m_frame->OnSelectionChanged();
        }
        SetFocus();
        Refresh();
        return;
    }

    if (m_movingSelection) {
        m_movingSelection = false;

        // Check whether any selected piece actually moved from its start position
        auto& state = m_frame->State();
        auto selected = state.Selected();
        bool actuallyMoved = false;
        for (size_t i = 0; i < selected.size() && i < m_moveOrigTransforms.size(); ++i) {
            MC::Vec2 delta = selected[i]->transform.pos - m_moveOrigTransforms[i].pos;
            if (delta.length() > 1.0) { actuallyMoved = true; break; }
        }

        if (actuallyMoved) {
            // Break joints to non-selected neighbours — they've been left behind
            std::set<int> selIds;
            for (auto* p : selected) selIds.insert(p->id);
            for (auto* p : selected) {
                for (auto& j : p->joints) {
                    if (j.pieceId != -1 && selIds.find(j.pieceId) == selIds.end()) {
                        MC::PlacedPiece* other = state.FindById(j.pieceId);
                        if (other) {
                            for (auto& oj : other->joints)
                                if (oj.pieceId == p->id) { oj.pieceId = -1; oj.connIdx = -1; }
                        }
                        j.pieceId = -1; j.connIdx = -1;
                    }
                }
            }
        }
        // If snap was found, snap the piece into place and wire joint
        if (m_moveSnap.found) {
            auto& state = m_frame->State();
            auto selected = state.Selected();
            if (!selected.empty()) {
                // Find the actual piece that triggered the snap
                MC::PlacedPiece* snapPiece = nullptr;
                for (auto* p : selected)
                    if (p->id == m_moveSnapPieceId) { snapPiece = p; break; }
                if (!snapPiece) snapPiece = selected[0];

                const MC::TrackPiece* def = MC::GetCatalog().Find(snapPiece->catalogNumber);
                MC::PlacedPiece* tp = state.FindById(m_moveSnap.targetPieceId);
                if (def && tp) {
                    const MC::TrackPiece* tpDef = MC::GetCatalog().Find(tp->catalogNumber);
                    MC::Transform2D snapped = MC::SnapEngine::ComputeSnapTransform(
                        *def, m_moveSnap.sourceConnIdx,
                        tp->worldConnection(*tpDef, m_moveSnap.targetConnIdx));

                    // Apply the rigid motion that maps the snap piece's
                    // ORIGINAL transform to the snapped transform. Express
                    // every other selected piece's position relative to the
                    // snap piece's old origin, rotate by headingDelta, then
                    // translate by the snap piece's NEW origin. This pins
                    // the snap piece exactly at `snapped` no matter what
                    // headingDelta is — preventing the "+/+ repulsion"
                    // bug where the group flew out instead of snapping.
                    MC::Vec2  origSnap     = snapPiece->transform.pos;
                    double    origSnapHd   = snapPiece->transform.heading;
                    double    headingDelta = snapped.heading - origSnapHd;
                    double    cosH = std::cos(headingDelta * M_PI / 180.0);
                    double    sinH = std::sin(headingDelta * M_PI / 180.0);
                    for (auto* p : selected) {
                        double rx = p->transform.pos.x - origSnap.x;
                        double ry = p->transform.pos.y - origSnap.y;
                        p->transform.pos.x = snapped.pos.x + rx*cosH - ry*sinH;
                        p->transform.pos.y = snapped.pos.y + rx*sinH + ry*cosH;
                        p->transform.heading += headingDelta;
                    }

                    // If any of the moved pieces are helix stubs, carry
                    // the helix's center/heading along the same rigid
                    // motion so the spirals stay aligned with the stubs.
                    {
                        std::set<int> movedHelixIds;
                        for (auto* p : selected)
                            if (p->helixId != -1) movedHelixIds.insert(p->helixId);
                        for (auto& h : state.helices) {
                            if (!movedHelixIds.count(h.id)) continue;
                            double rx = h.center.x - origSnap.x;
                            double ry = h.center.y - origSnap.y;
                            h.center.x = snapped.pos.x + rx*cosH - ry*sinH;
                            h.center.y = snapped.pos.y + rx*sinH + ry*cosH;
                            h.heading += headingDelta;
                        }
                    }

                    // Resize joints for all selected pieces first
                    for (auto* p : selected) {
                        const MC::TrackPiece* pd = MC::GetCatalog().Find(p->catalogNumber);
                        if (pd) p->joints.resize(pd->connections.size(), {-1,-1});
                    }

                    // Wire snap connector
                    snapPiece->joints[m_moveSnap.sourceConnIdx] = { tp->id, m_moveSnap.targetConnIdx };
                    tp->joints.resize(tpDef->connections.size(), {-1,-1});
                    tp->joints[m_moveSnap.targetConnIdx] = { snapPiece->id, m_moveSnap.sourceConnIdx };

                    // Re-derive the moved piece's elevation from the new
                    // anchor, then shift any other selected pieces by the
                    // same delta so the whole moved group keeps its
                    // internal z relationships. Without this, a piece
                    // detached from a z=0 stub and re-snapped to a z=260
                    // stub would keep its old z=0 in 3D.
                    //
                    // Helix stubs are excluded: their z is part of their
                    // identity (0=bottom, totalRise=top) and must not be
                    // re-derived from whatever the snap target sits at.
                    if (snapPiece->helixId == -1) {
                        double oldZ = snapPiece->z_mm;
                        ApplyGradeToNewPiece(*snapPiece, tp->id,
                                             m_moveSnap.targetConnIdx,
                                             m_moveSnap.sourceConnIdx);
                        double dz = snapPiece->z_mm - oldZ;
                        if (std::abs(dz) > 0.001) {
                            for (auto* p : selected) {
                                if (p->id == snapPiece->id) continue;
                                if (p->helixId != -1) continue;   // never shift helix stubs
                                p->z_mm += dz;
                            }
                        }
                    }

                    // Wire any other free connectors of moved group that now touch unselected pieces
                    std::set<int> selIds;
                    for (auto* p : selected) selIds.insert(p->id);
                    for (auto* p : selected) {
                        const MC::TrackPiece* pd = MC::GetCatalog().Find(p->catalogNumber);
                        if (!pd) continue;
                        for (int ci = 0; ci < (int)pd->connections.size(); ++ci) {
                            if (p->joints[ci].pieceId >= 0) continue;
                            MC::ConnectionPoint myConn = p->worldConnection(*pd, ci);
                            for (auto& other : state.pieces) {
                                if (selIds.count(other.id)) continue;
                                const MC::TrackPiece* od = MC::GetCatalog().Find(other.catalogNumber);
                                if (!od) continue;
                                for (int oi = 0; oi < (int)od->connections.size(); ++oi) {
                                    if (other.joints.size() > (size_t)oi && other.joints[oi].pieceId >= 0) continue;
                                    MC::ConnectionPoint oConn = other.worldConnection(*od, oi);
                                    double dx = myConn.pos.x - oConn.pos.x;
                                    double dy = myConn.pos.y - oConn.pos.y;
                                    if (dx*dx + dy*dy < 4.0*4.0) { // 4mm tolerance
                                        p->joints[ci] = { other.id, oi };
                                        const_cast<MC::PlacedPiece&>(other).joints.resize(od->connections.size(), {-1,-1});
                                        const_cast<MC::PlacedPiece&>(other).joints[oi] = { p->id, ci };
                                    }
                                }
                            }
                        }
                    }
                }
            }
            m_moveSnap = {};
        }
        m_frame->MarkDirty();
    }

    e.Skip();
}

// ── Right Down ────────────────────────────────────────────────────────────────
void CanvasPanel::OnRightDown(wxMouseEvent& e) {
    // During a piece drag: right-click rotates piece (unchanged)
    if (m_draggingPiece) {
        m_dragTransform.heading =
            MC::Transform2D::normalizeAngle(m_dragTransform.heading + 30.0);
        UpdateDragSnap(e.GetPosition());
        Refresh();
        return;
    }
    if (m_draggingScenery) {
        m_dragSceneryTransform.heading =
            MC::Transform2D::normalizeAngle(m_dragSceneryTransform.heading + 15.0);
        Refresh();
        return;
    }

    auto& state = m_frame->State();

    // Alt + right-click → rotate selected 15 degrees
    if (e.AltDown() && state.HasSelection()) {
        RotateSelection(true);
        return;
    }

    int hitId = HitTest(e.GetPosition());
    if (hitId < 0) {
        int hxId = HelixHitTest(e.GetPosition());
        if (hxId >= 0) {
            for (const auto& h : state.helices) {
                if (h.id == hxId) { hitId = h.bottomStubR1; break; }
            }
        }
    }

    // Right-click on a polygon shape → Copy context menu
    //
    // TRACK WINS. hitId above already told us whether track is under the
    // cursor. Without this guard the polygon block ran anyway: it reassigned
    // state.shapes[].selected -- wiping the user's two-piece track selection --
    // and popped the polygon's Copy menu instead of the Autofill menu. Which is
    // why Autofill only worked once the polygon was moved out of the way.
    {
        int shapeId = (hitId >= 0) ? -1 : ShapeHitTest(e.GetPosition());
        if (shapeId >= 0) {
            for (auto& s : state.shapes) s.selected = (s.id == shapeId);
            m_frame->OnSelectionChanged();
            wxMenu menu;
            menu.Append(wxID_COPY, "Copy");
            menu.Bind(wxEVT_MENU, [this, shapeId](wxCommandEvent&) {
                auto& st = m_frame->State();
                for (const auto& s : st.shapes) {
                    if (s.id == shapeId) {
                        m_clipboardShape      = s;
                        m_clipboardShapeValid = true;
                        break;
                    }
                }
            }, wxID_COPY);
            PopupMenu(&menu);
            return;
        }
    }

    // Right-click on a piece with selection → context menu
    if (hitId >= 0) {
        auto selected = state.Selected();
        // Make sure hit piece is selected
        MC::PlacedPiece* hitPiece = state.FindById(hitId);
        if (hitPiece && !hitPiece->selected) {
            state.ClearSelection();
            // If the hit piece is a helix stub, select the whole helix.
            if (!SelectHelixSiblings(state, hitId)) hitPiece->selected = true;
            m_frame->OnSelectionChanged();
            selected = state.Selected();
        }
        wxMenu menu;
        if (selected.size() == 2 && CanAutofill())
            menu.Append(wxID_HIGHEST + 1, "Autofill Gap...");

        // End Grade: only show when exactly one piece is selected and it
        // carries a non-zero grade (i.e. it is part of an elevation chain).
        if (selected.size() == 1 && hitPiece &&
            std::abs(hitPiece->gradeDeg) > 0.001) {
            menu.Append(wxID_HIGHEST + 2, "End Grade");
            menu.Bind(wxEVT_MENU, [this, hitId](wxCommandEvent&){
                auto& st = m_frame->State();
                MC::PlacedPiece* piece = st.FindById(hitId);
                if (!piece) return;

                st.PushUndo();

                auto pieceRunLen = [&](const MC::TrackPiece* d) -> double {
                    if (!d) return 0.0;
                    double L = d->lengthMm;
                    if (d->radiusMm > 0.0 && d->connections.size() >= 2) {
                        double cx = d->connections[1].pos.x;
                        if (cx > 1.0) L = cx;
                    }
                    return L;
                };

                // Height at a specific connector of a piece.
                // conn[0] = z_mm.  conn[1] = z_mm + rise (flipped if reversed).
                auto heightAtConn = [&](const MC::PlacedPiece* p, int cIdx) -> double {
                    if (!p) return 0.0;
                    if (cIdx == 0) return p->z_mm;
                    const MC::TrackPiece* d = MC::GetCatalog().Find(p->catalogNumber);
                    if (!d) return p->z_mm;
                    double rise = pieceRunLen(d) * std::sin(p->gradeDeg * M_PI / 180.0);
                    return p->z_mm + (p->chainReversed ? -rise : rise);
                };

                // Determine chain direction through this piece.
                bool rev = piece->chainReversed;
                int predJoint = rev ? 1 : 0;
                int succJoint = rev ? 0 : 1;

                piece->gradeDeg = 0.0;

                // Fix z_mm from the predecessor's actual joint connector height.
                // A flat piece has both connectors at the same world height,
                // so z_mm = the joint height regardless of direction.
                if (predJoint < (int)piece->joints.size() && piece->joints[predJoint].pieceId >= 0) {
                    const MC::PlacedPiece* prev = st.FindById(piece->joints[predJoint].pieceId);
                    if (prev) {
                        int prevConn = piece->joints[predJoint].connIdx;
                        piece->z_mm = heightAtConn(prev, prevConn);
                    }
                }

                // Walk forward along the chain and level off subsequent flat pieces.
                // For flat pieces (gradeDeg=0), heightAtConn returns z_mm for
                // either connector, so the walk propagates correctly.
                int curId = hitId;
                for (int iter = 0; iter < 200; ++iter) {
                    MC::PlacedPiece* cur = st.FindById(curId);
                    if (!cur) break;
                    bool curRev = cur->chainReversed;
                    int fwd = curRev ? 0 : 1;
                    if (fwd >= (int)cur->joints.size()) break;
                    int nextId = cur->joints[fwd].pieceId;
                    if (nextId < 0) break;
                    MC::PlacedPiece* next = st.FindById(nextId);
                    if (!next) break;
                    if (std::abs(next->gradeDeg) > 0.001) break;
                    // Use the actual connector on cur where this joint lives
                    int curConn = fwd;
                    next->z_mm = heightAtConn(cur, curConn);
                    curId = nextId;
                }

                // Stop the grade run
                int runAnchor = hitId;
                for (int iter = 0; iter < 100; ++iter) {
                    MC::PlacedPiece* cur = st.FindById(runAnchor);
                    if (!cur) break;
                    if (st.FindGradeRun(runAnchor)) break;
                    bool curRev = cur->chainReversed;
                    int fwd = curRev ? 0 : 1;
                    if (fwd < (int)cur->joints.size() && cur->joints[fwd].pieceId >= 0)
                        runAnchor = cur->joints[fwd].pieceId;
                    else break;
                }
                st.StopGradeRun(runAnchor);

                m_frame->SetStatusText("Grade ended - subsequent pieces levelled off", 0);
                m_frame->MarkDirty();
                m_frame->RefreshCanvas();
                m_frame->OnSelectionChanged();
            }, wxID_HIGHEST + 2);
        }

        menu.Append(wxID_COPY, "Copy");
        menu.Bind(wxEVT_MENU, [this](wxCommandEvent&){ DoAutofill(); }, wxID_HIGHEST + 1);
        menu.Bind(wxEVT_MENU, [this](wxCommandEvent&){ DoCopy(); }, wxID_COPY);
        PopupMenu(&menu);
        return;
    }
    if (hitId < 0) {
        // Empty canvas right-click - context menu
        wxMenu menu;
        if (!m_clipboard.empty() || !m_clipboardScenery.empty() || m_clipboardShapeValid) {
            wxPoint clickPt = e.GetPosition();
            menu.Append(wxID_PASTE, "Paste");
            menu.Bind(wxEVT_MENU, [this, clickPt](wxCommandEvent&){ DoPaste(clickPt); }, wxID_PASTE);
        } else {
            // Nothing to show - just clear selection
            if (!e.ShiftDown()) {
                state.ClearSelection();
                m_clipboard.clear();
                m_clipboardScenery.clear();
                m_clipboardShapeValid = false;
                m_frame->OnSelectionChanged();
            }
            return;
        }
        PopupMenu(&menu);
    }
}

void CanvasPanel::DoCopy() {
    auto& state = m_frame->State();
    auto selected   = state.Selected();
    auto selectedSc = state.SelectedScenery();
    if (selected.empty() && selectedSc.empty()) return;

    m_clipboard.clear();
    for (auto* p : selected)
        m_clipboard.push_back(*p);

    m_clipboardScenery.clear();
                m_clipboardShapeValid = false;
    for (auto* s : selectedSc)
        m_clipboardScenery.push_back(*s);
}

void CanvasPanel::DoPaste(wxPoint screenPt) {
    if (m_clipboard.empty() && m_clipboardScenery.empty() && !m_clipboardShapeValid) return;

    auto& state = m_frame->State();
    state.PushUndo();

    // Compute centre of the entire clipboard group (track + scenery + shape).
    double cx = 0, cy = 0;
    int n = 0;
    for (const auto& p : m_clipboard)        { cx += p.transform.pos.x; cy += p.transform.pos.y; ++n; }
    for (const auto& s : m_clipboardScenery) { cx += s.transform.pos.x; cy += s.transform.pos.y; ++n; }
    if (m_clipboardShapeValid) {
        for (const auto& v : m_clipboardShape.vertices) { cx += v.x; cy += v.y; ++n; }
    }
    if (n > 0) { cx /= n; cy /= n; }

    MC::Vec2 pastePos = m_view.ToWorld(screenPt);
    double dx = pastePos.x - cx;
    double dy = pastePos.y - cy;

    // Map old IDs to new IDs (track pieces only -- scenery has no joints)
    std::unordered_map<int, int> idMap;
    std::set<int> clipIds;
    for (const auto& p : m_clipboard) clipIds.insert(p.id);

    // Assign new IDs first
    std::vector<int> newIds;
    for (const auto& src : m_clipboard) {
        idMap[src.id] = state.nextId++;
        newIds.push_back(idMap[src.id]);
    }

    // Place track copies -- all at once to avoid reallocation issues
    for (int i = 0; i < (int)m_clipboard.size(); ++i) {
        const auto& src = m_clipboard[i];
        MC::PlacedPiece np = src;
        np.id = newIds[i];
        np.transform.pos.x += dx;
        np.transform.pos.y += dy;
        // Clear all joints
        for (auto& j : np.joints) j = {-1, -1};
        // Re-wire internal joints
        for (int ci = 0; ci < (int)src.joints.size(); ++ci) {
            int oldNeighbour = src.joints[ci].pieceId;
            if (clipIds.count(oldNeighbour))
                np.joints[ci] = { idMap[oldNeighbour], src.joints[ci].connIdx };
        }
        state.pieces.push_back(np);
    }

    // Place scenery copies.
    std::vector<int> newSceneryIds;
    for (const auto& src : m_clipboardScenery) {
        MC::PlacedScenery ns = src;
        ns.id = state.nextId++;
        ns.transform.pos.x += dx;
        ns.transform.pos.y += dy;
        ns.selected = false;
        state.scenery.push_back(ns);
        newSceneryIds.push_back(ns.id);
    }

    // Paste polygon shape if clipboard has one.
    if (m_clipboardShapeValid) {
        MC::CanvasShape ns = m_clipboardShape;
        ns.id = state.nextId++;
        ns.selected = true;
        for (auto& v : ns.vertices) { v.x += dx; v.y += dy; }
        state.shapes.push_back(ns);
    }

    // Select the newly pasted pieces + scenery
    state.ClearSelection();
    for (int newId : newIds) {
        MC::PlacedPiece* p = state.FindById(newId);
        if (p) p->selected = true;
    }
    for (int newId : newSceneryIds) {
        MC::PlacedScenery* s = state.FindSceneryById(newId);
        if (s) s->selected = true;
    }

    m_frame->OnSelectionChanged();
    m_frame->MarkDirty();
    Refresh();
}


// ── Autofill: shared solver ──────────────────────────────────────────────────
// Works out how (or whether) the gap between the two selected pieces can be
// filled. BOTH CanAutofill() and DoAutofill() call this, so the menu item can
// never appear for a gap that Autofill would then refuse to fill -- previously
// they each re-implemented part of the test and drifted apart.
//
// Returns false if no fill is possible. On success, fills in `out`.
bool CanvasPanel::FindAutofillSuggestions(AutofillResult& out) const {
    auto selected = m_frame->State().Selected();
    if (selected.size() != 2) return false;

    const MC::TrackPiece* defA = MC::GetCatalog().Find(selected[0]->catalogNumber);
    const MC::TrackPiece* defB = MC::GetCatalog().Find(selected[1]->catalogNumber);
    if (!defA || !defB) return false;

    // Autofill can only chain pieces from the same series.
    if (defA->series != defB->series) {
        out.mixedSeries = true;
        return false;
    }
    const MC::TrackSeries series = defA->series;

    MC::ConnectionPoint bestA, bestB;
    double bestDist = 1e9;
    bool found = false;

    // Pick the CLOSEST pair of free connectors. Deliberately no heading filter
    // here: a straight gap has connectors ~180 deg apart, but a CURVED gap can
    // have any heading relationship at all -- a semicircle leaves both free
    // connectors pointing the same way (0 deg apart), a 90 deg corner leaves
    // them 90 apart. Filtering on ~180 here would reject every curved gap
    // before the solver below ever ran. The straight/curved decision is made
    // afterwards, from the geometry.
    for (int ai = 0; ai < (int)defA->connections.size(); ++ai) {
        if (selected[0]->joints.size() > (size_t)ai && selected[0]->joints[ai].pieceId >= 0) continue;
        MC::ConnectionPoint ca = selected[0]->worldConnection(*defA, ai);
        for (int bi = 0; bi < (int)defB->connections.size(); ++bi) {
            if (selected[1]->joints.size() > (size_t)bi && selected[1]->joints[bi].pieceId >= 0) continue;
            MC::ConnectionPoint cb = selected[1]->worldConnection(*defB, bi);
            double dx = ca.pos.x - cb.pos.x, dy = ca.pos.y - cb.pos.y;
            double dist = std::sqrt(dx*dx + dy*dy);
            if (dist < bestDist) { bestDist = dist; bestA = ca; bestB = cb; found = true; }
        }
    }

    // No free connectors on one (or both) pieces: nothing to fill between.
    if (!found) return false;

    // Already touching: no gap.
    if (bestDist < 1.0) return false;

    // Check if gap is straight (headings opposite and connectors collinear)
    double gapDeg = std::fmod(std::abs(bestA.heading - bestB.heading), 360.0);
    if (gapDeg > 180.0) gapDeg = 360.0 - gapDeg;
    bool isStraight = (gapDeg > 170.0); // nearly opposite headings

    // Check collinearity for straight gaps
    if (isStraight) {
        // Vector from A to B
        double dx = bestB.pos.x - bestA.pos.x;
        double dy = bestB.pos.y - bestA.pos.y;
        double hr = bestA.heading * M_PI / 180.0;
        // Unit vector along A's exit direction (opposite of its inward heading)
        double ex = -std::cos(hr), ey = -std::sin(hr);
        // Because (ex,ey) is a UNIT vector, this cross product IS the
        // perpendicular distance from B to A's axis, in millimetres.
        // Do NOT divide it by the gap length -- that turns a distance into a
        // dimensionless ratio and the 5mm threshold stops meaning anything.
        // (A 30mm misalignment across a 400mm gap gives 0.075, which never
        // trips a "> 5" test, so two pieces on different lines were wrongly
        // treated as a straight gap.)
        double perp = std::abs(dx * ey - dy * ex);
        if (perp > 5.0) isStraight = false;   // off-axis by more than 5mm
    }

    // Search catalog for straight combinations
    // Suggestion is declared in CanvasPanel.h so DoAutofill() can consume it.
    std::vector<Suggestion> suggestions;

    if (isStraight) {
        double gap = bestDist;
        double tol = 1.5; // mm tolerance

        // Collect all straight pieces of matching series.
        // Only plain straight track -- no bridges, turntables, ramps,
        // adapters, specialty electronics, or anything that is not
        // regular rail geometry.
        std::vector<const MC::TrackPiece*> straights;
        for (int i = 0; i < MC::GetCatalog().Count(); ++i) {
            const MC::TrackPiece* p = MC::GetCatalog().GetByIndex(i);
            if (!p) continue;
            if (p->series != series) continue;
            if (p->type != MC::PieceType::Straight) continue;
            if (p->lengthMm <= 0) continue;
            if (p->isBridge) continue;
            if (p->isRamp)   continue;
            {
                const std::string& dn = p->displayName;
                auto has = [&](const char* s){ return dn.find(s) != std::string::npos; };
                if (has("Bridge"))     continue;
                if (has("Ramp"))       continue;
                if (has("Turntable"))  continue;
                if (has("Circuit"))    continue;
                if (has("Uncoupler"))  continue;
                if (has("Feeder"))     continue;
                if (has("Isolat"))     continue;
                if (has("Contact"))    continue;
                if (has("Adapter"))    continue;
                if (has("Spoke"))      continue;
                if (has("Compensation")) continue;
            }
            straights.push_back(p);
        }
        // Sort by length descending
        std::sort(straights.begin(), straights.end(),
            [](const MC::TrackPiece* a, const MC::TrackPiece* b){ return a->lengthMm > b->lengthMm; });

        // Search combinations of any length. The straights list is sorted
        // longest-first; we DFS by trying each piece at the current
        // position (combinations with repetition: index can stay or grow,
        // never shrink — keeps combos unique up to ordering). Stop when
        // the running total reaches the gap (within tol).
        //
        // Depth is capped to keep search bounded on large gaps. The cap is
        // set generously (gap / shortest_piece + a few) so any feasible
        // arrangement using the catalog's smallest straight fits within it.
        if (!straights.empty()) {
            // Iterative deepening: try depth=1, then 2, etc. Stop as soon
            // as we have enough solutions (or the depth limit kicks in).
            // This guarantees the simplest combinations are found first and
            // bounds the search space tightly — large gaps no longer blow
            // up memory or stack.
            double shortest = straights.back()->lengthMm; // smallest length
            if (shortest <= 0.0) shortest = 1.0;         // guard: no div-by-zero
            int    maxDepthCeiling = (int)std::ceil(gap / shortest) + 2;
            if (maxDepthCeiling < 1)  maxDepthCeiling = 1;
            if (maxDepthCeiling > 32) maxDepthCeiling = 32;

            const size_t kMaxSolutions = 64;   // collect at most this many
            std::vector<std::string> path;

            // DFS at a single fixed depth — only commits a solution that
            // exactly matches that depth, so iterative deepening doesn't
            // re-find shallower ones at deeper levels.
            int targetDepth = 0;
            std::function<void(int, double)> dfs =
                [&](int startIdx, double sum) {
                    if (suggestions.size() >= kMaxSolutions) return;
                    if ((int)path.size() == targetDepth) {
                        if (std::abs(sum - gap) <= tol)
                            suggestions.push_back({ path, sum });
                        return;
                    }
                    if (sum > gap + tol) return;
                    // Prune: even using the longest remaining piece, can we
                    // possibly reach gap-tol with the remaining slots?
                    if (startIdx >= (int)straights.size()) return;   // bounds guard
                    double longest = straights[startIdx]->lengthMm;
                    int remaining  = targetDepth - (int)path.size();
                    if (sum + longest * remaining < gap - tol) return;
                    for (int i = startIdx; i < (int)straights.size(); ++i) {
                        double L = straights[i]->lengthMm;
                        if (sum + L > gap + tol) continue;
                        path.push_back(straights[i]->catalogNumber);
                        dfs(i, sum + L);
                        path.pop_back();
                        if (suggestions.size() >= kMaxSolutions) return;
                    }
                };

            for (targetDepth = 1; targetDepth <= maxDepthCeiling; ++targetDepth) {
                dfs(0, 0.0);
                // Stop deepening once we have a comfortable number of
                // suggestions — the user will see 12 max anyway, and
                // shallower solutions are always better.
                if (suggestions.size() >= 16) break;
            }

            // Sort fewest-pieces first, then by tightest fit.
            std::sort(suggestions.begin(), suggestions.end(),
                [&](const Suggestion& a, const Suggestion& b){
                    if (a.pieces.size() != b.pieces.size())
                        return a.pieces.size() < b.pieces.size();
                    return std::abs(a.totalLen - gap) < std::abs(b.totalLen - gap);
                });
            // Cap the list shown to the user. 12 is plenty.
            if (suggestions.size() > 12) suggestions.resize(12);
        }
    } else {
        // ── Curved gap ────────────────────────────────────────────────────
        // Solve for the single circular arc that joins the two free
        // connectors, then fill it with a chain of identical catalog curves.
        //
        // Convention (see SnapEngine::ComputeSnapTransform): a connector's
        // heading points OUTWARD, away from its own piece, and two mating
        // connectors are 180 deg apart. So the arc DEPARTS bestA along
        // bestA.heading and ARRIVES at bestB along (bestB.heading + 180).
        //
        // Handles: circles (major arc, sweep > 180), ovals (the anti-parallel
        // U-turn), and ordinary corners. Anything needing an S-curve or a
        // mixed curve/straight chain yields no solution, as before.

        const double depart = bestA.heading * M_PI / 180.0;
        const double arrive = (bestB.heading + 180.0) * M_PI / 180.0;

        const double ux = std::cos(depart), uy = std::sin(depart);
        const double vx = std::cos(arrive), vy = std::sin(arrive);

        const double dotT   = ux*vx + uy*vy;
        const double crossT = ux*vy - uy*vx;

        const double dx = bestB.pos.x - bestA.pos.x;   // A -> B
        const double dy = bestB.pos.y - bestA.pos.y;
        const double chord = std::sqrt(dx*dx + dy*dy);

        // Candidate arcs: (radius, signed sweep in degrees).
        std::vector<std::pair<double,double>> arcs;
        const double kPosTol = 2.0;    // mm — how close the arc must land on B

        if (chord > 1e-6) {
            if (std::abs(crossT) < 1e-9 && dotT > 0.0) {
                // Departure and arrival directions are IDENTICAL.
                // Either a straight gap (B on the axis) or a U-turn (B offset
                // perpendicular) -- the latter is the oval case.
                double along = dx*ux + dy*uy;        // along travel direction
                double perp  = dx*(-uy) + dy*ux;     // perpendicular to it
                if (std::abs(perp) >= kPosTol && std::abs(along) <= kPosTol) {
                    // Clean 180-deg U-turn: centre is the midpoint of AB.
                    arcs.push_back({ std::abs(perp) / 2.0,
                                     (perp > 0.0) ? 180.0 : -180.0 });
                }
                // else: collinear (handled by the straight branch) or an
                // offset-and-shifted gap that no single arc can bridge.
            } else {
                std::vector<double> sweeps;
                if (std::abs(crossT) < 1e-9 && dotT < 0.0) {
                    // Anti-parallel tangents: a 180-deg turn, either direction.
                    sweeps = { 180.0, -180.0 };
                } else {
                    double base = std::atan2(crossT, dotT) * 180.0 / M_PI;
                    // Both ways round: the minor arc and the major arc. The
                    // major arc is what closes a circle (e.g. 300 deg).
                    sweeps.push_back(base);
                    sweeps.push_back(base > 0.0 ? base - 360.0 : base + 360.0);
                }

                for (double sweep : sweeps) {
                    if (std::abs(sweep) < 1e-6) continue;
                    double s  = (sweep > 0.0) ? 1.0 : -1.0;
                    double px = -uy * s, py = ux * s;   // unit vector A -> centre

                    // Centre C satisfies |C - A| = |C - B| = R, with
                    // C = A + R*p. Solving gives R = -|A-B|^2 / (2*(A-B).p).
                    double ex = -dx, ey = -dy;          // A - B
                    double denom = 2.0 * (ex*px + ey*py);
                    if (std::abs(denom) < 1e-9) continue;
                    double R = -(ex*ex + ey*ey) / denom;
                    if (R <= 1e-6) continue;

                    // Verify the arc actually lands on B.
                    double cx = bestA.pos.x + R*px;
                    double cy = bestA.pos.y + R*py;
                    double rb = std::sqrt((bestB.pos.x-cx)*(bestB.pos.x-cx) +
                                          (bestB.pos.y-cy)*(bestB.pos.y-cy));
                    if (std::abs(rb - R) > kPosTol) continue;

                    arcs.push_back({ R, sweep });
                }
            }
        }

        // For each candidate arc, find catalog curves of the right radius
        // whose angle divides the sweep into a whole number of pieces.
        const double kRadTol = 3.0;    // mm  — radius match tolerance
        const double kAngTol = 0.5;    // deg — total angular error allowed

        for (const auto& arc : arcs) {
            double R = arc.first, sweep = arc.second;

            for (int i = 0; i < MC::GetCatalog().Count(); ++i) {
                const MC::TrackPiece* p = MC::GetCatalog().GetByIndex(i);
                if (!p) continue;
                if (p->series != series) continue;
                if (p->type != MC::PieceType::Curve) continue;
                if (p->radiusMm <= 0.0 || p->angleDeg == 0.0) continue;

                // Same exclusions as the straight branch: only plain track.
                if (p->isBridge) continue;
                if (p->isRamp)   continue;
                {
                    const std::string& dn = p->displayName;
                    auto has = [&](const char* s){ return dn.find(s) != std::string::npos; };
                    if (has("Bridge"))     continue;
                    if (has("Ramp"))       continue;
                    if (has("Turntable"))  continue;
                    if (has("Circuit"))    continue;
                    if (has("Uncoupler"))  continue;
                    if (has("Feeder"))     continue;
                    if (has("Contact"))    continue;
                    if (has("Isolat"))     continue;
                    if (has("Adapter"))    continue;
                    if (has("Spoke"))      continue;
                    if (has("Compensation")) continue;
                }

                if (std::abs(p->radiusMm - R) > kRadTol) continue;

                double pieceAng = std::abs(p->angleDeg);
                if (pieceAng < 1e-6) continue;

                double exact = std::abs(sweep) / pieceAng;
                int    n     = (int)std::lround(exact);
                if (n < 1 || n > 64) continue;
                if (std::abs(exact - n) * pieceAng > kAngTol) continue;

                Suggestion s;
                s.pieces.assign(n, p->catalogNumber);
                s.totalLen = n * pieceAng;   // total sweep, in degrees

                // A catalog curve bends one fixed way (the sign of angleDeg).
                // If the arc we solved for turns the OTHER way, each piece must
                // be placed end-for-end (snapped by conn[1]) to mirror its bend.
                // Without this the chain closes a valid circle -- but the mirror
                // image of the one the user wants.
                bool pieceTurnsPositive = (p->angleDeg > 0.0);
                bool arcTurnsPositive   = (sweep > 0.0);
                s.reversed = (pieceTurnsPositive != arcTurnsPositive);

                suggestions.push_back(s);
            }
        }

        // Fewest pieces first.
        std::sort(suggestions.begin(), suggestions.end(),
            [](const Suggestion& a, const Suggestion& b){
                return a.pieces.size() < b.pieces.size();
            });
        if (suggestions.size() > 12) suggestions.resize(12);
    }


    if (suggestions.empty()) return false;

    out.suggestions = suggestions;
    out.bestA       = bestA;
    out.bestB       = bestB;
    out.bestDist    = bestDist;
    out.isStraight  = isStraight;
    return true;
}

// True only if Autofill would actually produce at least one fill. Delegates
// entirely to the shared solver -- no separate logic to drift out of step.
bool CanvasPanel::CanAutofill() const {
    AutofillResult r;
    return FindAutofillSuggestions(r);
}

void CanvasPanel::DoAutofill() {
    auto& state = m_frame->State();
    auto selected = state.Selected();
    if (selected.size() != 2) return;

    const MC::TrackPiece* defA = MC::GetCatalog().Find(selected[0]->catalogNumber);
    const MC::TrackPiece* defB = MC::GetCatalog().Find(selected[1]->catalogNumber);
    if (!defA || !defB) return;

    AutofillResult res;
    if (!FindAutofillSuggestions(res)) {
        // Explain why, rather than failing silently.
        if (res.mixedSeries) {
            auto seriesName = [](MC::TrackSeries s) -> wxString {
                switch (s) {
                    case MC::TrackSeries::MarlinC:  return "Marklin C-Track";
                    case MC::TrackSeries::MarlinK:  return "Marklin K-Track";
                    case MC::TrackSeries::TrixC:    return "TRIX C-Track";
                    case MC::TrackSeries::MarlinZ:  return "Marklin Z-Track";
                    case MC::TrackSeries::Gauge1:   return "Marklin Gauge 1";
                    case MC::TrackSeries::MarlinM:  return "Marklin M-Track";
                }
                return "Unknown";
            };
            wxString msg;
            msg.Printf("Autofill can only run on pieces of the same track "
                       "series.\n\nSelected pieces:\n  - %s (%s)\n  - %s (%s)",
                       selected[0]->catalogNumber, seriesName(defA->series),
                       selected[1]->catalogNumber, seriesName(defB->series));
            wxMessageBox(msg, "Autofill - Mixed Track Systems",
                         wxOK | wxICON_INFORMATION, this);
        } else {
            wxMessageBox("No combination of catalog track fills the gap between "
                         "these two pieces.\n\nAutofill can bridge a straight gap, "
                         "or a gap spanned by a single arc (a corner, a semicircle, "
                         "or the rest of a circle). Gaps needing an S-curve, or a "
                         "mix of curves and straights, are not supported.",
                         "Autofill - No Solution", wxOK | wxICON_INFORMATION, this);
        }
        state.ClearSelection();
        m_frame->OnSelectionChanged();
        Refresh();
        return;
    }

    // Unpack the solved result. These names match what the UI/placement code
    // below already expects.
    auto&  suggestions = res.suggestions;
    const  MC::ConnectionPoint bestA = res.bestA;
    const  MC::ConnectionPoint bestB = res.bestB;
    const  double bestDist   = res.bestDist;
    const  bool   isStraight = res.isStraight;

    if (suggestions.empty()) {
        wxString msg;
        if (isStraight) {
            msg.Printf("Gap is %.1fmm. No combination of catalog straights "
                       "fills it within tolerance.", bestDist);
        } else {
            msg.Printf("Gap is %.1fmm. No single arc of catalog curves fills "
                       "it within tolerance.\\n\\nAutofill can bridge a gap with "
                       "one arc (a corner, a semicircle, or the rest of a "
                       "circle). Gaps needing an S-curve, or a mix of curves "
                       "and straights, are not supported.", bestDist);
        }
        wxMessageBox(msg, "Autofill - No Solution", wxOK | wxICON_INFORMATION, this);
        state.ClearSelection();
        m_frame->OnSelectionChanged();
        Refresh();
        return;
    }

    // Show suggestions dialog
    wxDialog dlg(this, wxID_ANY, "Autofill Gap",
                 wxDefaultPosition, wxSize(380, 320),
                 wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
    wxBoxSizer* sz = new wxBoxSizer(wxVERTICAL);
    wxString gapStr; gapStr.Printf("Gap: %.1f mm. Select fill:", bestDist);
    sz->Add(new wxStaticText(&dlg, wxID_ANY, gapStr), 0, wxALL, 8);

    wxArrayString items;
    for (auto& s : suggestions) {
        wxString line;
        // A curved fill is a run of identical curves -- listing
        // "24130 + 24130 + ..." ten times is unreadable, so collapse it
        // to "10 x 24130". For curves, totalLen holds the total sweep in
        // degrees rather than a length in mm.
        bool allSame = !s.pieces.empty();
        for (size_t i = 1; i < s.pieces.size(); ++i)
            if (s.pieces[i] != s.pieces[0]) { allSame = false; break; }

        if (!isStraight && allSame) {
            line.Printf("%d x %s  (%.1f deg)",
                        (int)s.pieces.size(),
                        wxString(s.pieces[0]),
                        s.totalLen);
        } else {
            for (int i = 0; i < (int)s.pieces.size(); ++i) {
                if (i > 0) line += " + ";
                const MC::TrackPiece* pd = MC::GetCatalog().Find(s.pieces[i]);
                line += pd ? wxString(pd->catalogNumber) : wxString(s.pieces[i]);
            }
            line += wxString::Format("  (%.1f mm)", s.totalLen);
        }
        items.Add(line);
    }
    wxListBox* lb = new wxListBox(&dlg, wxID_ANY, wxDefaultPosition,
                                   wxDefaultSize, items, wxLB_SINGLE);
    lb->SetSelection(0);
    sz->Add(lb, 1, wxEXPAND | wxLEFT | wxRIGHT, 8);

    wxBoxSizer* btn = new wxBoxSizer(wxHORIZONTAL);
    btn->AddStretchSpacer();
    wxButton* ok  = new wxButton(&dlg, wxID_OK,     "Place");
    wxButton* can = new wxButton(&dlg, wxID_CANCEL, "Cancel");
    btn->Add(ok, 0, wxRIGHT, 4); btn->Add(can, 0, wxRIGHT, 8);
    sz->Add(btn, 0, wxEXPAND | wxALL, 8);
    dlg.SetSizer(sz);
    dlg.Centre();
    lb->Bind(wxEVT_LISTBOX_DCLICK, [&](wxCommandEvent&){ dlg.EndModal(wxID_OK); });

    if (dlg.ShowModal() != wxID_OK) return;
    int sel = lb->GetSelection();
    if (sel < 0 || sel >= (int)suggestions.size()) return;

    // ── Place the fill pieces ────────────────────────────────────────────
    // CRITICAL: state.pieces is a std::vector. Every push_back can reallocate
    // it, invalidating ALL PlacedPiece* pointers -- including selected[0] and
    // selected[1], which point into that vector. Never hold a raw pointer
    // across an insertion. We capture IDs up front and re-look-up by ID
    // after each push_back.

    // Find connector indices on A and B (before any mutation).
    int connIdxA = -1, connIdxB = -1;
    for (int ai = 0; ai < (int)defA->connections.size(); ++ai) {
        MC::ConnectionPoint ca = selected[0]->worldConnection(*defA, ai);
        double dx = ca.pos.x - bestA.pos.x, dy = ca.pos.y - bestA.pos.y;
        if (dx*dx + dy*dy < 1.0) { connIdxA = ai; break; }
    }
    for (int bi = 0; bi < (int)defB->connections.size(); ++bi) {
        MC::ConnectionPoint cb = selected[1]->worldConnection(*defB, bi);
        double dx = cb.pos.x - bestB.pos.x, dy = cb.pos.y - bestB.pos.y;
        if (dx*dx + dy*dy < 1.0) { connIdxB = bi; break; }
    }
    if (connIdxA < 0 || connIdxB < 0) return;

    // Capture IDs and level/z now -- the pointers die on the first push_back.
    const int pieceIdA = selected[0]->id;
    const int pieceIdB = selected[1]->id;
    const int fillLevelId = selected[0]->levelId;
    const double fillZ    = selected[0]->z_mm;

    // Every fill piece must exist in the catalog and have >= 2 connectors,
    // or the wiring below would index out of range. Validate before we
    // mutate any state, so a bad suggestion can't leave a half-built chain.
    for (const auto& catNum : suggestions[sel].pieces) {
        const MC::TrackPiece* fd = MC::GetCatalog().Find(catNum);
        if (!fd || fd->connections.size() < 2) {
            wxMessageBox("Internal error: a fill piece is missing or has too "
                         "few connectors. Autofill aborted.",
                         "Autofill", wxOK | wxICON_ERROR, this);
            return;
        }
    }

    state.PushUndo();

    // bestA is the world connection point on piece A's free connector.
    // The fill piece connects here with its conn[0].
    MC::ConnectionPoint snapTarget = bestA;

    int prevPieceId = pieceIdA;     // ID, not pointer
    int prevConnIdx = connIdxA;

    // Which connector each fill piece is snapped BY, and which it advances
    // through. Normally in via conn[0], out via conn[1]. For a curved fill
    // that must bend against the catalog piece's natural direction, the piece
    // is flipped end-for-end: in via conn[1], out via conn[0].
    const bool reversed = suggestions[sel].reversed;
    const int  inIdx    = reversed ? 1 : 0;
    const int  outIdx   = reversed ? 0 : 1;

    for (const auto& catNum : suggestions[sel].pieces) {
        const MC::TrackPiece* fillDef = MC::GetCatalog().Find(catNum);
        if (!fillDef) continue;     // already validated, but stay defensive

        MC::Transform2D t = MC::SnapEngine::ComputeSnapTransform(*fillDef, inIdx, snapTarget);

        MC::PlacedPiece np;
        np.catalogNumber = catNum;
        np.transform     = t;
        np.joints.resize(fillDef->connections.size(), {-1,-1});
        np.joints[inIdx] = { prevPieceId, prevConnIdx };
        np.id            = state.nextId++;
        // Inherit level and z so a gap filled on an elevated deck doesn't
        // drop the fill pieces back to z=0.
        np.levelId       = fillLevelId;
        np.z_mm          = fillZ;
        np.gradeDeg      = 0.0;

        const int newId = np.id;
        state.pieces.push_back(np);          // ← invalidates all PlacedPiece*

        // Re-look-up by ID (safe after the reallocation).
        MC::PlacedPiece* prev = state.FindById(prevPieceId);
        MC::PlacedPiece* cur  = state.FindById(newId);
        if (!prev || !cur) break;            // shouldn't happen; bail safely

        // Wire back to the previous piece, guarding the connector index.
        if (prevConnIdx >= 0 && prevConnIdx < (int)prev->joints.size())
            prev->joints[prevConnIdx] = { newId, inIdx };

        // Advance: next snap target is this piece's exit connector.
        snapTarget  = cur->worldConnection(*fillDef, outIdx);
        prevPieceId = newId;
        prevConnIdx = outIdx;
    }

    // Wire the last fill piece to piece B. Re-look-up both by ID.
    {
        MC::PlacedPiece* prev = state.FindById(prevPieceId);
        MC::PlacedPiece* pb   = state.FindById(pieceIdB);
        if (prev && pb) {
            if (prevConnIdx >= 0 && prevConnIdx < (int)prev->joints.size())
                prev->joints[prevConnIdx] = { pieceIdB, connIdxB };
            if (connIdxB >= 0 && connIdxB < (int)pb->joints.size())
                pb->joints[connIdxB] = { prevPieceId, prevConnIdx };
        }
    }

    m_frame->MarkDirty();
    m_frame->RefreshCanvas();
    Refresh();
}

void CanvasPanel::OnRightUp(wxMouseEvent& e) {
    if (HasCapture()) ReleaseMouse();
    e.Skip();
}

// ── Middle (pan) ──────────────────────────────────────────────────────────────
void CanvasPanel::OnMiddleDown(wxMouseEvent& e) {
    m_panning   = true;
    m_panStart  = e.GetPosition();
    m_panStartX = m_view.panX;
    m_panStartY = m_view.panY;
    SetCursor(wxCursor(wxCURSOR_HAND));
    if (!HasCapture()) CaptureMouse();
}

void CanvasPanel::OnMiddleUp(wxMouseEvent& e) {
    if (m_panning) {
        m_panning = false;
        SetCursor(wxNullCursor);
        if (HasCapture()) ReleaseMouse();
    }
    e.Skip();
}

void CanvasPanel::OnMouseWheel(wxMouseEvent& e) {
    // While dragging a piece or scenery from the palette, the wheel rotates
    // the item in 30° steps (15° with Shift) instead of zooming, so the user
    // can pre-rotate before dropping.
    if (m_draggingPiece) {
        double step = (e.ShiftDown() ? 15.0 : 30.0);
        if (e.GetWheelRotation() < 0) step = -step;
        m_dragTransform.heading =
            MC::Transform2D::normalizeAngle(m_dragTransform.heading + step);
        UpdateDragSnap(e.GetPosition());
        Refresh();
        return;
    }
    if (m_draggingScenery) {
        double step = (e.ShiftDown() ? 15.0 : 30.0);
        if (e.GetWheelRotation() < 0) step = -step;
        m_dragSceneryTransform.heading =
            MC::Transform2D::normalizeAngle(m_dragSceneryTransform.heading + step);
        Refresh();
        return;
    }

    // Wheel scheme:
    //   - Plain wheel        : always zoom (regardless of selection)
    //   - Ctrl + wheel       : rotate the current selection around its
    //                          centroid by 15 deg per notch (5 deg with
    //                          Shift held). No selection -> Ctrl+wheel
    //                          just zooms (no-op rotation).
    //
    // We dropped the cursor-proximity heuristic that previously triggered
    // rotation when the pointer happened to be over the selection: it
    // misfired on large multi-piece selections whose bounding box covered
    // most of the canvas, rotating when the user wanted to zoom. The
    // explicit modifier is unambiguous.
    auto& state = m_frame->State();
    auto sel  = state.Selected();
    auto selS = state.SelectedScenery();
    auto selP = state.SelectedShapes();   // polygons rotate too
    bool hasSel = (!sel.empty() || !selS.empty() || !selP.empty());

    if (e.ControlDown() && hasSel) {
        // Compute the selection's bounding box for the rotation pivot.
        // Use each piece's full footprint (connectors + curve arc) so the
        // pivot is the visual centroid, not just the anchor point.
        double minX =  1e18, minY =  1e18;
        double maxX = -1e18, maxY = -1e18;
        auto expand = [&](double x, double y) {
            minX = std::min(minX, x); minY = std::min(minY, y);
            maxX = std::max(maxX, x); maxY = std::max(maxY, y);
        };
        auto& catalog = MC::GetCatalog();
        for (auto* p : sel) {
            expand(p->transform.pos.x, p->transform.pos.y);
            const MC::TrackPiece* def = catalog.Find(p->catalogNumber);
            if (!def) continue;
            for (size_t ci = 0; ci < def->connections.size(); ++ci) {
                MC::ConnectionPoint cp = p->worldConnection(*def, (int)ci);
                expand(cp.pos.x, cp.pos.y);
            }
            if (def->radiusMm > 0.0 && def->connections.size() >= 2) {
                double hr = p->transform.heading * M_PI / 180.0;
                double midX_local = def->lengthMm * 0.5;
                double bulge      = def->radiusMm
                                   * (1.0 - std::cos(def->angleDeg * 0.5 * M_PI / 180.0));
                double midY_local = (def->angleDeg >= 0 ? -bulge : bulge);
                double wx = p->transform.pos.x
                          + midX_local * std::cos(hr)
                          - midY_local * std::sin(hr);
                double wy = p->transform.pos.y
                          + midX_local * std::sin(hr)
                          + midY_local * std::cos(hr);
                expand(wx, wy);
            }
        }
        for (auto* s : selS) {
            const MC::SceneryItem* sdef = MC::GetSceneryCatalog().Find(s->itemId);
            if (sdef) {
                double hw = sdef->widthMm  * s->scaleX * 0.5;
                double hd = sdef->heightMm * s->scaleY * 0.5;
                expand(s->transform.pos.x - hw, s->transform.pos.y - hd);
                expand(s->transform.pos.x + hw, s->transform.pos.y + hd);
            } else {
                expand(s->transform.pos.x, s->transform.pos.y);
            }
        }
        for (auto* sh : selP) {
            for (const auto& v : sh->vertices) expand(v.x, v.y);
        }
        MC::Vec2 pivot { (minX + maxX) * 0.5, (minY + maxY) * 0.5 };

        double step = (e.ShiftDown() ? 5.0 : 15.0);
        if (e.GetWheelRotation() < 0) step = -step;

        state.PushUndo();
        double rad = step * M_PI / 180.0;
        double cosR = std::cos(rad), sinR = std::sin(rad);
        for (auto* p : sel) {
            MC::Vec2 rel = p->transform.pos - pivot;
            p->transform.pos = pivot + MC::Vec2{
                rel.x * cosR - rel.y * sinR,
                rel.x * sinR + rel.y * cosR
            };
            p->transform.heading =
                MC::Transform2D::normalizeAngle(p->transform.heading + step);
        }
        for (auto* s : selS) {
            MC::Vec2 rel = s->transform.pos - pivot;
            s->transform.pos = pivot + MC::Vec2{
                rel.x * cosR - rel.y * sinR,
                rel.x * sinR + rel.y * cosR
            };
            s->transform.heading =
                MC::Transform2D::normalizeAngle(s->transform.heading + step);
        }
        // Polygons: rotate every vertex about the same pivot.
        for (auto* sh : selP) {
            for (auto& v : sh->vertices) {
                MC::Vec2 rel = v - pivot;
                v = pivot + MC::Vec2{
                    rel.x * cosR - rel.y * sinR,
                    rel.x * sinR + rel.y * cosR
                };
            }
        }
        m_frame->MarkDirty();
        Refresh();
        return;
    }

    double factor = (e.GetWheelRotation() > 0) ? 1.15 : (1.0/1.15);
    m_view.ZoomAt(factor, e.GetPosition());
    Refresh();
}

void CanvasPanel::OnKeyDown(wxKeyEvent& e) {
    // Escape cancels elevation wizard pick modes.
    if (e.GetKeyCode() == WXK_ESCAPE && m_elevMode > 0) {
        m_elevMode = 0;
        m_elevMethod = 0;
        SetCursor(wxNullCursor);
        m_frame->SetStatusText("Elevation Wizard cancelled", 0);
        Refresh();
        return;
    }

    // Escape cancels in-progress measurement placement and exits Measure mode.
    if (e.GetKeyCode() == WXK_ESCAPE &&
        m_frame->State().toolMode == MC::ToolMode::Measure)
    {
        m_measuring = false;
        m_frame->State().toolMode = MC::ToolMode::Select;
        wxToolBar* tb = m_frame->GetToolBar();
        if (tb) tb->ToggleTool(ID_Measure, false);
        SetCursor(wxNullCursor);
        Refresh();
        return;
    }

    // Polygon / BaseboardDraw tool keys.
    if (m_frame->State().toolMode == MC::ToolMode::Polygon ||
        m_frame->State().toolMode == MC::ToolMode::BaseboardDraw)
    {
        bool isBaseboard = (m_frame->State().toolMode == MC::ToolMode::BaseboardDraw);
        if (e.GetKeyCode() == WXK_ESCAPE) {
            m_drawingPolygon = false;
            m_polygonVerts.clear();
            m_polygonCloseHover = false;
            m_frame->State().toolMode = MC::ToolMode::Select;
            wxToolBar* tb = m_frame->GetToolBar();
            if (tb && !isBaseboard) tb->ToggleTool(ID_Polygon, false);
            SetCursor(wxNullCursor);
            if (isBaseboard) m_frame->SetStatusText("");
            Refresh();
            return;
        }
        if (e.GetKeyCode() == WXK_BACK && m_drawingPolygon) {
            if (!m_polygonVerts.empty()) m_polygonVerts.pop_back();
            if (m_polygonVerts.empty()) m_drawingPolygon = false;
            Refresh();
            return;
        }
        if ((e.GetKeyCode() == WXK_RETURN || e.GetKeyCode() == WXK_NUMPAD_ENTER)
            && m_drawingPolygon && m_polygonVerts.size() >= 3)
        {
            auto& topState = m_frame->State();
            topState.PushUndo();
            if (isBaseboard) {
                topState.baseboard = m_polygonVerts;
                topState.lastShape.shapeId = 5;   // SH_FREEFORM
            } else {
                MC::CanvasShape s;
                s.id       = topState.nextId++;
                s.vertices = m_polygonVerts;
                int bestId = topState.activeLevelId;
                double bestH = -1e9;
                for (const auto& l : topState.levels) {
                    if (!l.visible) continue;
                    if (l.heightMm > bestH) { bestH = l.heightMm; bestId = l.id; }
                }
                s.levelId = bestId;
                topState.shapes.push_back(s);
            }
            m_drawingPolygon = false;
            m_polygonVerts.clear();
            m_polygonCloseHover = false;
            topState.toolMode = MC::ToolMode::Select;
            wxToolBar* tb = m_frame->GetToolBar();
            if (tb && !isBaseboard) tb->ToggleTool(ID_Polygon, false);
            SetCursor(wxNullCursor);
            if (isBaseboard) m_frame->SetStatusText("");
            m_frame->MarkDirty();
            Refresh();
            return;
        }
    }

    if (m_draggingPiece) {
        switch (e.GetKeyCode()) {
            case WXK_ESCAPE: CancelDrag(); return;
            case 'R': case 'r':
                m_dragTransform.heading =
                    MC::Transform2D::normalizeAngle(m_dragTransform.heading + 30.0);
                Refresh(); return;
        }
    }
    if (m_draggingScenery) {
        switch (e.GetKeyCode()) {
            case WXK_ESCAPE:
                m_draggingScenery = false;
                if (HasCapture()) ReleaseMouse();
                Refresh(); return;
            case 'R': case 'r':
                m_dragSceneryTransform.heading =
                    MC::Transform2D::normalizeAngle(m_dragSceneryTransform.heading + 15.0);
                Refresh(); return;
        }
    }
    if (e.GetKeyCode() == WXK_ESCAPE) {
        m_frame->State().ClearSelection();
        m_frame->OnSelectionChanged();
        Refresh();
    }
    if (e.GetKeyCode() == WXK_DELETE || e.GetKeyCode() == WXK_BACK) {
        auto& state = m_frame->State();
        bool hadSel = state.HasSelection();
        bool hadLabel = false;
        for (const auto& l : state.labels) if (l.selected) { hadLabel = true; break; }
        bool hadMeasure = false;
        for (const auto& m : state.measurements) if (m.selected) { hadMeasure = true; break; }

        if (hadSel || hadLabel || hadMeasure) {
            if (SelectionSingleHelixId(state) == -1) {
                if (!WarnIfTunneled(this, state, "delete")) return;
            }
            if (!ConfirmGradedDeletion(this, state)) return;
            // Expand selection to include pair partners
            for (auto& p : state.pieces) {
                if (!p.selected) continue;
                const MC::TrackPiece* def = MC::GetCatalog().Find(p.catalogNumber);
                if (!def || !def->pairPiece) continue;
                for (auto& j : p.joints) {
                    if (j.pieceId < 0) continue;
                    MC::PlacedPiece* partner = state.FindById(j.pieceId);
                    if (partner) {
                        const MC::TrackPiece* pd = MC::GetCatalog().Find(partner->catalogNumber);
                        if (pd && pd->pairPiece) partner->selected = true;
                    }
                }
            }
            state.DeleteSelected();
            m_frame->OnSelectionChanged();
            m_frame->MarkDirty();
            Refresh();
            return;
        }
    }
    e.Skip();
}

void CanvasPanel::OnMouseLeave(wxMouseEvent& e) { e.Skip(); }

// ── Hit Test ──────────────────────────────────────────────────────────────────
int CanvasPanel::HitTest(const wxPoint& screenPt) const {
    MC::Vec2 world = m_view.ToWorld(screenPt);
    auto& catalog  = MC::GetCatalog();
    auto& pieces   = m_frame->State().pieces;

    auto testPiece = [&](const MC::PlacedPiece& p) -> bool {
        const MC::TrackPiece* def = catalog.Find(p.catalogNumber);
        if (!def) return false;

        // Hit half-width: use actual track half-width + small tolerance
        double hw = (def->series == MC::TrackSeries::MarlinZ) ? 7.0 : 20.0;

        MC::Vec2 local = (world - p.transform.pos).rotated(-p.transform.heading);

        if (def->type == MC::PieceType::Turntable) {
            double dx = world.x - p.transform.pos.x;
            double dy = world.y - p.transform.pos.y;
            return std::sqrt(dx*dx + dy*dy) <= def->lengthMm / 2.0 + 10.0;
        } else if (def->type == MC::PieceType::TurnoutCurved) {
            if (local.x >= -5.0 && local.x <= def->lengthMm + 5.0 &&
                local.y >= -hw - 5.0 && local.y <= hw + 5.0)
                return true;
            double R    = def->radiusMm;
            double sign = (def->angleDeg >= 0.0) ? 1.0 : -1.0;
            auto testArc = [&](double ox) -> bool {
                double dx2 = local.x - ox, dy2 = local.y - sign * R;
                double d = std::sqrt(dx2*dx2 + dy2*dy2);
                return (d >= R - hw - 5.0 && d <= R + hw + 5.0);
            };
            return testArc(0.0) || testArc(def->lengthMm);
        } else if (def->type == MC::PieceType::Curve && def->radiusMm > 0.0) {
            double R     = def->radiusMm;
            double aDeg  = def->angleDeg;
            double sign  = (aDeg >= 0.0) ? 1.0 : -1.0;
            double acx = 0.0, acy = sign * R;
            double dx = local.x - acx, dy = local.y - acy;
            double dist = std::sqrt(dx*dx + dy*dy);
            if (dist < R - hw - 5.0 || dist > R + hw + 5.0) return false;
            double clickAngle = std::atan2(dy, dx) * 180.0 / M_PI;
            double startAngle = std::atan2(0.0 - acy, 0.0 - acx) * 180.0 / M_PI;
            double endAngle   = startAngle + aDeg;
            double lo = std::min(startAngle, endAngle);
            double hi = std::max(startAngle, endAngle);
            while (clickAngle < lo - 180.0) clickAngle += 360.0;
            while (clickAngle > hi + 180.0) clickAngle -= 360.0;
            return (clickAngle >= lo - 3.0 && clickAngle <= hi + 3.0);
        } else {
            return (local.x >= -5.0 && local.x <= def->lengthMm + 5.0 &&
                    local.y >= -hw && local.y <= hw);
        }
    };

    // Pick the piece whose origin is closest to the click point.
    // When multiple pieces overlap (e.g. track on level 3 directly over track
    // on level 1), the one on the HIGHER level wins so the click lands on
    // the topmost visible deck. Pieces on hidden levels are skipped entirely
    // so toggling a level off makes its track non-selectable.
    const auto& state = m_frame->State();
    int    bestId    = -1;
    double bestHeight = -1e9;
    double bestDist  = 1e9;
    for (int i = (int)pieces.size()-1; i >= 0; --i) {
        if (!state.IsLevelVisible(pieces[i].levelId)) continue;
        if (!testPiece(pieces[i])) continue;
        wxPoint sc = m_view.ToScreen(pieces[i].transform.pos);
        double dx = sc.x - screenPt.x;
        double dy = sc.y - screenPt.y;
        double dist = dx*dx + dy*dy;
        double pieceH = state.LevelHeight(pieces[i].levelId);
        // Higher level always wins. Within the same level, closer origin wins.
        if (pieceH > bestHeight + 0.001 ||
            (std::abs(pieceH - bestHeight) <= 0.001 && dist < bestDist))
        {
            bestHeight = pieceH;
            bestDist   = dist;
            bestId     = pieces[i].id;
        }
    }
    return bestId;
}

// ── HelixHitTest ──────────────────────────────────────────────────────────────
// Returns the helix.id whose R1 or R2 ring is under the click, or -1 if none.
// The rendered helix consists of two concentric rings whose radii come from
// the helix's variant. We accept a click within a small band around either.
int CanvasPanel::HelixHitTest(const wxPoint& screenPt) const {
    MC::Vec2 world = m_view.ToWorld(screenPt);
    const auto& state = m_frame->State();

    const double hw   = 20.0;   // track half-width tolerance (matches HitTest)
    const double tol  = 5.0;    // extra forgiveness in mm

    // Pick the highest helix whose ring is under the click; helixes on hidden
    // levels are skipped entirely.
    int bestId = -1;
    double bestHeight = -1e9;
    for (const auto& h : state.helices) {
        const MC::HelixVariant* v = MC::FindHelixVariant(h.variantId);
        if (!v) v = MC::FindHelixVariant(MC::DefaultHelixVariantId());
        if (!v) continue;
        int hLevel = 1;
        for (const auto& p : state.pieces)
            if (p.helixId == h.id) { hLevel = p.levelId; break; }
        if (!state.IsLevelVisible(hLevel)) continue;
        double dx = world.x - h.center.x;
        double dy = world.y - h.center.y;
        double d  = std::sqrt(dx*dx + dy*dy);
        bool hit = (d >= v->radiusR1 - hw - tol && d <= v->radiusR1 + hw + tol)
                || (d >= v->radiusR2 - hw - tol && d <= v->radiusR2 + hw + tol);
        if (!hit) continue;
        double pieceH = state.LevelHeight(hLevel);
        if (pieceH > bestHeight + 0.001) {
            bestHeight = pieceH;
            bestId     = h.id;
        }
    }
    return bestId;
}

// ── DeckHitTest ───────────────────────────────────────────────────────────────
// Returns the level id whose deck outline is under the screen point, or -1.
// "Near the outline" = within a tolerance of any edge of the polygon. We test
// visible levels from highest deck to lowest so the topmost deck wins.
int CanvasPanel::DeckHitTest(const wxPoint& screenPt) const {
    const auto& state = m_frame->State();
    MC::Vec2 w = m_view.ToWorld(screenPt);
    const double tolMm = 8.0 / std::max(0.0001, m_view.scale); // ~8px in world mm

    // Order levels by descending height so the topmost deck is hit first.
    std::vector<const MC::Level*> ordered;
    for (const auto& l : state.levels) ordered.push_back(&l);
    std::sort(ordered.begin(), ordered.end(),
              [](const MC::Level* a, const MC::Level* b){ return a->heightMm > b->heightMm; });

    auto distToSeg = [](const MC::Vec2& p, const MC::Vec2& a, const MC::Vec2& b) -> double {
        double vx = b.x - a.x, vy = b.y - a.y;
        double wx = p.x - a.x, wy = p.y - a.y;
        double len2 = vx*vx + vy*vy;
        double t = (len2 > 1e-9) ? (wx*vx + wy*vy) / len2 : 0.0;
        t = std::max(0.0, std::min(1.0, t));
        double cx = a.x + t*vx, cy = a.y + t*vy;
        double dx = p.x - cx, dy = p.y - cy;
        return std::sqrt(dx*dx + dy*dy);
    };

    for (const MC::Level* l : ordered) {
        if (!l->visible || l->shape.size() < 3) continue;
        const auto& poly = l->shape;
        for (size_t i = 0; i < poly.size(); ++i) {
            const MC::Vec2& a = poly[i];
            const MC::Vec2& b = poly[(i + 1) % poly.size()];
            if (distToSeg(w, a, b) <= tolMm) return l->id;
        }
    }
    return -1;
}

// ── ShapeHitTest ──────────────────────────────────────────────────────────────
// Returns the id of a free-form polygon shape under the cursor, or -1.
// "Under" = point-in-polygon (even-odd rule) OR within ~8px of an edge.
// Shapes on hidden levels are skipped. When several shapes overlap, the
// most recently added (top of draw order) wins.
int CanvasPanel::ShapeHitTest(const wxPoint& screenPt) const {
    const auto& state = m_frame->State();
    MC::Vec2 w = m_view.ToWorld(screenPt);
    const double tolMm = 8.0 / std::max(0.0001, m_view.scale);

    auto distToSeg = [](const MC::Vec2& p, const MC::Vec2& a, const MC::Vec2& b) {
        double vx = b.x - a.x, vy = b.y - a.y;
        double wx = p.x - a.x, wy = p.y - a.y;
        double len2 = vx*vx + vy*vy;
        double t = (len2 > 1e-9) ? (wx*vx + wy*vy) / len2 : 0.0;
        t = std::max(0.0, std::min(1.0, t));
        double cx = a.x + t*vx, cy = a.y + t*vy;
        double dx = p.x - cx, dy = p.y - cy;
        return std::sqrt(dx*dx + dy*dy);
    };
    auto pointInPoly = [](const MC::Vec2& p, const std::vector<MC::Vec2>& v) {
        bool inside = false;
        int n = (int)v.size();
        for (int i = 0, j = n - 1; i < n; j = i++) {
            bool cond = ((v[i].y > p.y) != (v[j].y > p.y)) &&
                (p.x < (v[j].x - v[i].x) * (p.y - v[i].y) / (v[j].y - v[i].y) + v[i].x);
            if (cond) inside = !inside;
        }
        return inside;
    };

    // Iterate newest-first so the topmost shape wins when overlapping.
    for (int i = (int)state.shapes.size() - 1; i >= 0; --i) {
        const MC::CanvasShape& s = state.shapes[i];
        if (s.vertices.size() < 3) continue;
        if (!state.IsLevelVisible(s.levelId)) continue;
        if (pointInPoly(w, s.vertices)) return s.id;
        // Also accept clicks within ~8px of any edge (helps grab thin shapes).
        for (size_t k = 0; k < s.vertices.size(); ++k) {
            const MC::Vec2& a = s.vertices[k];
            const MC::Vec2& b = s.vertices[(k + 1) % s.vertices.size()];
            if (distToSeg(w, a, b) <= tolMm) return s.id;
        }
    }
    return -1;
}

// ── ShowShapeEditorDialog ─────────────────────────────────────────────────────
// Modal editor for a free-form polygon shape: label (name), fill color,
// height, outline visibility. Called when the user double-clicks a shape.
void CanvasPanel::ShowShapeEditorDialog(int shapeId) {
    auto& state = m_frame->State();
    MC::CanvasShape* sp = nullptr;
    for (auto& s : state.shapes) if (s.id == shapeId) { sp = &s; break; }
    if (!sp) return;

    wxDialog dlg(this, wxID_ANY, "Shape Properties",
                 wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE);
    wxBoxSizer* top = new wxBoxSizer(wxVERTICAL);

    // Label (name)
    wxBoxSizer* nameRow = new wxBoxSizer(wxHORIZONTAL);
    nameRow->Add(new wxStaticText(&dlg, wxID_ANY, "Name:"),
                 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
    wxTextCtrl* txtName = new wxTextCtrl(&dlg, wxID_ANY,
        wxString::FromUTF8(sp->label), wxDefaultPosition, wxSize(240, -1));
    nameRow->Add(txtName, 1, wxALIGN_CENTER_VERTICAL);
    top->Add(nameRow, 0, wxEXPAND | wxALL, 10);

    // Color
    wxBoxSizer* colRow = new wxBoxSizer(wxHORIZONTAL);
    colRow->Add(new wxStaticText(&dlg, wxID_ANY, "Color:"),
                0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
    wxColourPickerCtrl* colorPick = new wxColourPickerCtrl(&dlg, wxID_ANY,
        wxColour(sp->r, sp->g, sp->b));
    colRow->Add(colorPick, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 12);
    wxStaticText* alphaLbl = new wxStaticText(&dlg, wxID_ANY, "Opacity %:");
    colRow->Add(alphaLbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
    wxSpinCtrl* spinAlpha = new wxSpinCtrl(&dlg, wxID_ANY, "",
        wxDefaultPosition, wxSize(70, -1),
        wxSP_ARROW_KEYS, 0, 100, (int)std::round(sp->alpha * 100.0 / 255.0));
    colRow->Add(spinAlpha, 0, wxALIGN_CENTER_VERTICAL);
    top->Add(colRow, 0, wxLEFT | wxRIGHT | wxBOTTOM, 10);

    // Height
    wxString hUnit = (state.units == 1) ? "cm"
                   : (state.units == 2) ? "in" : "mm";
    double hShown  = (state.units == 1) ? sp->heightMm / 10.0
                   : (state.units == 2) ? sp->heightMm / 25.4 : sp->heightMm;
    double hInc    = (state.units == 2) ? 0.1 : 1.0;
    wxBoxSizer* hRow = new wxBoxSizer(wxHORIZONTAL);
    hRow->Add(new wxStaticText(&dlg, wxID_ANY,
        wxString::Format("Height (above level deck, %s):", hUnit)),
        0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
    wxSpinCtrlDouble* spinH = new wxSpinCtrlDouble(&dlg, wxID_ANY, "",
        wxDefaultPosition, wxSize(110, -1), wxSP_ARROW_KEYS,
        0.0, 5000.0, hShown, hInc);
    spinH->SetDigits(state.units == 2 ? 2 : 1);
    hRow->Add(spinH, 0, wxALIGN_CENTER_VERTICAL);
    top->Add(hRow, 0, wxLEFT | wxRIGHT | wxBOTTOM, 10);

    // Outline checkbox
    wxCheckBox* chkOutline = new wxCheckBox(&dlg, wxID_ANY, "Show outline");
    chkOutline->SetValue(sp->hasOutline);
    top->Add(chkOutline, 0, wxLEFT | wxRIGHT | wxBOTTOM, 10);

    top->Add(new wxStaticLine(&dlg), 0, wxEXPAND | wxALL, 4);
    wxSizer* okc = dlg.CreateButtonSizer(wxOK | wxCANCEL);
    if (okc) top->Add(okc, 0, wxEXPAND | wxALL, 8);
    dlg.SetSizerAndFit(top);
    dlg.Centre();

    if (dlg.ShowModal() != wxID_OK) return;

    state.PushUndo();
    sp->label      = txtName->GetValue().ToStdString();
    wxColour c     = colorPick->GetColour();
    sp->r = c.Red(); sp->g = c.Green(); sp->b = c.Blue();
    sp->alpha      = (uint8_t)std::round(spinAlpha->GetValue() * 255.0 / 100.0);
    double newH    = spinH->GetValue();
    if      (state.units == 1) newH *= 10.0;
    else if (state.units == 2) newH *= 25.4;
    sp->heightMm   = newH;
    sp->hasOutline = chkOutline->GetValue();

    m_frame->MarkDirty();
    if (m_frame) m_frame->RefreshCanvas();
}


// Returns the id of a measurement whose line or label sits under the cursor.
// *what is set to 1 (line) or 2 (label). Label takes priority over line if
// both overlap. Tolerance ~8px in world units. -1 = no hit.
int CanvasPanel::MeasurementHitTest(const wxPoint& screenPt, int* what) const {
    if (what) *what = 0;
    const auto& state = m_frame->State();
    MC::Vec2 w = m_view.ToWorld(screenPt);
    const double scale  = std::max(0.0001, m_view.scale);
    const double tolMm  = 8.0 / scale;
    const double labelHalfW = 32.0 / scale;   // ~64px wide
    const double labelHalfH = 9.0  / scale;

    auto distToSeg = [](const MC::Vec2& p, const MC::Vec2& a, const MC::Vec2& b) {
        double vx = b.x - a.x, vy = b.y - a.y;
        double wx = p.x - a.x, wy = p.y - a.y;
        double len2 = vx*vx + vy*vy;
        double t = (len2 > 1e-9) ? (wx*vx + wy*vy) / len2 : 0.0;
        t = std::max(0.0, std::min(1.0, t));
        double cx = a.x + t*vx, cy = a.y + t*vy;
        double dx = p.x - cx, dy = p.y - cy;
        return std::sqrt(dx*dx + dy*dy);
    };

    // Iterate newest-first so the most recently placed measurement wins ties.
    for (int i = (int)state.measurements.size() - 1; i >= 0; --i) {
        const MC::Measurement& m = state.measurements[i];
        MC::Vec2 mid { (m.a.x + m.b.x) * 0.5 + m.labelOffset.x,
                       (m.a.y + m.b.y) * 0.5 + m.labelOffset.y };
        // Label hit (rectangle around the centered label pos)
        if (std::abs(w.x - mid.x) <= labelHalfW &&
            std::abs(w.y - mid.y) <= labelHalfH) {
            if (what) *what = 2;
            return m.id;
        }
        // Line hit
        if (distToSeg(w, m.a, m.b) <= tolMm) {
            if (what) *what = 1;
            return m.id;
        }
    }
    return -1;
}

// ── Snap ──────────────────────────────────────────────────────────────────────
void CanvasPanel::UpdateDragSnap(const wxPoint& screenPt) {
    m_dragTransform.pos = m_view.ToWorld(screenPt);
    const MC::TrackPiece* def = MC::GetCatalog().Find(m_dragCatalogNum);
    if (!def) return;
    auto& state = m_frame->State();
    if (state.snapEnabled) {
        // Only snap to pieces on visible levels — a hidden level should
        // not pull a drag toward track the user can't see.
        std::vector<MC::PlacedPiece> visible;
        visible.reserve(state.pieces.size());
        for (const auto& p : state.pieces)
            if (state.IsLevelVisible(p.levelId)) visible.push_back(p);
        m_currentSnap = MC::SnapEngine::FindSnap(*def, m_dragTransform,
                            visible, MC::GetCatalog());
    } else {
        m_currentSnap = {};
    }
}

void CanvasPanel::CommitDrag(const wxPoint& screenPt) {
    if (HasCapture()) ReleaseMouse();
    const MC::TrackPiece* def = MC::GetCatalog().Find(m_dragCatalogNum);
    if (!def) { CancelDrag(); return; }

    // Check for series-incompatible snap attempt before placing
    if (!m_currentSnap.found && m_frame->State().snapEnabled) {
        auto incompat = MC::SnapEngine::FindIncompatibleSnap(
            *def, m_dragTransform,
            m_frame->State().pieces, MC::GetCatalog());
        if (incompat.found) {
            auto seriesName = [](MC::TrackSeries s) -> wxString {
                switch (s) {
                    case MC::TrackSeries::MarlinC:  return "Marklin C-Track";
                    case MC::TrackSeries::MarlinK:  return "Marklin K-Track";
                    case MC::TrackSeries::TrixC:    return "TRIX C-Track";
                    case MC::TrackSeries::MarlinZ:  return "Marklin Z-Track";
                    case MC::TrackSeries::Gauge1:   return "Marklin Gauge 1";
                    case MC::TrackSeries::MarlinM:  return "Marklin M-Track";
                    default: return "Unknown";
                }
            };
            wxMessageBox(
                wxString::Format(
                    "%s cannot connect to %s.\n\n"
                    "These track systems are not compatible.\n"
                    "Use adapter track 24922 to connect C-Track to K-Track.",
                    seriesName(incompat.dragSeries),
                    seriesName(incompat.targSeries)),
                "Incompatible Track Systems",
                wxOK | wxICON_WARNING, m_frame);
            CancelDrag();
            return;
        }
    }

    MC::PlacedPiece placed;
    placed.catalogNumber = m_dragCatalogNum;
    placed.joints.resize(def->connections.size());
    placed.transform = m_currentSnap.found
                       ? m_currentSnap.snappedTransform
                       : m_dragTransform;

    auto& state = m_frame->State();
    state.EnsureDefaultLevel();

    // Inherit level from snap target if joining, else use the highest
    // currently-visible level (so dropping onto an empty area while the
    // user is working "up top" defaults to the upper deck, not level 1).
    if (m_currentSnap.found) {
        MC::PlacedPiece* tp = state.FindById(m_currentSnap.targetPieceId);
        placed.levelId = tp ? tp->levelId : state.activeLevelId;
        // Snapping onto an existing piece: inherit its elevation, so a chain
        // built on a raised deck stays on that deck.
        if (tp) placed.z_mm = tp->z_mm;
        else    placed.z_mm = state.LevelHeight(placed.levelId);
    } else {
        int bestId = state.activeLevelId;
        double bestH = -1e9;
        for (const auto& l : state.levels) {
            if (!l.visible) continue;
            if (l.heightMm > bestH) { bestH = l.heightMm; bestId = l.id; }
        }
        placed.levelId = bestId;
        // A piece must start at its level's height. This used to be left at
        // zero: the piece was assigned to a raised level but sat on the
        // ground, and only a later edit of the level's height (which pushes
        // z_mm onto its pieces) would lift it.
        placed.z_mm = state.LevelHeight(bestId);
    }

    int newId = state.AddPiece(placed);

    // Auto-zoom when first Z-Track piece is placed — keep origin (0,0) visible
    if (def->series == MC::TrackSeries::MarlinZ && state.pieces.size() == 1) {
        m_view.scale = 1.0;
        // Place origin at top-left with margin so 0,0 stays visible
        const int margin = 60;
        m_view.panX = margin;
        m_view.panY = margin;
    }

    if (m_currentSnap.found) {
        MC::PlacedPiece* np = state.FindById(newId);
        MC::PlacedPiece* tp = state.FindById(m_currentSnap.targetPieceId);
        if (np && tp) {
            np->joints[m_currentSnap.sourceConnIdx] =
                { m_currentSnap.targetPieceId, m_currentSnap.targetConnIdx };
            tp->joints[m_currentSnap.targetConnIdx] =
                { newId, m_currentSnap.sourceConnIdx };
            // Apply grade if a run is active
            ApplyGradeToNewPiece(*np, m_currentSnap.targetPieceId,
                                      m_currentSnap.targetConnIdx,
                                      m_currentSnap.sourceConnIdx);
        }
    }

    // 74861 turntable auto-spawn: when a turntable is placed, six 74871
    // spoke-extension pieces ship with it. Three sit at consecutive
    // spokes near one end of the bridge (slots 0, 1, 29), three at the
    // opposite end (slots 14, 15, 16). Each spawned 74871 has
    // freeWithTurntable=true so it stays out of the buy-track tally.
    {
        MC::PlacedPiece* np = state.FindById(newId);
        if (np && np->catalogNumber == "74861") {
            const MC::TrackPiece* extDef = MC::GetCatalog().Find("74871");
            const MC::TrackPiece* ttDef  = MC::GetCatalog().Find("74861");
            if (extDef && ttDef) {
                // Slots in the turntable's connection array correspond to
                // 12-deg increments around the rim (see CatalogLoader).
                // Pick three adjacent slots on each side of the bridge
                // axis. With bridge default heading = 0 deg, the east end
                // points at slot 0; the west end at slot 15. We use
                // {29, 0, 1} and {14, 15, 16} for "three centred on each
                // bridge tip".
                const int slots[6] = { 29, 0, 1, 14, 15, 16 };
                for (int s : slots) {
                    // Compute the world-space connector position+heading
                    // on the turntable for slot s. The turntable's local
                    // conn[s] is what we snap the new 74871's conn[0] to.
                    MC::ConnectionPoint ttWorld = np->worldConnection(*ttDef, s);

                    MC::Transform2D xf = MC::SnapEngine::ComputeSnapTransform(
                        *extDef, 0, ttWorld);

                    MC::PlacedPiece ext;
                    ext.id               = state.nextId++;
                    ext.catalogNumber    = "74871";
                    ext.transform        = xf;
                    ext.joints.resize(extDef->connections.size(), {-1,-1});
                    ext.joints[0]        = { np->id, s };
                    ext.levelId          = np->levelId;
                    ext.z_mm             = np->z_mm;
                    ext.gradeDeg         = 0.0;
                    ext.freeWithTurntable = true;
                    state.pieces.push_back(ext);

                    // FindById may have invalidated np by the push_back;
                    // re-resolve it before adding the joint back-reference.
                    np = state.FindById(newId);
                    if (!np) break;
                    np->joints[s] = { ext.id, 0 };
                }
            }
        }
    }

    m_draggingPiece = false;
    m_currentSnap   = {};
    m_frame->MarkDirty();
    wxString pc; pc << "Pieces: " << (int)state.pieces.size();
    m_frame->SetStatusText(pc, 1);

    // Pair pieces — immediately place second copy joined to first
    if (def->pairPiece) {
        MC::PlacedPiece* fp = state.FindById(newId);
        if (fp && def->connections.size() > 1 && fp->joints[1].pieceId == -1) {
            MC::ConnectionPoint exitConn = fp->worldConnection(*def, 1);
            MC::Transform2D t2 = MC::SnapEngine::ComputeSnapTransform(*def, 0, exitConn);
            MC::PlacedPiece pair;
            pair.catalogNumber = m_dragCatalogNum;
            pair.transform     = t2;
            pair.joints.resize(def->connections.size());
            pair.levelId       = fp->levelId;
            int pairId = state.AddPiece(pair);
            MC::PlacedPiece* pp  = state.FindById(pairId);
            MC::PlacedPiece* fp2 = state.FindById(newId);
            if (pp && fp2) {
                pp->joints[0]  = { fp2->id, 1 };
                fp2->joints[1] = { pp->id,  0 };
                ApplyGradeToNewPiece(*pp, fp2->id, 1, 0);
            }
        }
    }

    // Select the newly placed piece
    state.ClearSelection();
    MC::PlacedPiece* placed2 = state.FindById(newId);
    if (placed2) placed2->selected = true;
    m_frame->OnSelectionChanged();

    Refresh();
}

void CanvasPanel::CancelDrag() {
    if (HasCapture()) ReleaseMouse();
    m_draggingPiece   = false;
    m_draggingScenery = false;
    m_draggingHelix   = false;
    m_dragHelixVariant.clear();
    m_currentSnap     = {};
    Refresh();
}

void CanvasPanel::CommitSceneryDrag() {
    if (HasCapture()) ReleaseMouse();
    const MC::SceneryItem* def = MC::GetSceneryCatalog().Find(m_dragSceneryId);
    if (!def) { m_draggingScenery = false; Refresh(); return; }

    MC::PlacedScenery placed;
    placed.itemId    = m_dragSceneryId;
    placed.transform = m_dragSceneryTransform;

    auto& state = m_frame->State();

    // Turntable-aware snap: when the roundhouse 72886 is dropped near a
    // turntable (id "74861"), align its fan apex with the turntable
    // hub and rotate so the central stall points along the nearest 12°
    // spoke direction. Snap range = turntable radius + 250 mm (generous,
    // since the roundhouse sits outside the pit).
    if (m_dragSceneryId == "72886") {
        const MC::PlacedScenery* nearest = nullptr;
        double bestDist = 1e9;
        for (const auto& t : state.scenery) {
            if (t.itemId != "74861") continue;
            double dx = t.transform.pos.x - placed.transform.pos.x;
            double dy = t.transform.pos.y - placed.transform.pos.y;
            double d  = std::sqrt(dx*dx + dy*dy);
            if (d < bestDist) { bestDist = d; nearest = &t; }
        }
        if (nearest && bestDist < 700.0 /* mm */) {
            // The roundhouse's fan apex is at local (widthMm/2, 0) — i.e.
            // bottom-middle of the footprint. Since the placement transform
            // anchors at the geometric center (widthMm/2, heightMm/2), the
            // apex sits at local offset (0, -heightMm/2) from the anchor.
            // In world coordinates, after a rotation by `heading`, the
            // anchor needs to move so the apex lands on the turntable
            // centre. Effective: anchor_pos = ttCenter - rotate((0,-h/2), heading).
            double h = def->heightMm;
            // Snap heading to nearest 12° relative to "up" (the building's
            // natural "facing-turntable" orientation has heading = 0,
            // meaning the front faces +y in canvas coords... but headings
            // in this app are typically measured in degrees with 0 = east.
            // We'll snap to the nearest 12° step regardless of base
            // reference; the user can dial it in.
            double curDeg = placed.transform.heading;
            double snapped = std::round(curDeg / 12.0) * 12.0;
            placed.transform.heading = snapped;
            double rad = snapped * M_PI / 180.0;
            // The roundhouse's apex (the turntable hub) sits outside the
            // bbox, 349.6 mm below the bbox's TOP edge in local coords
            // (which is 349.6 mm into the negative-y direction from the
            // bbox's front edge at local y=0). The bbox anchor is the
            // geometric centre (widthMm/2, heightMm/2). So the apex offset
            // from the anchor, in piece-local coords, is:
            //   local apex_x = 0
            //   local apex_y = -(heightMm/2 + 349.6)
            // Matches the constant used in the catalog (apexY = -349.6
            // in bbox-local coords with origin at top-left).
            const double gapBelowBbox = 349.6;
            double lx = 0.0;
            double ly = -(h * 0.5 + gapBelowBbox);
            double wx = lx * std::cos(rad) - ly * std::sin(rad);
            double wy = lx * std::sin(rad) + ly * std::cos(rad);
            // anchor + apex_world = turntable_centre, so:
            placed.transform.pos.x = nearest->transform.pos.x - wx;
            placed.transform.pos.y = nearest->transform.pos.y - wy;
        }
    }

    state.EnsureDefaultLevel();
    {
        int bestId = state.activeLevelId;
        double bestH = -1e9;
        for (const auto& l : state.levels) {
            if (!l.visible) continue;
            if (l.heightMm > bestH) { bestH = l.heightMm; bestId = l.id; }
        }
        placed.levelId = bestId;
    }
    state.ClearSelection();
    int newId = state.AddScenery(placed);
    MC::PlacedScenery* ns = state.FindSceneryById(newId);
    if (ns) ns->selected = true;

    m_draggingScenery = false;
    m_frame->MarkDirty();
    wxString pc; pc << "Pieces: " << (int)state.pieces.size();
    m_frame->SetStatusText(pc, 1);
    Refresh();
}

// ── SceneryHitTest ────────────────────────────────────────────────────────────
// Returns scenery id at screen point, or -1.
int CanvasPanel::SceneryHitTest(const wxPoint& screenPt) const {
    MC::Vec2 world = m_view.ToWorld(screenPt);
    auto& cat      = MC::GetSceneryCatalog();
    auto& state    = m_frame->State();
    auto& items    = state.scenery;
    int bestId = -1;
    double bestHeight = -1e9;
    for (int i = (int)items.size()-1; i >= 0; --i) {
        const auto& s = items[i];
        if (!state.IsLevelVisible(s.levelId)) continue;
        const MC::SceneryItem* def = cat.Find(s.itemId);
        if (!def) continue;
        double cx = def->widthMm  * s.scaleX * 0.5;
        double cy = def->heightMm * s.scaleY * 0.5;
        MC::Vec2 rel = world - s.transform.pos;
        MC::Vec2 local = rel.rotated(-s.transform.heading);
        if (local.x >= -cx - 3.0 && local.x <= cx + 3.0 &&
            local.y >= -cy - 3.0 && local.y <= cy + 3.0)
        {
            double pieceH = state.LevelHeight(s.levelId);
            if (pieceH > bestHeight + 0.001) {
                bestHeight = pieceH;
                bestId = s.id;
            }
        }
    }
    return bestId;
}

// ── Platform resize helpers ──────────────────────────────────────────────────

// Resize handles activate only when the user has exactly one scenery item
// selected (no tracks, no labels) AND that item is a Platform. This keeps the
// resize UI out of the way for any other context.
int CanvasPanel::GetResizablePlatformId() const {
    auto& state = m_frame->State();
    auto selS = const_cast<MC::CanvasState&>(state).SelectedScenery();
    if (selS.size() != 1) return -1;
    // Any selected track or label disqualifies (we don't want handles
    // appearing while the user is mid-multiselection).
    for (const auto& p : state.pieces)  if (p.selected) return -1;
    for (const auto& l : state.labels)  if (l.selected) return -1;
    const MC::SceneryItem* def = MC::GetSceneryCatalog().Find(selS[0]->itemId);
    if (!def || def->category != MC::SceneryCategory::Platform) return -1;
    return selS[0]->id;
}

// Handle index layout in the item's local (heading=0) frame:
//   0=NW   1=N    2=NE
//   7=W           3=E
//   6=SW   5=S    4=SE
// Opposite pairings: corners diagonally (0↔4, 2↔6), edges across (1↔5, 3↔7).
int CanvasPanel::OppositeHandle(int idx) {
    switch (idx) {
        case 0: return 4;
        case 1: return 5;
        case 2: return 6;
        case 3: return 7;
        case 4: return 0;
        case 5: return 1;
        case 6: return 2;
        case 7: return 3;
    }
    return -1;
}

MC::Vec2 CanvasPanel::PlatformHandleWorldPos(const MC::PlacedScenery& s,
                                              const MC::SceneryItem& def,
                                              int idx) const
{
    double cx = def.widthMm  * s.scaleX * 0.5;
    double cy = def.heightMm * s.scaleY * 0.5;
    // Local positions (item center at origin, scale already applied):
    static const double LX[8] = { -1, 0, +1, +1, +1, 0, -1, -1 };
    static const double LY[8] = { -1, -1, -1, 0, +1, +1, +1, 0 };
    double lx = LX[idx] * cx;
    double ly = LY[idx] * cy;
    // Rotate by item heading, translate to world.
    double rad = s.transform.heading * M_PI / 180.0;
    double c = std::cos(rad), si = std::sin(rad);
    return MC::Vec2{ s.transform.pos.x + lx * c - ly * si,
                     s.transform.pos.y + lx * si + ly * c };
}

int CanvasPanel::HitTestPlatformHandle(const wxPoint& screenPt, int platformId) const {
    auto& state = m_frame->State();
    const MC::PlacedScenery* s =
        const_cast<MC::CanvasState&>(state).FindSceneryById(platformId);
    if (!s) return -1;
    const MC::SceneryItem* def = MC::GetSceneryCatalog().Find(s->itemId);
    if (!def) return -1;
    // 6px radius in screen space — generous enough for touchpad users
    const int hitR = 6;
    for (int i = 0; i < 8; ++i) {
        MC::Vec2 w = PlatformHandleWorldPos(*s, *def, i);
        wxPoint hs = m_view.ToScreen(w);
        int dx = screenPt.x - hs.x;
        int dy = screenPt.y - hs.y;
        if (dx*dx + dy*dy <= hitR*hitR) return i;
    }
    return -1;
}

void CanvasPanel::DrawPlatformHandles(wxDC& dc, int platformId) {
    auto& state = m_frame->State();
    const MC::PlacedScenery* s = state.FindSceneryById(platformId);
    if (!s) return;
    const MC::SceneryItem* def = MC::GetSceneryCatalog().Find(s->itemId);
    if (!def) return;
    dc.SetPen(wxPen(wxColour(40, 90, 200), 1));
    dc.SetBrush(wxBrush(wxColour(255, 255, 255)));
    for (int i = 0; i < 8; ++i) {
        MC::Vec2 w = PlatformHandleWorldPos(*s, *def, i);
        wxPoint hs = m_view.ToScreen(w);
        // Square handles for corners, slightly smaller circles for edges.
        bool isCorner = (i == 0 || i == 2 || i == 4 || i == 6);
        if (isCorner) {
            dc.DrawRectangle(hs.x - 4, hs.y - 4, 9, 9);
        } else {
            dc.DrawCircle(hs, 3);
        }
    }
}

void CanvasPanel::UpdateStatusBar(const MC::Vec2& worldPos) {
    int units = m_frame->State().units;
    wxString pos;
    if (units == 2) {
        pos = wxString::Format("%.2f in,  %.2f in",
            worldPos.x / 25.4, worldPos.y / 25.4);
    } else if (units == 1) {
        pos = wxString::Format("%.1f cm,  %.1f cm",
            worldPos.x / 10.0, worldPos.y / 10.0);
    } else {
        pos = wxString::Format("%.0f mm,  %.0f mm",
            worldPos.x, worldPos.y);
    }
    m_frame->SetStatusText(pos, 2);
}

// ── Elevation Dialog ──────────────────────────────────────────────────────────
void CanvasPanel::ApplyGradeToNewPiece(MC::PlacedPiece& newPiece,
                                        int anchorId, int anchorConnIdx,
                                        int newPieceConnIdx)
{
    auto& state = m_frame->State();
    const MC::PlacedPiece* anchor = state.FindById(anchorId);
    if (!anchor) return;
    const MC::TrackPiece* anchorDef = MC::GetCatalog().Find(anchor->catalogNumber);
    if (!anchorDef || anchorDef->lengthMm <= 0) return;
    const MC::TrackPiece* newDef = MC::GetCatalog().Find(newPiece.catalogNumber);

    // ── INVARIANT ────────────────────────────────────────────────────────────
    // PlacedPiece::z_mm is the elevation at conn[0] (X=0 in piece-local space).
    // The 3D viewer translates to (x,y,z_mm) and tilts pivoting about local X=0.
    // Any new z_mm we write here must be the elevation AT THAT END of the new
    // piece, not at whichever end happens to be doing the joining.
    // ─────────────────────────────────────────────────────────────────────────

    // Run length of a piece along its entry→exit axis (chord X for curves).
    auto pieceRun = [](const MC::TrackPiece* d) -> double {
        if (!d) return 0.0;
        double L = d->lengthMm;
        if (d->radiusMm > 0.0 && d->connections.size() >= 2) {
            double cx = d->connections[1].pos.x;
            if (cx > 1.0) L = cx;
        }
        return L;
    };

    // Elevation at conn[1] of an existing piece (z_mm is at conn[0]).
    // If the piece is chain-reversed, conn[0] is the chain EXIT and conn[1]
    // is the chain ENTRY; the elevation at conn[1] is reached by going
    // BACKWARDS along the chain (subtract pieceRise instead of adding).
    auto exitHeight = [&](const MC::PlacedPiece* p, const MC::TrackPiece* d) -> double {
        if (!p || !d) return 0.0;
        double rise = pieceRun(d) * std::sin(p->gradeDeg * M_PI / 180.0);
        return p->z_mm + (p->chainReversed ? -rise : rise);
    };

    // Elevation at the anchor's joining connection (conn[0] = entry z; conn[1] = exit z).
    auto anchorJointHeight = [&]() -> double {
        return (anchorConnIdx == 0) ? anchor->z_mm : exitHeight(anchor, anchorDef);
    };

    MC::CanvasState::GradeRun* run = state.ActiveRunForAnchor(anchorId);

    // Bridge pieces that are NOT ramps are always flat — they sit at the joint
    // height and end the grade run. Ramps (isRamp=true) participate in grade
    // chains normally. A flat piece has no rise across it, so z_mm at conn[0]
    // equals the joint altitude regardless of which conn joins.
    if (newDef && newDef->isBridge && !newDef->isRamp) {
        newPiece.z_mm     = anchorJointHeight();
        newPiece.gradeDeg = 0.0;
        if (run) {
            state.StopGradeRun(anchorId);
            state.SyncGradeFromRuns();
        }
        m_frame->Properties()->RefreshProperties();
        return;
    }

    if (run) {
        double jointZ    = anchorJointHeight();
        double pieceRise = pieceRun(newDef)
                         * std::sin(run->gradeDeg * M_PI / 180.0);

        // gradeDeg always means "rise along the chain's direction of
        // travel", positive = ascending. This is the natural user-facing
        // meaning. The geometry caveat is that the piece may be joined
        // forward (conn[0] = entry) or backward (conn[1] = entry) by the
        // snap engine; chainReversed records which.
        //
        // z_mm is always the height at conn[0]:
        //   newPieceConnIdx == 0: conn[0] IS the chain entry, so z_mm = jointZ.
        //   newPieceConnIdx == 1: conn[1] is the chain entry at jointZ.
        //                          conn[0] is the chain EXIT, so it sits
        //                          jointZ + pieceRise (ascending exit ends
        //                          higher than entry).
        if (newPieceConnIdx == 1) {
            newPiece.z_mm          = jointZ + pieceRise;
            newPiece.chainReversed = true;
        } else {
            newPiece.z_mm          = jointZ;
            newPiece.chainReversed = false;
        }
        newPiece.gradeDeg = run->gradeDeg;

        run->anchorId = newPiece.id;
        run->connIdx  = (newPieceConnIdx == 1) ? 0 : 1;
        state.SyncGradeFromRuns();
    } else {
        // No active run: new piece is flat, sitting at the joint altitude.
        newPiece.z_mm     = anchorJointHeight();
        newPiece.gradeDeg = 0.0;
    }

    m_frame->Properties()->RefreshProperties();
}

// ── ShowSceneryResizeDialog ───────────────────────────────────────────────────
void CanvasPanel::ShowSceneryResizeDialog(int sceneryId)
{
    auto& state = m_frame->State();
    MC::PlacedScenery* item = state.FindSceneryById(sceneryId);
    if (!item) return;
    const MC::SceneryItem* def = MC::GetSceneryCatalog().Find(item->itemId);
    if (!def) return;

    wxDialog dlg(this, wxID_ANY, "Resize Scenery",
                 wxDefaultPosition, wxDefaultSize,
                 wxDEFAULT_DIALOG_STYLE | wxSTAY_ON_TOP);

    wxBoxSizer* top = new wxBoxSizer(wxVERTICAL);

    // Item name
    wxStaticText* nameLabel = new wxStaticText(&dlg, wxID_ANY,
        wxString::FromUTF8(def->displayName.c_str()));
    wxFont nf = nameLabel->GetFont();
    nf.SetWeight(wxFONTWEIGHT_BOLD);
    nameLabel->SetFont(nf);
    top->Add(nameLabel, 0, wxALL, 8);
    top->Add(new wxStaticLine(&dlg), 0, wxEXPAND | wxLEFT | wxRIGHT, 8);

    // Current size info
    wxString sizeInfo = wxString::Format(
        "Catalog size: %.0f x %.0f mm    Current: %.0f x %.0f mm",
        def->widthMm, def->heightMm,
        def->widthMm * item->scaleX, def->heightMm * item->scaleY);
    top->Add(new wxStaticText(&dlg, wxID_ANY, sizeInfo), 0, wxALL, 8);

    // Width scale
    wxBoxSizer* wRow = new wxBoxSizer(wxHORIZONTAL);
    wRow->Add(new wxStaticText(&dlg, wxID_ANY, "Width scale:"),
              0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
    wxSpinCtrlDouble* spinW = new wxSpinCtrlDouble(&dlg, wxID_ANY, "",
        wxDefaultPosition, wxSize(80,-1), wxSP_ARROW_KEYS, 0.1, 10.0, item->scaleX, 0.1);
    wRow->Add(spinW, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
    wRow->Add(new wxStaticText(&dlg, wxID_ANY,
        wxString::Format("(= %.0f mm)", def->widthMm * item->scaleX)),
        0, wxALIGN_CENTER_VERTICAL);
    top->Add(wRow, 0, wxLEFT | wxRIGHT | wxBOTTOM, 8);

    // Height scale
    wxBoxSizer* hRow = new wxBoxSizer(wxHORIZONTAL);
    hRow->Add(new wxStaticText(&dlg, wxID_ANY, "Depth scale:"),
              0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
    wxSpinCtrlDouble* spinH = new wxSpinCtrlDouble(&dlg, wxID_ANY, "",
        wxDefaultPosition, wxSize(80,-1), wxSP_ARROW_KEYS, 0.1, 10.0, item->scaleY, 0.1);
    hRow->Add(spinH, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
    hRow->Add(new wxStaticText(&dlg, wxID_ANY,
        wxString::Format("(= %.0f mm)", def->heightMm * item->scaleY)),
        0, wxALIGN_CENTER_VERTICAL);
    top->Add(hRow, 0, wxLEFT | wxRIGHT | wxBOTTOM, 8);

    // Reset button
    wxCheckBox* cbLock = new wxCheckBox(&dlg, wxID_ANY, "Lock aspect ratio");
    cbLock->SetValue(true);
    top->Add(cbLock, 0, wxLEFT | wxRIGHT | wxBOTTOM, 8);

    top->Add(new wxStaticLine(&dlg), 0, wxEXPAND | wxLEFT | wxRIGHT, 8);

    wxBoxSizer* btnRow = new wxBoxSizer(wxHORIZONTAL);
    wxButton* btnReset  = new wxButton(&dlg, wxID_ANY, "Reset to Default");
    wxButton* btnOK     = new wxButton(&dlg, wxID_ANY, "OK");
    wxButton* btnCancel = new wxButton(&dlg, wxID_ANY, "Cancel");
    btnRow->Add(btnReset,  0, wxRIGHT, 6);
    btnRow->AddStretchSpacer();
    btnRow->Add(btnOK,     0, wxRIGHT, 6);
    btnRow->Add(btnCancel, 0);
    top->Add(btnRow, 0, wxALL | wxEXPAND, 8);

    const int RET_OK     = 200;
    const int RET_RESET  = 201;
    const int RET_CANCEL = 202;
    btnOK    ->Bind(wxEVT_BUTTON, [&](wxCommandEvent&){ dlg.EndModal(RET_OK);     });
    btnReset ->Bind(wxEVT_BUTTON, [&](wxCommandEvent&){ dlg.EndModal(RET_RESET);  });
    btnCancel->Bind(wxEVT_BUTTON, [&](wxCommandEvent&){ dlg.EndModal(RET_CANCEL); });

    // Lock aspect: when W changes, update H proportionally
    double origRatio = (item->scaleY > 0) ? item->scaleX / item->scaleY : 1.0;
    spinW->Bind(wxEVT_SPINCTRLDOUBLE, [&](wxSpinDoubleEvent&) {
        if (cbLock->GetValue()) {
            spinH->SetValue(spinW->GetValue() / origRatio);
        }
    });

    dlg.SetSizerAndFit(top);
    dlg.SetDefaultItem(btnOK);
    int result = dlg.ShowModal();

    if (result == RET_CANCEL) return;

    state.PushUndo();
    if (result == RET_RESET) {
        item->scaleX = 1.0;
        item->scaleY = 1.0;
    } else {
        item->scaleX = std::max(0.1, spinW->GetValue());
        item->scaleY = std::max(0.1, spinH->GetValue());
    }
    m_frame->MarkDirty();
    Refresh();
}


// ── Elevation Wizard: canvas interaction ─────────────────────────────────────

void CanvasPanel::BeginElevationPick(int method) {
    m_elevMethod    = method;
    m_elevMode      = 1;   // picking anchor
    m_elevAnchorId  = -1;
    m_elevAnchorConn = -1;

    m_frame->State().ClearSelection();
    m_frame->OnSelectionChanged();

    SetCursor(wxCursor(wxCURSOR_HAND));
    m_frame->SetStatusText("Elevation Wizard: click a piece to set the starting point", 0);
    Refresh();
}

// ── Method 1: Set Grade ──────────────────────────────────────────────────────
// Show a compact dialog for grade angle + direction, then start the run.
void CanvasPanel::DoElevationGrade(int pieceId, int connIdx) {
    auto& state = m_frame->State();
    MC::PlacedPiece* piece = state.FindById(pieceId);
    if (!piece) return;
    const MC::TrackPiece* def = MC::GetCatalog().Find(piece->catalogNumber);
    if (!def) return;

    wxDialog dlg(this, wxID_ANY, "Elevation Wizard - Set Grade",
                 wxDefaultPosition, wxDefaultSize,
                 wxDEFAULT_DIALOG_STYLE);
    dlg.SetMinSize(wxSize(360, -1));

    wxBoxSizer* top = new wxBoxSizer(wxVERTICAL);

    // Piece info
    double zDisplay;
    wxString zUnit;
    if      (state.units == 1) { zDisplay = piece->z_mm / 10.0;  zUnit = "cm"; }
    else if (state.units == 2) { zDisplay = piece->z_mm / 25.4;  zUnit = "in"; }
    else                       { zDisplay = piece->z_mm;          zUnit = "mm"; }

    wxStaticText* title = new wxStaticText(&dlg, wxID_ANY, "Set Grade");
    wxFont tf = title->GetFont();
    tf.SetWeight(wxFONTWEIGHT_BOLD);
    tf.SetPointSize(tf.GetPointSize() + 2);
    title->SetFont(tf);
    top->Add(title, 0, wxALL, 10);

    top->Add(new wxStaticText(&dlg, wxID_ANY,
        wxString::Format("Starting piece: %s\nCurrent height: %.1f %s",
            def->displayName, zDisplay, zUnit)),
        0, wxLEFT | wxRIGHT | wxBOTTOM, 10);

    top->Add(new wxStaticLine(&dlg), 0, wxEXPAND | wxLEFT | wxRIGHT, 10);

    // Direction
    wxBoxSizer* dirRow = new wxBoxSizer(wxHORIZONTAL);
    dirRow->Add(new wxStaticText(&dlg, wxID_ANY, "Direction:"),
                0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
    wxRadioButton* rbUp   = new wxRadioButton(&dlg, wxID_ANY, "Up",
                                               wxDefaultPosition, wxDefaultSize, wxRB_GROUP);
    wxRadioButton* rbDown = new wxRadioButton(&dlg, wxID_ANY, "Down");
    rbUp->SetValue(true);
    dirRow->Add(rbUp,   0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
    dirRow->Add(rbDown, 0, wxALIGN_CENTER_VERTICAL);
    top->Add(dirRow, 0, wxALL, 10);

    // Grade
    wxBoxSizer* gradRow = new wxBoxSizer(wxHORIZONTAL);
    gradRow->Add(new wxStaticText(&dlg, wxID_ANY, "Grade:"),
                 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
    wxSpinCtrlDouble* spinGrad = new wxSpinCtrlDouble(&dlg, wxID_ANY, "2.0",
        wxDefaultPosition, wxSize(80, -1), wxSP_ARROW_KEYS, 0.1, 6.0, 2.0, 0.1);
    gradRow->Add(spinGrad, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
    gradRow->Add(new wxStaticText(&dlg, wxID_ANY, "deg  (max 6)"),
                 0, wxALIGN_CENTER_VERTICAL);
    top->Add(gradRow, 0, wxLEFT | wxRIGHT | wxBOTTOM, 10);

    top->Add(new wxStaticText(&dlg, wxID_ANY,
        "New pieces snapped to this track will inherit\n"
        "the grade automatically. Right-click a graded\n"
        "piece and choose End Grade when done."),
        0, wxLEFT | wxRIGHT | wxBOTTOM, 10);

    top->Add(new wxStaticLine(&dlg), 0, wxEXPAND | wxLEFT | wxRIGHT, 10);

    // Buttons
    wxBoxSizer* btnRow = new wxBoxSizer(wxHORIZONTAL);
    btnRow->AddStretchSpacer();
    wxButton* btnOK     = new wxButton(&dlg, wxID_OK,     "Start Grade");
    wxButton* btnCancel = new wxButton(&dlg, wxID_CANCEL, "Cancel");
    btnRow->Add(btnOK,     0, wxRIGHT, 6);
    btnRow->Add(btnCancel, 0);
    top->Add(btnRow, 0, wxEXPAND | wxALL, 10);

    dlg.SetSizerAndFit(top);
    dlg.SetDefaultItem(btnOK);
    dlg.Centre();

    if (dlg.ShowModal() != wxID_OK) return;

    double gradVal = spinGrad->GetValue();
    bool   goUp    = rbUp->GetValue();
    double deg     = goUp ? gradVal : -gradVal;

    state.PushUndo();
    state.StartGradeRun(deg, pieceId, connIdx);
    piece = state.FindById(pieceId);   // re-lookup after potential mutation
    if (!piece) return;

    // ── Adjust z_mm and chainReversed ────────────────────────────────────
    // The piece was placed flat by ApplyGradeToNewPiece (or directly), so
    // its z_mm is the correct joint height at conn[0]. For a flat piece
    // both connectors are at the same world height, so z_mm IS the joint
    // height regardless of which connector is the entry.
    //
    // connIdx is the FREE connector (where new track will extend).
    // The CONNECTED connector is the chain entry side.
    int entryConn = (connIdx == 1) ? 0 : 1;

    auto pieceRun = [](const MC::TrackPiece* d) -> double {
        if (!d) return 0.0;
        double L = d->lengthMm;
        if (d->radiusMm > 0.0 && d->connections.size() >= 2) {
            double cx = d->connections[1].pos.x;
            if (cx > 1.0) L = cx;
        }
        return L;
    };

    // The joint height is the piece's current z_mm (already correct from
    // when it was placed flat — both connectors at the same height).
    double jointZ    = piece->z_mm;
    double pieceRise = pieceRun(def) * std::sin(deg * M_PI / 180.0);

    if (entryConn == 0) {
        // Chain enters at conn[0], exits at conn[1] (normal direction).
        // z_mm is already at conn[0] = the entry = jointZ. No change.
        piece->chainReversed = false;
    } else {
        // Chain enters at conn[1], exits at conn[0] (reversed).
        // z_mm must be at conn[0] which is now the EXIT, sitting at
        // jointZ + pieceRise (the exit end of a downgrade is lower).
        piece->z_mm          = jointZ + pieceRise;
        piece->chainReversed = true;
    }
    piece->gradeDeg = deg;

    state.ClearSelection();
    piece->selected = true;
    m_frame->OnSelectionChanged();
    m_frame->SetStatusText(
        wxString::Format("Elevation: %s %.1f deg - new pieces will inherit the grade",
            goUp ? "UP" : "DOWN", gradVal), 0);
    m_frame->MarkDirty();
    m_frame->RefreshCanvas();
}

// ── Method 2: Set Target Height ──────────────────────────────────────────────
// After anchor and end point are picked, show a dialog for target height,
// then compute the grade, run the autofill solver, and place pieces.
void CanvasPanel::DoElevationTarget(int pieceId, int connIdx) {
    auto& state = m_frame->State();
    MC::PlacedPiece* piece = state.FindById(pieceId);
    if (!piece) return;
    const MC::TrackPiece* def = MC::GetCatalog().Find(piece->catalogNumber);
    if (!def) return;

    MC::ConnectionPoint anchorConn = piece->worldConnection(*def, connIdx);

    m_elevAnchorId   = pieceId;
    m_elevAnchorConn = connIdx;
    m_elevAnchorPt   = anchorConn;
    m_elevMode       = 2;   // pick end point

    SetCursor(wxCursor(wxCURSOR_CROSS));
    m_frame->SetStatusText(
        "Elevation Wizard: click where the elevated run should end", 0);
    Refresh();
}

void CanvasPanel::PlaceElevatedRun(int anchorId, int anchorConnIdx,
                                    const MC::ConnectionPoint& anchorConn,
                                    double distance, double targetHeightMm)
{
    auto& state = m_frame->State();
    MC::PlacedPiece* anchor = state.FindById(anchorId);
    if (!anchor) return;
    const MC::TrackPiece* anchorDef = MC::GetCatalog().Find(anchor->catalogNumber);
    if (!anchorDef) return;

    const MC::TrackSeries series = anchorDef->series;
    const double startZ = anchor->z_mm;
    const double rise   = targetHeightMm - startZ;

    // ── Collect candidate straight pieces ────────────────────────────────
    // Only plain straight track. No bridges, turntables, ramps, adapters,
    // specialty electronics, or any piece that is not regular rail geometry.
    std::vector<const MC::TrackPiece*> straights;
    for (int i = 0; i < MC::GetCatalog().Count(); ++i) {
        const MC::TrackPiece* p = MC::GetCatalog().GetByIndex(i);
        if (!p) continue;
        if (p->series != series) continue;
        if (p->type != MC::PieceType::Straight) continue;
        if (p->lengthMm <= 0) continue;
        if (p->isBridge) continue;
        if (p->isRamp)   continue;
        {
            const std::string& dn = p->displayName;
            auto has = [&](const char* s){ return dn.find(s) != std::string::npos; };
            if (has("Bridge"))     continue;
            if (has("Ramp"))       continue;
            if (has("Turntable"))  continue;
            if (has("Circuit"))    continue;
            if (has("Uncoupler"))  continue;
            if (has("Feeder"))     continue;
            if (has("Isolat"))     continue;
            if (has("Contact"))    continue;
            if (has("Adapter"))    continue;
            if (has("Spoke"))      continue;
            if (has("Compensation")) continue;
        }
        straights.push_back(p);
    }
    std::sort(straights.begin(), straights.end(),
        [](const MC::TrackPiece* a, const MC::TrackPiece* b){
            return a->lengthMm > b->lengthMm;
        });

    if (straights.empty()) {
        wxMessageBox("No straight track pieces found in the catalog for this series.",
                     "Elevation Wizard", wxOK | wxICON_WARNING, this);
        return;
    }

    // ── Find combinations that fill the distance ─────────────────────────
    const double tol = 1.5;
    std::vector<Suggestion> suggestions;
    {
        double shortest = straights.back()->lengthMm;
        if (shortest <= 0.0) shortest = 1.0;
        int maxDepth = (int)std::ceil(distance / shortest) + 2;
        if (maxDepth < 1)  maxDepth = 1;
        if (maxDepth > 32) maxDepth = 32;

        const size_t kMaxSolutions = 64;
        std::vector<std::string> path;

        int targetDepth = 0;
        std::function<void(int, double)> dfs =
            [&](int startIdx, double sum) {
                if (suggestions.size() >= kMaxSolutions) return;
                if ((int)path.size() == targetDepth) {
                    if (std::abs(sum - distance) <= tol)
                        suggestions.push_back({ path, sum });
                    return;
                }
                if (sum > distance + tol) return;
                int remaining = targetDepth - (int)path.size();
                for (int i = startIdx; i < (int)straights.size(); ++i) {
                    double maxLeft = straights[i]->lengthMm * remaining;
                    if (sum + maxLeft < distance - tol) continue;
                    path.push_back(straights[i]->catalogNumber);
                    dfs(i, sum + straights[i]->lengthMm);
                    path.pop_back();
                    if (suggestions.size() >= kMaxSolutions) return;
                }
            };

        for (targetDepth = 1;
             targetDepth <= maxDepth && suggestions.size() < kMaxSolutions;
             ++targetDepth) {
            dfs(0, 0.0);
        }
    }

    if (suggestions.empty()) {
        wxMessageBox(
            wxString::Format("No combination of catalog straights fills the %.0f mm "
                             "distance within tolerance.", distance),
            "Elevation Wizard - No Solution", wxOK | wxICON_INFORMATION, this);
        return;
    }

    // ── Compute the grade ────────────────────────────────────────────────
    double ratio = rise / distance;
    if (ratio >  1.0) ratio =  1.0;
    if (ratio < -1.0) ratio = -1.0;
    const double gradeDeg = std::asin(ratio) * 180.0 / M_PI;

    double scale = (state.units == 1) ? 0.1 : (state.units == 2) ? 1.0/25.4 : 1.0;
    const char* unit = (state.units == 1) ? "cm" : (state.units == 2) ? "in" : "mm";

    if (std::abs(gradeDeg) > 6.0) {
        wxString msg;
        msg.Printf("Reaching %.1f %s over this distance needs a grade of %.2f deg, "
                   "which exceeds the 6 deg maximum normally recommended.\n\n"
                   "Continue anyway?",
                   targetHeightMm * scale, unit, std::abs(gradeDeg));
        if (wxMessageBox(msg, "Elevation Wizard - Steep Grade",
                         wxYES_NO | wxICON_WARNING | wxNO_DEFAULT, this) != wxYES)
            return;
    }

    // ── Show suggestions dialog ──────────────────────────────────────────
    wxDialog dlg(this, wxID_ANY, "Elevation Wizard - Select Track",
                 wxDefaultPosition, wxSize(400, 340),
                 wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
    wxBoxSizer* sz = new wxBoxSizer(wxVERTICAL);

    wxString info;
    info.Printf("Distance: %.0f mm    Grade: %.2f deg (%.1f%%)\n"
                "Height: %.1f %s -> %.1f %s\n\nSelect fill:",
                distance, std::abs(gradeDeg),
                std::tan(std::abs(gradeDeg) * M_PI / 180.0) * 100.0,
                startZ * scale, unit, targetHeightMm * scale, unit);
    sz->Add(new wxStaticText(&dlg, wxID_ANY, info), 0, wxALL, 8);

    wxArrayString items;
    for (auto& s : suggestions) {
        wxString line;
        for (int i = 0; i < (int)s.pieces.size(); ++i) {
            if (i > 0) line += " + ";
            line += wxString(s.pieces[i]);
        }
        line += wxString::Format("  (%.1f mm)", s.totalLen);
        items.Add(line);
    }
    wxListBox* lb = new wxListBox(&dlg, wxID_ANY, wxDefaultPosition,
                                   wxDefaultSize, items, wxLB_SINGLE);
    lb->SetSelection(0);
    sz->Add(lb, 1, wxEXPAND | wxLEFT | wxRIGHT, 8);

    wxBoxSizer* btn = new wxBoxSizer(wxHORIZONTAL);
    btn->AddStretchSpacer();
    wxButton* ok  = new wxButton(&dlg, wxID_OK,     "Place");
    wxButton* can = new wxButton(&dlg, wxID_CANCEL, "Cancel");
    btn->Add(ok, 0, wxRIGHT, 4);
    btn->Add(can, 0, wxRIGHT, 8);
    sz->Add(btn, 0, wxEXPAND | wxALL, 8);
    dlg.SetSizer(sz);
    dlg.Centre();
    lb->Bind(wxEVT_LISTBOX_DCLICK, [&](wxCommandEvent&){ dlg.EndModal(wxID_OK); });

    if (dlg.ShowModal() != wxID_OK) return;
    int sel = lb->GetSelection();
    if (sel < 0 || sel >= (int)suggestions.size()) return;

    // ── Validate fill pieces ─────────────────────────────────────────────
    for (const auto& catNum : suggestions[sel].pieces) {
        const MC::TrackPiece* fd = MC::GetCatalog().Find(catNum);
        if (!fd || fd->connections.size() < 2) {
            wxMessageBox("Internal error: a fill piece is missing or has too "
                         "few connectors.",
                         "Elevation Wizard", wxOK | wxICON_ERROR, this);
            return;
        }
    }

    // ── Place the fill pieces with progressive elevation ─────────────────
    state.PushUndo();

    MC::ConnectionPoint snapTarget = anchorConn;
    int prevPieceId = anchorId;
    int prevConnIdx = anchorConnIdx;
    double z = startZ;

    auto pieceRun = [](const MC::TrackPiece* d) -> double {
        if (!d) return 0.0;
        double L = d->lengthMm;
        if (d->radiusMm > 0.0 && d->connections.size() >= 2) {
            double cx = d->connections[1].pos.x;
            if (cx > 1.0) L = cx;
        }
        return L;
    };

    for (const auto& catNum : suggestions[sel].pieces) {
        const MC::TrackPiece* fillDef = MC::GetCatalog().Find(catNum);
        if (!fillDef) continue;

        MC::Transform2D t = MC::SnapEngine::ComputeSnapTransform(*fillDef, 0, snapTarget);

        MC::PlacedPiece np;
        np.catalogNumber = catNum;
        np.transform     = t;
        np.joints.resize(fillDef->connections.size(), {-1, -1});
        np.joints[0]     = { prevPieceId, prevConnIdx };
        np.id            = state.nextId++;
        np.levelId       = anchor->levelId;
        np.z_mm          = z;
        np.gradeDeg      = gradeDeg;

        const int newId = np.id;
        state.pieces.push_back(np);   // invalidates all PlacedPiece*

        MC::PlacedPiece* prev = state.FindById(prevPieceId);
        MC::PlacedPiece* cur  = state.FindById(newId);
        if (!prev || !cur) break;

        if (prevConnIdx >= 0 && prevConnIdx < (int)prev->joints.size())
            prev->joints[prevConnIdx] = { newId, 0 };

        // Advance z
        z += pieceRun(fillDef) * std::sin(gradeDeg * M_PI / 180.0);

        // Advance snap target
        snapTarget  = cur->worldConnection(*fillDef, 1);
        prevPieceId = newId;
        prevConnIdx = 1;
    }

    // Re-lookup anchor (may have been invalidated by push_backs)
    anchor = state.FindById(anchorId);

    m_frame->SetStatusText(
        wxString::Format("Elevated %d pieces to %.1f %s  (%.2f deg)",
                         (int)suggestions[sel].pieces.size(),
                         targetHeightMm * scale, unit, gradeDeg), 0);
    m_frame->MarkDirty();
    m_frame->RefreshCanvas();
    Refresh();
}
