#include "canvas/Renderer.h"
#include "io/CatalogLoader.h"
#include "io/SceneryCatalog.h"
#include "model/HelixVariant.h"
#include <algorithm>
#include <wx/dcbuffer.h>
#include <cmath>
#include <unordered_set>

namespace MC {

// ── Colours ───────────────────────────────────────────────────────────────────
static const wxColour kBallast      (210, 210, 205);  // C-Track light grey roadbed
static const wxColour kBallastK     (215, 200, 175);  // K-Track warm tan roadbed
static const wxColour kBallastM     (160, 155, 145);  // M-Track tinplate (no ballast, bare metal look)
static const wxColour kBallastSel   (180, 200, 230);  // selected piece
static const wxColour kBallastGhost (200, 225, 200);  // drag ghost
static const wxColour kBorder       (120, 120, 115);  // outline
static const wxColour kBorderSel    ( 40, 100, 200);  // selected outline
static const wxColour kConnOpen     (255, 140,   0);  // open endpoint
static const wxColour kConnJoined   ( 60, 180,  60);  // joined endpoint
static const wxColour kGrid         (220, 220, 215);  // grid lines
static const wxColour kLabel        ( 60,  60,  60);  // catalog number text
static const wxColour kBackground   (255, 255, 255);  // white like SCARM

// Track width in mm by series
static constexpr double kTrackWidth   = 37.4;  // HO C-Track / K-Track ballast bed
static constexpr double kTrackWidthZ  = 12.0;  // Z-Track (Mini-Club)
static constexpr double kTrackWidthG1 = 63.0;  // Gauge 1 (45mm gauge, wider ballast)
static constexpr double kHalfWidth    = kTrackWidth  / 2.0;
static constexpr double kHalfWidthZ   = kTrackWidthZ / 2.0;
static constexpr double kHalfWidthG1  = kTrackWidthG1 / 2.0;

static double HalfWidthFor(const TrackPiece& def) {
    if (def.series == TrackSeries::MarlinZ) return kHalfWidthZ;
    if (def.series == TrackSeries::Gauge1)  return kHalfWidthG1;
    return kHalfWidth;
}

// ── Rails ─────────────────────────────────────────────────────────────────────
// Rail gauge (distance between the two rails) in mm, by series.
// Real gauges: HO 16.5mm, Z 6.5mm, Gauge 1 45mm.
static constexpr double kRailGaugeHO = 16.5;
static constexpr double kRailGaugeZ  =  6.5;
static constexpr double kRailGaugeG1 = 45.0;
static const wxColour   kRailColour(55, 55, 58);   // dark gray rail

static double RailHalfGaugeFor(const TrackPiece& def) {
    if (def.series == TrackSeries::MarlinZ) return kRailGaugeZ  / 2.0;
    if (def.series == TrackSeries::Gauge1)  return kRailGaugeG1 / 2.0;
    return kRailGaugeHO / 2.0;
}

// Draw the two rails along a straight run from world point a to world point b.
static void DrawRailsStraight(wxDC& dc, const Vec2& a, const Vec2& b,
                              const TrackPiece& def, const ViewTransform& view)
{
    Vec2 d{ b.x - a.x, b.y - a.y };
    double len = std::sqrt(d.x*d.x + d.y*d.y);
    if (len < 0.001) return;
    Vec2 perp{ -d.y/len, d.x/len };
    double hg = RailHalfGaugeFor(def);
    dc.SetPen(wxPen(kRailColour, 1));
    for (double s : { +1.0, -1.0 }) {
        Vec2 p0{ a.x + perp.x*hg*s, a.y + perp.y*hg*s };
        Vec2 p1{ b.x + perp.x*hg*s, b.y + perp.y*hg*s };
        dc.DrawLine(view.ToScreen(p0), view.ToScreen(p1));
    }
}

// Draw the two rails along an arc. Start point, start heading (deg),
// radius (mm) and sweep (deg, sign = direction) define the centreline.
static void DrawRailsArc(wxDC& dc, const Vec2& start, double headingDeg,
                         double radius, double sweepDeg,
                         const TrackPiece& def, const ViewTransform& view)
{
    if (radius < 0.001 || std::abs(sweepDeg) < 0.01) return;
    double h    = headingDeg * M_PI / 180.0;
    double sign = (sweepDeg >= 0) ? 1.0 : -1.0;
    // Arc centre: perpendicular to heading, on the inside of the turn.
    Vec2 centre{ start.x + sign * radius * (-std::sin(h)),
                 start.y + sign * radius * ( std::cos(h)) };
    double a0    = std::atan2(start.y - centre.y, start.x - centre.x);
    double sweep = sweepDeg * M_PI / 180.0;
    double hg    = RailHalfGaugeFor(def);
    const int steps = 24;

    dc.SetPen(wxPen(kRailColour, 1));
    for (double s : { +1.0, -1.0 }) {
        // Rail radius: outer rail is further from centre, inner rail nearer.
        double r = radius + s * sign * hg;
        wxPoint prev;
        for (int i = 0; i <= steps; ++i) {
            double t = (double)i / steps;
            double a = a0 + sweep * t;
            Vec2 pt{ centre.x + r * std::cos(a), centre.y + r * std::sin(a) };
            wxPoint sp = view.ToScreen(pt);
            if (i > 0) dc.DrawLine(prev, sp);
            prev = sp;
        }
    }
}

// ── Helper: build polygon points for a straight piece ─────────────────────────
static std::vector<wxPoint> StraightPoly(
    const PlacedPiece& p, const TrackPiece& def, const ViewTransform& view)
{
    // Use piece length and heading directly — avoids connection point access issues
    double length = def.lengthMm;
    double heading = p.transform.heading;
    double rad = heading * M_PI / 180.0;

    // Direction vector from heading
    Vec2 dir{ std::cos(rad), std::sin(rad) };
    Vec2 perp{ -dir.y, dir.x };

    Vec2 p0 = p.transform.pos;
    Vec2 p1 = p0 + dir * length;

    Vec2 a = p0 + perp *  HalfWidthFor(def);
    Vec2 b = p0 + perp * -HalfWidthFor(def);
    Vec2 c = p1 + perp * -HalfWidthFor(def);
    Vec2 d = p1 + perp *  HalfWidthFor(def);
    return { view.ToScreen(a), view.ToScreen(b),
             view.ToScreen(c), view.ToScreen(d) };
}

// ── Helper: build polygon points for an arc (curve/turnout branch) ────────────
// Builds a filled arc shape by sweeping inner and outer radius arcs.
// The arc centre is perpendicular to the track direction at the entry point.
// For positive angleDeg (left curve): centre is at (0, +R) in local space.
// For negative angleDeg (right curve): centre is at (0, -R) in local space.
static std::vector<wxPoint> ArcPoly(
    const PlacedPiece& p, const TrackPiece& def,
    const ViewTransform& view, int connA, int connB)
{
    double radius   = def.radiusMm;
    double angleDeg = def.angleDeg;

    // For triple switch conn[3] = right branch
    if (def.connections.size() >= 4 && connB == 3)
        angleDeg = -std::abs(angleDeg);

    // Right turnout branch curves the other way
    if (def.type == PieceType::TurnoutRight && connB == 2)
        angleDeg = std::abs(angleDeg);

    // For normal turnout branches, cap sweep angle to piece length to prevent overshoot
    if ((def.type == PieceType::TurnoutLeft || def.type == PieceType::TurnoutRight)
        && connB == 2 && radius > 1.0 && def.lengthMm > 0.0)
    {
        double maxAngleRad = std::asin(std::min(def.lengthMm / radius, 1.0));
        double maxAngleDeg = maxAngleRad * 180.0 / M_PI;
        if (angleDeg > 0)  angleDeg = std::min( angleDeg,  maxAngleDeg);
        else               angleDeg = std::max( angleDeg, -maxAngleDeg);
    }

    if (radius < 1.0) return StraightPoly(p, def, view);

    int    segments = std::max(16, (int)(std::abs(angleDeg) / 1.5));
    double angleRad = angleDeg * M_PI / 180.0;

    // Arc centre in piece-local space
    // Entry is at (0,0). Centre is perpendicular to entry direction.
    // Entry direction is east (heading=0 after transform), so centre is north (+y) or south (-y)
    double centreY = (angleDeg >= 0) ? radius : -radius;
    Vec2   lc{ 0.0, centreY };

    // Starting angle: from centre to entry point
    // entry point is at (0,0), centre at (0, centreY)
    // vector from centre to entry = (0,0)-(0,centreY) = (0,-centreY)
    double startA = (angleDeg >= 0) ? -M_PI/2.0 : M_PI/2.0;

    auto worldArcPt = [&](double t, double r) -> Vec2 {
        double a = startA + t * angleRad;
        Vec2 local{ lc.x + r * std::cos(a), lc.y + r * std::sin(a) };
        return p.transform.transformPoint(local);
    };

    std::vector<wxPoint> pts;
    double rOuter = radius + HalfWidthFor(def);
    double rInner = radius - HalfWidthFor(def);

    // Outer arc (entry to exit)
    for (int i = 0; i <= segments; ++i)
        pts.push_back(view.ToScreen(worldArcPt((double)i/segments, rOuter)));
    // Inner arc (exit back to entry)
    for (int i = segments; i >= 0; --i)
        pts.push_back(view.ToScreen(worldArcPt((double)i/segments, rInner)));

    return pts;
}

// ── Draw a small indicator rectangle rotated to match a piece's heading ───────
// Used for the 24994 (circuit contact) and 24997 (uncoupler) centre marks.
// centreOffsetMm is the distance along the heading from `pos` to the
// rectangle's centre (e.g. half the piece length). halfW/halfH are the
// rectangle's half-width/half-height in mm, measured along/across the
// piece's heading so it stays visually aligned with the track at any angle.
static void DrawHeadingRect(wxDC& dc, const ViewTransform& view,
                            const Vec2& pos, double headingDeg,
                            double centreOffsetMm, double halfWMm, double halfHMm,
                            int borderW)
{
    double hr = headingDeg * M_PI / 180.0;
    Vec2 dir{ std::cos(hr), std::sin(hr) };
    Vec2 perp{ -dir.y, dir.x };
    Vec2 centre = pos + dir * centreOffsetMm;

    Vec2 a = centre + dir * halfWMm + perp * halfHMm;
    Vec2 b = centre + dir * halfWMm - perp * halfHMm;
    Vec2 c = centre - dir * halfWMm - perp * halfHMm;
    Vec2 d = centre - dir * halfWMm + perp * halfHMm;

    std::vector<wxPoint> pts = {
        view.ToScreen(a), view.ToScreen(b), view.ToScreen(c), view.ToScreen(d)
    };
    dc.SetPen(wxPen(wxColour(60,60,160), std::max(1, borderW)));
    dc.SetBrush(wxBrush(wxColour(100,120,220)));
    dc.DrawPolygon((int)pts.size(), pts.data());
}

// ── Draw filled polygon ───────────────────────────────────────────────────────
static void FillPoly(wxDC& dc, const std::vector<wxPoint>& pts,
                     const wxColour& fill, const wxColour& border, int borderW)
{
    if (pts.size() < 3) return;
    dc.SetBrush(wxBrush(fill));
    dc.SetPen(wxPen(border, borderW));
    dc.DrawPolygon((int)pts.size(), pts.data());
}

// ── Draw ──────────────────────────────────────────────────────────────────────
void Renderer::Draw(wxDC& dc, const CanvasState& state,
                    const ViewTransform& view, const wxSize& clientSize)
{
    m_state = &state;   // valid for the duration of this Draw call
    dc.SetBackground(wxBrush(kBackground));
    dc.Clear();

    // Baseboard (the physical table the layout sits on) — fill FIRST so the
    // grid and decks draw on top of it. Per-level decks sit on the
    // baseboard; both render together below the track.
    if (state.showBaseboard && state.baseboard.size() >= 3) {
        std::vector<wxPoint> pts;
        pts.reserve(state.baseboard.size());
        for (auto& v : state.baseboard) pts.push_back(view.ToScreen(v));
        dc.SetBrush(wxBrush(wxColour(245, 235, 210)));
        dc.SetPen(wxPen(wxColour(120, 90, 50), 2));
        dc.DrawPolygon((int)pts.size(), pts.data());
    }

    // Draw per-level deck shapes on top of the baseboard so they read as
    // raised platforms. Decks render in ascending level height so a higher
    // deck overlays the ones below it. A level with an empty shape
    // contributes no deck. (Hidden levels are skipped.)
    {
        std::vector<const Level*> ordered;
        for (const auto& l : state.levels) ordered.push_back(&l);
        std::sort(ordered.begin(), ordered.end(),
                  [](const Level* a, const Level* b){ return a->heightMm < b->heightMm; });
        for (const Level* l : ordered) {
            if (!l->visible || l->shape.size() < 3) continue;
            std::vector<wxPoint> pts;
            pts.reserve(l->shape.size());
            for (auto& v : l->shape) pts.push_back(view.ToScreen(v));
            // Tint the fill slightly toward the level's color so stacked
            // decks are visually distinguishable.
            wxColour fill = l->hasTrackColor
                ? wxColour((l->r + 245*2)/3, (l->g + 235*2)/3, (l->b + 210*2)/3)
                : wxColour(235, 220, 190);
            dc.SetBrush(wxBrush(fill));
            dc.SetPen(wxPen(wxColour(120, 90, 50), 2));
            dc.DrawPolygon((int)pts.size(), pts.data());
        }
    }

    if (state.showGrid)
        DrawGrid(dc, state, view, clientSize);

    // Baseboard outline + edge dimension labels — drawn ON TOP of the grid
    // so the user can always see the board's extent and measurements.
    if (state.showBaseboard && state.baseboard.size() >= 3) {
        std::vector<wxPoint> pts;
        pts.reserve(state.baseboard.size());
        for (auto& v : state.baseboard) pts.push_back(view.ToScreen(v));
        dc.SetBrush(*wxTRANSPARENT_BRUSH);
        dc.SetPen(wxPen(wxColour(120, 90, 50), 2));
        dc.DrawPolygon((int)pts.size(), pts.data());

        dc.SetFont(wxFont(11, wxFONTFAMILY_DEFAULT,
                          wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD));
        dc.SetTextForeground(wxColour(80, 50, 20));
        dc.SetBackgroundMode(wxTRANSPARENT);

        int n = (int)state.baseboard.size();
        MC::Vec2 centroid{0,0};
        for (auto& v : state.baseboard) { centroid.x += v.x; centroid.y += v.y; }
        centroid.x /= n; centroid.y /= n;

        for (int i = 0; i < n; ++i) {
            MC::Vec2 a = state.baseboard[i];
            MC::Vec2 b = state.baseboard[(i+1) % n];
            double lenMm = std::sqrt((b.x-a.x)*(b.x-a.x)+(b.y-a.y)*(b.y-a.y));
            if (lenMm < 10.0) continue;

            MC::Vec2 mid = { (a.x+b.x)*0.5, (a.y+b.y)*0.5 };
            double ex = b.x - a.x, ey = b.y - a.y;
            double nx = -ey, ny = ex;
            double len = std::sqrt(nx*nx + ny*ny);
            if (len > 0) { nx /= len; ny /= len; }
            // Flip outward (away from centroid)
            double toCx = centroid.x - mid.x, toCy = centroid.y - mid.y;
            if (nx*toCx + ny*toCy > 0) { nx = -nx; ny = -ny; }

            const double kOffsetMm = 12.0;
            MC::Vec2 labelPos = { mid.x + nx*kOffsetMm, mid.y + ny*kOffsetMm };
            wxPoint sp = view.ToScreen(labelPos);

            wxString lbl;
            if      (state.units == 1) lbl = wxString::Format("%.1f cm", lenMm/10.0);
            else if (state.units == 2) lbl = wxString::Format("%.2f\"",  lenMm/25.4);
            else                       lbl = wxString::Format("%.0f mm", lenMm);

            wxSize ts = dc.GetTextExtent(lbl);
            dc.SetBrush(wxBrush(wxColour(255, 248, 220, 200)));
            dc.SetPen(wxPen(wxColour(180, 140, 80), 1));
            dc.DrawRoundedRectangle(sp.x - ts.x/2 - 4, sp.y - ts.y/2 - 2,
                                    ts.x + 8, ts.y + 4, 3);
            dc.DrawText(lbl, sp.x - ts.x/2, sp.y - ts.y/2);
        }
    }

    // Draw free-form polygon shapes: between the baseboard/decks and the
    // track. Each shape is filled with its color (semi-transparent so track
    // beneath remains visible). Ordered by ascending level so a higher-level
    // shape overlays lower ones — matching the deck rule. Shapes on hidden
    // levels are skipped.
    {
        std::vector<const CanvasShape*> ordered;
        for (const auto& s : state.shapes) ordered.push_back(&s);
        std::sort(ordered.begin(), ordered.end(),
            [&state](const CanvasShape* a, const CanvasShape* b){
                double ha = state.LevelHeight(a->levelId);
                double hb = state.LevelHeight(b->levelId);
                return ha < hb;
            });
        for (const CanvasShape* s : ordered) {
            if (s->vertices.size() < 3) continue;
            if (!state.IsLevelVisible(s->levelId)) continue;
            std::vector<wxPoint> pts;
            pts.reserve(s->vertices.size());
            for (const auto& v : s->vertices) pts.push_back(view.ToScreen(v));
            wxColour fill(s->r, s->g, s->b, s->alpha);
            wxColour outline = s->selected
                ? wxColour(40, 80, 200) : wxColour(s->r/2, s->g/2, s->b/2);
            dc.SetBrush(wxBrush(fill));
            dc.SetPen(s->hasOutline
                ? wxPen(outline, s->selected ? 3 : 2)
                : wxPen(*wxTRANSPARENT_PEN));
            dc.DrawPolygon((int)pts.size(), pts.data(),
                           0, 0, wxODDEVEN_RULE);
            if (!s->label.empty()) {
                double cx = 0, cy = 0;
                for (const auto& v : s->vertices) { cx += v.x; cy += v.y; }
                cx /= s->vertices.size();
                cy /= s->vertices.size();
                wxPoint sp = view.ToScreen({cx, cy});
                dc.SetFont(wxFont(11, wxFONTFAMILY_DEFAULT,
                                  wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD));
                dc.SetTextForeground(wxColour(20, 20, 20));
                dc.SetBackgroundMode(wxTRANSPARENT);
                wxSize ts = dc.GetTextExtent(wxString::FromUTF8(s->label));
                dc.DrawText(wxString::FromUTF8(s->label),
                            sp.x - ts.x/2, sp.y - ts.y/2);
            }

            // Edge dimension labels: length of each side at its midpoint,
            // offset inward toward the centroid.
            {
                double cx2 = 0, cy2 = 0;
                for (const auto& v : s->vertices) { cx2 += v.x; cy2 += v.y; }
                cx2 /= s->vertices.size();
                cy2 /= s->vertices.size();

                int fontSize = std::max(8, std::min(12, (int)(10 * view.scale)));
                dc.SetFont(wxFont(fontSize, wxFONTFAMILY_DEFAULT,
                                  wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL));
                dc.SetTextForeground(wxColour(30, 30, 30));
                dc.SetBackgroundMode(wxSOLID);
                dc.SetTextBackground(wxColour(255, 255, 255));

                int n = (int)s->vertices.size();
                for (int ei = 0; ei < n; ++ei) {
                    const Vec2& va = s->vertices[ei];
                    const Vec2& vb = s->vertices[(ei + 1) % n];
                    double dx = vb.x - va.x;
                    double dy = vb.y - va.y;
                    double lenMm = std::sqrt(dx*dx + dy*dy);
                    if (lenMm < 1.0) continue;

                    wxString lbl;
                    if      (state.units == 1) lbl = wxString::Format("%.1f cm", lenMm/10.0);
                    else if (state.units == 2) lbl = wxString::Format("%.2f\"",  lenMm/25.4);
                    else                       lbl = wxString::Format("%.0f mm", lenMm);

                    Vec2 mid{ (va.x + vb.x) * 0.5, (va.y + vb.y) * 0.5 };
                    wxPoint msc = view.ToScreen(mid);

                    // Perpendicular offset inward toward centroid
                    double ex = -dy / lenMm;
                    double ey =  dx / lenMm;
                    Vec2 toC{ cx2 - mid.x, cy2 - mid.y };
                    if (toC.x * ex + toC.y * ey < 0) { ex = -ex; ey = -ey; }
                    int offsetPx = 12;
                    wxPoint lpt(msc.x + (int)(ex * offsetPx),
                                msc.y - (int)(ey * offsetPx));

                    wxSize ts2 = dc.GetTextExtent(lbl);
                    dc.DrawText(lbl, lpt.x - ts2.x/2, lpt.y - ts2.y/2);
                }
            }
        }
    }

    auto& catalog = MC::GetCatalog();
    auto& scenaryCat = MC::GetSceneryCatalog();

    // Helper: is this scenery item a tunnel portal? (drawn ABOVE track)
    // Consult the explicit Tunnel records on state, not the catalog id, so a
    // stray portal scenery item from an old save without a Tunnel record
    // simply draws like any other scenery (behind track).
    auto isTunnelPortal = [&](const PlacedScenery& s) {
        return state.IsPortalScenery(s.id);
    };

    // Draw non-portal scenery FIRST (behind track), non-selected then selected
    for (int pass = 0; pass < 2; ++pass) {
        for (const auto& s : state.scenery) {
            if ((pass == 0) == s.selected) continue;
            if (isTunnelPortal(s)) continue;
            if (!state.IsLevelVisible(s.levelId)) continue;
            const SceneryItem* def = scenaryCat.Find(s.itemId);
            if (!def) continue;
            DrawScenery(dc, s, *def, view, s.selected);
        }
    }

    // Compute the set of pieces hidden inside tunnels so they render dimmed.
    // Tunnels are explicit records on state.tunnels (no derivation).
    std::unordered_set<int> tunneledPieces;
    for (const auto& t : state.tunnels)
        for (int pid : t.coveredPieceIds)
            tunneledPieces.insert(pid);

    // ── Helix-connected track ────────────────────────────────────────────
    // Track physically joined to a helix sits on one of its decks, so:
    //   * Track connected (transitively) to a BOTTOM stub passes UNDER the
    //     helix -- it must draw before the curves so the helix overlays it.
    //   * Track connected to either deck is "deck" track, not a ramp, so it
    //     must not get the elevation (orange) tint.
    // We flood the joint graph from the stub pieces. The helix interior is
    // ghost-only (no real joints), so a bottom flood and a top flood stay
    // separate unless the user routes external track all the way around.
    m_helixAttachedIds.clear();
    std::unordered_set<int> bottomChainIds;   // track under the helix
    {
        // Map id → piece for quick lookup, and identify stubs by role.
        std::unordered_set<int> bottomStubIds, topStubIds;
        for (const auto& p : state.pieces) {
            if (p.helixId == -1) continue;
            if (p.z_mm < 0.5) bottomStubIds.insert(p.id);
            else              topStubIds.insert(p.id);
        }

        auto flood = [&](const std::unordered_set<int>& seeds,
                         std::unordered_set<int>& out) {
            std::vector<int> stack(seeds.begin(), seeds.end());
            while (!stack.empty()) {
                int id = stack.back(); stack.pop_back();
                const PlacedPiece* p = nullptr;
                for (const auto& q : state.pieces) if (q.id == id) { p = &q; break; }
                if (!p) continue;
                for (const auto& j : p->joints) {
                    if (j.pieceId < 0) continue;
                    // Don't traverse INTO a helix stub from outside; stubs are
                    // only seeds. This keeps bottom and top floods distinct.
                    const PlacedPiece* nb = nullptr;
                    for (const auto& q : state.pieces) if (q.id == j.pieceId) { nb = &q; break; }
                    if (!nb || nb->helixId != -1) continue;
                    if (out.insert(j.pieceId).second) stack.push_back(j.pieceId);
                }
            }
        };

        // Bottom chain: external track hanging off the bottom stubs.
        flood(bottomStubIds, bottomChainIds);

        // Tint-suppression set: everything joined to either deck (whole chain)
        // plus the stubs themselves.
        std::unordered_set<int> topChainIds;
        flood(topStubIds, topChainIds);
        for (int id : bottomStubIds) m_helixAttachedIds.insert(id);
        for (int id : topStubIds)    m_helixAttachedIds.insert(id);
        for (int id : bottomChainIds) m_helixAttachedIds.insert(id);
        for (int id : topChainIds)    m_helixAttachedIds.insert(id);
    }

    // Helix layering:
    //   1. Bottom stubs (z=0) AND track chained to them draw FIRST so the
    //      curves visibly overlap them (that track runs under the helix).
    //   2. Interior curves draw next.
    //   3. Top stubs and everything else draw LAST so the upper deck sits
    //      on top of the spiral.
    auto isHelixBottomStub = [](const PlacedPiece& p) {
        return p.helixId != -1 && p.z_mm < 0.5;
    };
    auto drawsUnderHelix = [&](const PlacedPiece& p) {
        return isHelixBottomStub(p) || bottomChainIds.count(p.id) > 0;
    };

    // Pass A: bottom stubs + their chained track (non-selected first, selected on top)
    for (int pass = 0; pass < 2; ++pass) {
        for (const auto& placed : state.pieces) {
            if (!drawsUnderHelix(placed)) continue;
            if ((pass == 0) == placed.selected) continue;
            if (!state.IsLevelVisible(placed.levelId)) continue;
            const TrackPiece* def = catalog.Find(placed.catalogNumber);
            if (!def) continue;
            bool inTunnel = tunneledPieces.count(placed.id) > 0;
            DrawPiece(dc, placed, *def, view, placed.selected, state.showLabels,
                       inTunnel);
        }
    }

    // Pass B: helix interior curves (synthesized ghosts)
    DrawHelices(dc, state, view);

    // Pass C: everything else (including helix top stubs).
    for (int pass = 0; pass < 2; ++pass) {
        for (const auto& placed : state.pieces) {
            if (drawsUnderHelix(placed)) continue;   // already drawn under the curves
            if ((pass == 0) == placed.selected) continue;
            // Skip pieces on hidden levels
            if (!state.IsLevelVisible(placed.levelId)) continue;
            const TrackPiece* def = catalog.Find(placed.catalogNumber);
            if (!def) continue;
            bool inTunnel = tunneledPieces.count(placed.id) > 0;
            DrawPiece(dc, placed, *def, view, placed.selected, state.showLabels,
                       inTunnel);
        }
    }

    // 74871 spoke extensions are short stubs that mount on a turntable's
    // outer rim. The turntable draws a filled "outer footprint" disc
    // covering radius up to ~189 mm; an unselected 74871 (which spans
    // radius 139 to 196.5 mm) sits underneath that disc and is invisible
    // until clicked. Re-draw all 74871 pieces here, on top of everything
    // else, so they're always visible regardless of selection state.
    for (const auto& placed : state.pieces) {
        if (placed.catalogNumber != "74871") continue;
        if (!state.IsLevelVisible(placed.levelId)) continue;
        const TrackPiece* def = catalog.Find(placed.catalogNumber);
        if (!def) continue;
        bool inTunnel = tunneledPieces.count(placed.id) > 0;
        DrawPiece(dc, placed, *def, view, placed.selected, state.showLabels,
                   inTunnel);
    }

    // Tunnel portals are drawn AFTER track so the masonry visibly covers the
    // rails -- a portal "spans" its track piece in plan view.
    for (int pass = 0; pass < 2; ++pass) {
        for (const auto& s : state.scenery) {
            if ((pass == 0) == s.selected) continue;
            if (!isTunnelPortal(s)) continue;
            if (!state.IsLevelVisible(s.levelId)) continue;
            const SceneryItem* def = scenaryCat.Find(s.itemId);
            if (!def) continue;
            DrawScenery(dc, s, *def, view, s.selected);
        }
    }

    // Grade run indicators: arrow on each active grade anchor piece
    if (!state.gradeRuns.empty()) {
        for (const auto& run : state.gradeRuns) {
            if (run.anchorId < 0) continue;
            const PlacedPiece* placed = nullptr;
            for (const auto& p : state.pieces)
                if (p.id == run.anchorId) { placed = &p; break; }
            if (!placed) continue;
            const TrackPiece* def = catalog.Find(placed->catalogNumber);
            if (!def) continue;

            double hr = placed->transform.heading * M_PI / 180.0;
            Vec2 dir{ std::cos(hr), std::sin(hr) };

            bool conn0free = placed->joints.empty() || placed->joints[0].pieceId == -1;
            bool conn1free = placed->joints.size() < 2 || placed->joints[1].pieceId == -1;
            if (conn0free && !conn1free) { dir.x = -dir.x; dir.y = -dir.y; }

            Vec2 base = placed->transform.pos + dir * (def->lengthMm * 0.5);
            Vec2 tip  = base + dir * 20.0;
            Vec2 perp{ -dir.y, dir.x };

            wxPoint ptBase  = view.ToScreen(base);
            wxPoint ptTip   = view.ToScreen(tip);
            wxPoint ptLeft  = view.ToScreen(tip - dir*8.0 + perp*6.0);
            wxPoint ptRight = view.ToScreen(tip - dir*8.0 - perp*6.0);

            wxColour arrowCol = run.gradeDeg >= 0
                ? wxColour(220, 100, 20)
                : wxColour(30,  100, 200);

            dc.SetPen(wxPen(arrowCol, 3));
            dc.SetBrush(wxBrush(arrowCol));
            dc.DrawLine(ptBase, ptTip);
            wxPoint head[3] = { ptTip, ptLeft, ptRight };
            dc.DrawPolygon(3, head);

            if (view.scale >= 0.25) {
                dc.SetFont(wxFont(std::max(7,(int)(8*view.scale)),
                                  wxFONTFAMILY_DEFAULT,
                                  wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD));
                dc.SetTextForeground(arrowCol);
                dc.SetBackgroundMode(wxTRANSPARENT);
                wxString lbl = wxString::Format("%+.1f deg", run.gradeDeg);
                dc.DrawText(lbl, ptTip.x + 4, ptTip.y - 10);
            }
        }
    }

    // Draw canvas labels
    for (const auto& lbl : state.labels) {
        wxPoint pt = view.ToScreen(lbl.pos);
        int fontSize = std::max(8, (int)(11 * view.scale));
        wxFont lblFont(fontSize, wxFONTFAMILY_DEFAULT,
                       wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD);
        dc.SetFont(lblFont);
        dc.SetTextForeground(lbl.selected ? wxColour(40,80,200) : wxColour(20,20,20));
        dc.SetBackgroundMode(wxSOLID);
        dc.SetTextBackground(wxColour(255,255,220,200));
        dc.DrawText(wxString::FromUTF8(lbl.text), pt.x, pt.y);
        if (lbl.selected) {
            wxSize ext = dc.GetTextExtent(wxString::FromUTF8(lbl.text));
            dc.SetPen(wxPen(wxColour(40,80,200), 1, wxPENSTYLE_DOT));
            dc.SetBrush(*wxTRANSPARENT_BRUSH);
            dc.DrawRectangle(pt.x-2, pt.y-2, ext.x+4, ext.y+4);
        }
    }

    // Draw measuring-tape annotations: line with arrowheads + length label.
    auto fmtLen = [&](double mm) -> wxString {
        if      (state.units == 1) return wxString::Format("%.1f cm", mm/10.0);
        else if (state.units == 2) return wxString::Format("%.2f\"",  mm/25.4);
        return wxString::Format("%.0f mm", mm);
    };
    for (const auto& m : state.measurements) {
        wxPoint pa = view.ToScreen(m.a);
        wxPoint pb = view.ToScreen(m.b);
        wxColour lineCol = m.selected ? wxColour(40, 80, 200) : wxColour(60, 60, 60);
        int lineW = m.selected ? 3 : 2;
        dc.SetPen(wxPen(lineCol, lineW));
        dc.DrawLine(pa, pb);

        // Arrowheads — short isoceles triangles pointing inward at each end.
        double dxs = (double)(pb.x - pa.x);
        double dys = (double)(pb.y - pa.y);
        double L   = std::sqrt(dxs*dxs + dys*dys);
        if (L > 6) {
            double ux = dxs / L, uy = dys / L;
            double px = -uy,     py = ux;            // perp
            const double headLen = 10.0;
            const double headHalf = 5.0;
            auto arrow = [&](wxPoint tip, double sgn) {
                wxPoint base { (int)(tip.x - sgn * ux * headLen),
                               (int)(tip.y - sgn * uy * headLen) };
                wxPoint left { (int)(base.x + px * headHalf),
                               (int)(base.y + py * headHalf) };
                wxPoint right{ (int)(base.x - px * headHalf),
                               (int)(base.y - py * headHalf) };
                wxPoint poly[3] = { tip, left, right };
                dc.SetBrush(wxBrush(lineCol));
                dc.DrawPolygon(3, poly);
            };
            arrow(pa, -1.0);     // tip at A, pointing back toward A
            arrow(pb, +1.0);     // tip at B, pointing toward B
        }

        // Label: distance in world mm, displayed at midpoint + offset.
        double distMm = std::sqrt(
            (m.b.x - m.a.x) * (m.b.x - m.a.x) +
            (m.b.y - m.a.y) * (m.b.y - m.a.y));
        MC::Vec2 lblPosWorld {
            (m.a.x + m.b.x) * 0.5 + m.labelOffset.x,
            (m.a.y + m.b.y) * 0.5 + m.labelOffset.y };
        wxPoint lp = view.ToScreen(lblPosWorld);
        wxString text = fmtLen(distMm);
        dc.SetFont(wxFont(10, wxFONTFAMILY_DEFAULT,
                          wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD));
        dc.SetBackgroundMode(wxTRANSPARENT);
        wxSize ts = dc.GetTextExtent(text);
        wxRect pill(lp.x - ts.x/2 - 5, lp.y - ts.y/2 - 2,
                    ts.x + 10, ts.y + 4);
        dc.SetBrush(wxBrush(m.selected
            ? wxColour(220, 232, 255, 235)
            : wxColour(255, 248, 220, 235)));
        dc.SetPen(wxPen(lineCol, 1));
        dc.DrawRoundedRectangle(pill, 3);
        dc.SetTextForeground(wxColour(20, 20, 20));
        dc.DrawText(text, lp.x - ts.x/2, lp.y - ts.y/2);
    }
}

// ── DrawSceneryShapes ─────────────────────────────────────────────────────────
void Renderer::DrawSceneryShapes(wxDC& dc, const SceneryItem& def,
                                  const Transform2D& transform,
                                  const ViewTransform& view, bool selected,
                                  double scaleX, double scaleY)
{
    double cx = def.widthMm  * scaleX * 0.5;
    double cy = def.heightMm * scaleY * 0.5;
    double hr = transform.heading * M_PI / 180.0;
    double cosH = std::cos(hr), sinH = std::sin(hr);

    // Transform a local point (relative to item top-left, scaled) to world then screen
    auto toScreen = [&](double lx, double ly) -> wxPoint {
        // Apply scale then rotate around scaled item centre
        double rx = lx * scaleX - cx, ry = ly * scaleY - cy;
        double wx = rx*cosH - ry*sinH + transform.pos.x;
        double wy = rx*sinH + ry*cosH + transform.pos.y;
        return view.ToScreen({wx, wy});
    };

    for (const auto& shape : def.shapes) {
        wxColour fill  (shape.fill.r,   shape.fill.g,   shape.fill.b,   shape.fill.a);
        wxColour stroke(shape.stroke.r, shape.stroke.g, shape.stroke.b, shape.stroke.a);

        // Apply selection tint
        if (selected) {
            fill = wxColour(
                (int)(fill.Red()  *0.7 + 100*0.3),
                (int)(fill.Green()*0.7 + 160*0.3),
                (int)(fill.Blue() *0.7 + 220*0.3),
                fill.Alpha());
        }

        dc.SetBrush(fill.Alpha() > 0 ? wxBrush(fill) : *wxTRANSPARENT_BRUSH);
        dc.SetPen(stroke.Alpha() > 0 && shape.strokePx > 0
                  ? wxPen(stroke, shape.strokePx) : *wxTRANSPARENT_PEN);

        switch (shape.type) {
            case SceneryShapeType::FilledRect:
            case SceneryShapeType::OutlineRect: {
                if (shape.type == SceneryShapeType::OutlineRect)
                    dc.SetBrush(*wxTRANSPARENT_BRUSH);
                // Build rotated rectangle as polygon
                wxPoint pts[4] = {
                    toScreen(shape.x,            shape.y),
                    toScreen(shape.x + shape.w,  shape.y),
                    toScreen(shape.x + shape.w,  shape.y + shape.h),
                    toScreen(shape.x,             shape.y + shape.h)
                };
                dc.DrawPolygon(4, pts);
                break;
            }
            case SceneryShapeType::FilledEllipse:
            case SceneryShapeType::OutlineEllipse: {
                if (shape.type == SceneryShapeType::OutlineEllipse)
                    dc.SetBrush(*wxTRANSPARENT_BRUSH);
                // For rotated ellipses, approximate with polygon
                int segs = std::max(12, (int)(std::max(shape.w, shape.h) * view.scale * 0.5));
                segs = std::min(segs, 64);
                std::vector<wxPoint> pts;
                pts.reserve(segs);
                double ecx = shape.x + shape.w * 0.5;
                double ecy = shape.y + shape.h * 0.5;
                double rx  = shape.w * 0.5;
                double ry  = shape.h * 0.5;
                for (int i = 0; i < segs; ++i) {
                    double a = 2.0 * M_PI * i / segs;
                    pts.push_back(toScreen(ecx + rx*std::cos(a), ecy + ry*std::sin(a)));
                }
                dc.DrawPolygon((int)pts.size(), pts.data());
                break;
            }
            case SceneryShapeType::FilledPolygon: {
                std::vector<wxPoint> pts;
                pts.reserve(shape.pts.size());
                for (const auto& pt : shape.pts)
                    pts.push_back(toScreen(pt.x, pt.y));
                if (!pts.empty())
                    dc.DrawPolygon((int)pts.size(), pts.data());
                break;
            }
            case SceneryShapeType::Line: {
                dc.SetPen(wxPen(fill.Alpha() > 0 ? fill : stroke, shape.strokePx));
                dc.SetBrush(*wxTRANSPARENT_BRUSH);
                wxPoint p0 = toScreen(shape.x, shape.y);
                wxPoint p1 = toScreen(shape.w, shape.h);
                dc.DrawLine(p0, p1);
                break;
            }
            case SceneryShapeType::Text: {
                // Match the canvas label font: family=DEFAULT, bold, min 8 px.
                // shape.h is the base font size in mm; multiplied by view.scale
                // to convert to screen pixels just like the label code.
                int fontPx = std::max(8, (int)(shape.h * view.scale));
                wxFont font(fontPx, wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL,
                            wxFONTWEIGHT_BOLD);
                dc.SetFont(font);
                dc.SetTextForeground(fill);
                dc.SetBackgroundMode(wxTRANSPARENT);
                wxString txt = wxString::FromUTF8(shape.textStr.c_str());
                wxSize ts = dc.GetTextExtent(txt);
                wxPoint center = toScreen(shape.x, shape.y);
                dc.DrawText(txt, center.x - ts.x/2, center.y - ts.y/2);
                break;
            }
        }
    }

    // Selection outline — dashed rectangle around bounding box
    if (selected) {
        dc.SetBrush(*wxTRANSPARENT_BRUSH);
        dc.SetPen(wxPen(wxColour(40,100,200), 1, wxPENSTYLE_DOT));
        wxPoint pts[4] = {
            toScreen(0,            0),
            toScreen(def.widthMm,  0),
            toScreen(def.widthMm,  def.heightMm),
            toScreen(0,            def.heightMm)
        };
        dc.DrawPolygon(4, pts);
    }
}

// ── DrawScenery ───────────────────────────────────────────────────────────────
void Renderer::DrawScenery(wxDC& dc, const PlacedScenery& placed,
                            const SceneryItem& def, const ViewTransform& view,
                            bool selected)
{
    DrawSceneryShapes(dc, def, placed.transform, view, selected,
                      placed.scaleX, placed.scaleY);
}

// ── DrawSceneryGhost ──────────────────────────────────────────────────────────
void Renderer::DrawSceneryGhost(wxDC& dc, const SceneryItem& def,
                                 const Transform2D& transform,
                                 const ViewTransform& view)
{
    DrawSceneryShapes(dc, def, transform, view, false, 1.0, 1.0);

    // Ghost outline
    double cx = def.widthMm  * 0.5;
    double cy = def.heightMm * 0.5;
    double hr = transform.heading * M_PI / 180.0;
    double cosH = std::cos(hr), sinH = std::sin(hr);
    auto toScreen = [&](double lx, double ly) -> wxPoint {
        double rx = lx-cx, ry = ly-cy;
        double wx = rx*cosH - ry*sinH + transform.pos.x;
        double wy = rx*sinH + ry*cosH + transform.pos.y;
        return view.ToScreen({wx, wy});
    };
    dc.SetBrush(*wxTRANSPARENT_BRUSH);
    dc.SetPen(wxPen(wxColour(80,150,80), 1, wxPENSTYLE_DOT));
    wxPoint pts[4] = {
        toScreen(0, 0), toScreen(def.widthMm, 0),
        toScreen(def.widthMm, def.heightMm), toScreen(0, def.heightMm)
    };
    dc.DrawPolygon(4, pts);
}

// ── Grid ──────────────────────────────────────────────────────────────────────
void Renderer::DrawGrid(wxDC& dc, const CanvasState& state,
                        const ViewTransform& view, const wxSize& clientSize)
{
    double cellMm = state.quantizer.CellSize();
    double cellPx = cellMm * view.scale;
    if (cellPx < 6.0 || cellPx > 400.0) return;

    dc.SetPen(wxPen(kGrid, 1));
    double startX = std::floor(-view.panX / cellPx) * cellPx + view.panX;
    double startY = std::floor(-view.panY / cellPx) * cellPx + view.panY;
    for (double x = startX; x < clientSize.x; x += cellPx)
        dc.DrawLine((int)x, 0, (int)x, clientSize.y);
    for (double y = startY; y < clientSize.y; y += cellPx)
        dc.DrawLine(0, (int)y, clientSize.x, (int)y);

    // Origin cross
    wxPoint o = view.ToScreen({0,0});
    dc.SetPen(wxPen(wxColour(180,180,220), 2));
    dc.DrawLine(o.x-10, o.y, o.x+10, o.y);
    dc.DrawLine(o.x, o.y-10, o.x, o.y+10);
}

// ── DrawPiece ─────────────────────────────────────────────────────────────────
// ── Draw rails for every route of a piece ────────────────────────────────────
// Traces each route (straight or arc) and draws a rail pair along it.
void Renderer::DrawPieceRails(wxDC& dc, const PlacedPiece& placed,
                              const TrackPiece& def, const ViewTransform& view)
{
    const Vec2   origin = placed.transform.pos;
    const double head   = placed.transform.heading;
    const double hRad   = head * M_PI / 180.0;
    const Vec2   dir{ std::cos(hRad), std::sin(hRad) };

    auto worldOf = [&](const MC::ConnectionPoint& c) {
        return placed.transform.transformPoint(c.pos);
    };
    auto straightRun = [&](double lengthMm) {
        Vec2 end{ origin.x + dir.x * lengthMm, origin.y + dir.y * lengthMm };
        DrawRailsStraight(dc, origin, end, def, view);
    };
    auto arcRun = [&](const Vec2& from, double startHead,
                      double radius, double sweep) {
        DrawRailsArc(dc, from, startHead, radius, sweep, def, view);
    };
    auto stubEnd = [&](double stub) {
        return Vec2{ origin.x + dir.x * stub, origin.y + dir.y * stub };
    };

    switch (def.type) {
        case PieceType::Straight:
        case PieceType::Uncoupler:
        case PieceType::FlexTrack:
            straightRun(def.lengthMm);
            break;

        case PieceType::Bumper:
            straightRun(def.lengthMm);
            break;

        case PieceType::Curve:
            arcRun(origin, head, def.radiusMm, def.angleDeg);
            break;

        case PieceType::TurnoutLeft:
        case PieceType::TurnoutRight: {
            // Straight main route + curved branch, both from the entry.
            straightRun(def.lengthMm);
            arcRun(origin, head, def.radiusMm, def.angleDeg);
            break;
        }

        case PieceType::TurnoutY: {
            // Two curved routes. Inner from entry; outer after a stub.
            double stub = (def.connections.size() > 2)
                ? std::abs(def.connections[2].pos.x - def.connections[1].pos.x)
                : 0.0;
            // Inner: radius derived from conn[1] so dual-radius pieces work.
            double aAbs = std::abs(def.angleDeg) * M_PI / 180.0;
            double sinA = std::sin(aAbs);
            double rIn  = def.radiusMm;
            if (def.connections.size() > 1 && sinA > 0.001)
                rIn = def.connections[1].pos.x / sinA;
            arcRun(origin, head, rIn, def.angleDeg);
            arcRun(stubEnd(stub), head, def.radiusMm, def.angleDeg);
            if (stub > 0.01) DrawRailsStraight(dc, origin, stubEnd(stub), def, view);
            break;
        }

        case PieceType::TurnoutCurved: {
            // Inner arc from entry, stub, outer arc from stub end.
            double aAbs = std::abs(def.angleDeg) * M_PI / 180.0;
            double sinA = std::sin(aAbs);
            double rIn  = def.radiusMm;
            if (def.connections.size() > 1 && sinA > 0.001)
                rIn = def.connections[1].pos.x / sinA;
            arcRun(origin, head, rIn, def.angleDeg);
            if (def.lengthMm > 0.01) {
                DrawRailsStraight(dc, origin, stubEnd(def.lengthMm), def, view);
                arcRun(stubEnd(def.lengthMm), head, def.radiusMm, def.angleDeg);
            } else {
                arcRun(origin, head, def.radiusMm, def.angleDeg);
            }
            break;
        }

        case PieceType::Crossing:
        case PieceType::DoubleCrossover: {
            // Two straight routes crossing. Route 1: conn[0] -> conn[1].
            // Route 2: conn[2] -> conn[3].
            if (def.connections.size() >= 2)
                DrawRailsStraight(dc, worldOf(def.connections[0]),
                                      worldOf(def.connections[1]), def, view);
            if (def.connections.size() >= 4)
                DrawRailsStraight(dc, worldOf(def.connections[2]),
                                      worldOf(def.connections[3]), def, view);
            break;
        }

        case PieceType::Turntable:
            // Spokes are drawn by the ballast pass; no rails overlay.
            break;

        default:
            straightRun(def.lengthMm);
            break;
    }
}

void Renderer::DrawPiece(wxDC& dc, const PlacedPiece& placed,
                         const TrackPiece& def, const ViewTransform& view,
                         bool selected, bool showDots, bool inTunnel)
{
    wxColour fill   = selected ? kBallastSel
                    : (def.series == TrackSeries::MarlinK) ? kBallastK
                    : (def.series == TrackSeries::MarlinM) ? kBallastM
                    : kBallast;
    // Level track color: if the piece's level has a track color assigned,
    // it becomes the default fill for pieces on that level.
    bool hasExplicitColor = false;   // level colour, or the piece's own
    if (!selected) {
        const Level* lvl = m_state ? m_state->FindLevel(placed.levelId) : nullptr;
        if (lvl && lvl->hasTrackColor) {
            fill = wxColour(lvl->r, lvl->g, lvl->b);
            hasExplicitColor = true;
        }
    }
    // A piece's own custom color overrides the level color.
    if (!selected && placed.hasColor) {
        fill = wxColour(placed.colorR, placed.colorG, placed.colorB);
        hasExplicitColor = true;
    }
    wxColour border = selected ? kBorderSel   : kBorder;
    int      bw     = 1;

    // Elevation tint: only genuinely graded/elevated track gets the orange
    // climb tint. Track that merely inherits a high z by sitting on a helix
    // deck is FLAT (gradeDeg ~ 0) and part of the helix chain -- it should
    // read as normal track, not a ramp. A real ramp climbing off a helix
    // deck still has a non-zero grade, so it keeps its tint.
    bool isFlatHelixDeck = (std::abs(placed.gradeDeg) < 0.001)
                        && ( placed.helixId != -1
                          || m_helixAttachedIds.count(placed.id) > 0 );
    // The tint is an automatic hint about height. If the user has explicitly
    // chosen a colour -- either on the level or on the piece itself -- that is
    // a much stronger signal of intent, so leave it alone. Previously the tint
    // overwrote it unconditionally: a level set to yellow and raised past
    // 120mm came out orange-red instead, because the tint saturates there.
    if (!selected && !hasExplicitColor
        && placed.z_mm > 0.5 && !isFlatHelixDeck) {
        double t = std::min(placed.z_mm / 120.0, 1.0);
        double r, g, b;
        if (t < 0.5) {
            double u = t / 0.5;
            r = 210 + (255-210)*u; g = 210 + (220-210)*u; b = 205 + (60-205)*u;
        } else {
            double u = (t - 0.5) / 0.5;
            r = 255 + (180-255)*u; g = 220 + (110-220)*u; b = 60 + (0-60)*u;
        }
        fill   = wxColour((int)r, (int)g, (int)b);
        border = wxColour(std::max(0,(int)r-60), std::max(0,(int)g-60), std::max(0,(int)b-30));
    }

    // Pieces inside a tunnel are dimmed so the user can still see where they
    // are but it's clear they're hidden by the mountain. We mix the fill and
    // border colors 40/60 toward a light gray.
    if (inTunnel && !selected) {
        auto blend = [](const wxColour& c, int bgR, int bgG, int bgB, double t) {
            return wxColour(
                (int)(c.Red()   * (1 - t) + bgR * t),
                (int)(c.Green() * (1 - t) + bgG * t),
                (int)(c.Blue()  * (1 - t) + bgB * t));
        };
        fill   = blend(fill,   220, 220, 220, 0.60);
        border = blend(border, 220, 220, 220, 0.60);
    }

    switch (def.type) {
        case PieceType::Straight:
        case PieceType::Bumper:
        case PieceType::Uncoupler:
            FillPoly(dc, StraightPoly(placed, def, view), fill, border, bw);
            // Draw bridge arch indicator in 2D
            if (def.isBridge) {
                double hr  = placed.transform.heading * M_PI / 180.0;
                double cx  = placed.transform.pos.x + std::cos(hr) * def.lengthMm * 0.5;
                double cy  = placed.transform.pos.y + std::sin(hr) * def.lengthMm * 0.5;
                int r = (int)(def.lengthMm * 0.5 * view.scale);
                wxPoint centre = view.ToScreen({cx, cy});
                dc.SetPen(wxPen(wxColour(80,100,130,180), std::max(1,(int)(1.5*view.scale))));
                dc.SetBrush(*wxTRANSPARENT_BRUSH);
                double hDeg = placed.transform.heading;
                dc.DrawEllipticArc(centre.x-r, centre.y-r/4, r*2, r/2,
                    (int)(hDeg + 180.0), (int)(hDeg + 360.0));
            }
            // Contact track indicator: small rectangle in centre, rotated
            // to match the piece's heading so it stays aligned with the
            // track at any angle (previously axis-aligned, looked wrong
            // on anything but heading 0).
            if (def.catalogNumber == "24994") {
                DrawHeadingRect(dc, view, placed.transform.pos, placed.transform.heading,
                                def.lengthMm * 0.5, 8.0, 4.0, bw);
            }
            // Uncoupler indicator: same rectangle as 24994 but twice as long.
            if (def.catalogNumber == "24997") {
                DrawHeadingRect(dc, view, placed.transform.pos, placed.transform.heading,
                                def.lengthMm * 0.5, 16.0, 4.0, bw);
            }
            // Bumper stop cap: solid line across the buffer end (conn[1], the
            // open end opposite the track connection).
            if (def.type == PieceType::Bumper) {
                double hr  = placed.transform.heading * M_PI / 180.0;
                // Buffer end is at lengthMm along the heading from conn[0]
                // But conn[0] heading is 180 deg, so conn[0] is at (0,0) and
                // the piece runs toward conn[1] in the +heading direction.
                // The open/buffer end of the piece:
                double bex = placed.transform.pos.x + std::cos(hr) * def.lengthMm;
                double bey = placed.transform.pos.y + std::sin(hr) * def.lengthMm;
                // Perpendicular to heading (the cap line runs across the track)
                double capLen = 8.0;  // mm, half-width each side
                double px = -std::sin(hr) * capLen;
                double py =  std::cos(hr) * capLen;
                wxPoint pa = view.ToScreen({bex + px, bey + py});
                wxPoint pb = view.ToScreen({bex - px, bey - py});
                int capW = std::max(4, (int)(4.0 * view.scale));
                dc.SetPen(wxPen(wxColour(40, 40, 40), capW));
                dc.DrawLine(pa, pb);
            }
            break;

        case PieceType::Curve:
            FillPoly(dc, ArcPoly(placed, def, view, 0, 1), fill, border, bw);
            // Curved bridge indicator
            if (def.isBridge) {
                double hr  = placed.transform.heading * M_PI / 180.0;
                double mid = std::abs(def.angleDeg) * 0.5 * M_PI / 180.0;
                double R   = def.radiusMm;
                double connY = def.connections.size() >= 2 ? def.connections[1].pos.y : 1.0;
                double sg = connY >= 0 ? 1.0 : -1.0;
                // Arc midpoint in local space
                double lx = R * std::sin(mid);
                double ly = sg * R * (1.0 - std::cos(mid));
                // Rotate by heading
                double wx = placed.transform.pos.x + lx*std::cos(hr) - ly*std::sin(hr);
                double wy = placed.transform.pos.y + lx*std::sin(hr) + ly*std::cos(hr);
                int r = (int)(R * 0.22 * view.scale);
                wxPoint centre = view.ToScreen({wx, wy});
                // Rotate the arc angle with the piece heading
                double arcStartDeg = placed.transform.heading + 90.0 * sg;
                dc.SetPen(wxPen(wxColour(80,100,130,200), std::max(1,(int)(1.5*view.scale))));
                dc.SetBrush(*wxTRANSPARENT_BRUSH);
                dc.DrawEllipticArc(centre.x-r, centre.y-r/3, r*2, r*2/3,
                    (int)arcStartDeg, (int)(arcStartDeg + 180.0));
            }
            // Contact track indicator for 24294
            if (def.catalogNumber == "24294") {
                // Midpoint of the arc
                double hr = placed.transform.heading * M_PI / 180.0;
                double midA = std::abs(def.angleDeg) * 0.5 * M_PI / 180.0;
                double connY = def.connections.size() >= 2 ? def.connections[1].pos.y : 1.0;
                double sg2 = connY >= 0 ? 1.0 : -1.0;
                double centreY2 = sg2 * def.radiusMm;
                double lcx2 = def.radiusMm * std::sin(midA);
                double lcy2 = centreY2 - sg2 * def.radiusMm * std::cos(midA);
                MC::Vec2 midWorld = placed.transform.transformPoint({lcx2, lcy2});
                wxPoint centre = view.ToScreen(midWorld);
                int rw = std::max(3, (int)(8 * view.scale));
                int rh = std::max(2, (int)(4 * view.scale));
                dc.SetPen(wxPen(wxColour(60,60,160), std::max(1, bw)));
                dc.SetBrush(wxBrush(wxColour(100,120,220)));
                dc.DrawRectangle(centre.x - rw, centre.y - rh, rw*2, rh*2);
            }
            break;

        case PieceType::TurnoutLeft:
        case PieceType::TurnoutRight: {
            wxColour branchFill(fill.Red()-15, fill.Green()-15, fill.Blue()-10);
            if (def.connections.size() >= 4) {
                // Triple switch: branches first, straight on top
                FillPoly(dc, ArcPoly(placed, def, view, 0, 2), branchFill, border, bw);
                FillPoly(dc, ArcPoly(placed, def, view, 0, 3), branchFill, border, bw);
                FillPoly(dc, StraightPoly(placed, def, view), fill, border, bw);
            } else {
                // Normal turnout: straight main route + curved branch
                FillPoly(dc, StraightPoly(placed, def, view), fill, border, bw);
                FillPoly(dc, ArcPoly(placed, def, view, 0, 2), branchFill, border, bw);
            }
            break;
        }

        case PieceType::TurnoutCurved: {
            wxColour branchFill(fill.Red()-15, fill.Green()-15, fill.Blue()-10);

            // Dual-radius curved turnout (e.g. Gauge 1 59085/59086):
            // Both routes curve; conn[1]=outer arc (R=radiusMm), conn[2]=inner arc (smaller R).
            // Stub of lengthMm before the inner arc diverges.
            // Detected by conn[1].x != conn[2].x.
            if (def.connections.size() >= 3 &&
                std::abs(def.connections[1].pos.x - def.connections[2].pos.x) > 1.0) {

                // Inner arc from entry using conn[1].x for radius
                double aRad = std::abs(def.angleDeg) * M_PI / 180.0;
                double sinA = std::sin(aRad);
                double innerExtX = def.connections[1].pos.x;
                MC::TrackPiece innerDef = def;
                innerDef.type     = PieceType::Curve;
                innerDef.radiusMm = (sinA > 0.001) ? innerExtX / sinA : def.radiusMm * 0.85;
                innerDef.angleDeg = def.angleDeg;
                FillPoly(dc, ArcPoly(placed, innerDef, view, 0, 1), fill, border, bw);

                // Straight stub from entry
                FillPoly(dc, StraightPoly(placed, def, view), branchFill, border, bw);

                // Outer arc from stub end
                double headRad = placed.transform.heading * M_PI / 180.0;
                MC::PlacedPiece stubEnd = placed;
                stubEnd.transform.pos = placed.transform.pos + Vec2{
                    std::cos(headRad) * def.lengthMm,
                    std::sin(headRad) * def.lengthMm
                };
                MC::TrackPiece outerDef = def;
                outerDef.type     = PieceType::Curve;
                outerDef.radiusMm = def.radiusMm;
                outerDef.angleDeg = def.angleDeg;
                FillPoly(dc, ArcPoly(stubEnd, outerDef, view, 0, 1), branchFill, border, bw);
                break;
            }

            // Standard curved turnout (e.g. Z-Track 8568/8569):
            // inner arc from entry + straight stub + outer arc from stub end.
            MC::TrackPiece arcDef = def;
            arcDef.type = PieceType::Curve;
            FillPoly(dc, ArcPoly(placed, arcDef, view, 0, 1), fill, border, bw);

            // straight stub
            FillPoly(dc, StraightPoly(placed, def, view), branchFill, border, bw);

            // Outer arc: starts at end of stub
            double headRad = placed.transform.heading * M_PI / 180.0;
            MC::PlacedPiece stubEnd = placed;
            stubEnd.transform.pos = placed.transform.pos + Vec2{
                std::cos(headRad) * def.lengthMm,
                std::sin(headRad) * def.lengthMm
            };
            FillPoly(dc, ArcPoly(stubEnd, arcDef, view, 0, 1), branchFill, border, bw);
            break;
        }

        case PieceType::TurnoutY: {
            wxColour branchFill(fill.Red()-15, fill.Green()-15, fill.Blue()-10);

            MC::TrackPiece curveDef = def;
            curveDef.type     = PieceType::Curve;
            curveDef.radiusMm = def.radiusMm;

            // Three-way with straight main (e.g. M-Track 5214):
            // conn[0]=entry, conn[1]=straight exit (y≈0), conn[2]=left, conn[3]=right
            bool hasStraightMain = def.connections.size() >= 4 &&
                                   std::abs(def.connections[1].pos.y) < 1.0;

            if (hasStraightMain) {
                // Straight main route
                FillPoly(dc, StraightPoly(placed, def, view), fill, border, bw);

                // Left branch arc
                MC::TrackPiece leftDef = def;
                leftDef.type = PieceType::Curve;
                leftDef.radiusMm = def.radiusMm;
                leftDef.angleDeg = std::abs(def.angleDeg);
                FillPoly(dc, ArcPoly(placed, leftDef, view, 0, 1), branchFill, border, bw);

                // Right branch arc (mirrored)
                MC::TrackPiece rightDef = def;
                rightDef.type = PieceType::Curve;
                rightDef.radiusMm = def.radiusMm;
                rightDef.angleDeg = -std::abs(def.angleDeg);
                FillPoly(dc, ArcPoly(placed, rightDef, view, 0, 1), branchFill, border, bw);
                break;
            }

            // Symmetric Y turnout (e.g. C-Track 24671/24672): two curves, no straight main.
            // Use def.angleDeg directly — catalog stores correct sign for screen direction.
            // Inner route: curve from entry
            FillPoly(dc, ArcPoly(placed, curveDef, view, 0, 1), fill, border, bw);

            // Stub length from catalog
            double stubLen = (def.connections.size() > 2)
                ? std::abs(def.connections[2].pos.x - def.connections[1].pos.x) : 77.5;

            // Outer route: straight stub
            {
                double rad = placed.transform.heading * M_PI / 180.0;
                Vec2 dir{ std::cos(rad), std::sin(rad) };
                Vec2 perp{ -dir.y, dir.x };
                Vec2 p0 = placed.transform.pos;
                Vec2 p1 = p0 + dir * stubLen;
                FillPoly(dc, {
                    view.ToScreen(p0 + perp *  HalfWidthFor(def)),
                    view.ToScreen(p0 + perp * -HalfWidthFor(def)),
                    view.ToScreen(p1 + perp * -HalfWidthFor(def)),
                    view.ToScreen(p1 + perp *  HalfWidthFor(def))
                }, branchFill, border, bw);
            }
            // Outer route: curve from stub tip
            {
                MC::PlacedPiece stub = placed;
                double rad = placed.transform.heading * M_PI / 180.0;
                Vec2 dir{ std::cos(rad), std::sin(rad) };
                stub.transform.pos = placed.transform.pos + dir * stubLen;
                FillPoly(dc, ArcPoly(stub, curveDef, view, 0, 1), branchFill, border, bw);
            }
            break;
        }

        case PieceType::DoubleCrossover: {
            wxColour crossFill(fill.Red()-8, fill.Green()-8, fill.Blue()-4);
            wxColour slipFill(
                std::min(255, fill.Red()  + 10),
                std::min(255, fill.Green()+ 10),
                std::min(255, fill.Blue() + 18));

            // Check if this piece has straight routes:
            // conn[1] is at (length, 0) for straight, but offset in Y for all-curved
            bool hasStraightRoutes = (def.connections.size() >= 2 &&
                std::abs(def.connections[1].pos.y) < 1.0);

            if (hasStraightRoutes) {
                // Route 1: straight along heading (conn[0]→conn[1])
                FillPoly(dc, StraightPoly(placed, def, view), fill, border, bw);
                // Route 2: straight from conn[2] to conn[3]
                if (def.connections.size() >= 4) {
                    MC::Vec2 c2 = def.connections[2].pos;
                    MC::Vec2 c3 = def.connections[3].pos;
                    double ddx = c3.x-c2.x, ddy = c3.y-c2.y;
                    double route2len = std::sqrt(ddx*ddx + ddy*ddy);
                    MC::PlacedPiece cross2 = placed;
                    cross2.transform.pos = placed.transform.transformPoint(c2);
                    cross2.transform.heading = MC::Transform2D::normalizeAngle(
                        placed.transform.heading + def.angleDeg);
                    MC::TrackPiece crossDef2 = def;
                    crossDef2.type     = PieceType::Straight;
                    crossDef2.lengthMm = route2len;
                    FillPoly(dc, StraightPoly(cross2, crossDef2, view), crossFill, border, bw);
                }
            }

            // Slip arcs (both straight and all-curved variants)
            if (def.radiusMm > 0) {
                // Build arc polygons to extract corner points
                MC::TrackPiece slipDefA = def;
                slipDefA.type     = PieceType::Curve;
                slipDefA.angleDeg = def.angleDeg;
                auto polyA = ArcPoly(placed, slipDefA, view, 0, 1);

                MC::PlacedPiece slipB = placed;
                slipB.transform.pos = placed.transform.transformPoint(
                    def.connections[2].pos);
                slipB.transform.heading = MC::Transform2D::normalizeAngle(
                    placed.transform.heading + def.angleDeg);
                MC::TrackPiece slipDefB = def;
                slipDefB.type     = PieceType::Curve;
                slipDefB.angleDeg = -def.angleDeg;
                auto polyB = ArcPoly(slipB, slipDefB, view, 0, 1);

                // Draw both arcs filled (no outline — we'll draw specific lines after)
                FillPoly(dc, polyA, slipFill, slipFill, 0);
                FillPoly(dc, polyB, slipFill, slipFill, 0);

                // Extract corners: polygon is outer arc [0..segs] then inner arc [segs+1..2*segs+1]
                if (!polyA.empty() && !polyB.empty()) {
                    int nA = (int)polyA.size();
                    int nB = (int)polyB.size();
                    int segsA = (nA - 2) / 2;
                    int segsB = (nB - 2) / 2;

                    // Upper arc A: outer=TOP, inner=BOTTOM
                    wxPoint A1 = polyA[0];           // top-left
                    wxPoint A3 = polyA[segsA];        // top-right
                    wxPoint A4 = polyA[segsA + 1];    // bottom-right
                    wxPoint A2 = polyA[nA - 1];       // bottom-left

                    // Lower arc B: outer=BOTTOM, inner=TOP (inverted)
                    wxPoint B2 = polyB[0];            // bottom-left
                    wxPoint B4 = polyB[segsB];        // bottom-right
                    wxPoint B3 = polyB[segsB + 1];    // top-right
                    wxPoint B1 = polyB[nB - 1];       // top-left

                    // Outer edges only (no inner/crossing outlines)
                    dc.SetPen(wxPen(border, bw));
                    // Top edge of upper arc
                    dc.DrawLines(segsA + 1, polyA.data());
                    // Bottom edge of lower arc
                    dc.DrawLines(segsB + 1, polyB.data());

                    // Four diagonal cross lines
                    dc.SetPen(wxPen(border, std::max(1, bw - 1)));
                    dc.DrawLine(A1, B3);   // top-left upper → top-right lower
                    dc.DrawLine(A2, B4);   // bottom-left upper → bottom-right lower
                    dc.DrawLine(B1, A3);   // top-left lower → top-right upper
                    dc.DrawLine(B2, A4);   // bottom-left lower → bottom-right upper
                }
            }
            break;
        }

        case PieceType::Crossing: {
            // Route 1: straight along piece heading (conn[0] to conn[1])
            FillPoly(dc, StraightPoly(placed, def, view), fill, border, bw);
            // Route 2: from conn[2] to conn[3] — forms the X
            wxColour crossFill(fill.Red()-10, fill.Green()-10, fill.Blue()-5);
            if (def.connections.size() >= 4) {
                // Length of route 2 = distance from conn[2] to conn[3] in local space
                MC::Vec2 c2 = def.connections[2].pos;
                MC::Vec2 c3 = def.connections[3].pos;
                double dx = c3.x - c2.x, dy = c3.y - c2.y;
                double route2len = std::sqrt(dx*dx + dy*dy);

                MC::PlacedPiece cross = placed;
                cross.transform.pos = placed.transform.transformPoint(c2);
                cross.transform.heading = MC::Transform2D::normalizeAngle(
                    placed.transform.heading + def.angleDeg);
                MC::TrackPiece crossDef = def;
                crossDef.type = PieceType::Straight;
                crossDef.lengthMm = route2len;
                FillPoly(dc, StraightPoly(cross, crossDef, view), crossFill, border, bw);
            }
            break;
        }

        case PieceType::Turntable: {
            double pitR   = def.radiusMm;
            double outerR = def.lengthMm / 2.0;
            wxPoint centre = view.ToScreen(placed.transform.pos);
            int pitPx   = (int)(pitR   * view.scale);
            int outerPx = (int)(outerR * view.scale);

            // Outer footprint
            dc.SetBrush(wxBrush(wxColour(220, 215, 210)));
            dc.SetPen(wxPen(border, bw));
            dc.DrawCircle(centre, outerPx);

            // Pit
            dc.SetBrush(wxBrush(fill));
            dc.SetPen(wxPen(border, bw));
            dc.DrawCircle(centre, pitPx);

            // Deck line rotated with heading
            {
                double headRad = placed.transform.heading * M_PI / 180.0;
                double dx = std::cos(headRad) * pitR;
                double dy = std::sin(headRad) * pitR;
                wxPoint p1 = view.ToScreen(placed.transform.pos + Vec2{ dx,  dy});
                wxPoint p2 = view.ToScreen(placed.transform.pos + Vec2{-dx, -dy});
                dc.SetPen(wxPen(selected ? kBorderSel : wxColour(80,60,40), 3));
                dc.DrawLine(p1, p2);
            }

            // Spoke stubs where track is connected
            for (int i = 0; i < (int)def.connections.size(); ++i) {
                bool connected = (i < (int)placed.joints.size() && placed.joints[i].pieceId >= 0);
                if (!connected) continue;
                Vec2 inner = placed.transform.transformPoint(def.connections[i].pos);
                double spokeRad = def.connections[i].heading * M_PI / 180.0
                                + placed.transform.heading * M_PI / 180.0;
                Vec2 outerPt = placed.transform.pos + Vec2{
                    std::cos(spokeRad) * outerR,
                    std::sin(spokeRad) * outerR
                };
                dc.SetPen(wxPen(wxColour(100,80,60), 2));
                dc.DrawLine(view.ToScreen(inner), view.ToScreen(outerPt));
            }

            // Dots at all possible spoke positions
            dc.SetBrush(wxBrush(wxColour(160,140,100)));
            dc.SetPen(*wxTRANSPARENT_PEN);
            for (const auto& conn : def.connections) {
                Vec2 wp = placed.transform.transformPoint(conn.pos);
                dc.DrawCircle(view.ToScreen(wp), std::max(2, (int)(3 * view.scale)));
            }
            break;
        }

        default:
            FillPoly(dc, StraightPoly(placed, def, view), fill, border, bw);
            break;
    }

    // ── Rails ─────────────────────────────────────────────────────────────
    // Drawn on top of the ballast. Each route of the piece gets a rail pair.
    // Toggle via View > Show Rails (state.showRails).
    if (m_state && m_state->showRails && view.scale >= 0.15 && !inTunnel) {
        DrawPieceRails(dc, placed, def, view);
    }

    // Catalog number label + elevation marker + connection dots
    // All hidden when showDots=false (i.e. showLabels=false)
    if (showDots) {
    // For curves, centre label at arc midpoint
    if (view.scale >= 0.2) {
        Vec2 centre;
        if (def.type == PieceType::Curve && def.radiusMm > 0) {
            double angleDeg = def.angleDeg;
            double centreY = (angleDeg >= 0) ? def.radiusMm : -def.radiusMm;
            double startA  = (angleDeg >= 0) ? -M_PI/2.0 : M_PI/2.0;
            double midA    = startA + (angleDeg * M_PI / 180.0) * 0.5;
            double midR    = def.radiusMm; // label at track centre radius
            Vec2 localMid{ centreY * 0 + midR * std::cos(midA),
                           centreY + midR * std::sin(midA) - def.radiusMm + (angleDeg>=0?0:0)};
            // Simpler: just interpolate connection points
            double rad0 = placed.transform.heading * M_PI / 180.0;
            Vec2 dir{ std::cos(rad0), std::sin(rad0) };
            // Arc midpoint in local space
            double halfAngle = angleDeg * 0.5 * M_PI / 180.0;
            Vec2 lc{ 0.0, (angleDeg >= 0) ? def.radiusMm : -def.radiusMm };
            double mA = startA + halfAngle;
            Vec2 localArcMid{ lc.x + def.radiusMm * std::cos(mA),
                              lc.y + def.radiusMm * std::sin(mA) };
            centre = placed.transform.transformPoint(localArcMid);
        } else {
            double rad = placed.transform.heading * M_PI / 180.0;
            Vec2 dir{ std::cos(rad), std::sin(rad) };
            centre = placed.transform.pos + dir * (def.lengthMm / 2.0);
        }
        wxPoint pt = view.ToScreen(centre);

        wxFont f = dc.GetFont();
        int ptSize = std::max(6, (int)(9 * view.scale));
        f.SetPointSize(ptSize);
        f.SetWeight(wxFONTWEIGHT_BOLD);
        dc.SetFont(f);
        dc.SetTextForeground(selected ? kBorderSel : wxColour(50, 50, 50));

        wxString label(def.catalogNumber.c_str(), wxConvLibc);
        wxSize ts = dc.GetTextExtent(label);
        dc.SetBackgroundMode(wxTRANSPARENT);
        dc.DrawText(label, pt.x - ts.x/2, pt.y - ts.y/2);
    }

    // Connection dots
    this->DrawConnectionDots(dc, placed, def, view);
    } // end showDots
}

// ── DrawGhost ─────────────────────────────────────────────────────────────────
void Renderer::DrawGhost(wxDC& dc, const TrackPiece& def,
                         const Transform2D& transform, const ViewTransform& view)
{
    PlacedPiece ghost;
    ghost.catalogNumber = def.catalogNumber;
    ghost.transform     = transform;
    ghost.joints.resize(def.connections.size());

    wxColour fill   = kBallastGhost;
    wxColour border = wxColour(80, 150, 80);
    int bw = 1;

    switch (def.type) {
        case PieceType::Curve:
            FillPoly(dc, ArcPoly(ghost, def, view, 0, 1), fill, border, bw);
            // Contact rectangle for 24294
            if (def.catalogNumber == "24294") {
                double midA = std::abs(def.angleDeg) * 0.5 * M_PI / 180.0;
                double connY = def.connections.size() >= 2 ? def.connections[1].pos.y : 1.0;
                double sg = connY >= 0 ? 1.0 : -1.0;
                double cY = sg * def.radiusMm;
                double lcx = def.radiusMm * std::sin(midA);
                double lcy = cY - sg * def.radiusMm * std::cos(midA);
                MC::Vec2 midWorld = ghost.transform.transformPoint({lcx, lcy});
                wxPoint centre = view.ToScreen(midWorld);
                int rw = std::max(3, (int)(8*view.scale)), rh = std::max(2,(int)(4*view.scale));
                dc.SetPen(wxPen(wxColour(60,60,160), 1));
                dc.SetBrush(wxBrush(wxColour(100,120,220)));
                dc.DrawRectangle(centre.x-rw, centre.y-rh, rw*2, rh*2);
            }
            break;
        case PieceType::TurnoutLeft:
        case PieceType::TurnoutRight: {
            wxColour branchFill(fill.Red()-15, fill.Green()-15, fill.Blue()-10);
            if (def.connections.size() >= 4) {
                FillPoly(dc, ArcPoly(ghost, def, view, 0, 2), branchFill, border, bw);
                FillPoly(dc, ArcPoly(ghost, def, view, 0, 3), branchFill, border, bw);
                FillPoly(dc, StraightPoly(ghost, def, view), fill, border, bw);
            } else {
                FillPoly(dc, StraightPoly(ghost, def, view), fill, border, bw);
                FillPoly(dc, ArcPoly(ghost, def, view, 0, 2), branchFill, border, bw);
            }
            break;
        }
        case PieceType::TurnoutY: {
            wxColour branchFill(fill.Red()-15, fill.Green()-15, fill.Blue()-10);
            // Check if both exits are curved (no straight main route) — like 24771/24772
            // Detect: if conn[1] has non-zero Y offset, it's an all-curved turnout
            bool allCurved = def.connections.size() >= 3 &&
                             std::abs(def.connections[1].pos.y) > 5.0;
            if (allCurved) {
                // Both routes are arcs — draw inner arc from origin, outer arc offset by stub
                MC::TrackPiece arcDef = def; arcDef.type = PieceType::Curve;
                // Inner arc: conn[0]→conn[1]
                FillPoly(dc, ArcPoly(ghost, arcDef, view, 0, 1), fill, border, bw);
                // Outer arc: starts at stub offset (conn[2].x - conn[1].x along heading)
                double stubLen = def.connections[2].pos.x - def.connections[1].pos.x;
                double headRad = ghost.transform.heading * M_PI / 180.0;
                MC::PlacedPiece stubEnd = ghost;
                stubEnd.transform.pos = ghost.transform.pos + MC::Vec2{
                    std::cos(headRad) * stubLen, std::sin(headRad) * stubLen };
                FillPoly(dc, ArcPoly(stubEnd, arcDef, view, 0, 1), branchFill, border, bw);
            } else {
                FillPoly(dc, StraightPoly(ghost, def, view), fill, border, bw);
                FillPoly(dc, ArcPoly(ghost, def, view, 0, 1), fill, border, bw);
                FillPoly(dc, ArcPoly(ghost, def, view, 0, 2), branchFill, border, bw);
            }
            break;
        }
        case PieceType::TurnoutCurved: {
            wxColour branchFill(fill.Red()-15, fill.Green()-15, fill.Blue()-10);
            double aRad = std::abs(def.angleDeg) * M_PI / 180.0;
            double sinA = std::sin(aRad);

            // Dual-radius: inner arc from entry, stub, outer arc from stub end
            if (def.connections.size() >= 3 &&
                std::abs(def.connections[1].pos.x - def.connections[2].pos.x) > 1.0) {
                double aRad2 = std::abs(def.angleDeg) * M_PI / 180.0;
                double sinA2 = std::sin(aRad2);
                // Inner arc from entry: radius from conn[1] (inner exit) minus stub
                double innerExtX2 = def.connections[1].pos.x;
                MC::TrackPiece innerDef = def; innerDef.type = PieceType::Curve;
                innerDef.radiusMm = (sinA2 > 0.001) ? innerExtX2 / sinA2 : def.radiusMm * 0.85;
                innerDef.angleDeg = def.angleDeg;
                FillPoly(dc, ArcPoly(ghost, innerDef, view, 0, 1), fill, border, bw);
                FillPoly(dc, StraightPoly(ghost, def, view), branchFill, border, bw);
                double headRad2 = ghost.transform.heading * M_PI / 180.0;
                MC::PlacedPiece stubEnd2 = ghost;
                stubEnd2.transform.pos = ghost.transform.pos + Vec2{
                    std::cos(headRad2) * def.lengthMm, std::sin(headRad2) * def.lengthMm };
                MC::TrackPiece outerDef = def; outerDef.type = PieceType::Curve;
                outerDef.radiusMm = def.radiusMm; outerDef.angleDeg = def.angleDeg;
                FillPoly(dc, ArcPoly(stubEnd2, outerDef, view, 0, 1), branchFill, border, bw);
            } else {
                // Standard curved turnout (8568/8569): inner arc + stub + outer arc
                MC::TrackPiece arcDef = def; arcDef.type = PieceType::Curve;
                FillPoly(dc, ArcPoly(ghost, arcDef, view, 0, 1), fill, border, bw);
                FillPoly(dc, StraightPoly(ghost, def, view), branchFill, border, bw);
                double headRad = ghost.transform.heading * M_PI / 180.0;
                MC::PlacedPiece stubEnd = ghost;
                stubEnd.transform.pos = ghost.transform.pos + Vec2{
                    std::cos(headRad) * def.lengthMm, std::sin(headRad) * def.lengthMm };
                FillPoly(dc, ArcPoly(stubEnd, arcDef, view, 0, 1), branchFill, border, bw);
            }
            break;
        }
        case PieceType::DoubleCrossover: {
            wxColour crossFill(fill.Red()-8, fill.Green()-8, fill.Blue()-4);
            wxColour slipFill(fill.Red()+10, fill.Green()+10, fill.Blue()+18);
            bool hasStraight = def.connections.size() >= 2 && std::abs(def.connections[1].pos.y) < 1.0;
            if (hasStraight) {
                FillPoly(dc, StraightPoly(ghost, def, view), fill, border, bw);
                if (def.connections.size() >= 4) {
                    MC::Vec2 c2 = def.connections[2].pos, c3 = def.connections[3].pos;
                    double ddx=c3.x-c2.x, ddy=c3.y-c2.y;
                    PlacedPiece cross2 = ghost;
                    cross2.transform.pos = ghost.transform.transformPoint(c2);
                    cross2.transform.heading = MC::Transform2D::normalizeAngle(ghost.transform.heading + def.angleDeg);
                    TrackPiece crossDef2 = def; crossDef2.type = PieceType::Straight;
                    crossDef2.lengthMm = std::sqrt(ddx*ddx+ddy*ddy);
                    FillPoly(dc, StraightPoly(cross2, crossDef2, view), crossFill, border, bw);
                }
            }
            if (def.radiusMm > 0) {
                TrackPiece slipDefA = def; slipDefA.type = PieceType::Curve; slipDefA.angleDeg = def.angleDeg;
                auto polyA = ArcPoly(ghost, slipDefA, view, 0, 1);
                PlacedPiece slipB = ghost;
                slipB.transform.pos = ghost.transform.transformPoint(def.connections[2].pos);
                slipB.transform.heading = MC::Transform2D::normalizeAngle(ghost.transform.heading + def.angleDeg);
                TrackPiece slipDefB = def; slipDefB.type = PieceType::Curve; slipDefB.angleDeg = -def.angleDeg;
                auto polyB = ArcPoly(slipB, slipDefB, view, 0, 1);
                FillPoly(dc, polyA, slipFill, slipFill, 0);
                FillPoly(dc, polyB, slipFill, slipFill, 0);
                if (!polyA.empty() && !polyB.empty()) {
                    int nA=(int)polyA.size(), nB=(int)polyB.size();
                    int segsA=(nA-2)/2, segsB=(nB-2)/2;
                    wxPoint A1=polyA[0], A3=polyA[segsA], A4=polyA[segsA+1], A2=polyA[nA-1];
                    wxPoint B2=polyB[0], B4=polyB[segsB], B3=polyB[segsB+1], B1=polyB[nB-1];
                    dc.SetPen(wxPen(border, bw));
                    dc.DrawLines(segsA+1, polyA.data());
                    dc.DrawLines(segsB+1, polyB.data());
                    dc.SetPen(wxPen(border, std::max(1,bw-1)));
                    dc.DrawLine(A1,B3); dc.DrawLine(A2,B4);
                    dc.DrawLine(B1,A3); dc.DrawLine(B2,A4);
                }
            }
            break;
        }
        case PieceType::Crossing: {
            wxColour crossFill(fill.Red()-10, fill.Green()-10, fill.Blue()-5);
            FillPoly(dc, StraightPoly(ghost, def, view), fill, border, bw);
            if (def.connections.size() >= 4) {
                MC::Vec2 c2 = def.connections[2].pos, c3 = def.connections[3].pos;
                double ddx=c3.x-c2.x, ddy=c3.y-c2.y;
                PlacedPiece cross2 = ghost;
                cross2.transform.pos = ghost.transform.transformPoint(c2);
                cross2.transform.heading = MC::Transform2D::normalizeAngle(
                    ghost.transform.heading + def.angleDeg);
                TrackPiece crossDef2 = def; crossDef2.type = PieceType::Straight;
                crossDef2.lengthMm = std::sqrt(ddx*ddx+ddy*ddy);
                FillPoly(dc, StraightPoly(cross2, crossDef2, view), crossFill, border, bw);
            }
            break;
        }
        case PieceType::Straight:
        case PieceType::Bumper:
        case PieceType::Uncoupler:
            FillPoly(dc, StraightPoly(ghost, def, view), fill, border, bw);
            if (def.catalogNumber == "24994") {
                DrawHeadingRect(dc, view, ghost.transform.pos, ghost.transform.heading,
                                def.lengthMm * 0.5, 8.0, 4.0, 1);
            }
            if (def.catalogNumber == "24997") {
                DrawHeadingRect(dc, view, ghost.transform.pos, ghost.transform.heading,
                                def.lengthMm * 0.5, 16.0, 4.0, 1);
            }
            if (def.type == PieceType::Bumper) {
                double hr  = ghost.transform.heading * M_PI / 180.0;
                double bex = ghost.transform.pos.x + std::cos(hr) * def.lengthMm;
                double bey = ghost.transform.pos.y + std::sin(hr) * def.lengthMm;
                double capLen = 8.0;
                double px = -std::sin(hr) * capLen;
                double py =  std::cos(hr) * capLen;
                wxPoint pa = view.ToScreen({bex + px, bey + py});
                wxPoint pb = view.ToScreen({bex - px, bey - py});
                int capW = std::max(4, (int)(4.0 * view.scale));
                dc.SetPen(wxPen(wxColour(40, 40, 40), capW));
                dc.DrawLine(pa, pb);
            }
            break;
        case PieceType::Turntable: {
            // Full turntable shape: outer disk + pit + bridge deck across
            // centre. Mirrors the placed render so the drag preview reads
            // as the actual circular footprint, not a tiny rectangle.
            double pitR   = def.radiusMm;
            double outerR = def.lengthMm / 2.0;
            wxPoint centre = view.ToScreen(ghost.transform.pos);
            int pitPx   = (int)(pitR   * view.scale);
            int outerPx = (int)(outerR * view.scale);
            dc.SetBrush(wxBrush(wxColour(220, 215, 210, 180)));
            dc.SetPen(wxPen(border, bw));
            dc.DrawCircle(centre, outerPx);
            dc.SetBrush(wxBrush(fill));
            dc.SetPen(wxPen(border, bw));
            dc.DrawCircle(centre, pitPx);
            double headRad = ghost.transform.heading * M_PI / 180.0;
            double dx = std::cos(headRad) * pitR;
            double dy = std::sin(headRad) * pitR;
            wxPoint p1 = view.ToScreen(ghost.transform.pos + Vec2{ dx,  dy});
            wxPoint p2 = view.ToScreen(ghost.transform.pos + Vec2{-dx, -dy});
            dc.SetPen(wxPen(wxColour(80, 60, 40), 3));
            dc.DrawLine(p1, p2);
            break;
        }
        default:
            FillPoly(dc, StraightPoly(ghost, def, view), fill, border, bw);
            break;
    }
    // Show connection dots
    if (def.type == PieceType::TurnoutLeft  ||
        def.type == PieceType::TurnoutRight ||
        def.type == PieceType::TurnoutY     ||
        def.type == PieceType::TurnoutCurved||
        def.type == PieceType::Crossing     ||
        def.type == PieceType::DoubleCrossover)
        DrawConnectionDots(dc, ghost, def, view);
}

// ── Connection dots ───────────────────────────────────────────────────────────
void Renderer::DrawConnectionDots(wxDC& dc, const PlacedPiece& placed,
                                   const TrackPiece& def,
                                   const ViewTransform& view)
{
    if (view.scale < 0.2) return;
    int r = std::max(3, (int)(5.0 * view.scale / 2.0));

    for (int i = 0; i < (int)def.connections.size(); ++i) {
        ConnectionPoint wc = placed.worldConnection(def, i);
        bool isOpen = (i < (int)placed.joints.size())
                      ? placed.joints[i].pieceId == -1 : true;
        wxColour col = isOpen ? kConnOpen : kConnJoined;
        dc.SetBrush(wxBrush(col));
        dc.SetPen(wxPen(col.ChangeLightness(70), 1));
        dc.DrawCircle(view.ToScreen(wc.pos), r);
    }
}

// ── DrawHelices ───────────────────────────────────────────────────────────────
// Renders the spiral curves of each helix on the canvas. Geometry comes
// from each helix's variant in the registry, so adding new variants needs
// no changes here. The curves are not real PlacedPieces -- we synthesize
// them on the fly from the helix's center + heading so the user sees the
// spiral but can't select interior pieces individually. The spiral winds
// counter-clockwise in plan view so a train entering at the bottom climbs
// while curving to its left.
void Renderer::DrawHelices(wxDC& dc, const CanvasState& state,
                            const ViewTransform& view)
{
    auto& catalog = MC::GetCatalog();
    const int pieces_per_turn = 12;     // 360 / 30

    for (const auto& h : state.helices) {
        const MC::HelixVariant* variant = MC::FindHelixVariant(h.variantId);
        if (!variant) variant = MC::FindHelixVariant(MC::DefaultHelixVariantId());
        if (!variant) continue;

        const TrackPiece* defR1 = catalog.Find(variant->innerCurve);
        const TrackPiece* defR2 = catalog.Find(variant->outerCurve);
        if (!defR1 || !defR2) continue;

        const int total_pieces = (int)(variant->turns * pieces_per_turn);

        // A helix lives on the level of its stubs. If that level is hidden,
        // skip the whole helix (curves + the implicit deck), matching how
        // regular pieces respect level visibility.
        int helixLevel = 1;
        for (const auto& p : state.pieces) {
            if (p.helixId == h.id) { helixLevel = p.levelId; break; }
        }
        if (!state.IsLevelVisible(helixLevel)) continue;

        // The helix is "selected" when any of its stubs is selected; in that
        // case the ghost curves render with the selection highlight too, so
        // the helix lights up as a single unit.
        bool helixSelected = false;
        for (const auto& p : state.pieces) {
            if (p.helixId == h.id && p.selected) { helixSelected = true; break; }
        }

        auto drawArcs = [&](double radius, const TrackPiece& def) {
            // CCW spiral in plan view (Y-down canvas). The catalog curve is
            // used as-is: we place each piece with its conn[0] at the
            // spiral's "next" endpoint (theta further CCW) and heading set
            // to the reverse-CCW tangent there. The piece's natural arc
            // then sweeps back along the CCW path, occupying the arc
            // between two adjacent spiral points. A train entering at the
            // bottom stub (North side, z=0) climbs to the top stub (South
            // side, z=variant->totalRise) curving to its left.
            for (int i = 0; i < total_pieces; ++i) {
                double thetaDeg = -90.0 - (i + 1) * 30.0;   // conn[0] position
                double thetaRad = thetaDeg * M_PI / 180.0;
                double lx = radius * std::cos(thetaRad);
                double ly = radius * std::sin(thetaRad);
                double headingLocal = thetaDeg + 90.0;       // reverse-CCW tangent
                double hr = h.heading * M_PI / 180.0;
                double cs = std::cos(hr), sn = std::sin(hr);
                PlacedPiece ghost;
                ghost.catalogNumber = def.catalogNumber;
                ghost.transform.pos = {
                    h.center.x + cs*lx - sn*ly,
                    h.center.y + sn*lx + cs*ly
                };
                ghost.transform.heading = headingLocal + h.heading;
                ghost.helixId = h.id;
                ghost.levelId = helixLevel;   // pick up level track color
                ghost.joints.resize(def.connections.size());
                DrawPiece(dc, ghost, def, view, /*selected=*/helixSelected,
                           /*showDots=*/false, /*inTunnel=*/false);
            }
        };
        drawArcs(variant->radiusR1, *defR1);
        drawArcs(variant->radiusR2, *defR2);
    }
}

} // namespace MC
