#include "view3d/View3DFrame.h"
#include "io/CatalogLoader.h"
#include "io/SceneryCatalog.h"
#include "model/HelixVariant.h"
#include <cmath>
#include <algorithm>
#include <array>
#include <unordered_set>

// OpenGL headers. Windows insists <windows.h> comes first, and macOS keeps
// them under OpenGL/ rather than GL/ -- the only place this file needs to know
// which platform it is on.
#ifdef __WXMSW__
  #include <windows.h>
  #include <GL/gl.h>
  #include <GL/glu.h>
#elif defined(__APPLE__)
  #include <OpenGL/gl.h>
  #include <OpenGL/glu.h>
#else
  #include <GL/gl.h>
  #include <GL/glu.h>
#endif

#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

// ── Track cross-section dimensions (HO, mm) ───────────────────────────────────
static constexpr float RAIL_HEIGHT   =  2.5f;
static constexpr float RAIL_WIDTH    =  1.2f;
static constexpr float RAIL_GAUGE    = 16.5f;
static constexpr float BED_HEIGHT    =  2.0f;
static constexpr float BED_WIDTH     = 37.4f;
static constexpr float SLEEPER_W     = 26.0f;
static constexpr float SLEEPER_D     =  3.5f;
static constexpr float SLEEPER_H     =  2.2f;
static constexpr float SLEEPER_SPACE = 13.0f;

static Color3 cBed        { 0.76f, 0.74f, 0.70f };
static Color3 cBedSide    { 0.58f, 0.56f, 0.52f };
static Color3 cRail       { 0.52f, 0.52f, 0.56f };
static Color3 cRailTop    { 0.74f, 0.74f, 0.78f };
static Color3 cSleeper    { 0.38f, 0.27f, 0.16f };
static Color3 cSleeperSide{ 0.28f, 0.18f, 0.10f };

// ── Vec3 helpers ──────────────────────────────────────────────────────────────
static V3 v3sub(V3 a, V3 b) { return {a.x-b.x, a.y-b.y, a.z-b.z}; }
static V3 v3add(V3 a, V3 b) { return {a.x+b.x, a.y+b.y, a.z+b.z}; }
static V3 v3scale(V3 v, float s) { return {v.x*s, v.y*s, v.z*s}; }
static V3 v3cross(V3 a, V3 b) {
    return {a.y*b.z-a.z*b.y, a.z*b.x-a.x*b.z, a.x*b.y-a.y*b.x};
}
static V3 v3norm(V3 v) {
    float l = std::sqrt(v.x*v.x+v.y*v.y+v.z*v.z);
    return (l > 1e-7f) ? V3{v.x/l, v.y/l, v.z/l} : V3{0,0,1};
}

// ── Quad emitter ─────────────────────────────────────────────────────────────
static void AddQuad(Mesh& m, V3 a, V3 b, V3 c, V3 d, Color3 col, V3 n) {
    unsigned base = (unsigned)m.verts.size();
    for (auto& v : {a,b,c,d}) { m.verts.push_back(v); m.normals.push_back(n); m.colors.push_back(col); }
    m.indices.insert(m.indices.end(), {base,base+1,base+2, base,base+2,base+3});
}

// ── Catmull-Rom spline ────────────────────────────────────────────────────────
// Evaluate position at t in [0,1] through span p1→p2 with ghost p0, p3.
static V3 CatmullRomPos(V3 p0, V3 p1, V3 p2, V3 p3, float t) {
    float t2=t*t, t3=t2*t;
    auto b = [&](float c0,float c1,float c2,float c3) {
        return (-0.5f*c0+1.5f*c1-1.5f*c2+0.5f*c3)*t3
              +(     c0-2.5f*c1+2.0f*c2-0.5f*c3)*t2
              +(-0.5f*c0         +0.5f*c2       )*t
              +             c1;
    };
    return { b(p0.x,p1.x,p2.x,p3.x),
             b(p0.y,p1.y,p2.y,p3.y),
             b(p0.z,p1.z,p2.z,p3.z) };
}
// Tangent (derivative) at t
static V3 CatmullRomTan(V3 p0, V3 p1, V3 p2, V3 p3, float t) {
    float t2=t*t;
    auto db = [&](float c0,float c1,float c2,float c3) {
        return (-1.5f*c0+4.5f*c1-4.5f*c2+1.5f*c3)*t2
              +(  2.0f*c0-5.0f*c1+4.0f*c2-1.0f*c3)*t
              + (-0.5f*c0         +0.5f*c2);
    };
    return v3norm({ db(p0.x,p1.x,p2.x,p3.x),
                    db(p0.y,p1.y,p2.y,p3.y),
                    0.0f });
}

// ── Oriented frame ────────────────────────────────────────────────────────────
struct Frame {
    V3 pos;
    V3 tan;   // along track (unit)
    V3 lat;   // lateral (unit, perpendicular to tan in XY plane)
};

static Frame MakeFrame(V3 pos, V3 tan) {
    tan = v3norm(tan);
    V3 lat = v3norm({-tan.y, tan.x, 0.0f});
    return {pos, tan, lat};
}

// ── Generate frames along a circular arc ──────────────────────────────────────
// cx,cy = centre in local XY plane; radius, startAngle, sweepAngle in radians.
// Uses Catmull-Rom through arc-sampled control points for extra smoothness.
static std::vector<Frame> ArcFrames(float cx, float cy, float radius,
                                     float startAngle, float sweepAngle,
                                     int segments)
{
    if (segments < 2) segments = 2;

    // Sample arc points (in 2D, z=0)
    std::vector<V3> pts(segments+1);
    for (int i = 0; i <= segments; ++i) {
        float a = startAngle + sweepAngle * ((float)i / segments);
        pts[i] = { cx + radius*std::cos(a), cy + radius*std::sin(a), 0.0f };
    }

    // Ghost points one step beyond each end
    float da = sweepAngle / segments;
    V3 g0 = { cx + radius*std::cos(startAngle - da),
               cy + radius*std::sin(startAngle - da), 0.0f };
    V3 gN = { cx + radius*std::cos(startAngle + sweepAngle + da),
               cy + radius*std::sin(startAngle + sweepAngle + da), 0.0f };

    std::vector<Frame> frames(segments+1);
    for (int i = 0; i <= segments; ++i) {
        V3 p0 = (i == 0)           ? g0       : pts[i-1];
        V3 p1 = pts[i];
        V3 p2 = (i < segments)     ? pts[i+1] : gN;
        V3 p3 = (i+1 < segments)   ? pts[i+2] : gN;
        // Use CR tangent at t=0 of the span [p1..p2]
        V3 tan = CatmullRomTan(p0, p1, p2, p3, 0.0f);
        frames[i] = MakeFrame(pts[i], tan);
    }
    return frames;
}

// ── Generate frames along a straight ─────────────────────────────────────────
static std::vector<Frame> StraightFrames(float x0, float y0, float x1, float y1,
                                          int segments)
{
    if (segments < 1) segments = 1;
    float dx=x1-x0, dy=y1-y0;
    float len = std::sqrt(dx*dx+dy*dy);
    V3 tan = (len > 1e-6f) ? V3{dx/len, dy/len, 0.0f} : V3{1,0,0};
    std::vector<Frame> frames(segments+1);
    for (int i = 0; i <= segments; ++i) {
        float t = (float)i/segments;
        frames[i] = MakeFrame({x0+dx*t, y0+dy*t, 0.0f}, tan);
    }
    return frames;
}

// ── Extrude track cross-section along a spine of frames ───────────────────────
static void ExtrudeTrack(Mesh& m, const std::vector<Frame>& frames)
{
    if (frames.size() < 2) return;

    // Cumulative arc length at each frame
    std::vector<float> s(frames.size(), 0.0f);
    for (size_t i = 1; i < frames.size(); ++i) {
        V3 d = v3sub(frames[i].pos, frames[i-1].pos);
        s[i] = s[i-1] + std::sqrt(d.x*d.x+d.y*d.y+d.z*d.z);
    }

    float nextSleeper = SLEEPER_SPACE * 0.5f;

    // Convenience: get point relative to a frame
    auto P = [](const Frame& f, float latOff, float z) -> V3 {
        return { f.pos.x + f.lat.x*latOff,
                 f.pos.y + f.lat.y*latOff,
                 z };
    };

    for (size_t i = 0; i+1 < frames.size(); ++i) {
        const Frame& f0 = frames[i];
        const Frame& f1 = frames[i+1];

        float hw = BED_WIDTH * 0.5f;
        float bh = BED_HEIGHT;

        // ── Ballast bed ───────────────────────────────────────────────────
        // Top
        AddQuad(m, P(f0,-hw,bh), P(f0,hw,bh), P(f1,hw,bh), P(f1,-hw,bh),
                cBed, {0,0,1});
        // Left side
        AddQuad(m, P(f0,-hw,0), P(f0,-hw,bh), P(f1,-hw,bh), P(f1,-hw,0),
                cBedSide, v3norm({-f0.lat.x,-f0.lat.y,0}));
        // Right side
        AddQuad(m, P(f0,hw,bh), P(f0,hw,0), P(f1,hw,0), P(f1,hw,bh),
                cBedSide, v3norm({f0.lat.x,f0.lat.y,0}));

        // ── Sleepers ──────────────────────────────────────────────────────
        while (nextSleeper <= s[i+1] + 1e-3f) {
            float segLen = s[i+1] - s[i];
            float t = (segLen > 1e-3f) ? (nextSleeper - s[i]) / segLen : 0.0f;
            t = std::max(0.0f, std::min(1.0f, t));

            Frame sf;
            sf.pos = { f0.pos.x + (f1.pos.x-f0.pos.x)*t,
                       f0.pos.y + (f1.pos.y-f0.pos.y)*t, 0.0f };
            sf.tan = v3norm({ f0.tan.x + (f1.tan.x-f0.tan.x)*t,
                              f0.tan.y + (f1.tan.y-f0.tan.y)*t, 0.0f });
            sf.lat = { -sf.tan.y, sf.tan.x, 0.0f };

            float sw  = SLEEPER_W * 0.5f;
            float shd = SLEEPER_D * 0.5f;
            float z0  = bh;
            float zt  = bh + SLEEPER_H;
            V3 td = v3scale(sf.tan, shd);

            // Four corners (lat direction)
            V3 L0 = {sf.pos.x - sf.lat.x*sw, sf.pos.y - sf.lat.y*sw, 0};
            V3 L1 = {sf.pos.x + sf.lat.x*sw, sf.pos.y + sf.lat.y*sw, 0};

            V3 a={L0.x-td.x,L0.y-td.y,z0}, b={L1.x-td.x,L1.y-td.y,z0};
            V3 c={L1.x+td.x,L1.y+td.y,z0}, d={L0.x+td.x,L0.y+td.y,z0};
            V3 at={a.x,a.y,zt}, bt={b.x,b.y,zt}, ct={c.x,c.y,zt}, dt={d.x,d.y,zt};

            AddQuad(m, at, bt, ct, dt, cSleeper, {0,0,1});                         // top
            AddQuad(m, a, d, dt, at, cSleeperSide, v3norm(v3scale(sf.tan,-1.0f))); // back
            AddQuad(m, b, bt, ct, c, cSleeperSide, sf.tan);                         // front

            nextSleeper += SLEEPER_SPACE;
        }

        // ── Rails ─────────────────────────────────────────────────────────
        float zr = bh + SLEEPER_H;
        float rw = RAIL_WIDTH * 0.5f;
        float rh = RAIL_HEIGHT;

        for (int side : {-1, 1}) {
            float rOff = side * RAIL_GAUGE * 0.5f;
            // Top
            AddQuad(m, P(f0,rOff-rw,zr+rh), P(f0,rOff+rw,zr+rh),
                       P(f1,rOff+rw,zr+rh), P(f1,rOff-rw,zr+rh),
                    cRailTop, {0,0,1});
            // Outer face
            AddQuad(m, P(f0,rOff-(float)side*rw,zr+rh), P(f0,rOff-(float)side*rw,zr),
                       P(f1,rOff-(float)side*rw,zr),     P(f1,rOff-(float)side*rw,zr+rh),
                    cRail, v3norm({-(float)side*f0.lat.x, -(float)side*f0.lat.y, 0}));
            // Inner face
            AddQuad(m, P(f0,rOff+(float)side*rw,zr),     P(f0,rOff+(float)side*rw,zr+rh),
                       P(f1,rOff+(float)side*rw,zr+rh),  P(f1,rOff+(float)side*rw,zr),
                    cRail, v3norm({(float)side*f0.lat.x, (float)side*f0.lat.y, 0}));
        }
    }
}

// ── Build local-space mesh for one catalog piece ──────────────────────────────
//
// Built in canvas-local space: X=east, Y=DOWN (screen), Z=up.
// Entry connection always at (0,0) heading east.
// Y-flip to GL space happens only in DrawInstance (glScalef(1,-1,1)).
//
// PROVEN ARC RULE (verified against all catalog connection points):
//   Given an arc from origin whose exit has connY = conn.pos.y:
//     sign    = (connY >= 0) ? +1 : -1
//     cy      = sign * R          ← centre on SAME side as exit
//     startA  = -sign * pi/2      ← points from centre toward (0,0)
//     sweep   = sign * absDeg * pi/180
//
// Forward declarations — defined after AddBeam3D
static void BuildBridgeArch(Mesh& m, float len);
static void BuildBridgeTruss(Mesh& m, float len);
static void BuildBridgeRamp(Mesh& m, const MC::TrackPiece& def);

static PieceMesh BuildPieceMesh(const MC::TrackPiece& def)
{
    PieceMesh pm;
    auto type = def.type;

    auto segsLen   = [](float  l) { return std::max(2, (int)(l / 20.0f)); };
    auto segsAng   = [](double a) { return std::max(8, (int)(std::abs(a) / 2.5)); };

    // Arc from (ox, 0) whose exit Y matches connY.
    auto arcAt = [&](double R, double absDeg, double connY, float ox, int segs)
    {
        float s      = (connY >= 0.0) ? 1.0f : -1.0f;
        float cy     =  s * (float)R;
        float startA = -s * (float)M_PI * 0.5f;
        float sweep  =  s * (float)(absDeg * M_PI / 180.0);
        return ArcFrames(ox, cy, (float)R, startA, sweep, segs);
    };
    // Convenience: arc from origin
    auto arcO = [&](double R, double absDeg, double connY, int segs) {
        return arcAt(R, absDeg, connY, 0.0f, segs);
    };

    // ── Straight / Uncoupler ─────────────────────────────────────────────
    if (type == MC::PieceType::Straight ||
        type == MC::PieceType::Uncoupler)
    {
        float len = (float)def.lengthMm;
        ExtrudeTrack(pm.mesh, StraightFrames(0, 0, len, 0, segsLen(len)));

        if (def.isBridge) {
            if (def.catalogNumber == "74620" || def.catalogNumber == "7262" ||
                def.catalogNumber == "62620" || def.catalogNumber == "89759")
                BuildBridgeTruss(pm.mesh, len);
            else if (def.catalogNumber == "74623" || def.catalogNumber == "74613" ||
                     def.catalogNumber == "74618" ||
                     def.catalogNumber == "7569"  || def.catalogNumber == "7267"  ||
                     def.catalogNumber == "62623" || def.catalogNumber == "62613" ||
                     def.catalogNumber == "8976")
                BuildBridgeRamp(pm.mesh, def);
            else  // 74636, 7263, 62636, 8975
                BuildBridgeArch(pm.mesh, len);
        }

        return pm;
    }

    // ── Bumper — built by BuildBumperMesh() defined after AddBox3D ────────
    if (type == MC::PieceType::Bumper)
        return pm;  // replaced in RebuildMesh call

    // ── Curve ─────────────────────────────────────────────────────────────
    // All catalog curves have positive angleDeg and conn[1].y > 0.
    if (type == MC::PieceType::Curve)
    {
        if (def.connections.size() >= 2 && def.radiusMm > 0) {
            double connY = def.connections[1].pos.y;
            ExtrudeTrack(pm.mesh, arcO(def.radiusMm, std::abs(def.angleDeg),
                                        connY, segsAng(def.angleDeg)));
            if (def.isBridge)
                BuildBridgeRamp(pm.mesh, def);  // 74623, 74613, 7569, 7267
        }
        return pm;
    }

    // ── Left/Right Turnout (including triple switch 24630) ─────────────────
    // Straight route (entry→conn[1]) + arc branch(es) (entry→conn[2], conn[3]).
    if (type == MC::PieceType::TurnoutLeft ||
        type == MC::PieceType::TurnoutRight)
    {
        float len = (float)def.lengthMm;
        ExtrudeTrack(pm.mesh, StraightFrames(0, 0, len, 0, segsLen(len)));

        // First curved branch: conn[2]
        if (def.connections.size() >= 3 && def.radiusMm > 0) {
            double connY  = def.connections[2].pos.y;
            double absDeg = std::abs(def.angleDeg);
            ExtrudeTrack(pm.mesh, arcO(def.radiusMm, absDeg, connY, segsAng(absDeg)));
        }
        // Second curved branch (triple switch 24630): conn[3]
        if (def.connections.size() >= 4 && def.radiusMm > 0) {
            double connY  = def.connections[3].pos.y;
            double absDeg = std::abs(def.angleDeg);
            ExtrudeTrack(pm.mesh, arcO(def.radiusMm, absDeg, connY, segsAng(absDeg)));
        }
        return pm;
    }

    // ── Y-Turnout ──────────────────────────────────────────────────────────
    // Route 1: arc from origin → conn[1].
    // Route 2: straight stub (length = conn[2].x - arc_exit_x) + arc → conn[2].
    if (type == MC::PieceType::TurnoutY)
    {
        double R      = def.radiusMm;
        double absDeg = std::abs(def.angleDeg);
        double arcExX = R * std::sin(absDeg * M_PI / 180.0);  // arc exit X

        // Route 1
        if (def.connections.size() >= 2) {
            ExtrudeTrack(pm.mesh, arcO(R, absDeg,
                                        def.connections[1].pos.y, segsAng(absDeg)));
        }
        // Route 2
        if (def.connections.size() >= 3) {
            double stubLen = def.connections[2].pos.x - arcExX;
            if (stubLen < 0) stubLen = 0;
            double connY   = def.connections[2].pos.y;
            ExtrudeTrack(pm.mesh, StraightFrames(0, 0, (float)stubLen, 0,
                                                  segsLen((float)stubLen)));
            ExtrudeTrack(pm.mesh, arcAt(R, absDeg, connY, (float)stubLen,
                                         segsAng(absDeg)));
        }
        return pm;
    }

    // ── Crossing ──────────────────────────────────────────────────────────
    // Route 1: straight conn[0]→conn[1].
    // Route 2: straight conn[2]→conn[3] (at crossing angle, offset from centreline).
    if (type == MC::PieceType::Crossing)
    {
        ExtrudeTrack(pm.mesh, StraightFrames(0, 0, (float)def.lengthMm, 0,
                                              segsLen((float)def.lengthMm)));
        if (def.connections.size() >= 4) {
            float ox = (float)def.connections[2].pos.x;
            float oy = (float)def.connections[2].pos.y;
            float ex = (float)def.connections[3].pos.x;
            float ey = (float)def.connections[3].pos.y;
            float cl = std::sqrt((ex-ox)*(ex-ox)+(ey-oy)*(ey-oy));
            ExtrudeTrack(pm.mesh, StraightFrames(ox, oy, ex, ey, segsLen(cl)));
        }
        return pm;
    }

    // ── Double Slip Switch ────────────────────────────────────────────────
    // Two crossing straights + two slip arcs.
    // Slip arc A: from (0,0) curves toward conn[3].y (+Y) → arcO
    // Slip arc B: from conn[2] heading at angleDeg, curves back by -angleDeg
    //             Use general ArcFrames with precomputed centre.
    if (type == MC::PieceType::DoubleCrossover && def.radiusMm > 0)
    {
        float len     = (float)def.lengthMm;
        double R      = def.radiusMm;
        double deg    = def.angleDeg;   // positive (24.3° or 12.1°)
        double absDeg = std::abs(deg);

        // Route 1: straight
        ExtrudeTrack(pm.mesh, StraightFrames(0, 0, len, 0, segsLen(len)));

        // Route 2: straight from conn[2]→conn[3]
        if (def.connections.size() >= 4) {
            float ox = (float)def.connections[2].pos.x;
            float oy = (float)def.connections[2].pos.y;
            float ex = (float)def.connections[3].pos.x;
            float ey = (float)def.connections[3].pos.y;
            float cl = std::sqrt((ex-ox)*(ex-ox)+(ey-oy)*(ey-oy));
            ExtrudeTrack(pm.mesh, StraightFrames(ox, oy, ex, ey, segsLen(cl)));
        }

        int arcSegs = segsAng(absDeg);

        // Slip arc A: (0,0) → conn[3] (conn[3].y > 0 → curves +Y)
        // arcO with connY = +R works perfectly here
        ExtrudeTrack(pm.mesh, arcO(R, absDeg, +1.0, arcSegs));

        // Slip arc B: conn[2] → conn[1]
        // conn[2] is at (ox, oy) with oy < 0; arc curves toward +Y (toward 0)
        // Centre at (conn[1].x, -R) in piece-local space (geometrically derived)
        {
            float cx  = (float)def.lengthMm;   // = conn[1].x = 188.3 or 236.1
            float cy  = -(float)R;              // centre below (curves +Y from conn[2])
            float startA = (float)(M_PI * 0.5 + deg * M_PI / 180.0); // atan from centre to conn[2]
            float sweep  = (float)(-absDeg * M_PI / 180.0);
            ExtrudeTrack(pm.mesh, ArcFrames(cx, cy, (float)R, startA, sweep, arcSegs));
        }
        return pm;
    }

    // ── Turntable ─────────────────────────────────────────────────────────
    if (type == MC::PieceType::Turntable) {
        // Build the visible pit ring + low disk floor + rotating bridge.
        // radiusMm = pit radius (inner). lengthMm = outer footprint diameter.
        // The deck "track" itself is a short straight along +x to provide
        // a place for trains to sit on; the bridge geometry is purely
        // cosmetic but reads as the turntable in 3D.
        float pitR    = (float)def.radiusMm;
        float outerR  = (float)def.lengthMm * 0.5f;
        float wallH   = 8.0f;     // height of pit wall above ground
        float deckThk = 4.0f;     // thickness of rotating bridge deck

        const int kCircleSegs = 48;
        Color3 cWall  { 0.50f, 0.50f, 0.55f };
        Color3 cFloor { 0.24f, 0.24f, 0.28f };
        Color3 cDeck  { 0.45f, 0.42f, 0.40f };

        // Floor: thin disk at z=0, top side facing +z. Fan from centre.
        {
            // Triangle fan: centre + perimeter ring of kCircleSegs verts
            unsigned base = (unsigned)pm.mesh.verts.size();
            pm.mesh.verts.push_back({0, 0, 0.1f});
            pm.mesh.normals.push_back({0,0,1});
            pm.mesh.colors.push_back(cFloor);
            for (int i = 0; i < kCircleSegs; ++i) {
                float a = (float)(2 * M_PI) * i / kCircleSegs;
                pm.mesh.verts.push_back({pitR * std::cos(a),
                                          pitR * std::sin(a), 0.1f});
                pm.mesh.normals.push_back({0,0,1});
                pm.mesh.colors.push_back(cFloor);
            }
            for (int i = 0; i < kCircleSegs; ++i) {
                unsigned a = base;
                unsigned b = base + 1 + (unsigned)i;
                unsigned c = base + 1 + (unsigned)((i + 1) % kCircleSegs);
                pm.mesh.indices.insert(pm.mesh.indices.end(), { a, b, c });
            }
        }
        // Pit wall ring: thin band from pitR to pitR+wallW, height 0..wallH.
        const float wallW = (outerR - pitR) * 0.5f;
        for (int i = 0; i < kCircleSegs; ++i) {
            float a0 = (float)(2 * M_PI) * i / kCircleSegs;
            float a1 = (float)(2 * M_PI) * (i + 1) / kCircleSegs;
            float c0 = std::cos(a0), s0 = std::sin(a0);
            float c1 = std::cos(a1), s1 = std::sin(a1);
            // Top of the wall (a thin ring at z=wallH)
            V3 inT0 { pitR * c0, pitR * s0, wallH };
            V3 inT1 { pitR * c1, pitR * s1, wallH };
            V3 ouT0 { (pitR + wallW) * c0, (pitR + wallW) * s0, wallH };
            V3 ouT1 { (pitR + wallW) * c1, (pitR + wallW) * s1, wallH };
            // Bottom of inner face (ring at z=0, radius pitR)
            V3 inB0 { pitR * c0, pitR * s0, 0 };
            V3 inB1 { pitR * c1, pitR * s1, 0 };
            // Inner wall (facing the pit) — quad from inB0,inB1,inT1,inT0
            AddQuad(pm.mesh, inB0, inB1, inT1, inT0, cWall, V3{-c0, -s0, 0});
            // Top of the wall ring
            AddQuad(pm.mesh, inT0, inT1, ouT1, ouT0, cWall, V3{0,0,1});
        }
        // Rotating bridge deck: a thin rectangle across the pit at heading 0.
        // Length = 2*pitR (chord), width ~30 mm.
        {
            float halfL = pitR - 2.0f;
            float halfW = 18.0f;
            float z0 = wallH + 0.5f;
            float z1 = z0 + deckThk;
            // 8 corners of the box
            V3 v[8] = {
                {-halfL, -halfW, z0}, { halfL, -halfW, z0},
                { halfL,  halfW, z0}, {-halfL,  halfW, z0},
                {-halfL, -halfW, z1}, { halfL, -halfW, z1},
                { halfL,  halfW, z1}, {-halfL,  halfW, z1},
            };
            AddQuad(pm.mesh, v[0], v[1], v[2], v[3], cDeck, V3{0,0,-1}); // bot
            AddQuad(pm.mesh, v[4], v[7], v[6], v[5], cDeck, V3{0,0, 1}); // top
            AddQuad(pm.mesh, v[0], v[4], v[5], v[1], cDeck, V3{0,-1,0});
            AddQuad(pm.mesh, v[2], v[6], v[7], v[3], cDeck, V3{0, 1,0});
            AddQuad(pm.mesh, v[1], v[5], v[6], v[2], cDeck, V3{ 1,0,0});
            AddQuad(pm.mesh, v[3], v[7], v[4], v[0], cDeck, V3{-1,0,0});
        }
        return pm;
    }

    // ── Curved Turnout (TurnoutCurved) ─────────────────────────────────────
    // Used by Z 8568/8569 and K 2268/2269. C-Track uses TurnoutY for its
    // curved turnouts and is handled above.
    // Geometry (per CatalogLoader):
    //   - lengthMm = straight stub length along +x from origin.
    //   - Inner route:  arc from (0, 0) curving by |angleDeg| toward conn[1].
    //   - Outer route:  stub from (0,0) to (lengthMm, 0), then an arc
    //                   starting at (lengthMm, 0) curving by |angleDeg|
    //                   toward conn[2].
    if (type == MC::PieceType::TurnoutCurved)
    {
        float stubLen = (float)def.lengthMm;
        // Stub straight (shared start of the outer route; also drawn as
        // the visible "throat" of the turnout).
        ExtrudeTrack(pm.mesh, StraightFrames(0, 0, stubLen, 0,
                                             segsLen(stubLen)));
        double absDeg = std::abs(def.angleDeg);
        if (def.connections.size() >= 2 && def.radiusMm > 0) {
            // Inner arc from origin
            ExtrudeTrack(pm.mesh, arcO(def.radiusMm, absDeg,
                                        def.connections[1].pos.y,
                                        segsAng(absDeg)));
        }
        if (def.connections.size() >= 3 && def.radiusMm > 0) {
            // Outer arc starting at the stub end
            ExtrudeTrack(pm.mesh, arcAt(def.radiusMm, absDeg,
                                         def.connections[2].pos.y,
                                         stubLen, segsAng(absDeg)));
        }
        return pm;
    }

    // ── Fallback ───────────────────────────────────────────────────────────
    float len = (float)std::max(def.lengthMm, 10.0);
    ExtrudeTrack(pm.mesh, StraightFrames(0, 0, len, 0, segsLen(len)));
    return pm;
}

// ── Scenery 3D mesh builder ───────────────────────────────────────────────────
//
// Meshes are built centred on the world origin, Y-UP (GL convention).
// X runs east (along track), Y runs away from camera (perpendicular to track),
// Z is up. This matches DrawSceneryInstance which just does:
//   translate(wx,wy,-1) + rotate(heading) — no Y-flip needed.
//
// Coordinate mapping from 2D canvas convention (origin=top-left, Y-down):
//   2D local (lx, ly)  →  3D local (lx - halfW,  -(ly - halfH), 0)
// So: canvas top-left  → 3D (-halfW, +halfH)
//     canvas top-right → 3D (+halfW, +halfH)
//     canvas bot-left  → 3D (-halfW, -halfH)   ← trackside when placed above track
//     canvas bot-right → 3D (+halfW, -halfH)
//
// Helper macros to convert from 2D local to 3D centred coords:
#define CX(lx, W)  ((lx) - (W)*0.5f)
#define CY(ly, H)  (-((ly) - (H)*0.5f))

// Helper: add a flat-topped extruded box (footprint + walls + roof)
// x,y,w,d are in 2D-local space (origin=top-left, Y-down); converted internally.
static void AddBox3D(Mesh& m, float x, float y, float z,
                     float w, float d, float h, Color3 wall, Color3 roof,
                     float W, float H)  // full item width/height for centring
{
    auto addQuadN = [&](V3 a, V3 b, V3 c, V3 d2, Color3 col, V3 n) {
        unsigned base = (unsigned)m.verts.size();
        for (auto& v : {a,b,c,d2}) { m.verts.push_back(v); m.normals.push_back(n); m.colors.push_back(col); }
        m.indices.insert(m.indices.end(),{base,base+1,base+2, base,base+2,base+3});
    };

    // Convert 2D-local corners to 3D-centred coords
    float x0=CX(x,W),   x1=CX(x+w,W);
    float y0=CY(y,H),   y1=CY(y+d,H);
    // Note: in 3D y0 > y1 when d>0 (because CY flips), so swap for correct winding
    if (y0 < y1) std::swap(y0, y1);

    V3 p000={x0,y1,z},   p100={x1,y1,z},   p110={x1,y0,z},   p010={x0,y0,z};
    V3 p001={x0,y1,z+h}, p101={x1,y1,z+h}, p111={x1,y0,z+h}, p011={x0,y0,z+h};

    addQuadN(p001,p101,p111,p011, roof, {0,0,1});
    addQuadN(p000,p010,p011,p001, wall, {-1,0,0});
    addQuadN(p100,p101,p111,p110, wall, {1,0,0});
    addQuadN(p000,p001,p101,p100, wall, {0,-1,0});
    addQuadN(p010,p110,p111,p011, wall, {0,1,0});
}

// Helper: add a beam between two 3D points with given half-width
static void AddBeam3D(Mesh& m, V3 p0, V3 p1, float hw, Color3 col) {
    // Direction vector
    V3 dir = v3norm(v3sub(p1, p0));
    // Find a perpendicular (use world Y if dir is not parallel to it)
    V3 up = {0, 0, 1};
    if (std::abs(dir.z) > 0.9f) up = {0, 1, 0};
    V3 right = v3norm(v3cross(dir, up));
    V3 norm  = v3norm(v3cross(right, dir));

    // 8 corners of the beam box
    auto c = [&](V3 p, float r, float n) -> V3 {
        return {p.x + right.x*r + norm.x*n,
                p.y + right.y*r + norm.y*n,
                p.z + right.z*r + norm.z*n};
    };

    V3 a0=c(p0,-hw,-hw), b0=c(p0,hw,-hw), c0=c(p0,hw,hw), d0=c(p0,-hw,hw);
    V3 a1=c(p1,-hw,-hw), b1=c(p1,hw,-hw), c1=c(p1,hw,hw), d1=c(p1,-hw,hw);

    auto addQ = [&](V3 a, V3 b, V3 cc, V3 d2, V3 n) {
        unsigned base = (unsigned)m.verts.size();
        for (auto& v : {a,b,cc,d2}) { m.verts.push_back(v); m.normals.push_back(n); m.colors.push_back(col); }
        m.indices.insert(m.indices.end(),{base,base+1,base+2,base,base+2,base+3});
    };
    addQ(a0,b0,b1,a1, v3norm(v3cross(v3sub(b0,a0), v3sub(a1,a0))));
    addQ(b0,c0,c1,b1, v3norm(v3cross(v3sub(c0,b0), v3sub(b1,b0))));
    addQ(c0,d0,d1,c1, v3norm(v3cross(v3sub(d0,c0), v3sub(c1,c0))));
    addQ(d0,a0,a1,d1, v3norm(v3cross(v3sub(a0,d0), v3sub(d1,d0))));
    addQ(a0,d0,c0,b0, {-dir.x,-dir.y,-dir.z});
    addQ(a1,b1,c1,d1, dir);
}

// ── BuildBridgeArch — appends Märklin 74636 arch geometry to a track mesh ────
static void BuildBridgeArch(Mesh& m, float len)
{
    Color3 steel  = {0.55f, 0.58f, 0.62f};
    Color3 steelD = {0.38f, 0.40f, 0.44f};
    Color3 steelL = {0.70f, 0.72f, 0.76f};

    float archH = 99.0f;
    float halfD = 32.0f;
    float deckZ = 0.0f;
    float ribY0 =  halfD - 5.0f;
    float ribY1 = -(halfD - 5.0f);

    // Use 0..len directly — same space as track extrusion
    // Arc: x = (1-cos(a))*0.5*len maps 0→0, π→len
    auto arcX = [&](float a) { return (1.0f - std::cos(a)) * 0.5f * len; };
    auto arcZ = [&](float a) { return deckZ + std::sin(a) * archH; };

    // Deck plate
    {
        float y0=-(halfD-8.0f), y1=halfD-8.0f, z0=-4.0f, z1=deckZ;
        auto addQ = [&](V3 a, V3 b, V3 c, V3 d2, V3 n) {
            unsigned base=(unsigned)m.verts.size();
            for(auto& v:{a,b,c,d2}){m.verts.push_back(v);m.normals.push_back(n);m.colors.push_back(steelD);}
            m.indices.insert(m.indices.end(),{base,base+1,base+2,base,base+2,base+3});
        };
        addQ({0,y0,z1},{len,y0,z1},{len,y1,z1},{0,y1,z1},{0,0,1});
        addQ({0,y0,z0},{0,y1,z0},{len,y1,z0},{len,y0,z0},{0,0,-1});
        addQ({0,y0,z0},{len,y0,z0},{len,y0,z1},{0,y0,z1},{0,-1,0});
        addQ({0,y1,z0},{0,y1,z1},{len,y1,z1},{len,y1,z0},{0,1,0});
        addQ({0,y0,z0},{0,y0,z1},{0,y1,z1},{0,y1,z0},{-1,0,0});
        addQ({len,y0,z0},{len,y1,z0},{len,y1,z1},{len,y0,z1},{1,0,0});
    }

    // Bottom chord girders
    AddBeam3D(m, {0, ribY0, -8}, {len, ribY0, -8}, 5.0f, steel);
    AddBeam3D(m, {0, ribY1, -8}, {len, ribY1, -8}, 5.0f, steel);

    // Arch rib footings
    for (int s = 0; s < 2; s++) {
        float yR = s==0 ? ribY0 : ribY1;
        AddBeam3D(m, {0,   yR, -18}, {0,   yR, deckZ}, 5.0f, steelD);
        AddBeam3D(m, {len, yR, -18}, {len, yR, deckZ}, 5.0f, steelD);
    }

    // Arch ribs
    for (int s = 0; s < 2; s++) {
        float yR = s==0 ? ribY0 : ribY1;
        for (int i = 0; i < 14; i++) {
            float a0=(float)i/14*(float)M_PI, a1=(float)(i+1)/14*(float)M_PI;
            AddBeam3D(m, {arcX(a0),yR,arcZ(a0)}, {arcX(a1),yR,arcZ(a1)}, 4.0f, steelL);
        }
    }

    // Vertical hangers
    for (int s = 0; s < 2; s++) {
        float yR = s==0 ? ribY0 : ribY1;
        for (int i = 1; i < 8; i++) {
            float a=(float)i/8*(float)M_PI;
            AddBeam3D(m, {arcX(a),yR,deckZ+2}, {arcX(a),yR,arcZ(a)-4}, 1.8f, steelD);
        }
    }

    // K-bracing
    for (int s = 0; s < 2; s++) {
        float yR = s==0 ? ribY0 : ribY1;
        for (int i = 0; i < 8; i++) {
            float t0=(float)i/8, t1=(float)(i+1)/8, tm=(t0+t1)*0.5f;
            float a0=t0*(float)M_PI, a1=t1*(float)M_PI, am=tm*(float)M_PI;
            float zm=(deckZ+arcZ(am))*0.5f;
            AddBeam3D(m, {arcX(a0),yR,deckZ+4}, {arcX(am),yR,zm}, 1.5f, steelD);
            AddBeam3D(m, {arcX(a1),yR,deckZ+4}, {arcX(am),yR,zm}, 1.5f, steelD);
        }
    }

    // Cross members
    for (int i = 0; i <= 7; i++) {
        float a=(float)i/7*(float)M_PI;
        AddBeam3D(m, {arcX(a),ribY0,arcZ(a)}, {arcX(a),ribY1,arcZ(a)}, 2.5f, steelD);
    }
}

// ── BuildBridgeTruss — Märklin 74620 truss bridge 180mm ──────────────────────
static void BuildBridgeTruss(Mesh& m, float len)
{
    Color3 steel  = {0.55f, 0.58f, 0.62f};
    Color3 steelD = {0.38f, 0.40f, 0.44f};
    Color3 steelL = {0.68f, 0.70f, 0.74f};

    float halfD = 32.0f;
    float wallH = 41.0f;   // 55 * 0.75 = ~41mm
    float deckZ = 0.0f;

    // Use 0..len directly — same space as track extrusion
    float y0 = -(halfD-6.0f), y1 = halfD-6.0f;

    // Deck plate
    {
        float z0=-4.0f, z1=deckZ;
        auto addQ = [&](V3 a, V3 b, V3 c, V3 d2, V3 n) {
            unsigned base=(unsigned)m.verts.size();
            for(auto& v:{a,b,c,d2}){m.verts.push_back(v);m.normals.push_back(n);m.colors.push_back(steelD);}
            m.indices.insert(m.indices.end(),{base,base+1,base+2,base,base+2,base+3});
        };
        addQ({0,y0,z1},{len,y0,z1},{len,y1,z1},{0,y1,z1},{0,0,1});
        addQ({0,y0,z0},{0,y1,z0},{len,y1,z0},{len,y0,z0},{0,0,-1});
        addQ({0,y0,z0},{len,y0,z0},{len,y0,z1},{0,y0,z1},{0,-1,0});
        addQ({0,y1,z0},{0,y1,z1},{len,y1,z1},{len,y1,z0},{0,1,0});
        addQ({0,y0,z0},{0,y0,z1},{0,y1,z1},{0,y1,z0},{-1,0,0});
        addQ({len,y0,z0},{len,y1,z0},{len,y1,z1},{len,y0,z1},{1,0,0});
    }

    // Two truss walls
    float ribY[2] = { y0, y1 };
    for (int s = 0; s < 2; s++) {
        float yR = ribY[s];

        // Top and bottom chords
        AddBeam3D(m, {0,yR,wallH}, {len,yR,wallH}, 3.5f, steelL);
        AddBeam3D(m, {0,yR,deckZ}, {len,yR,deckZ}, 3.5f, steel);

        // Vertical end posts only (no internal ties)
        AddBeam3D(m, {0,  yR,deckZ}, {0,  yR,wallH}, 2.5f, steelD);
        AddBeam3D(m, {len,yR,deckZ}, {len,yR,wallH}, 2.5f, steelD);

        // K-pattern diagonals — 6 panels
        int nP = 6;
        for (int i = 0; i < nP; i++) {
            float x0=(float)i/nP*len;
            float x1=(float)(i+1)/nP*len;
            float xm=(x0+x1)*0.5f, zm=wallH*0.5f;
            AddBeam3D(m, {x0,yR,deckZ+3}, {xm,yR,zm},      1.8f, steelD);
            AddBeam3D(m, {x1,yR,deckZ+3}, {xm,yR,zm},      1.8f, steelD);
            AddBeam3D(m, {xm,yR,zm},      {x0,yR,wallH-3}, 1.8f, steelD);
            AddBeam3D(m, {xm,yR,zm},      {x1,yR,wallH-3}, 1.8f, steelD);
        }
    }

    // No top cross members on 74620 (open top truss)
}

// ── BuildBridgeSupport — oval brick pier between baseZ and topZ ──────────────
// wx,wy = world centre (GL coords); baseZ = bottom of support (level deck);
// topZ = top of support (track elevation); headingRad = piece heading.
static void BuildBridgeSupport(Mesh& m, float wx, float wy,
                                float baseZ, float topZ, float headingRad)
{
    if (topZ - baseZ < 2.0f) return;  // no visible support needed

    Color3 brick  = {0.62f, 0.58f, 0.54f};
    Color3 brickD = {0.50f, 0.46f, 0.42f};
    Color3 cap    = {0.68f, 0.65f, 0.62f};

    float hw  = 18.0f;   // half-length of oval (along bridge axis) — slimmer
    float hd  = 11.0f;   // half-depth of oval (across bridge axis) — slimmer
    int   segs = 16;     // segments for oval

    // Build oval perimeter points (in local space, then rotate by heading)
    // Oval = two semicircles + two straight sides
    std::vector<std::pair<float,float>> pts;
    // Right semicircle (centre at +hw-hd, 0)
    for (int i = 0; i <= segs/2; i++) {
        float a = -(float)M_PI/2 + (float)M_PI * i / (segs/2);
        pts.push_back({ (hw-hd) + hd*std::cos(a), hd*std::sin(a) });
    }
    // Left semicircle (centre at -(hw-hd), 0)
    for (int i = 0; i <= segs/2; i++) {
        float a = (float)M_PI/2 + (float)M_PI * i / (segs/2);
        pts.push_back({ -(hw-hd) + hd*std::cos(a), hd*std::sin(a) });
    }

    float ch = std::cos(headingRad), sh = std::sin(headingRad);
    auto rot = [&](float x, float y) -> std::pair<float,float> {
        return { wx + x*ch - y*sh, wy + x*sh + y*ch };
    };

    int n = (int)pts.size();
    // Outer wall faces — vertical quads around perimeter
    for (int i = 0; i < n; i++) {
        int j = (i+1) % n;
        auto [x0,y0] = rot(pts[i].first, pts[i].second);
        auto [x1,y1] = rot(pts[j].first, pts[j].second);

        // Alternate brick colour for rows
        Color3 c = ((int)(i*3/n) % 2 == 0) ? brick : brickD;

        unsigned base = (unsigned)m.verts.size();
        V3 v0b={x0,y0,baseZ}, v1b={x1,y1,baseZ};
        V3 v0t={x0,y0,topZ},  v1t={x1,y1,topZ};
        // Normal: outward from centre
        float nx = (pts[i].first + pts[j].first) * 0.5f;
        float ny = (pts[i].second + pts[j].second) * 0.5f;
        float nl = std::sqrt(nx*nx+ny*ny); if (nl<0.001f) nl=1;
        nx/=nl; ny/=nl;
        float wnx = nx*ch - ny*sh, wny = nx*sh + ny*ch;
        V3 n3 = {wnx, wny, 0};
        for (auto& v : {v0b,v1b,v1t,v0t}) { m.verts.push_back(v); m.normals.push_back(n3); m.colors.push_back(c); }
        m.indices.insert(m.indices.end(), {base,base+1,base+2, base,base+2,base+3});
    }

    // Top cap — flat polygon
    {
        unsigned base = (unsigned)m.verts.size();
        V3 norm = {0,0,1};
        for (int i = 0; i < n; i++) {
            auto [x,y] = rot(pts[i].first, pts[i].second);
            m.verts.push_back({x,y,topZ}); m.normals.push_back(norm); m.colors.push_back(cap);
        }
        // Fan triangulation from first vertex
        for (int i = 1; i < n-1; i++)
            m.indices.insert(m.indices.end(), {base, base+(unsigned)i, base+(unsigned)i+1});
    }
}

// ── BuildHelixSupport — thin wooden upright for helix perimeter ──────────────
// Much slimmer than a bridge pier. A simple square column rotated to face
// radially outward; cap on top. wx,wy = world centre (GL coords);
// baseZ = bottom (level deck the helix sits on); topZ = top of helix.
static void BuildHelixSupport(Mesh& m, float wx, float wy,
                               float baseZ, float topZ, float headingRad)
{
    if (topZ - baseZ < 2.0f) return;

    Color3 wood  = {0.55f, 0.42f, 0.30f};
    Color3 woodD = {0.45f, 0.34f, 0.24f};

    float hx = 3.5f;   // half-width along radial
    float hy = 3.5f;   // half-width across radial

    float ch = std::cos(headingRad), sh = std::sin(headingRad);
    auto rot = [&](float x, float y) -> std::pair<float,float> {
        return { wx + x*ch - y*sh, wy + x*sh + y*ch };
    };

    // 4 perimeter points (local space, before rotation)
    std::pair<float,float> local[4] = {
        { +hx, +hy }, { +hx, -hy }, { -hx, -hy }, { -hx, +hy }
    };
    std::pair<float,float> world[4];
    for (int i = 0; i < 4; ++i) world[i] = rot(local[i].first, local[i].second);

    // Four side quads
    for (int i = 0; i < 4; ++i) {
        int j = (i + 1) % 4;
        unsigned base = (unsigned)m.verts.size();
        V3 v0b = { world[i].first, world[i].second, baseZ };
        V3 v1b = { world[j].first, world[j].second, baseZ };
        V3 v0t = { world[i].first, world[i].second, topZ };
        V3 v1t = { world[j].first, world[j].second, topZ };
        // Outward normal: midpoint of local edge, rotated
        float mx = (local[i].first  + local[j].first ) * 0.5f;
        float my = (local[i].second + local[j].second) * 0.5f;
        float nl = std::sqrt(mx*mx + my*my); if (nl < 0.001f) nl = 1;
        mx /= nl; my /= nl;
        float wnx = mx*ch - my*sh, wny = mx*sh + my*ch;
        V3 n3 = { wnx, wny, 0 };
        Color3 c = (i % 2 == 0) ? wood : woodD;
        for (auto& v : { v0b, v1b, v1t, v0t }) {
            m.verts.push_back(v); m.normals.push_back(n3); m.colors.push_back(c);
        }
        m.indices.insert(m.indices.end(),
            { base, base+1, base+2, base, base+2, base+3 });
    }

    // Top cap
    {
        unsigned base = (unsigned)m.verts.size();
        V3 norm = { 0, 0, 1 };
        for (int i = 0; i < 4; ++i) {
            m.verts.push_back({ world[i].first, world[i].second, topZ });
            m.normals.push_back(norm);
            m.colors.push_back(wood);
        }
        m.indices.insert(m.indices.end(),
            { base, base+1, base+2, base, base+2, base+3 });
    }
}

// ── BuildBridgeRamp — curved or straight ramp with guardrails ─────────────────
static void BuildBridgeRamp(Mesh& m, const MC::TrackPiece& def)
{
    // Straight ramp: just deck + guardrails along X axis
    if (def.radiusMm <= 0.0) {
        float len   = (float)def.lengthMm;
        float deckW = 26.0f;
        float railH = 8.0f;
        float postT = 1.5f;
        Color3 steelD = {0.42f, 0.44f, 0.48f};
        Color3 steel  = {0.58f, 0.60f, 0.64f};
        Color3 deck   = {0.50f, 0.52f, 0.55f};

        // Deck
        auto addQ = [&](V3 a, V3 b, V3 c, V3 d2, V3 n) {
            unsigned base=(unsigned)m.verts.size();
            for(auto& v:{a,b,c,d2}){m.verts.push_back(v);m.normals.push_back(n);m.colors.push_back(deck);}
            m.indices.insert(m.indices.end(),{base,base+1,base+2,base,base+2,base+3});
        };
        float y0=-deckW, y1=deckW, z0=-3.0f, z1=0.0f;
        addQ({0,y0,z1},{len,y0,z1},{len,y1,z1},{0,y1,z1},{0,0,1});
        addQ({0,y0,z0},{0,y1,z0},{len,y1,z0},{len,y0,z0},{0,0,-1});
        addQ({0,y0,z0},{len,y0,z0},{len,y0,z1},{0,y0,z1},{0,-1,0});
        addQ({0,y1,z0},{0,y1,z1},{len,y1,z1},{len,y1,z0},{0,1,0});
        addQ({0,y0,z0},{0,y0,z1},{0,y1,z1},{0,y1,z0},{-1,0,0});
        addQ({len,y0,z0},{len,y1,z0},{len,y1,z1},{len,y0,z1},{1,0,0});

        // Posts and rails on each side
        int nPosts = 6;
        for (int side = 0; side < 2; side++) {
            float sy = side==0 ? -deckW : deckW;
            for (int i = 0; i <= nPosts; i++) {
                float px = (float)i/nPosts*len;
                float hw = postT;
                auto addP = [&](V3 a, V3 b, V3 c, V3 d2, V3 n) {
                    unsigned base=(unsigned)m.verts.size();
                    for(auto& v:{a,b,c,d2}){m.verts.push_back(v);m.normals.push_back(n);m.colors.push_back(steelD);}
                    m.indices.insert(m.indices.end(),{base,base+1,base+2,base,base+2,base+3});
                };
                addP({px-hw,sy-hw,0},{px+hw,sy-hw,0},{px+hw,sy+hw,0},{px-hw,sy+hw,0},{0,0,-1});
                addP({px-hw,sy-hw,railH},{px-hw,sy+hw,railH},{px+hw,sy+hw,railH},{px+hw,sy-hw,railH},{0,0,1});
                addP({px-hw,sy-hw,0},{px+hw,sy-hw,0},{px+hw,sy-hw,railH},{px-hw,sy-hw,railH},{0,-1,0});
                addP({px-hw,sy+hw,0},{px-hw,sy+hw,railH},{px+hw,sy+hw,railH},{px+hw,sy+hw,0},{0,1,0});
                addP({px-hw,sy-hw,0},{px-hw,sy-hw,railH},{px-hw,sy+hw,railH},{px-hw,sy+hw,0},{-1,0,0});
                addP({px+hw,sy-hw,0},{px+hw,sy+hw,0},{px+hw,sy+hw,railH},{px+hw,sy-hw,railH},{1,0,0});
            }
            // Rails
            for (int i = 0; i < nPosts; i++) {
                float x0p=(float)i/nPosts*len, x1p=(float)(i+1)/nPosts*len;
                for (float rz : {railH, railH*0.5f})
                    AddBeam3D(m, {x0p,sy,rz}, {x1p,sy,rz}, postT*0.7f, steel);
            }
        }
        return;
    }
    Color3 steelD = {0.42f, 0.44f, 0.48f};
    Color3 steel  = {0.58f, 0.60f, 0.64f};
    Color3 deck   = {0.50f, 0.52f, 0.55f};

    float R      = (float)def.radiusMm;
    float angRad = (float)std::abs(def.angleDeg) * (float)M_PI / 180.0f;
    float deckW  = 26.0f;  // match 74620 half-width (halfD-6 = 32-6 = 26)
    float railH  = 8.0f;
    float postT  = 1.5f;
    int   segs   = std::max(12, (int)(std::abs(def.angleDeg) / 2.5f));

    float connY  = def.connections.size() >= 2 ? (float)def.connections[1].pos.y : 1.0f;
    float s      = connY >= 0 ? 1.0f : -1.0f;
    float cy     = s * R;
    float startA = -s * (float)M_PI / 2.0f;
    float sweep  =  s * angRad;

    // Arc centerline points
    struct Pt { float x, y; };
    std::vector<Pt> pts(segs+1);
    for (int i = 0; i <= segs; i++) {
        float a = startA + sweep * ((float)i / segs);
        pts[i] = { R * std::cos(a), cy + R * std::sin(a) };
    }

    // Lateral direction at each point (perpendicular to arc tangent)
    auto lateral = [&](int i) -> std::pair<float,float> {
        int pi = i < segs ? i : i-1;
        int pj = i < segs ? i+1 : i;
        float dx = pts[pj].x - pts[pi].x;
        float dy = pts[pj].y - pts[pi].y;
        float l  = std::sqrt(dx*dx + dy*dy); if (l < 0.001f) l = 1.0f;
        return { -dy/l, dx/l };  // left normal in canvas space
    };

    // Deck slab
    float dz = 3.0f;
    for (int i = 0; i < segs; i++) {
        auto [nx0,ny0] = lateral(i);
        auto [nx1,ny1] = lateral(i+1);
        float lx0=pts[i].x  - nx0*deckW, ly0=pts[i].y  - ny0*deckW;
        float rx0=pts[i].x  + nx0*deckW, ry0=pts[i].y  + ny0*deckW;
        float lx1=pts[i+1].x- nx1*deckW, ly1=pts[i+1].y- ny1*deckW;
        float rx1=pts[i+1].x+ nx1*deckW, ry1=pts[i+1].y+ ny1*deckW;

        auto addQ = [&](V3 a, V3 b, V3 c, V3 d2, V3 n) {
            unsigned base=(unsigned)m.verts.size();
            for(auto& v:{a,b,c,d2}){m.verts.push_back(v);m.normals.push_back(n);m.colors.push_back(deck);}
            m.indices.insert(m.indices.end(),{base,base+1,base+2,base,base+2,base+3});
        };
        addQ({lx0,ly0,0},{rx0,ry0,0},{rx1,ry1,0},{lx1,ly1,0},{0,0,1});
        addQ({lx0,ly0,-dz},{lx1,ly1,-dz},{rx1,ry1,-dz},{rx0,ry0,-dz},{0,0,-1});
        addQ({lx0,ly0,-dz},{lx0,ly0,0},{lx1,ly1,0},{lx1,ly1,-dz},{-nx0,-ny0,0});
        addQ({rx0,ry0,0},{rx0,ry0,-dz},{rx1,ry1,-dz},{rx1,ry1,0},{nx0,ny0,0});
    }

    // Guardrails: simple vertical posts + horizontal rails on each side
    for (int side = 0; side < 2; side++) {
        float sg = side == 0 ? -1.0f : 1.0f;

        // Posts — one per segment endpoint
        for (int i = 0; i <= segs; i++) {
            auto [nx,ny] = lateral(i);
            float px = pts[i].x + sg*nx*deckW;
            float py = pts[i].y + sg*ny*deckW;
            float hw = postT;
            float z0 = 0.0f, z1 = railH;

            auto addPost = [&](V3 a, V3 b, V3 c, V3 d2, V3 n) {
                unsigned base=(unsigned)m.verts.size();
                for(auto& v:{a,b,c,d2}){m.verts.push_back(v);m.normals.push_back(n);m.colors.push_back(steelD);}
                m.indices.insert(m.indices.end(),{base,base+1,base+2,base,base+2,base+3});
            };
            addPost({px-hw,py-hw,z0},{px+hw,py-hw,z0},{px+hw,py+hw,z0},{px-hw,py+hw,z0},{0,0,-1});
            addPost({px-hw,py-hw,z1},{px-hw,py+hw,z1},{px+hw,py+hw,z1},{px+hw,py-hw,z1},{0,0,1});
            addPost({px-hw,py-hw,z0},{px-hw,py-hw,z1},{px+hw,py-hw,z1},{px+hw,py-hw,z0},{0,-1,0});
            addPost({px-hw,py+hw,z0},{px+hw,py+hw,z0},{px+hw,py+hw,z1},{px-hw,py+hw,z1},{0,1,0});
            addPost({px-hw,py-hw,z0},{px-hw,py+hw,z0},{px-hw,py+hw,z1},{px-hw,py-hw,z1},{-1,0,0});
            addPost({px+hw,py-hw,z0},{px+hw,py-hw,z1},{px+hw,py+hw,z1},{px+hw,py+hw,z0},{1,0,0});
        }

        // Horizontal rails connecting post tops
        for (int i = 0; i < segs; i++) {
            auto [nx0,ny0] = lateral(i);
            auto [nx1,ny1] = lateral(i+1);
            float px0=pts[i].x  +sg*nx0*deckW, py0=pts[i].y  +sg*ny0*deckW;
            float px1=pts[i+1].x+sg*nx1*deckW, py1=pts[i+1].y+sg*ny1*deckW;
            // Top rail and mid rail
            for (float rz : {railH, railH*0.5f})
                AddBeam3D(m, {px0,py0,rz}, {px1,py1,rz}, postT*0.7f, steel);
        }
    }
}

static void AddCone(Mesh& m, float cx, float cy, float z, float r, float h, Color3 col, int segs=12)
{
    V3 apex = {cx, cy, z+h};
    for (int i = 0; i < segs; ++i) {
        float a0 = 2*M_PI* i    /segs;
        float a1 = 2*M_PI*(i+1)/segs;
        V3 b0 = {cx+r*std::cos(a0), cy+r*std::sin(a0), z};
        V3 b1 = {cx+r*std::cos(a1), cy+r*std::sin(a1), z};
        V3 n  = v3norm({std::cos((a0+a1)/2), std::sin((a0+a1)/2), r/h});
        unsigned base = (unsigned)m.verts.size();
        for (auto& v : {b0, b1, apex}) { m.verts.push_back(v); m.normals.push_back(n); m.colors.push_back(col); }
        m.indices.insert(m.indices.end(),{base,base+1,base+2});
    }
    // Base disc
    V3 cen = {cx,cy,z};
    V3 dn  = {0,0,-1};
    for (int i = 0; i < segs; ++i) {
        float a0=2*M_PI*i/segs, a1=2*M_PI*(i+1)/segs;
        V3 b0={cx+r*std::cos(a0),cy+r*std::sin(a0),z};
        V3 b1={cx+r*std::cos(a1),cy+r*std::sin(a1),z};
        unsigned base=(unsigned)m.verts.size();
        for(auto& v:{cen,b1,b0}){m.verts.push_back(v);m.normals.push_back(dn);m.colors.push_back(col);}
        m.indices.insert(m.indices.end(),{base,base+1,base+2});
    }
}

// Helper: add a sphere approximation (for deciduous tree canopy)
static void AddSphere(Mesh& m, float cx, float cy, float cz, float r, Color3 col, int segs=10)
{
    for (int lat = 0; lat < segs; ++lat) {
        float a0 = M_PI * lat     / segs - M_PI/2;
        float a1 = M_PI * (lat+1) / segs - M_PI/2;
        for (int lon = 0; lon < segs*2; ++lon) {
            float b0 = 2*M_PI* lon    /(segs*2);
            float b1 = 2*M_PI*(lon+1) /(segs*2);
            auto pt = [&](float a, float b) -> V3 {
                return {cx+r*std::cos(a)*std::cos(b),
                        cy+r*std::cos(a)*std::sin(b),
                        cz+r*std::sin(a)};
            };
            auto nm = [&](float a, float b) -> V3 {
                return {std::cos(a)*std::cos(b), std::cos(a)*std::sin(b), std::sin(a)};
            };
            V3 p00=pt(a0,b0), p10=pt(a1,b0), p11=pt(a1,b1), p01=pt(a0,b1);
            V3 n00=nm(a0,b0), n10=nm(a1,b0), n11=nm(a1,b1), n01=nm(a0,b1);
            unsigned base=(unsigned)m.verts.size();
            for(auto& pv:{p00,p10,p11,p01}){m.verts.push_back(pv);m.colors.push_back(col);}
            m.normals.push_back(n00);m.normals.push_back(n10);
            m.normals.push_back(n11);m.normals.push_back(n01);
            m.indices.insert(m.indices.end(),{base,base+1,base+2, base,base+2,base+3});
        }
    }
}

// ── BuildBumperMesh — Märklin 24977/24978 buffer stop ────────────────────────
// Piece runs from x=0 (entry/track side) to x=len (buffer side)
// All coords in piece-local space: X=along track, Y=lateral, Z=up
// Using AddBox3D with W=len, H=hw*2 so CX/CY centre correctly
static PieceMesh BuildBumperMesh(const MC::TrackPiece& def)
{
    PieceMesh pm;
    Mesh& m = pm.mesh;
    float len = (float)def.lengthMm;
    float hw  = (def.series == MC::TrackSeries::MarlinZ) ? 6.0f : 18.7f;
    float rg  = hw * 0.44f;
    // Use len as W and hw*2 as H for CX/CY so piece is centred on track axis
    float W = len, H = hw * 2.0f;

    Color3 white  = {0.92f, 0.92f, 0.92f};
    Color3 whiteD = {0.72f, 0.72f, 0.72f};
    Color3 red    = {0.82f, 0.08f, 0.08f};
    Color3 redD   = {0.62f, 0.06f, 0.06f};
    Color3 black  = {0.12f, 0.12f, 0.12f};
    Color3 blackD = {0.07f, 0.07f, 0.07f};
    Color3 grey   = {0.50f, 0.50f, 0.50f};
    Color3 earth  = {0.38f, 0.28f, 0.18f};
    Color3 earthD = {0.28f, 0.20f, 0.12f};

    // Track bed
    ExtrudeTrack(pm.mesh, StraightFrames(0, 0, len, 0, 2));

    // Buffer beam is at EXIT end (x near len), facing back toward entry
    // In local coords: x=0 is entry, x=len is exit (buffer end)
    float bx  = len * 0.75f;  // beam position along track
    float bH  = hw * 0.50f;   // beam centre height
    float bW  = H  * 1.05f;   // beam spans full track width
    float bT  = hw * 0.14f;   // beam thickness in X
    float bHt = hw * 0.38f;   // beam height in Z
    float yOff = (H - bW) * 0.5f;  // y offset to centre bW within H

    // White buffer beam
    AddBox3D(m, bx-bT*0.5f, yOff, bH-bHt*0.5f, bT, bW, bHt, white, whiteD, W, H);

    // Red center block on the entry-facing side of beam
    float rW=hw*0.50f, rH=hw*0.40f, rT=hw*0.20f;
    float rY = (H-rW)*0.5f;
    AddBox3D(m, bx-bT*0.5f-rT, rY, bH-rH*0.5f, rT, rW, rH, red, redD, W, H);

    // Buffer rods projecting toward entry (negative X direction from beam)
    float rodL=hw*0.55f, rodR=hw*0.08f;
    float rodY1 = (H*0.5f - rg) / H * H - rodR;  // left rod Y in local
    float rodY2 = (H*0.5f + rg) / H * H - rodR;  // right rod Y in local
    // Simplify: use direct Y offsets centered on H/2
    for (int s = -1; s <= 1; s += 2) {
        float ry = H*0.5f + s*rg - rodR;
        AddBox3D(m, bx-bT*0.5f-rodL, ry, bH-rodR, rodL, rodR*2, rodR*2, grey, black, W, H);
        AddBox3D(m, bx-bT*0.5f-rodL-rodR*2.5f, ry-rodR*0.8f, bH-rodR*1.8f,
                 rodR*3.5f, rodR*3.5f, rodR*3.5f, black, blackD, W, H);
    }

    // A-frame support
    float legT=hw*0.11f, aX=bx-hw*0.55f;
    AddBox3D(m, aX, yOff, 0, hw*0.45f, legT, bH*0.88f, black, blackD, W, H);
    AddBox3D(m, aX, yOff+bW-legT, 0, hw*0.45f, legT, bH*0.88f, black, blackD, W, H);
    AddBox3D(m, aX+hw*0.1f, yOff+legT, bH*0.40f, hw*0.18f, bW-legT*2, legT, black, blackD, W, H);

    // Earth mound behind buffer beam (exit side)
    float mX = bx + bT*0.5f;
    float mW = len - mX;
    if (mW > 1.0f) {
        float mYoff = (H - H*0.85f)*0.5f;
        AddBox3D(m, mX, mYoff, 0, mW, H*0.85f, hw*0.40f, earth, earthD, W, H);
    }

    return pm;
}

static PieceMesh BuildSceneryMesh(const MC::SceneryItem& def)
{
    PieceMesh pm;
    Mesh& m = pm.mesh;
    float W = (float)def.widthMm;
    float D = (float)def.heightMm;  // depth in 2D = Y axis

    // Convenience wrapper: captures W,D so call sites don't need to pass them
    auto AB = [&](float x, float y, float z, float w, float d, float h,
                  Color3 wall, Color3 roof) {
        AddBox3D(m, x, y, z, w, d, h, wall, roof, W, D);
    };

    // Also need centred cone/sphere helpers that account for CX/CY
    // For cones and spheres, convert centre from 2D-local to 3D-centred:
    auto cx3 = [&](float lx) { return CX(lx, W); };
    auto cy3 = [&](float ly) { return CY(ly, D); };

    // ── Shared roof helpers (used by station_large, station_small, etc) ───
    // Gable roof: ridge runs along X (parallel to long building axis).
    auto addGable = [&](float x0, float y0, float w, float d,
                        float zBase, float h, Color3 col, Color3 colShade)
    {
        float gx0 = cx3(x0),       gx1 = cx3(x0 + w);
        float gyA = cy3(y0),       gyB = cy3(y0 + d);
        float gyM = cy3(y0 + d * 0.5f);
        float zTop = zBase + h;
        V3 a{gx0, gyA, zBase}, b{gx1, gyA, zBase};
        V3 c{gx1, gyM, zTop},  dd{gx0, gyM, zTop};
        AddQuad(m, a, b, c, dd, col, V3{0, (gyM-gyA) > 0 ? -0.6f : 0.6f, 0.8f});
        V3 e{gx0, gyB, zBase}, f{gx1, gyB, zBase};
        V3 g{gx1, gyM, zTop},  hh{gx0, gyM, zTop};
        AddQuad(m, f, e, hh, g, colShade, V3{0, (gyM-gyB) > 0 ? -0.6f : 0.6f, 0.8f});
        V3 wA{gx0, gyA, zBase}, wB{gx0, gyB, zBase}, wApex{gx0, gyM, zTop};
        AddQuad(m, wA, wB, wApex, wApex, colShade, V3{-1, 0, 0});
        V3 eA{gx1, gyB, zBase}, eB{gx1, gyA, zBase}, eApex{gx1, gyM, zTop};
        AddQuad(m, eA, eB, eApex, eApex, colShade, V3{1, 0, 0});
    };

    // Gable roof with ridge running along Y (perpendicular to long axis -- used
    // for buildings whose short side faces the track).
    auto addGableY = [&](float x0, float y0, float w, float d,
                         float zBase, float h, Color3 col, Color3 colShade)
    {
        float gx0 = cx3(x0),       gx1 = cx3(x0 + w);
        float gxM = cx3(x0 + w * 0.5f);
        float gyA = cy3(y0),       gyB = cy3(y0 + d);
        float zTop = zBase + h;
        // West slope
        V3 a{gx0, gyA, zBase}, b{gx0, gyB, zBase};
        V3 c{gxM, gyB, zTop},  dd{gxM, gyA, zTop};
        AddQuad(m, a, b, c, dd, col, V3{-0.6f, 0, 0.8f});
        // East slope
        V3 e{gx1, gyA, zBase}, f{gx1, gyB, zBase};
        V3 g{gxM, gyB, zTop},  hh{gxM, gyA, zTop};
        AddQuad(m, f, e, hh, g, colShade, V3{0.6f, 0, 0.8f});
        // North/South triangular gable ends
        V3 nA{gx0, gyA, zBase}, nB{gx1, gyA, zBase}, nApex{gxM, gyA, zTop};
        AddQuad(m, nB, nA, nApex, nApex, colShade, V3{0, gyA-gyB>0?1.0f:-1.0f, 0});
        V3 sA{gx1, gyB, zBase}, sB{gx0, gyB, zBase}, sApex{gxM, gyB, zTop};
        AddQuad(m, sB, sA, sApex, sApex, colShade, V3{0, gyB-gyA>0?1.0f:-1.0f, 0});
    };

    // Hip roof: 4 inset slopes + flat top deck.
    auto addHip = [&](float x0, float y0, float w, float d,
                      float zBase, float h, Color3 col, Color3 colShade)
    {
        float ix = std::min(w, d) * 0.20f;
        float gx0 = cx3(x0),       gx1 = cx3(x0 + w);
        float gtx0 = cx3(x0 + ix), gtx1 = cx3(x0 + w - ix);
        float gyA = cy3(y0),       gyB = cy3(y0 + d);
        float gtyA = cy3(y0 + ix), gtyB = cy3(y0 + d - ix);
        float zTop = zBase + h;
        V3 tnw{gtx0, gtyA, zTop}, tne{gtx1, gtyA, zTop};
        V3 tse{gtx1, gtyB, zTop}, tsw{gtx0, gtyB, zTop};
        V3 bnw{gx0,  gyA,  zBase},bne{gx1,  gyA,  zBase};
        V3 bse{gx1,  gyB,  zBase},bsw{gx0,  gyB,  zBase};
        AddQuad(m, bnw, bne, tne, tnw, col,      V3{0, gtyA>gyA?-0.6f:0.6f, 0.4f});
        AddQuad(m, bne, bse, tse, tne, colShade, V3{1, 0, 0.4f});
        AddQuad(m, bse, bsw, tsw, tse, colShade, V3{0, gtyB>gyB?-0.6f:0.6f, 0.4f});
        AddQuad(m, bsw, bnw, tnw, tsw, col,      V3{-1, 0, 0.4f});
        AddQuad(m, tnw, tne, tse, tsw, col,      V3{0, 0, 1});
    };

    // 4-sided spire (square pyramid).
    auto addSpire = [&](float x0, float y0, float w, float d,
                        float zBase, float h, Color3 col)
    {
        float gx0 = cx3(x0),       gx1 = cx3(x0 + w);
        float gyA = cy3(y0),       gyB = cy3(y0 + d);
        float ax  = cx3(x0 + w * 0.5f);
        float ay  = cy3(y0 + d * 0.5f);
        V3 a{gx0, gyA, zBase}, b{gx1, gyA, zBase};
        V3 c{gx1, gyB, zBase}, dd{gx0, gyB, zBase};
        V3 apex{ax, ay, zBase + h};
        AddQuad(m, a, b, apex, apex, col, V3{0,-1, 0.5f});
        AddQuad(m, b, c, apex, apex, col, V3{1, 0, 0.5f});
        AddQuad(m, c, dd, apex, apex, col, V3{0, 1, 0.5f});
        AddQuad(m, dd, a, apex, apex, col, V3{-1, 0, 0.5f});
    };

    // Colors
    Color3 cPlatform  = {0.72f,0.70f,0.66f};
    Color3 cPlatEdge  = {0.55f,0.53f,0.50f};
    Color3 cPlatYell  = {0.86f,0.78f,0.38f};
    Color3 cWall      = {0.86f,0.78f,0.66f};
    Color3 cRoof      = {0.55f,0.32f,0.24f};
    Color3 cRoofDark  = {0.40f,0.22f,0.14f};
    Color3 cConcrete  = {0.66f,0.64f,0.62f};
    Color3 cConcreteTop = {0.72f,0.70f,0.67f};
    Color3 cRoad      = {0.38f,0.38f,0.38f};
    Color3 cGrass     = {0.38f,0.62f,0.24f};
    Color3 cTrunk     = {0.40f,0.28f,0.16f};
    Color3 cLeafGreen = {0.24f,0.52f,0.20f};
    Color3 cLeafDark  = {0.18f,0.40f,0.16f};
    Color3 cConifer   = {0.12f,0.36f,0.18f};
    Color3 cMetal     = {0.56f,0.58f,0.62f};
    Color3 cCarRed    = {0.78f,0.20f,0.16f};
    Color3 cCarGray   = {0.62f,0.62f,0.62f};
    Color3 cWood      = {0.58f,0.42f,0.24f};
    Color3 cGround    = {0.52f,0.48f,0.42f};
    Color3 cBrick     = {0.62f,0.30f,0.30f};   // roundhouse: pink/red brick

    const std::string& id = def.id;

    // ── Tunnel portal ─────────────────────────────────────────────────────
    // 2D footprint: W=40 (depth perpendicular to track, X), D=80 (across track, Y).
    // The horseshoe opening faces +X (arch's "mouth" looks outward away from
    // the covered chain). In 3D we build a stone facade with:
    //   * two side pillars (masonry on either side of the opening)
    //   * an arched crown traced as a fan of voussoir-like wedges
    //   * a low parapet topping the masonry
    if (id == "tunnel_portal_single") {
        Color3 cStoneA{0.55f, 0.51f, 0.46f};
        Color3 cStoneB{0.40f, 0.37f, 0.33f};
        Color3 cKey   {0.62f, 0.57f, 0.50f};
        float depth   = W;          // along track, X
        float halfD   = D * 0.5f;
        float openR   = D * 0.32f;  // opening radius
        float pillarH = D * 0.55f;
        float crownTop= D * 0.75f;
        float pillarW = halfD - openR;

        // Left pillar (Y from 0 to pillarW)
        AB(0, 0,           0, depth, pillarW, crownTop, cStoneA, cStoneB);
        // Right pillar (Y from D-pillarW to D)
        AB(0, D - pillarW, 0, depth, pillarW, crownTop, cStoneA, cStoneB);
        // Arch crown: a ring of wedges spanning the top of the opening.
        // Each wedge is a thin slab oriented radially around the opening's
        // center axis (which runs along X at Y=halfD, Z=pillarH).
        const int   wedges = 12;
        const float wedgeT = depth;       // wedges go full depth
        const float wedgeW = (float)(M_PI * openR / wedges) * 1.05f;  // arc width
        const float wedgeR = openR;
        for (int i = 0; i < wedges; ++i) {
            float t  = (i + 0.5f) / wedges;       // 0..1
            float theta = (float)M_PI * t;        // 0 at right (Y=D), pi at left (Y=0)
            // Wedge centre point on the arch
            float ycm = halfD + std::cos(theta) * wedgeR;
            float zcm = pillarH + std::sin(theta) * wedgeR;
            // Wedge thickness (radial) -- small block extending outward
            float radH = 8.0f;
            // Place a small box at the wedge centre, aligned roughly with the
            // arch tangent. Approximation: we simply place an axis-aligned box
            // at each (ycm, zcm), slightly oversized to fill gaps.
            float yLo = ycm - wedgeW * 0.5f * std::sin(theta);
            float zLo = zcm - wedgeW * 0.5f * std::cos(theta);
            // Alternate color for the voussoir pattern
            Color3 col = (i & 1) ? cStoneA : cStoneB;
            AB(0, yLo - 1, zLo, wedgeT, wedgeW + 2, radH, col, col);
        }
        // Keystone in the centre top
        AB(0, halfD - 5, crownTop - 4, depth, 10, 6, cKey, cKey);
    }
    // ── Platforms ─────────────────────────────────────────────────────────
    else if (id == "platform") {
        float h = 12.0f; // 12mm = ~1m platform height in HO
        AB( 0, 0, 0, W, D, h, cPlatEdge, cPlatform);
        // Yellow safety line: painted ON the top surface (not a barrier).
        // Tiny vertical thickness (0.1 mm) lifts it just above the deck so
        // depth-buffer fighting doesn't cause Z-flicker with the platform top.
        AB( 0, D-8.0f, h+0.05f, W, 8.0f, 0.1f, cPlatYell, cPlatYell);
    }
    else if (id == "platform_island") {
        float h = 12.0f;
        AB( 0, 0, 0, W, D, h, cPlatEdge, cPlatform);
        // Yellow safety lines both long sides, painted on (not raised).
        AB( 0, 0,      h+0.05f, W, 8.0f, 0.1f, cPlatYell, cPlatYell);
        AB( 0, D-8.0f, h+0.05f, W, 8.0f, 0.1f, cPlatYell, cPlatYell);
        // Simple shelter posts
        for (float x : {W*0.25f, W*0.5f, W*0.75f}) {
            AB( x-1.5f, D*0.3f, h, 3, D*0.4f, 28, cMetal, cMetal);
        }
        // Shelter roof strip
        AB( W*0.15f, D*0.28f, h+28, W*0.7f, D*0.44f, 3, cMetal, {0.70f,0.72f,0.76f});
    }

    // ── Buildings ─────────────────────────────────────────────────────────
    else if (id == "station_small") {
        // Güglingen-style small station: 308 x 140 x ~142mm.
        // Two-storey main house with steep cross-gable, plus left canopy
        // and right freight wing. Ridge of main hall runs along X.

        Color3 cStuc      {0.92f, 0.84f, 0.69f};
        Color3 cStucDark  {0.86f, 0.76f, 0.60f};
        Color3 cRoofTerra {0.59f, 0.27f, 0.22f};
        Color3 cRoofShade {0.43f, 0.20f, 0.16f};
        Color3 cRoofSlate {0.43f, 0.41f, 0.43f};
        Color3 cBeam      {0.37f, 0.25f, 0.16f};
        Color3 cShutter   {0.43f, 0.51f, 0.57f};
        Color3 cWin       {0.50f, 0.65f, 0.78f};
        Color3 cChim      {0.42f, 0.32f, 0.28f};
        Color3 cSign      {0.93f, 0.90f, 0.82f};

        // --- Platform shelter (X=0..70) ---
        // Deck (very thin)
        AB(0, 90, 0, 70, 50, 2, cStucDark, cStucDark);
        // 3 support posts
        AB( 6, 92, 2, 4, 4, 26, cBeam, cBeam);
        AB(31, 92, 2, 4, 4, 26, cBeam, cBeam);
        AB(56, 92, 2, 4, 4, 26, cBeam, cBeam);
        // Slate roof over canopy (light gable)
        addGable(0, 70, 70, 70, 28, 6, cRoofSlate, cRoofShade);

        // --- Main building body (X=70..195, Y=20..140) ---
        // 2 storeys = ~60mm tall
        AB(70, 20, 0, 125, 120, 60, cStuc, cStuc);
        // Main gable roof (ridge along X, steep -- 40mm above wall top)
        addGable(70, 20, 125, 120, 60, 40, cRoofTerra, cRoofShade);
        // Front cross-gable: a smaller gable jutting forward (south) from the
        // main roof. Sits at the front half (Y=80..140) and pokes up between
        // the upper floor and the main ridge.
        AB(105, 80, 0, 55, 60, 60, cStuc, cStuc);  // body of the front wing
        addGable(105, 80, 55, 60, 60, 32, cRoofTerra, cRoofShade);
        // Two chimneys on the main ridge
        AB(108, 76, 60, 7, 8, 22, cChim, cChim);
        AB(153, 76, 60, 7, 8, 22, cChim, cChim);
        // Upper-floor windows (3 across) on trackside (y=20 face)
        for (float x = 80; x < 195; x += 38) {
            // Shutters (raised beam strips)
            AB(x - 5,  18, 28, 5, 1, 26, cShutter, cShutter);
            AB(x + 18, 18, 28, 5, 1, 26, cShutter, cShutter);
            // Window
            AB(x, 18.5f, 28, 18, 1, 26, cWin, cWin);
        }
        // Window in the cross-gable peak (front face y=140)
        AB(120, 138.5f, 36, 22, 1, 16, cWin, cWin);
        // Station sign band centered on trackside
        AB(108, 18, 22, 50, 1, 10, cSign, cSign);
        AB(112, 17.5f, 22.5f, 42, 1, 4, cBeam, cBeam);  // dark band for "text"
        // Ground floor: 3 doors with arched-look (boxes with brown caps)
        for (int i = 0; i < 3; ++i) {
            float x = 78 + i * 38.0f;
            // Lintel/window above door (1st floor base band)
            AB(x,    18, 0, 28, 1, 18, cWin, cWin);
            // Door itself
            AB(x+ 4, 18, 0, 20, 1, 22, cBeam, cBeam);
        }

        // Opposite face (y=140) of main body: matching upper-floor windows
        // with shutters. The cross-gable juts forward over this face from
        // x=105..160, so put upper windows in the side strips x=80..105 and
        // x=160..195 (which are clear of the cross-gable bulge).
        for (float x : {80.0f, 165.0f}) {
            AB(x - 5,  140, 28, 5, 1, 26, cShutter, cShutter);
            AB(x + 18, 140, 28, 5, 1, 26, cShutter, cShutter);
            AB(x,      140, 28, 18, 1, 26, cWin, cWin);
        }
        // Ground-floor windows mirrored on the y=140 face (similar to door
        // lintels but as windows on the rear).
        for (int i = 0; i < 3; ++i) {
            float x = 78 + i * 38.0f;
            AB(x + 4, 140, 4, 20, 1, 22, cWin, cWin);
        }

        // --- Right freight wing (X=195..308, Y=60..140) ---
        // 1 storey (38mm tall)
        AB(195, 60, 0, 113, 80, 38, cStucDark, cStucDark);
        // Slate gable roof, ridge along X
        addGable(195, 60, 113, 80, 38, 16, cRoofSlate, cRoofShade);
        // Chimney on freight wing
        AB(283, 100, 38, 6, 6, 18, cChim, cChim);
        // Two trackside windows
        AB(212, 58.5f, 8, 22, 1, 22, cWin, cWin);
        AB(260, 58.5f, 8, 22, 1, 22, cWin, cWin);
        // Centered loading door
        AB(241, 58, 0, 20, 1, 30, cBeam, cBeam);
        // Mirrored windows + door on opposite face (y=140) of freight wing
        AB(212, 140, 8, 22, 1, 22, cWin, cWin);
        AB(260, 140, 8, 22, 1, 22, cWin, cWin);
        AB(241, 140, 0, 20, 1, 30, cBeam, cBeam);
        // Small skirt in front of freight wing (loading platform)
        AB(195, 137, 0, 113, 4, 6, cStucDark, cStucDark);
    }
    else if (id == "station_large") {
        // Schwarzburg-style station: 550 x 185 x ~230mm.
        // Approach: real pitched roof using AddQuad (not flat-topped boxes)
        // for the main hall and tower spire. Side wings keep simple hip-style.
        //
        // Coordinate system: x along width, y along depth (track side at y=0),
        // z is vertical. CX/CY in BuildSceneryMesh shift everything so the
        // origin is at the center, so we draw in [0..W]x[0..D] here.

        // Color palette (reused locally to mirror the 2D look)
        Color3 cStation   {0.84f, 0.79f, 0.66f};   // cream half-timber infill
        Color3 cStone     {0.74f, 0.70f, 0.62f};   // weathered stone (annex/tower)
        Color3 cTowerBase {0.70f, 0.66f, 0.58f};
        Color3 cMainRoof  {0.55f, 0.27f, 0.22f};   // weathered terracotta
        Color3 cRoofShade {0.40f, 0.20f, 0.16f};
        Color3 cSpire     {0.32f, 0.17f, 0.13f};
        Color3 cBeam      {0.35f, 0.23f, 0.15f};   // dark wooden beam
        Color3 cWindow    {0.50f, 0.65f, 0.78f};
        Color3 cChimney   {0.42f, 0.32f, 0.28f};
        Color3 cClockFace {0.94f, 0.92f, 0.85f};
        Color3 cDoor      {0.42f, 0.28f, 0.18f};
        Color3 cSign      {0.93f, 0.90f, 0.82f};

        // ============ Build the station ============

        // --- Left annex (X=0..130) - 1.5 storeys with hip roof ---
        AB( 0, 70, 0, 130, 115, 32, cTowerBase, cStone);
        addHip(0, 70, 130, 115, 32, 18, cMainRoof, cRoofShade);
        // Annex small windows along front
        for (float x = 10; x < 120; x += 22) {
            AB( x, 68.5f, 8, 12, 1, 14, cWindow, cWindow);
        }
        // Mirrored windows on opposite (street-side) face of annex
        for (float x = 10; x < 120; x += 22) {
            AB( x, 185, 8, 12, 1, 14, cWindow, cWindow);
        }

        // --- Clock tower (X=110..170) - much taller, square footprint ---
        AB( 110, 40, 0, 60, 120, 56, cTowerBase, cStone);
        // Tower mid section narrower stone band
        AB( 113, 43, 56, 54, 114, 4, cStone, cStone);
        // Spire on tower (steep pyramid)
        addSpire(110, 40, 60, 120, 60, 50, cSpire);
        // Tower clock face on south side
        AB( 120, 38, 38, 36, 4, 12, cClockFace, cClockFace);
        // Tower narrow slit windows
        AB( 124, 38, 14, 6, 1, 16, cWindow, cWindow);
        AB( 146, 38, 14, 6, 1, 16, cWindow, cWindow);

        // --- Main hall (X=170..400) - tall, real gable roof ---
        // Body 2 storeys = ~48mm
        AB( 170, 20, 0, 230, 165, 48, cStation, cStation);
        // Gable roof rising 40mm above wall top
        addGable(170, 20, 230, 165, 48, 40, cMainRoof, cRoofShade);
        // Chimneys poking above ridge
        AB( 250, 80, 48, 8, 10, 28, cChimney, cChimney);
        AB( 345, 80, 48, 8, 10, 28, cChimney, cChimney);
        // Two dormer windows on the south (trackside) slope
        AB( 220, 16, 56, 22, 14, 10, cStation, cStation);
        AB( 220, 16, 70, 22, 14, 4,  cMainRoof, cRoofShade);
        AB( 308, 16, 56, 22, 14, 10, cStation, cStation);
        AB( 308, 16, 70, 22, 14, 4,  cMainRoof, cRoofShade);
        // Half-timber framework (vertical beams on trackside facade y=20)
        for (float x = 178; x < 400; x += 28) {
            AB( x, 19.5f, 0, 2, 1, 48, cBeam, cBeam);
        }
        // Horizontal cross-beam (1st floor band)
        AB( 170, 19.5f, 22, 230, 1, 2, cBeam, cBeam);
        // Windows: 5 upper-floor windows on trackside
        for (float x = 185; x < 395; x += 42) {
            AB( x, 18.5f, 28, 26, 1, 16, cWindow, cWindow);
        }
        // Ground-floor windows + center door + windows
        for (float x = 185; x < 280; x += 35) {
            AB( x, 18.5f, 4, 22, 1, 22, cWindow, cWindow);
        }
        AB( 282, 18, 0, 35, 1, 28, cDoor, cDoor);
        // Door awning
        AB( 275, 16, 28, 50, 6, 2, cRoofShade, cRoofShade);
        for (float x = 322; x < 395; x += 35) {
            AB( x, 18.5f, 4, 22, 1, 22, cWindow, cWindow);
        }

        // Opposite (street-side) facade windows on main hall (y=185 face)
        // Half-timber beams mirrored
        for (float x = 178; x < 400; x += 28) {
            AB( x, 185, 0, 2, 1, 48, cBeam, cBeam);
        }
        AB( 170, 185, 22, 230, 1, 2, cBeam, cBeam);
        // 5 upper-floor windows
        for (float x = 185; x < 395; x += 42) {
            AB( x, 185.5f, 28, 26, 1, 16, cWindow, cWindow);
        }
        // Ground-floor: 6 windows (no central door on the street side)
        for (float x = 185; x < 395; x += 35) {
            AB( x, 185.5f, 4, 22, 1, 22, cWindow, cWindow);
        }

        // --- Right wing: covered platform shed (X=400..550) ---
        // Office body at the right end (490..550)
        AB( 490, 60, 0, 60, 125, 36, cTowerBase, cStone);
        // Roof over office (hip)
        addHip(490, 60, 60, 125, 36, 14, cMainRoof, cRoofShade);
        // Covered colonnade (400..490) - open structure, just a roof on columns
        // Columns
        for (float x = 408; x < 488; x += 14) {
            AB( x, 105, 0, 4, 4, 28, cBeam, cBeam);
        }
        // Roof over the colonnade (low gable, deck at z=28)
        addGable(400, 70, 90, 115, 28, 10, cMainRoof, cRoofShade);
        // "Schwarzburg" sign plate hanging from the colonnade edge
        AB( 408, 67, 22, 76, 3, 8, cSign, cSign);
        // Sign text simulated as dark band (no font support in mesh)
        AB( 412, 66.5f, 24, 68, 1, 4, cBeam, cBeam);
        // Office windows on north (street) face
        AB( 500, 184.5f, 8, 18, 1, 18, cWindow, cWindow);
        AB( 525, 184.5f, 8, 18, 1, 18, cWindow, cWindow);
        // Mirrored office windows on opposite (track-side) face
        AB( 500, 59, 8, 18, 1, 18, cWindow, cWindow);
        AB( 525, 59, 8, 18, 1, 18, cWindow, cWindow);
        // Right-end chimney
        AB( 520, 100, 36, 8, 10, 22, cChimney, cChimney);
    }
    else if (id == "signal_box") {
        // Ground floor
        AB( 5,0,0, W-10,D, 18, cWall, cRoof);
        // Upper cab (wider)
        AB( 0,0,18, W,D, 16, {0.76f,0.74f,0.70f}, cRoof);
    }
    else if (id == "shed_1road") {
        AB( 0,0,0, W,D, 38, cConcrete, cConcrete);
        AB( 0,0,38, W,D, 8, cRoofDark, cRoofDark);
    }
    else if (id == "shed_2road") {
        AB( 0,0,0, W,D, 38, cConcrete, cConcrete);
        AB( 0,0,38, W,D, 8, cRoofDark, cRoofDark);
    }
    else if (id == "72886") {
        // Three rectangular stalls, each rotated to its 12° spoke from a
        // common apex (the turntable hub). Each stall is extruded as a
        // box; the apex is OUTSIDE the bbox so the stall coords need to
        // be mapped from body frame → bbox-local → 3D.
        const float R_front = 368.0f;
        const float R_back  = 693.0f;
        const float w_s     = 100.0f;
        const float wallH   = 92.0f;
        const float roofH   = 23.0f;
        // Apex is at (W/2, apexLocalY) in bbox-local 2D coords (with
        // y measured from the bbox's top edge in canvas y = bbox-local 0).
        // From the catalog: apexY (bbox-local) = -349.6.
        const float apexLocalY = -349.6f;
        const float cx2D       = W * 0.5f;

        Color3 cBrickShade { cBrick.r * 0.78f, cBrick.g * 0.78f, cBrick.b * 0.78f };

        struct Pt2 { float x, y; };
        // bbox-local 2D point for a stall body coord (bx along stall,
        // by perpendicular) rotated by stall angle theta.
        auto bodyToLocal2D = [&](float bx, float by, float thetaDeg) -> Pt2 {
            float t = thetaDeg * (float)M_PI / 180.0f;
            float s = std::sin(t), c = std::cos(t);
            float wx = bx * s + by * c;
            float wy = bx * c - by * s;
            return Pt2{ cx2D + wx, apexLocalY + wy };
        };

        // Each stall: 4 wall quads + top + bottom + roof slab.
        for (float theta : { -12.0f, 0.0f, 12.0f }) {
            // 4 footprint corners in bbox-local 2D
            Pt2 fl = bodyToLocal2D(R_front, +w_s/2, theta);
            Pt2 fr = bodyToLocal2D(R_front, -w_s/2, theta);
            Pt2 br = bodyToLocal2D(R_back,  -w_s/2, theta);
            Pt2 bl = bodyToLocal2D(R_back,  +w_s/2, theta);
            // Convert to 3D world-relative (CX/CY shift)
            V3 FL { CX(fl.x, W), CY(fl.y, D), 0 };
            V3 FR { CX(fr.x, W), CY(fr.y, D), 0 };
            V3 BR { CX(br.x, W), CY(br.y, D), 0 };
            V3 BL { CX(bl.x, W), CY(bl.y, D), 0 };
            V3 FLt { FL.x, FL.y, wallH };
            V3 FRt { FR.x, FR.y, wallH };
            V3 BRt { BR.x, BR.y, wallH };
            V3 BLt { BL.x, BL.y, wallH };
            // Footprint bottom (facing -z): kept simple, single quad
            AddQuad(m, FL, FR, BR, BL, cBrickShade, V3{0,0,-1});
            // Top of walls (footprint at z=wallH)
            AddQuad(m, FLt, FRt, BRt, BLt, cBrickShade, V3{0,0, 1});
            // Front wall — split into two side pillars + a door panel
            // (slightly recessed, lighter color) + a lintel above the door.
            // Door width = 60 mm (loco clearance), height = 60 mm (out of
            // 92 mm wall height; lintel fills the gap above).
            const float doorHalfW = 30.0f;     // half door width (mm)
            const float doorH     = 60.0f;     // door height (mm)
            const float doorRecess = 4.0f;     // door panel pushed back into the building
            Color3 cDoor { 0.86f, 0.86f, 0.84f };

            // Body-frame coords of the two door edges (along -y / +y of body).
            // Door spans -doorHalfW..+doorHalfW in body y at bx = R_front.
            Pt2 fmL = bodyToLocal2D(R_front, +doorHalfW, theta);   // door-left
            Pt2 fmR = bodyToLocal2D(R_front, -doorHalfW, theta);   // door-right
            // Recessed-door equivalents (pushed slightly back into the building
            // along +x body axis):
            Pt2 fmLr = bodyToLocal2D(R_front + doorRecess, +doorHalfW, theta);
            Pt2 fmRr = bodyToLocal2D(R_front + doorRecess, -doorHalfW, theta);

            V3 FmL  { CX(fmL .x, W), CY(fmL .y, D), 0 };
            V3 FmR  { CX(fmR .x, W), CY(fmR .y, D), 0 };
            V3 FmLr { CX(fmLr.x, W), CY(fmLr.y, D), 0 };
            V3 FmRr { CX(fmRr.x, W), CY(fmRr.y, D), 0 };
            V3 FmLt { FmL.x, FmL.y, doorH };
            V3 FmRt { FmR.x, FmR.y, doorH };
            V3 FmLrt{ FmLr.x, FmLr.y, doorH };
            V3 FmRrt{ FmRr.x, FmRr.y, doorH };
            V3 FmLtop{ FmL.x, FmL.y, wallH };
            V3 FmRtop{ FmR.x, FmR.y, wallH };

            // Left pillar (FL to FmL): full-height brick segment
            AddQuad(m, FL, FmL, FmLtop, FLt, cBrick, V3{0,-1,0});
            // Right pillar (FmR to FR)
            AddQuad(m, FmR, FR, FRt, FmRtop, cBrick, V3{0,-1,0});
            // Lintel above the door (FmL to FmR at z in [doorH..wallH])
            AddQuad(m, FmLt, FmRt, FmRtop, FmLtop, cBrick, V3{0,-1,0});

            // Door panel (recessed) — a lighter rectangle in the door
            // opening. Facing outward like the rest of the front wall.
            AddQuad(m, FmLr, FmRr, FmRrt, FmLrt, cDoor, V3{0,-1,0});
            // Side reveals of the recess (top + two sides + bottom)
            // Left reveal: from FmL→FmLr at z 0..doorH, facing +y in body
            AddQuad(m, FmL, FmLr, FmLrt, FmLt, cBrick, V3{1, 0, 0});
            // Right reveal: FmRr→FmR
            AddQuad(m, FmRr, FmR, FmRt, FmRrt, cBrick, V3{-1, 0, 0});
            // Top reveal of the doorway (between FmLt-FmRt and the recessed edge)
            AddQuad(m, FmLt, FmRt, FmRrt, FmLrt, cBrick, V3{0, 0, -1});
            // Back wall
            AddQuad(m, BR, BL, BLt, BRt, cBrick, V3{0, 1,0});
            // Side wall (+y in body) - actually two sides
            AddQuad(m, FR, BR, BRt, FRt, cBrick, V3{ 1,0,0});
            AddQuad(m, BL, FL, FLt, BLt, cBrick, V3{-1,0,0});

            // Roof slab on top
            V3 FLr { FL.x, FL.y, wallH + roofH };
            V3 FRr { FR.x, FR.y, wallH + roofH };
            V3 BRr { BR.x, BR.y, wallH + roofH };
            V3 BLr { BL.x, BL.y, wallH + roofH };
            AddQuad(m, FLr, FRr, BRr, BLr, cRoofDark, V3{0,0,1});
            // Roof edges (sides)
            AddQuad(m, FLt, FRt, FRr, FLr, cRoofDark, V3{0,-1,0});
            AddQuad(m, BRt, BLt, BLr, BRr, cRoofDark, V3{0, 1,0});
            AddQuad(m, FRt, BRt, BRr, FRr, cRoofDark, V3{ 1,0,0});
            AddQuad(m, BLt, FLt, FLr, BLr, cRoofDark, V3{-1,0,0});

            // One roof ventilator near the back of this stall
            Pt2 vc = bodyToLocal2D(R_front + 250.0f, 0.0f, theta);
            AB(vc.x - 14.0f, vc.y - 22.0f, wallH + roofH,
               28.0f, 44.0f, 14.0f, cMetal, cMetal);
        }
    }
    else if (id == "goods_shed") {
        AB( 0,0,0, W,D, 28, cWood, cWood);
        AB( 0,0,28, W,D, 7, cRoofDark, cRoofDark);
    }
    else if (id == "warehouse") {
        AB( 0,0,0, W,D, 42, cConcrete, cConcrete);
        // Sawtooth skylights
        for (int i=0;i<4;i++) {
            float x0 = i*W/4;
            AB( x0,0,42, W/4,D, 14, cRoofDark, cMetal);
        }
    }

    // ── Roads ─────────────────────────────────────────────────────────────
    else if (id == "crossing_1track" || id == "crossing_2track" || id == "road_straight") {
        // Just a flat slab, 2mm thick
        AB( 0,0,0, W,D, 2, cRoad, cRoad);
        // Road markings as thin raised strips
        if (id != "road_straight") {
            // Crossing rubber pads
            AB( 0,24,2, W,14,1, {0.18f,0.18f,0.18f}, {0.18f,0.18f,0.18f});
            if (id == "crossing_2track")
                AB( 0,104,2, W,14,1, {0.18f,0.18f,0.18f}, {0.18f,0.18f,0.18f});
        }
    }

    // ── Special ───────────────────────────────────────────────────────────
    else if (id == "74861") {
        float R = W*0.5f;
        // Pit walls — ring centred on origin (the turntable IS centred on transform.pos)
        int pitSegs = 24;
        for (int i=0;i<pitSegs;i++) {
            float a0=2*M_PI*i/pitSegs, a1=2*M_PI*(i+1)/pitSegs;
            float x0=R*0.92f*std::cos(a0), y0=R*0.92f*std::sin(a0);
            float x1=R*0.92f*std::cos(a1), y1=R*0.92f*std::sin(a1);
            float dx=x1-x0, dy=y1-y0, len=std::sqrt(dx*dx+dy*dy);
            V3 n={-(y1-y0)/len,(x1-x0)/len,0};
            unsigned base=(unsigned)m.verts.size();
            V3 pts[8]={{x0,y0,-8},{x1,y1,-8},{x1,y1,0},{x0,y0,0},
                       {x0+n.x*4,y0+n.y*4,-8},{x1+n.x*4,y1+n.y*4,-8},
                       {x1+n.x*4,y1+n.y*4,0},{x0+n.x*4,y0+n.y*4,0}};
            for(auto& v:pts){m.verts.push_back(v);m.normals.push_back(n);m.colors.push_back(cConcrete);}
            m.indices.insert(m.indices.end(),{base,base+1,base+2,base,base+2,base+3});
        }
        // Bridge deck (centred: from -R to +R in X, -4 to +4 in Y)
        AB( 0, R-4, 0, W, 8, 3, cMetal, cMetal);
        // Centre pivot
        AB( R-6, R-6, 0, 12, 12, 4, {0.30f,0.38f,0.55f},{0.30f,0.38f,0.55f});
    }
    else if (id == "water_tower") {
        // Legs
        AB(  5,D*0.3f,0, 5,D*0.4f,28, cRoofDark, cRoofDark);
        AB( W-10,D*0.3f,0, 5,D*0.4f,28, cRoofDark, cRoofDark);
        // Tank
        AB( 5,5,25, W-10,D-10,22, {0.32f,0.40f,0.55f},{0.28f,0.36f,0.50f});
        // Dome
        AB( 8,8,47, W-16,D-16, 8, cRoofDark, cRoofDark);
    }
    else if (id == "coaling_tower") {
        AB( 5,5,0, W-10,D-10,28, cConcrete, cConcrete);
        AB( 10,10,28, W-20,D-20,18, cMetal, cMetal);
        // Hopper chute
        AB( W*0.3f,D*0.3f,46, W*0.4f,D*0.4f,10, cMetal, cMetal);
    }

    // ── Signals ───────────────────────────────────────────────────────────
    // Six HO variants + six Z variants. They share builders, with `s` derived
    // from the footprint W to scale everything proportionally.
    //   Semaphore: tall striped mast, red/white arm at top (arm angle = aspect)
    //   Color light: dark housing on green mast with red & green LED dots
    //   Distant:   round disc atop mast, angle indicates expect-stop vs clear
    else if (id == "signal_semaphore_stop" || id == "signal_semaphore_clear" ||
             id == "signal_z_semaphore_stop" || id == "signal_z_semaphore_clear")
    {
        bool clear = (id.find("_clear") != std::string::npos);
        float s = W / 10.0f;  // 1.0 for HO, 0.5 for Z
        float cx = W * 0.5f, cy = D * 0.5f;
        Color3 cMast {0.30f, 0.30f, 0.32f};
        Color3 cRed  {0.85f, 0.18f, 0.18f};
        Color3 cWhite{0.92f, 0.92f, 0.92f};
        // Square base plate
        AB(cx - 3*s, cy - 3*s, 0, 6*s, 6*s, 1*s, cMast, cMast);
        // Mast in red/white stripes
        float mastTop = 75 * s;
        float bandH = 8 * s;
        int bands = (int)(mastTop / bandH);
        for (int i = 0; i < bands; ++i) {
            Color3 c = (i & 1) ? cRed : cWhite;
            AB(cx - 0.8f*s, cy - 0.8f*s, i * bandH, 1.6f*s, 1.6f*s, bandH, c, c);
        }
        // Pivot block at top
        AB(cx - 1.5f*s, cy - 1.5f*s, mastTop, 3*s, 3*s, 4*s, cMast, cMast);
        // Semaphore arm:
        //   * extends across the track (along +Y).
        //   * broad face lies in the YZ plane (perpendicular to the track),
        //     facing the engineer who is looking down the track along X.
        //   * thin axis is along the track (X) -- the engineer sees the wide
        //     painted face, not the edge.
        // Approximate dimensions in box terms (X x Y x Z):
        //   * thin in X (1.5s along track)
        //   * long in Y (16s extending across track)
        //   * tall in Z (3s vertical), pivots about the mast top.
        float armPivotZ = mastTop + 2*s;
        float armThin  = 1.5f * s;
        float armWide  = 3.0f  * s;   // vertical extent of broad face
        float armReach = 16.0f * s;
        if (clear) {
            // Raised arm (~35 degrees up). Stack narrow boxes stepping out
            // along +Y and rising along +Z. Each segment is the same thin
            // plate facing the engineer.
            int segs = 6;
            float stepY = armReach / segs;
            float stepZ = stepY * 0.7f;       // ~35 deg rise
            for (int i = 0; i < segs; ++i) {
                float fy = cy + 2*s + i*stepY;
                float fz = armPivotZ + i*stepZ;
                AB(cx - armThin*0.5f, fy, fz - armWide*0.5f,
                   armThin, stepY*1.1f, armWide, cRed, cRed);
            }
            // White tip disc at the raised end (a thin plate face-on to the engineer)
            float tipY = cy + 2*s + segs*stepY;
            float tipZ = armPivotZ + segs*stepZ;
            AB(cx - armThin*0.5f, tipY - 2*s, tipZ - 2*s,
               armThin, 4*s, 4*s, cWhite, cWhite);
        } else {
            // Horizontal arm extending across the track
            AB(cx - armThin*0.5f, cy + 2*s, armPivotZ - armWide*0.5f,
               armThin, armReach, armWide, cRed, cRed);
            // White stripe down the centre of the arm face
            AB(cx - armThin*0.5f - 0.1f*s, cy + 4*s, armPivotZ - 0.5f*s,
               armThin + 0.2f*s, armReach - 4*s, 1*s, cWhite, cWhite);
            // White tip disc at the far end
            AB(cx - armThin*0.5f, cy + 2*s + armReach, armPivotZ - 2*s,
               armThin, 4*s, 4*s, cWhite, cWhite);
        }
    }
    else if (id == "signal_color_stop" || id == "signal_color_clear" ||
             id == "signal_z_color_stop" || id == "signal_z_color_clear")
    {
        bool clear = (id.find("_clear") != std::string::npos);
        float s = W / 10.0f;
        float cx = W * 0.5f, cy = D * 0.5f;
        Color3 cMast {0.18f, 0.36f, 0.24f};   // dark green
        Color3 cHead {0.18f, 0.20f, 0.24f};   // dark housing
        Color3 cRed  {0.85f, 0.18f, 0.18f};
        Color3 cGreen{0.30f, 0.78f, 0.32f};
        Color3 cDim  {0.30f, 0.30f, 0.30f};
        // Concrete base
        AB(cx - 3*s, cy - 3*s, 0, 6*s, 6*s, 1.5f*s, {0.7f,0.7f,0.7f}, {0.7f,0.7f,0.7f});
        // Green mast
        AB(cx - 0.8f*s, cy - 0.8f*s, 0, 1.6f*s, 1.6f*s, 60*s, cMast, cMast);
        // Route number plate -- mounted on the -X side of the mast so it faces
        // the same direction as the LEDs (matches the 2D arrow which points -X).
        AB(cx - 2.3f*s, cy - 1*s, 28*s, 1.5f*s, 2.5f*s, 3*s,
           {0.92f,0.92f,0.92f}, {0.92f,0.92f,0.92f});
        // Signal housing -- rectangular box at top, oriented so its face is
        // perpendicular to the track (LEDs face along -X to match the arrow).
        AB(cx - 2*s, cy - 2.5f*s, 60*s, 4*s, 5*s, 12*s, cHead, cHead);
        // Two LED dots on the -X face of the housing.
        // Lit one is bright; the other is dimmed.
        float dotZ_top    = 60*s + 8*s;
        float dotZ_bottom = 60*s + 3*s;
        Color3 redLed   = clear ? cDim : cRed;
        Color3 greenLed = clear ? cGreen : cDim;
        AB(cx - 2.3f*s, cy - 0.8f*s, dotZ_top,    0.3f*s, 1.6f*s, 1.6f*s,
           redLed, redLed);
        AB(cx - 2.3f*s, cy - 0.8f*s, dotZ_bottom, 0.3f*s, 1.6f*s, 1.6f*s,
           greenLed, greenLed);
    }

    else if (id == "bridge_truss_scenery" || id == "bridge_arch_scenery") {
        // Reuse the existing track-bridge geometry builders (they build in
        // raw 0..len x -halfD..+halfD space). After appending, translate the
        // newly-added vertices to centre the mesh on the scenery item origin
        // so DrawSceneryInstance places it correctly. Also drop z by a bit
        // so the deck sits near ground level (track-bridge builders use z=0
        // for the deck top; we want the deck-top at the scenery z reference).
        size_t v0 = m.verts.size();
        if (id == "bridge_truss_scenery") BuildBridgeTruss(m, (float)def.widthMm);
        else                              BuildBridgeArch (m, (float)def.widthMm);
        // Centre: shift X by -len/2 (mesh was 0..len). Y is already centred
        // around 0 in the bridge builders. The Y-flip convention of the
        // scenery frame matches the bridge builder (Y up in 2D = -Y in 3D),
        // so we negate the per-vertex Y to switch into the scenery space.
        float halfLen = (float)def.widthMm * 0.5f;
        for (size_t i = v0; i < m.verts.size(); ++i) {
            m.verts[i].x -= halfLen;
            m.verts[i].y  = -m.verts[i].y;
            // Normals along Y also flip
            m.normals[i].y = -m.normals[i].y;
        }
    }

    // ── Trees ─────────────────────────────────────────────────────────────
    else if (id == "tree_sm_dec") {
        float cx=W*0.5f, cy=D*0.5f;
        AB( cx-1.5f,cy-1.5f,0, 3,3,14, cTrunk, cTrunk);
        AddSphere(m, cx3(cx),cy3(cy),18, 13, cLeafGreen, 8);
    }
    else if (id == "tree_lg_dec") {
        float cx=W*0.5f, cy=D*0.5f;
        AB( cx-2,cy-2,0, 4,4,20, cTrunk, cTrunk);
        AddSphere(m, cx3(cx),cy3(cy),26, 22, cLeafGreen, 10);
        AddSphere(m, cx3(cx+6),cy3(cy-4),28, 14, cLeafDark, 8);
    }
    else if (id == "tree_conifer") {
        float cx=W*0.5f, cy=D*0.5f;
        AB( cx-1.5f,cy-1.5f,0, 3,3,10, cTrunk, cTrunk);
        AddCone(m, cx3(cx),cy3(cy), 8,  10, 16, cLeafDark,  10);
        AddCone(m, cx3(cx),cy3(cy),18,   7, 14, cConifer,   10);
        AddCone(m, cx3(cx),cy3(cy),26,   4, 12, cLeafGreen, 10);
    }
    else if (id == "tree_group") {
        struct TreePos { float x,y,r,h; Color3 col; };
        TreePos trees[] = {
            {22,D*0.5f,14,22,cLeafGreen},
            {W*0.5f,D*0.45f,18,28,cLeafDark},
            {W-20,D*0.5f,12,18,cLeafGreen},
        };
        for (auto& t : trees) {
            AB( t.x-1.5f,t.y-1.5f,0, 3,3,(int)(t.h*0.6f), cTrunk,cTrunk);
            AddSphere(m, cx3(t.x),cy3(t.y),t.h*0.65f, t.r, t.col, 8);
        }
    }
    else if (id == "bush") {
        float cx=W*0.5f, cy=D*0.5f;
        AddSphere(m, cx3(cx),cy3(cy), 4, 9, cLeafGreen, 7);
    }

    // ── Vehicles ──────────────────────────────────────────────────────────
    else if (id == "car") {
        // Body
        AB( 0,4,0, W,D-8,5, cCarRed, cCarRed);
        // Cabin
        AB( 10,5,5, W-20,D-10,7, {0.65f,0.16f,0.12f},{0.65f,0.16f,0.12f});
        // Wheels (low boxes)
        AB(  4,2,0, 7,D-4,4, {0.15f,0.15f,0.15f},{0.20f,0.20f,0.20f});
        AB( W-11,2,0, 7,D-4,4, {0.15f,0.15f,0.15f},{0.20f,0.20f,0.20f});
    }
    else if (id == "truck") {
        // Cab
        AB( 0,3,0, 22,D-6,14, {0.32f,0.40f,0.55f},{0.32f,0.40f,0.55f});
        // Trailer
        AB( 22,3,0, W-22,D-6,13, cCarGray, cConcrete);
        // Wheels
        AB( 3,1,0, 7,D-2,5, {0.15f,0.15f,0.15f},{0.20f,0.20f,0.20f});
        AB( 32,1,0, 7,D-2,5, {0.15f,0.15f,0.15f},{0.20f,0.20f,0.20f});
        AB( 52,1,0, 7,D-2,5, {0.15f,0.15f,0.15f},{0.20f,0.20f,0.20f});
    }

    // ── Misc ──────────────────────────────────────────────────────────────
    else if (id == "parking_lot") {
        AB( 0,0,0, W,D,2, cRoad, cRoad);
        // Lane markings (thin raised strips)
        for (int i=0;i<=10;i++)
            AB( i*25.0f,0,2, 1,D,0.5f, {0.90f,0.90f,0.90f},{0.90f,0.90f,0.90f});
    }
    else if (id == "ped_zone") {
        AB( 0,0,0, W,D,3, {0.76f,0.72f,0.66f},{0.76f,0.72f,0.66f});
        // Benches
        AB( 10,20,3, 20,5,4, cWood, cWood);
        AB( 85,20,3, 20,5,4, cWood, cWood);
    }
    // ── Bridges & Tunnels ─────────────────────────────────────────────────────
    else if (id == "bridge_arch") {
        // Märklin 74636 — 360 × 64 × 117mm steel arch bridge
        // Mesh is centred: X from -180 to +180, Y from -32 to +32, Z from 0 up
        Color3 steel  = {0.55f, 0.58f, 0.62f};
        Color3 steelD = {0.38f, 0.40f, 0.44f};
        Color3 steelL = {0.70f, 0.72f, 0.76f};

        float deckZ  = 18.0f;
        float archH  = 99.0f;
        float halfW  = W * 0.5f;
        float halfD  = D * 0.5f;
        float ribY0  =  halfD - 5.0f;
        float ribY1  = -halfD + 5.0f;

        // Deck plate
        AB( 0, 8, deckZ, W, D-16, 4, steelD, steelD);

        // Bottom chord girders
        AB( 0,  0, deckZ-8, W, 9, 10, steel, steelD);
        AB( 0, D-9, deckZ-8, W, 9, 10, steel, steelD);

        // Arch rib footings
        for (int side = 0; side < 2; side++) {
            float yRib = (side == 0) ? ribY0 : ribY1;
            AddBeam3D(m, {CX(0,W), yRib, 0}, {CX(0,W), yRib, deckZ}, 5.0f, steelD);
            AddBeam3D(m, {CX(W,W), yRib, 0}, {CX(W,W), yRib, deckZ}, 5.0f, steelD);
        }

        // Arch ribs
        {
            int segs = 14;
            float ribHW = 4.0f;
            for (int side = 0; side < 2; side++) {
                float yRib = (side == 0) ? ribY0 : ribY1;
                for (int i = 0; i < segs; i++) {
                    float t0=(float)i/segs, t1=(float)(i+1)/segs;
                    float a0=t0*(float)M_PI, a1=t1*(float)M_PI;
                    float x0=CX((1.0f-std::cos(a0))*0.5f*W,W);
                    float x1=CX((1.0f-std::cos(a1))*0.5f*W,W);
                    float z0=deckZ+std::sin(a0)*archH;
                    float z1=deckZ+std::sin(a1)*archH;
                    AddBeam3D(m, {x0,yRib,z0}, {x1,yRib,z1}, ribHW, steelL);
                }
            }
        }

        // Vertical hangers
        {
            int nH = 8;
            for (int side = 0; side < 2; side++) {
                float yRib = (side == 0) ? ribY0 : ribY1;
                for (int i = 1; i < nH; i++) {
                    float t = (float)i / nH;
                    float a = t * (float)M_PI;
                    float x = CX((1.0f-std::cos(a))*0.5f*W, W);
                    float zTop = deckZ + std::sin(a) * archH;
                    AddBeam3D(m, {x, yRib, deckZ+4}, {x, yRib, zTop-4}, 1.8f, steelD);
                }
            }
        }

        // K-bracing
        {
            int nH = 8;
            for (int side = 0; side < 2; side++) {
                float yRib = (side == 0) ? ribY0 : ribY1;
                for (int i = 0; i < nH; i++) {
                    float t0=(float)i/nH, t1=(float)(i+1)/nH, tm=(t0+t1)*0.5f;
                    float a0=t0*(float)M_PI, a1=t1*(float)M_PI, am=tm*(float)M_PI;
                    float x0=CX((1.0f-std::cos(a0))*0.5f*W,W);
                    float x1=CX((1.0f-std::cos(a1))*0.5f*W,W);
                    float xm=CX((1.0f-std::cos(am))*0.5f*W,W);
                    float zm=(deckZ+deckZ+std::sin(am)*archH)*0.5f;
                    AddBeam3D(m, {x0,yRib,deckZ+4}, {xm,yRib,zm}, 1.5f, steelD);
                    AddBeam3D(m, {x1,yRib,deckZ+4}, {xm,yRib,zm}, 1.5f, steelD);
                }
            }
        }

        // Cross members
        {
            int nX = 7;
            for (int i = 0; i <= nX; i++) {
                float t=(float)i/nX, a=t*(float)M_PI;
                float x=CX((1.0f-std::cos(a))*0.5f*W,W);
                float z=deckZ+std::sin(a)*archH;
                AddBeam3D(m, {x,ribY0,z}, {x,ribY1,z}, 2.5f, steelD);
            }
        }
    }
    else if (id == "bridge_truss") {
        // Steel truss bridge — 188 × 80mm
        Color3 steel = {0.35f, 0.38f, 0.42f};
        Color3 steelT= {0.30f, 0.33f, 0.37f};
        // Deck plate
        AB( 0, 14, 0, W, D-28, 4, steel, steel);
        // Bottom chords (longitudinal beams along deck edges)
        AB( 0,  8, 0, W, 8, 5, steel, steelT);
        AB( 0, D-16,0, W, 8, 5, steel, steelT);
        // Top chords (upper longitudinal beams)
        AB( 0,  8, 32, W, 6, 5, steelT, steelT);
        AB( 0, D-14,32, W, 6, 5, steelT, steelT);
        // Vertical posts at intervals
        float postSpacing = W / 4.0f;
        for (int i = 0; i <= 4; ++i) {
            float px = i * postSpacing;
            AB( px-2,  8, 0, 4, 6, 37, steel, steelT);
            AB( px-2, D-14, 0, 4, 6, 37, steel, steelT);
        }
        // End portal frames
        AB(  0,  8, 0, 6, D-16, 37, steelT, steelT);
        AB( W-6, 8, 0, 6, D-16, 37, steelT, steelT);
        AB(  0,  8, 32, W, 6, 5, steelT, steelT);  // top cross-member
    }
    else if (id == "viaduct_pier") {
        // Concrete viaduct pier — 40 × 60mm
        Color3 conc  = {0.68f, 0.65f, 0.60f};
        Color3 concT = {0.72f, 0.69f, 0.64f};
        // Base footing (wide)
        AB( 0, 0, 0, W, D, 8, conc, concT);
        // Shaft (slightly tapered — approximate as two boxes)
        AB( 4,  8, 8, W-8, D-16, 50, conc, concT);
        AB( 6, 10, 58, W-12, D-20, 8, concT, concT);
        // Cap beam (wider than shaft)
        AB( 0,  4, 66, W, D-8, 10, conc, concT);
    }
    else if (id == "tunnel_portal") {
        // Tunnel portal single track — 80 × 60mm
        Color3 rock  = {0.50f, 0.48f, 0.44f};
        Color3 rockT = {0.45f, 0.43f, 0.40f};
        Color3 dark  = {0.12f, 0.11f, 0.10f};
        // Main rock face / portal wall
        AB( 0, 0, 0, W, D, 55, rock, rockT);
        // Tunnel opening cut-out — approximate as a dark recessed box
        // The "void" is represented by a darker inset box on the front face
        AB(  8,  8, 0, W-16, D-16, 45, dark, dark);
        // Arch surround (stone trim around the opening)
        AB(  6,  6, 44, W-12, D-12, 8, rockT, rockT);
        // Keystone
        AB( W/2-4, 4, 50, 8, 6, 8, {0.58f,0.54f,0.50f}, {0.55f,0.51f,0.47f});
        // Wing walls
        AB(  0, 0, 0, 10, D, 42, rock, rockT);
        AB( W-10,0,0, 10, D, 42, rock, rockT);
    }
    else if (id == "tunnel_portal_2") {
        // Tunnel portal double track — 140 × 60mm
        Color3 rock  = {0.50f, 0.48f, 0.44f};
        Color3 rockT = {0.45f, 0.43f, 0.40f};
        Color3 dark  = {0.12f, 0.11f, 0.10f};
        // Main wall
        AB( 0, 0, 0, W, D, 55, rock, rockT);
        // Two tunnel openings
        AB(  6,  6, 0, 50, D-12, 45, dark, dark);
        AB( W-56, 6, 0, 50, D-12, 45, dark, dark);
        // Arch surrounds
        AB(  4,  4, 44, 54, D-8, 8, rockT, rockT);
        AB( W-58, 4, 44, 54, D-8, 8, rockT, rockT);
        // Centre pillar
        AB( 58, 0, 0, W-116, D, 55, rock, rockT);
        AB( 56, 2, 50, W-112, D-4, 8, rockT, rockT);
        // Wing walls
        AB(  0, 0, 0, 10, D, 42, rock, rockT);
        AB( W-10,0,0, 10, D, 42, rock, rockT);
    }
    else if (id == "retaining_wall") {
        // Retaining wall — 188 × 30mm
        Color3 stone  = {0.56f, 0.53f, 0.48f};
        Color3 stoneT = {0.50f, 0.47f, 0.43f};
        // Main wall body — tall (represents a significant embankment)
        AB( 0, 0, 0, W, D, 50, stone, stoneT);
        // Coping stones on top
        AB( 0, 0, 50, W, D, 6, stoneT, {0.62f,0.59f,0.54f});
        // Buttresses at intervals for visual interest
        for (int i = 0; i <= 4; ++i) {
            float bx = i * (W / 4.0f);
            AB( bx-3, 0, 0, 6, D+8, 48, {0.52f,0.49f,0.45f}, stoneT);
        }
    }
    else {
        // Generic flat slab fallback
        AB( 0,0,0, W,D,6, cGround, cGround);
    }

    return pm;
}

// ── GL Canvas ctor ────────────────────────────────────────────────────────────
wxBEGIN_EVENT_TABLE(TrackGLCanvas, wxGLCanvas)
    EVT_PAINT(TrackGLCanvas::OnPaint)
    EVT_SIZE(TrackGLCanvas::OnSize)
    EVT_MOTION(TrackGLCanvas::OnMouseMove)
    EVT_LEFT_DOWN(TrackGLCanvas::OnMouseDown)
    EVT_RIGHT_DOWN(TrackGLCanvas::OnMouseDown)
    EVT_LEFT_UP(TrackGLCanvas::OnMouseUp)
    EVT_RIGHT_UP(TrackGLCanvas::OnMouseUp)
    EVT_MOUSEWHEEL(TrackGLCanvas::OnMouseWheel)
    EVT_KEY_DOWN(TrackGLCanvas::OnKeyDown)
wxEND_EVENT_TABLE()

TrackGLCanvas::TrackGLCanvas(wxWindow* parent, const MC::CanvasState& state)
    : wxGLCanvas(parent, wxID_ANY, nullptr,
                 wxDefaultPosition, wxDefaultSize,
                 wxWANTS_CHARS | wxFULL_REPAINT_ON_RESIZE)
{
    wxGLContextAttrs attrs;
    attrs.PlatformDefaults().CoreProfile().OGLVersion(2,1).EndList();
    m_ctx = new wxGLContext(this, nullptr, &attrs);
    RebuildMesh(state);
}

// ── RebuildMesh ───────────────────────────────────────────────────────────────
void TrackGLCanvas::RebuildMesh(const MC::CanvasState& state)
{
    m_instances.clear();
    m_pieceMeshCache.clear();
    m_sceneryInstances.clear();
    m_sceneryMeshCache.clear();

    auto& catalog  = MC::GetCatalog();
    auto& scCatalog = MC::GetSceneryCatalog();
    float minX= 1e9f, minY= 1e9f, maxX=-1e9f, maxY=-1e9f;
    bool  any = false;

    for (const auto& placed : state.pieces) {
        const MC::TrackPiece* def = catalog.Find(placed.catalogNumber);
        if (!def) continue;
        if (!state.IsLevelVisible(placed.levelId)) continue;

        if (m_pieceMeshCache.find(placed.catalogNumber) == m_pieceMeshCache.end()) {
            if (def->type == MC::PieceType::Bumper)
                m_pieceMeshCache[placed.catalogNumber] = BuildBumperMesh(*def);
            else
                m_pieceMeshCache[placed.catalogNumber] = BuildPieceMesh(*def);
        }

        InstanceTransform inst;
        inst.catalogNumber = placed.catalogNumber;
        inst.wx         = (float) placed.transform.pos.x;
        inst.wy         = (float)-placed.transform.pos.y;
        inst.wz         = (float) placed.z_mm;
        inst.headingDeg = (float)-placed.transform.heading;
        inst.gradeDeg   = (float)placed.gradeDeg;
        inst.lengthMm   = (float)def->lengthMm;

        // chainReversed: when set, the piece is joined backwards in the
        // chain (conn[1] is the chain entry). The 3D renderer flips the
        // tilt direction so the elevation matches the chain's grade.
        inst.flipped = placed.chainReversed;
        m_instances.push_back(inst);

        float hr = (float)(placed.transform.heading * M_PI / 180.0);
        float ex = inst.wx + (float)def->lengthMm * std::cos(hr);
        float ey = inst.wy - (float)def->lengthMm * std::sin(hr);
        minX = std::min({minX, inst.wx, ex});
        maxX = std::max({maxX, inst.wx, ex});
        minY = std::min({minY, inst.wy, ey});
        maxY = std::max({maxY, inst.wy, ey});
        any = true;
    }

    // Bridge supports
    m_supportMesh.verts.clear();
    m_supportMesh.normals.clear();
    m_supportMesh.colors.clear();
    m_supportMesh.indices.clear();
    {
        // Collect bridge piece ids for quick lookup
        std::unordered_set<int> bridgeIds;
        for (const auto& p : state.pieces) {
            const MC::TrackPiece* d = catalog.Find(p.catalogNumber);
            if (d && d->isBridge) bridgeIds.insert(p.id);
        }

        // Track which world positions already have a support (to avoid doubles at joins)
        std::vector<std::pair<float,float>> usedPositions;
        auto posUsed = [&](float x, float y) {
            for (auto& u : usedPositions)
                if (std::abs(u.first-x)<2.0f && std::abs(u.second-y)<2.0f) return true;
            return false;
        };

        for (const auto& placed : state.pieces) {
            const MC::TrackPiece* def = catalog.Find(placed.catalogNumber);
            if (!def || !def->isBridge) continue;
            if (!state.IsLevelVisible(placed.levelId)) continue;

            float hr = (float)(placed.transform.heading * M_PI / 180.0);
            float wx = (float) placed.transform.pos.x;
            float wy = (float)-placed.transform.pos.y;
            float wz = (float) placed.z_mm;
            // Bridge piers drop from the bridge deck (topZ) down to the
            // deck of the bridge's own level (baseZ), so the supports
            // start at the level the bridge sits on — not the floor.
            // A bridge flat on its own level produces no visible pier.
            float baseZ = (float) state.LevelHeight(placed.levelId);
            float topZ  = wz;

            // Entry connector world position (GL coords)
            float ex0 = wx, ey0 = wy;
            // Exit connector world position in GL space
            // Canvas connector: (lx, ly) where ly is Y-down positive
            // GL: wy = -canvas_y, so GL offset = (lx, -ly), rotated by GL heading (-heading)
            float lx, ly;
            if (def->radiusMm > 0.0 && def->connections.size() >= 2) {
                lx = (float)def->connections[1].pos.x;
                ly = (float)def->connections[1].pos.y;  // canvas Y-down
            } else {
                lx = (float)def->lengthMm;
                ly = 0.0f;
            }
            // GL heading = -placed.transform.heading, so cos/sin use -hr
            float ex1 = wx + lx * std::cos(-hr) - (-ly) * std::sin(-hr);
            float ey1 = wy + lx * std::sin(-hr) + (-ly) * std::cos(-hr);

            // Support orientation: oval goes ACROSS the track (perpendicular to heading)
            float supportHeading = -hr + (float)M_PI * 0.5f;

            // Entry support — only if not shared with adjacent bridge
            bool entryIsBridge = placed.joints.size() > 0 &&
                                 bridgeIds.count(placed.joints[0].pieceId) > 0;
            if (!entryIsBridge && !posUsed(ex0, ey0)) {
                BuildBridgeSupport(m_supportMesh, ex0, ey0, baseZ, topZ, supportHeading);
                usedPositions.push_back({ex0, ey0});
            }

            // Exit support
            if (!posUsed(ex1, ey1)) {
                BuildBridgeSupport(m_supportMesh, ex1, ey1, baseZ, topZ, supportHeading);
                usedPositions.push_back({ex1, ey1});
            }
        }
    }

    // ── Helix curves and supports ────────────────────────────────────────
    // ── Helix curves and supports ────────────────────────────────────────
    // For each Helix, synthesize R1 + R2 spiral instances matching the 2D
    // view, with z rising linearly from 0 to variant->totalRise over
    // variant->turns turns. Each piece tilts by the per-piece grade so its
    // exit end sits one step higher than its entry. Vertical supports run
    // from the baseboard up to the helix top deck.
    if (!state.helices.empty()) {
        const int pieces_per_turn = 12;

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

            // Skip the whole helix (stubs, curves, supports) when the level
            // it lives on is hidden. The helix's level is taken from its
            // bottom R1 stub.
            int helixLevelId = 1;
            for (const auto& p : state.pieces)
                if (p.id == h.bottomStubR1) { helixLevelId = p.levelId; break; }
            if (!state.IsLevelVisible(helixLevelId)) continue;

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

            if (m_pieceMeshCache.find(variant->innerCurve) == m_pieceMeshCache.end())
                m_pieceMeshCache[variant->innerCurve] = BuildPieceMesh(*defR1);
            if (m_pieceMeshCache.find(variant->outerCurve) == m_pieceMeshCache.end())
                m_pieceMeshCache[variant->outerCurve] = BuildPieceMesh(*defR2);

            const int    total_pieces = (int)(variant->turns * pieces_per_turn);
            const double totalRise    = variant->totalRise;
            const double risePerPiece = (total_pieces > 0)
                                      ? totalRise / total_pieces : 0.0;

            // Per-radius arc length (mm) and grade (deg) so each piece climbs
            // exactly risePerPiece over its arc.
            auto arcLen = [](double radius) {
                return radius * (30.0 * M_PI / 180.0);
            };
            auto gradeFor = [&](double radius) {
                double L = arcLen(radius);
                return (L > 0.001)
                       ? std::asin(risePerPiece / L) * 180.0 / M_PI
                       : 0.0;
            };
            const double gradeR1 = gradeFor(variant->radiusR1);
            const double gradeR2 = gradeFor(variant->radiusR2);

            double hr = h.heading * M_PI / 180.0;
            double cs = std::cos(hr), sn = std::sin(hr);

            // Helix base z = elevation of its bottom stubs. When the helix is
            // assigned to a level above ground, the spiral and the supports
            // both lift by this base; the spiral's per-piece climb is added
            // on top.
            double baseZ = 0.0;
            for (const auto& p : state.pieces) {
                if (p.id == h.bottomStubR1) { baseZ = p.z_mm; break; }
            }

            auto addArcs = [&](double radius, const std::string& cat,
                                double gradeDeg) {
                for (int i = 0; i < total_pieces; ++i) {
                    // Match the 2D CCW placement: piece conn[0] sits at
                    // theta = -90 - (i+1)*30 (the spiral's "next" point
                    // along the CCW path), heading is the reverse-CCW
                    // tangent there. The train travels CCW, entering
                    // the piece via conn[1] and exiting via conn[0];
                    // so conn[0] is the HIGHER end. We assign wz at
                    // conn[0] = baseZ + (i+1) * risePerPiece and a NEGATIVE
                    // grade so conn[1] sits one step lower.
                    double thetaDeg = -90.0 - (i + 1) * 30.0;
                    double thetaRad = thetaDeg * M_PI / 180.0;
                    double lx = radius * std::cos(thetaRad);
                    double ly = radius * std::sin(thetaRad);
                    double headingLocal = thetaDeg + 90.0;
                    double canvasX  = h.center.x + cs*lx - sn*ly;
                    double canvasY  = h.center.y + sn*lx + cs*ly;
                    double canvasHd = headingLocal + h.heading;

                    InstanceTransform inst;
                    inst.catalogNumber = cat;
                    inst.wx         = (float) canvasX;
                    inst.wy         = (float)-canvasY;
                    inst.wz         = (float)(baseZ + (i + 1) * risePerPiece);
                    inst.headingDeg = (float)-canvasHd;
                    inst.gradeDeg   = (float)(-gradeDeg);
                    inst.lengthMm   = (float)arcLen(radius);
                    inst.flipped    = false;
                    m_instances.push_back(inst);

                    minX = std::min(minX, inst.wx);
                    maxX = std::max(maxX, inst.wx);
                    minY = std::min(minY, inst.wy);
                    maxY = std::max(maxY, inst.wy);
                    any = true;
                }
            };
            addArcs(variant->radiusR1, variant->innerCurve, gradeR1);
            addArcs(variant->radiusR2, variant->outerCurve, gradeR2);

            // Vertical supports: one per 30 deg around the outer (R2)
            // ring, between the helix's base deck and the top deck. Slim
            // wooden uprights matching the Proses kit.
            const double supportR = variant->radiusR2;
            const float  supportBase = (float) baseZ;
            const float  supportTop  = (float)(baseZ + totalRise);
            for (int i = 0; i < pieces_per_turn; ++i) {
                double thetaDeg = -90.0 + i * 30.0;
                double thetaRad = thetaDeg * M_PI / 180.0;
                double lx = supportR * std::cos(thetaRad);
                double ly = supportR * std::sin(thetaRad);
                double sx_canvas = h.center.x + cs*lx - sn*ly;
                double sy_canvas = h.center.y + sn*lx + cs*ly;
                float  sx = (float) sx_canvas;
                float  sy = (float)-sy_canvas;
                double radialDeg = thetaDeg + h.heading;
                float  supportHead = (float)(-radialDeg * M_PI / 180.0);
                BuildHelixSupport(m_supportMesh, sx, sy,
                                   supportBase, supportTop, supportHead);
            }

            // Extra support directly under the inner (R1) top stub so it
            // doesn't appear to float. (The outer R2 top stub is already
            // covered by the perimeter ring at theta=+90 deg.)
            {
                double thetaDeg = 90.0;   // top stub is at South post-swap
                double thetaRad = thetaDeg * M_PI / 180.0;
                double lx = variant->radiusR1 * std::cos(thetaRad);
                double ly = variant->radiusR1 * std::sin(thetaRad);
                double sx_canvas = h.center.x + cs*lx - sn*ly;
                double sy_canvas = h.center.y + sn*lx + cs*ly;
                float  sx = (float) sx_canvas;
                float  sy = (float)-sy_canvas;
                double radialDeg = thetaDeg + h.heading;
                float  supportHead = (float)(-radialDeg * M_PI / 180.0);
                BuildHelixSupport(m_supportMesh, sx, sy,
                                   supportBase, supportTop, supportHead);
            }
        }
    }

    // Free-form polygon shapes - extrude each visible shape from its level
    // deck up by heightMm. Colour AND opacity come from the shape itself: at
    // 100% opacity a shape is a solid block that hides the track beneath it;
    // below that you see through it. The caps are fan-triangulated from the
    // centroid (correct for convex polygons; concave/self-intersecting shapes
    // produce visible artifacts, which the user accepts when drawing them).
    //
    // Opacity is the ONLY control over what shows through. There used to be a
    // rule that force-flipped a shape to wireframe when it covered tunnel
    // track, because shapes were always opaque and would otherwise bury the
    // tunnel. Now that opacity actually works, that override is gone: if you
    // want to see the track under a mountain, turn the mountain's opacity down.
    m_shapeWires.clear();
    m_shapeMesh.clear();
    m_shapeMeshOpaque.clear();

    for (const auto& shape : state.shapes) {
        if (shape.vertices.size() < 3) continue;
        if (!state.IsLevelVisible(shape.levelId)) continue;

        const float alpha = shape.alpha / 255.0f;
        if (alpha <= 0.004f) continue;   // fully transparent: nothing to draw

        // A solid shape must NOT go through the blended pass. That pass runs
        // with depth writes off (necessary so transparent shapes don't occlude
        // each other), but with depth writes off a shape's own far walls paint
        // over its top cap -- the roof disappears and you see into the box.
        // Solid shapes therefore go to their own mesh and are drawn with the
        // rest of the opaque scene, depth writes on.
        const bool opaque = (shape.alpha >= 254);
        MeshRGBA&  mesh   = opaque ? m_shapeMeshOpaque : m_shapeMesh;

        Color4 col {
            shape.r / 255.0f,
            shape.g / 255.0f,
            shape.b / 255.0f,
            alpha
        };
        // Darker colour for the walls (gives a sense of depth).
        Color4 colDark {
            (shape.r * 0.7f) / 255.0f,
            (shape.g * 0.7f) / 255.0f,
            (shape.b * 0.7f) / 255.0f,
            alpha
        };

        float baseZ = (float) state.LevelHeight(shape.levelId);
        float topZ  = baseZ + (float) shape.heightMm;
        // Convert canvas Y → GL Y (negated).
        auto toGL = [](const MC::Vec2& v) -> std::pair<float,float> {
            return { (float)v.x, (float)-v.y };
        };

        int n = (int)shape.vertices.size();

        // Centroid in GL space for fan triangulation.
        double cxc = 0, cyc = 0;
        for (const auto& v : shape.vertices) { cxc += v.x; cyc += v.y; }
        cxc /= shape.vertices.size();
        cyc /= shape.vertices.size();
        float ccx = (float)cxc, ccy = (float)-cyc;

        // Top cap (fan from centroid). Normal points up.
        {
            unsigned base = (unsigned)mesh.verts.size();
            V3 norm = {0, 0, 1};
            // First push the centroid, then each perimeter vertex.
            mesh.verts.push_back({ccx, ccy, topZ});
            mesh.normals.push_back(norm);
            mesh.colors.push_back(col);
            for (int i = 0; i < n; ++i) {
                auto [x, y] = toGL(shape.vertices[i]);
                mesh.verts.push_back({x, y, topZ});
                mesh.normals.push_back(norm);
                mesh.colors.push_back(col);
            }
            for (int i = 0; i < n; ++i) {
                unsigned a = base;
                unsigned b = base + 1 + (unsigned)i;
                unsigned c = base + 1 + (unsigned)((i + 1) % n);
                mesh.indices.insert(mesh.indices.end(),
                    { a, b, c });
            }
        }

        // Side walls — only if shape has nonzero height.
        if (shape.heightMm > 0.5) {
            for (int i = 0; i < n; ++i) {
                auto [x0, y0] = toGL(shape.vertices[i]);
                auto [x1, y1] = toGL(shape.vertices[(i + 1) % n]);
                // Outward normal: perpendicular to edge in GL space.
                float ex = x1 - x0, ey = y1 - y0;
                // 2D right-perpendicular: (ey, -ex). Whether it's truly
                // "outward" depends on winding; this is a best-effort.
                float nx = ey, ny = -ex;
                float nl = std::sqrt(nx*nx + ny*ny);
                if (nl < 1e-6f) nl = 1;
                nx /= nl; ny /= nl;
                V3 n3 = { nx, ny, 0 };

                unsigned base = (unsigned)mesh.verts.size();
                V3 v0b = { x0, y0, baseZ };
                V3 v1b = { x1, y1, baseZ };
                V3 v0t = { x0, y0, topZ  };
                V3 v1t = { x1, y1, topZ  };
                for (auto& v : { v0b, v1b, v1t, v0t }) {
                    mesh.verts.push_back(v);
                    mesh.normals.push_back(n3);
                    mesh.colors.push_back(colDark);
                }
                mesh.indices.insert(mesh.indices.end(),
                    { base, base+1, base+2, base, base+2, base+3 });
            }
        }
    }

    // Scenery items
    for (const auto& placed : state.scenery) {
        const MC::SceneryItem* def = scCatalog.Find(placed.itemId);
        if (!def) continue;
        if (!state.IsLevelVisible(placed.levelId)) continue;

        if (m_sceneryMeshCache.find(placed.itemId) == m_sceneryMeshCache.end())
            m_sceneryMeshCache[placed.itemId] = BuildSceneryMesh(*def);

        SceneryInstance inst;
        inst.itemId     = placed.itemId;
        inst.wx         = (float) placed.transform.pos.x;
        inst.wy         = (float)-placed.transform.pos.y;
        inst.wz         = (float) state.LevelHeight(placed.levelId);
        inst.headingDeg = (float)-placed.transform.heading;
        inst.halfW      = (float)(def->widthMm  * 0.5);
        inst.halfH      = (float)(def->heightMm * 0.5);
        inst.scaleX     = (float) placed.scaleX;
        inst.scaleY     = (float) placed.scaleY;
        m_sceneryInstances.push_back(inst);

        minX = std::min(minX, inst.wx);
        maxX = std::max(maxX, inst.wx + (float)def->widthMm);
        minY = std::min(minY, inst.wy);
        maxY = std::max(maxY, inst.wy + (float)def->heightMm);
        any = true;
    }

    if (any) {
        m_cx   = (minX+maxX)*0.5f;
        m_cy   = (minY+maxY)*0.5f;
        float span = std::max(maxX-minX, maxY-minY);
        m_zoom = std::max(400.0f, span * 1.8f);
    }

    RebuildBaseboardMesh(state);
    RebuildTunnelMesh(state);
}

// ── Baseboard tessellation ────────────────────────────────────────────────────
// Use GLU's tessellator to handle arbitrary (including non-convex) polygons.
// The result is appended to m_baseboardMesh.verts/normals/colors/indices as
// triangles ready for glDrawElements(GL_TRIANGLES).

namespace {
struct TessCtx {
    Mesh* mesh        = nullptr;
    Color3 color      = {0.36f, 0.50f, 0.30f};   // muted lawn green
    // GLU may produce TRIANGLES, TRIANGLE_FAN, or TRIANGLE_STRIP. We assemble
    // independent triangles into the mesh indices regardless of which.
    GLenum currentPrim = GL_TRIANGLES;
    std::vector<unsigned> stripVerts; // indices emitted in current primitive

    void addVertex(const V3& v) {
        unsigned idx = (unsigned)mesh->verts.size();
        mesh->verts.push_back(v);
        mesh->normals.push_back({0.0f, 0.0f, 1.0f});
        mesh->colors.push_back(color);
        stripVerts.push_back(idx);
    }
    void flushPrimitive() {
        const auto& sv = stripVerts;
        if (sv.size() < 3) { stripVerts.clear(); return; }
        if (currentPrim == GL_TRIANGLES) {
            for (size_t i = 0; i + 2 < sv.size(); i += 3) {
                mesh->indices.push_back(sv[i]);
                mesh->indices.push_back(sv[i+1]);
                mesh->indices.push_back(sv[i+2]);
            }
        } else if (currentPrim == GL_TRIANGLE_FAN) {
            for (size_t i = 1; i + 1 < sv.size(); ++i) {
                mesh->indices.push_back(sv[0]);
                mesh->indices.push_back(sv[i]);
                mesh->indices.push_back(sv[i+1]);
            }
        } else if (currentPrim == GL_TRIANGLE_STRIP) {
            for (size_t i = 0; i + 2 < sv.size(); ++i) {
                if (i & 1) {
                    mesh->indices.push_back(sv[i+1]);
                    mesh->indices.push_back(sv[i]);
                    mesh->indices.push_back(sv[i+2]);
                } else {
                    mesh->indices.push_back(sv[i]);
                    mesh->indices.push_back(sv[i+1]);
                    mesh->indices.push_back(sv[i+2]);
                }
            }
        }
        stripVerts.clear();
    }
};
}  // namespace

extern "C" {
static void CALLBACK tessBegin(GLenum which, void* user) {
    auto* ctx = (TessCtx*)user;
    ctx->currentPrim = which;
    ctx->stripVerts.clear();
}
static void CALLBACK tessVertex(void* vertexData, void* user) {
    auto* ctx = (TessCtx*)user;
    auto* v = (double*)vertexData;
    ctx->addVertex(V3{(float)v[0], (float)v[1], (float)v[2]});
}
static void CALLBACK tessEnd(void* user) {
    auto* ctx = (TessCtx*)user;
    ctx->flushPrimitive();
}
static void CALLBACK tessCombine(GLdouble coords[3], void* /*vd*/[4],
                                  GLfloat /*w*/[4], void** outData,
                                  void* /*user*/) {
    // Combine callback fires when polygon has intersections; we just keep the
    // new vertex coordinates. Memory is leaked here (no free) but baseboards
    // shouldn't self-intersect in practice; this is a safety net.
    static thread_local std::vector<double*> sCombineStorage;
    auto* nv = new double[3]{coords[0], coords[1], coords[2]};
    sCombineStorage.push_back(nv);
    *outData = nv;
}
static void CALLBACK tessError(GLenum /*err*/, void* /*user*/) {
    // Swallow errors silently -- the partial result is still usable.
}
}

void TrackGLCanvas::RebuildBaseboardMesh(const MC::CanvasState& state)
{
    m_baseboardMesh.clear();
    if (!state.showBaseboard || state.baseboard.size() < 3) return;

    // Convert 2D polygon to 3D coords matching the rest of the scene's
    // convention: x = world.x, y = -world.y (Y is up-positive in 3D),
    // z = -0.5mm so track on the baseboard sits above the green surface.
    std::vector<std::array<double,3>> pts;
    pts.reserve(state.baseboard.size());
    for (const auto& v : state.baseboard) {
        pts.push_back({ (double)v.x, (double)(-v.y), -0.5 });
    }

    TessCtx ctx;
    ctx.mesh = &m_baseboardMesh;

    GLUtesselator* tess = gluNewTess();
    gluTessCallback(tess, GLU_TESS_BEGIN_DATA,   (void (CALLBACK*)())tessBegin);
    gluTessCallback(tess, GLU_TESS_VERTEX_DATA,  (void (CALLBACK*)())tessVertex);
    gluTessCallback(tess, GLU_TESS_END_DATA,     (void (CALLBACK*)())tessEnd);
    gluTessCallback(tess, GLU_TESS_COMBINE_DATA, (void (CALLBACK*)())tessCombine);
    gluTessCallback(tess, GLU_TESS_ERROR_DATA,   (void (CALLBACK*)())tessError);
    gluTessNormal(tess, 0, 0, 1);

    gluTessBeginPolygon(tess, &ctx);
    gluTessBeginContour(tess);
    for (auto& p : pts) {
        gluTessVertex(tess, p.data(), p.data());
    }
    gluTessEndContour(tess);
    gluTessEndPolygon(tess);
    gluDeleteTess(tess);
}

void TrackGLCanvas::DrawBaseboard()
{
    if (m_baseboardMesh.verts.empty()) return;
    glEnableClientState(GL_VERTEX_ARRAY);
    glEnableClientState(GL_NORMAL_ARRAY);
    glEnableClientState(GL_COLOR_ARRAY);
    glVertexPointer(3, GL_FLOAT, 0, m_baseboardMesh.verts.data());
    glNormalPointer(GL_FLOAT, 0, m_baseboardMesh.normals.data());
    glColorPointer(3, GL_FLOAT, 0, m_baseboardMesh.colors.data());
    glDrawElements(GL_TRIANGLES, (GLsizei)m_baseboardMesh.indices.size(),
                   GL_UNSIGNED_INT, m_baseboardMesh.indices.data());
    glDisableClientState(GL_VERTEX_ARRAY);
    glDisableClientState(GL_NORMAL_ARRAY);
    glDisableClientState(GL_COLOR_ARRAY);
}

// ── Tunnels ───────────────────────────────────────────────────────────────────
// Builds a "loaf of bread" mountain over each Tunnel's covered pieces. The
// loaf follows the chain's centerline -- one half-arch cross section per
// covered piece, stitched together with quads.
//
// Cross-section shape (in local across-track + vertical plane):
//
//          *  (ridge, top of loaf)
//         / \
//        /   \
//       *     *  (skirt corners, at track-top level)
//      |       |
//      *-------*  (base, at track level minus a small inset)
//
// We add a base ring at track level so the loaf doesn't appear to float.
//
void TrackGLCanvas::RebuildTunnelMesh(const MC::CanvasState& state)
{
    m_tunnelMesh.clear();
    if (state.tunnels.empty()) return;

    const float kHalfWidth = 60.0f;     // 120mm wide
    const float kHeight    = 90.0f;     // 90mm above track top -- comfortably
                                        // clears the portal arch (~70mm tall)
                                        // so the arch crown doesn't poke
                                        // through the loaf surface.
    const int   kRingSegs  = 14;        // semicircle subdivisions (must be even, >= 4)
    const Color3 cTop  {0.40f, 0.55f, 0.32f};   // grass green ridge
    const Color3 cSide {0.55f, 0.46f, 0.36f};   // brown sides

    // Quick lookup piece by id and scenery by id
    std::unordered_map<int, const MC::PlacedPiece*>   byId;
    std::unordered_map<int, const MC::PlacedScenery*> scById;
    for (const auto& p : state.pieces)  byId[p.id]   = &p;
    for (const auto& s : state.scenery) scById[s.id] = &s;

    for (const auto& tunnel : state.tunnels) {
        if (tunnel.coveredPieceIds.size() < 1) continue;

        // Build covered set
        std::unordered_set<int> covSet(tunnel.coveredPieceIds.begin(),
                                        tunnel.coveredPieceIds.end());

        // Find an endpoint piece: one that has exactly one covered neighbor.
        int startId = -1;
        for (int pid : tunnel.coveredPieceIds) {
            auto it = byId.find(pid);
            if (it == byId.end()) continue;
            int covNbrs = 0;
            for (const auto& j : it->second->joints)
                if (j.pieceId != -1 && covSet.count(j.pieceId)) ++covNbrs;
            if (covNbrs <= 1) { startId = pid; break; }
        }
        if (startId == -1) startId = tunnel.coveredPieceIds.front();

        // Walk the chain in order
        std::vector<const MC::PlacedPiece*> chain;
        std::unordered_set<int> visited;
        int cur = startId;
        while (cur != -1) {
            auto it = byId.find(cur);
            if (it == byId.end()) break;
            chain.push_back(it->second);
            visited.insert(cur);
            int next = -1;
            for (const auto& j : it->second->joints) {
                if (j.pieceId == -1) continue;
                if (!covSet.count(j.pieceId)) continue;
                if (visited.count(j.pieceId)) continue;
                next = j.pieceId;
                break;
            }
            cur = next;
        }
        if (chain.empty()) continue;

        // Loaf endpoint at each chain end = exactly where OnTunnel placed the
        // portal: step 1/3 of the piece length INTO the piece from its open
        // (non-covered) connection. Same formula both ends, no portal lookup,
        // no distance matching.
        struct Sample { float cx, cy, cz; float ax, ay; };
        std::vector<Sample> samples;

        auto pieceSample = [](const MC::PlacedPiece& p) {
            Sample s;
            s.cx = (float) p.transform.pos.x;
            s.cy = (float)-p.transform.pos.y;
            s.cz = (float) p.z_mm;
            float hr = (float)(p.transform.heading * M_PI / 180.0);
            s.ax = -std::sin(hr);
            s.ay = -std::cos(hr);
            return s;
        };

        // Loaf endpoint at each chain end = the open connection point of that
        // endpoint piece. Portals sit at exactly the same coordinate.
        auto portalSpotForEndpoint = [&](const MC::PlacedPiece& p) -> Sample {
            Sample s = pieceSample(p);
            int openConn = -1;
            for (size_t i = 0; i < p.joints.size(); ++i) {
                int nb = p.joints[i].pieceId;
                if (nb == -1) { openConn = (int)i; break; }
                if (!covSet.count(nb)) { openConn = (int)i; break; }
            }
            if (openConn < 0) return s;
            const MC::TrackPiece* def = MC::GetCatalog().Find(p.catalogNumber);
            if (!def) return s;
            MC::ConnectionPoint cp = p.worldConnection(*def, openConn);
            s.cx = (float)  cp.pos.x;
            s.cy = (float)-cp.pos.y;
            return s;
        };

        samples.push_back(portalSpotForEndpoint(*chain.front()));
        for (auto* p : chain) samples.push_back(pieceSample(*p));
        samples.push_back(portalSpotForEndpoint(*chain.back()));

        // Helper to add a vertex
        auto addV = [&](float x, float y, float z,
                         float nx, float ny, float nz, Color3 c) -> uint32_t {
            uint32_t idx = (uint32_t) m_tunnelMesh.verts.size();
            m_tunnelMesh.verts.push_back({x, y, z});
            m_tunnelMesh.normals.push_back({nx, ny, nz});
            m_tunnelMesh.colors.push_back(c);
            return idx;
        };
        auto addTri = [&](uint32_t a, uint32_t b, uint32_t c) {
            m_tunnelMesh.indices.push_back(a);
            m_tunnelMesh.indices.push_back(b);
            m_tunnelMesh.indices.push_back(c);
        };
        auto addQuad = [&](uint32_t a, uint32_t b, uint32_t c, uint32_t d) {
            addTri(a, b, c); addTri(a, c, d);
        };

        // For each sample build a "ring": kRingSegs+1 vertices around a half
        // dome in the across-vertical plane. Parameter theta: 0 = right base,
        // pi = left base, pi/2 = ridge top.
        std::vector<std::vector<uint32_t>> rings;
        rings.reserve(samples.size());
        for (const auto& s : samples) {
            std::vector<uint32_t> ring;
            ring.reserve(kRingSegs + 1);
            for (int k = 0; k <= kRingSegs; ++k) {
                float t = (float)k / (float)kRingSegs;     // 0..1
                float theta = t * (float)M_PI;             // 0..pi
                float dy = std::cos(theta);                // 1 -> -1
                float dz = std::sin(theta);                // 0 -> 1 -> 0
                float x = s.cx + s.ax * dy * kHalfWidth;
                float y = s.cy + s.ay * dy * kHalfWidth;
                float z = s.cz + dz * kHeight;
                // Normal: outward from centerline at this angle
                float nx = s.ax * dy;
                float ny = s.ay * dy;
                float nz = dz;
                // Interpolate color: grass at top, brown on the sides.
                float topness = dz;     // 0..1
                Color3 c;
                c.r = cSide.r + (cTop.r - cSide.r) * topness;
                c.g = cSide.g + (cTop.g - cSide.g) * topness;
                c.b = cSide.b + (cTop.b - cSide.b) * topness;
                ring.push_back(addV(x, y, z, nx, ny, nz, c));
            }
            rings.push_back(std::move(ring));
        }

        // Stitch adjacent rings with quads (kRingSegs quads per pair).
        for (size_t i = 0; i + 1 < rings.size(); ++i) {
            const auto& A = rings[i];
            const auto& B = rings[i+1];
            for (int k = 0; k < kRingSegs; ++k) {
                addQuad(A[k], B[k], B[k+1], A[k+1]);
            }
        }
    }
}

void TrackGLCanvas::DrawTunnels()
{
    if (m_tunnelMesh.verts.empty()) return;
    glEnableClientState(GL_VERTEX_ARRAY);
    glEnableClientState(GL_NORMAL_ARRAY);
    glEnableClientState(GL_COLOR_ARRAY);
    glVertexPointer(3, GL_FLOAT, 0, m_tunnelMesh.verts.data());
    glNormalPointer(GL_FLOAT, 0, m_tunnelMesh.normals.data());
    glColorPointer(3, GL_FLOAT, 0, m_tunnelMesh.colors.data());
    glDrawElements(GL_TRIANGLES, (GLsizei)m_tunnelMesh.indices.size(),
                   GL_UNSIGNED_INT, m_tunnelMesh.indices.data());
    glDisableClientState(GL_VERTEX_ARRAY);
    glDisableClientState(GL_NORMAL_ARRAY);
    glDisableClientState(GL_COLOR_ARRAY);
}
void TrackGLCanvas::InitGL()
{
    glEnable(GL_DEPTH_TEST);
    glDepthFunc(GL_LEQUAL);
    glEnable(GL_LIGHTING);
    glEnable(GL_LIGHT0);
    glEnable(GL_LIGHT1);
    glEnable(GL_COLOR_MATERIAL);
    glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE);
    glShadeModel(GL_SMOOTH);
    glEnable(GL_NORMALIZE);

    // Key light — warm, from above-left-front
    GLfloat a0[]={0.22f,0.22f,0.20f,1}, d0[]={0.92f,0.90f,0.84f,1};
    GLfloat p0[]={700.f, 500.f, 1000.f, 1.f};
    glLightfv(GL_LIGHT0, GL_AMBIENT,  a0);
    glLightfv(GL_LIGHT0, GL_DIFFUSE,  d0);
    glLightfv(GL_LIGHT0, GL_POSITION, p0);

    // Fill light — cool, from lower-right-rear
    GLfloat a1[]={0.04f,0.04f,0.07f,1}, d1[]={0.22f,0.26f,0.34f,1};
    GLfloat p1[]={-500.f,-700.f,250.f, 1.f};
    glLightfv(GL_LIGHT1, GL_AMBIENT,  a1);
    glLightfv(GL_LIGHT1, GL_DIFFUSE,  d1);
    glLightfv(GL_LIGHT1, GL_POSITION, p1);

    // Mild specular (makes rails gleam)
    GLfloat spec[]={0.35f,0.35f,0.38f,1};
    glLightfv(GL_LIGHT0, GL_SPECULAR, spec);
    glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, spec);
    glMaterialf (GL_FRONT_AND_BACK, GL_SHININESS, 20.0f);

    glClearColor(0.14f, 0.16f, 0.19f, 1.0f);
    m_glInit = true;
}

// ── SetupCamera ───────────────────────────────────────────────────────────────
void TrackGLCanvas::SetupCamera()
{
    wxSize sz = GetClientSize();
    if (sz.x<1||sz.y<1) return;
    glViewport(0, 0, sz.x, sz.y);
    glMatrixMode(GL_PROJECTION); glLoadIdentity();
    gluPerspective(40.0, (double)sz.x/sz.y, 1.0, 60000.0);
    glMatrixMode(GL_MODELVIEW);  glLoadIdentity();
    gluLookAt(0,0,m_zoom, 0,0,0, 0,1,0);
    glRotatef(m_rotX, 1,0,0);
    glRotatef(m_rotZ, 0,0,1);
    glTranslatef(-m_cx+m_panX, -m_cy+m_panY, 0);
}

// ── DrawGrid ──────────────────────────────────────────────────────────────────
void TrackGLCanvas::DrawGrid()
{
    glDisable(GL_LIGHTING);
    glColor3f(0.22f,0.24f,0.22f);
    glLineWidth(1.0f);
    float sz=3500.f, step=100.f;
    glBegin(GL_LINES);
    for (float x=-sz;x<=sz;x+=step){ glVertex3f(x,-sz,-0.5f); glVertex3f(x,sz,-0.5f); }
    for (float y=-sz;y<=sz;y+=step){ glVertex3f(-sz,y,-0.5f); glVertex3f(sz,y,-0.5f); }
    glEnd();
    glLineWidth(2.0f);
    glColor3f(0.45f,0.18f,0.18f); glBegin(GL_LINES); glVertex3f(0,-sz,-0.3f); glVertex3f(0,sz,-0.3f); glEnd();
    glColor3f(0.18f,0.45f,0.18f); glBegin(GL_LINES); glVertex3f(-sz,0,-0.3f); glVertex3f(sz,0,-0.3f); glEnd();
    glLineWidth(1.0f);
    glEnable(GL_LIGHTING);
}

// ── Draw one instance ─────────────────────────────────────────────────────────
void TrackGLCanvas::DrawInstance(const InstanceTransform& inst)
{
    auto it = m_pieceMeshCache.find(inst.catalogNumber);
    if (it == m_pieceMeshCache.end()) return;
    const Mesh& mesh = it->second.mesh;
    if (mesh.verts.empty()) return;

    glPushMatrix();

    // Place in GL world:
    //   1. Translate to world XY + entry Z
    //   2. Rotate by heading around Z axis
    //   3. Tilt by grade around local Y (lateral axis)
    //   4. Y-flip (canvas Y-down → GL Y-up)
    // ═══════════════════════════════════════════════════════════════════════
    // GRADE 3D TRANSFORM
    // z_mm is always the elevation at conn[0] (X=0 in local space).
    // gradeDeg means rise in the chain's direction of travel.
    //   - forward piece (chain enters conn[0]): conn[1] = z_mm + L*sin(gradeDeg)
    //   - reversed piece (chain enters conn[1]): conn[1] = z_mm - L*sin(gradeDeg)
    // Either way we tilt around Y at local X=0 (conn[0]); the sign flips for
    // the reversed case.
    glTranslatef(inst.wx, inst.wy, inst.wz);
    glRotatef(inst.headingDeg, 0, 0, 1);
    glRotatef(inst.flipped ? +inst.gradeDeg : -inst.gradeDeg, 0, 1, 0);
    glScalef(1.0f, -1.0f, 1.0f);
    // ═══════════════════════════════════════════════════════════════════════

    glEnableClientState(GL_VERTEX_ARRAY);
    glEnableClientState(GL_NORMAL_ARRAY);
    glEnableClientState(GL_COLOR_ARRAY);
    glVertexPointer(3, GL_FLOAT, 0, mesh.verts.data());
    glNormalPointer(   GL_FLOAT, 0, mesh.normals.data());
    glColorPointer (3, GL_FLOAT, 0, mesh.colors.data());
    glDrawElements(GL_TRIANGLES, (GLsizei)mesh.indices.size(),
                   GL_UNSIGNED_INT, mesh.indices.data());
    glDisableClientState(GL_COLOR_ARRAY);
    glDisableClientState(GL_NORMAL_ARRAY);
    glDisableClientState(GL_VERTEX_ARRAY);

    glPopMatrix();
}

// ── Draw one scenery instance ─────────────────────────────────────────────────
void TrackGLCanvas::DrawSceneryInstance(const SceneryInstance& inst)
{
    auto it = m_sceneryMeshCache.find(inst.itemId);
    if (it == m_sceneryMeshCache.end()) return;
    const Mesh& mesh = it->second.mesh;
    if (mesh.verts.empty()) return;

    glPushMatrix();
    // Mesh is pre-centred on origin with Y-up (GL convention).
    // Bridges need their deck at track level — offset so deck Z aligns with track surface
    float zOff = -1.0f;
    if (inst.itemId == "bridge_arch")  zOff = -18.0f;
    if (inst.itemId == "bridge_truss") zOff =  -4.0f;
    // placed.transform.pos is the CENTER of the item in 2D-world coords.
    // The mesh is built centered on the origin via CX/CY, so we translate
    // directly to the world position. inst.wz lifts the item to its level's
    // physical height.
    glTranslatef(inst.wx, inst.wy, zOff + inst.wz);
    glRotatef(inst.headingDeg, 0, 0, 1);
    // Apply user scale — X maps to track-parallel, Y maps to track-perpendicular
    if (inst.scaleX != 1.0f || inst.scaleY != 1.0f)
        glScalef(inst.scaleX, inst.scaleY, 1.0f);

    glEnableClientState(GL_VERTEX_ARRAY);
    glEnableClientState(GL_NORMAL_ARRAY);
    glEnableClientState(GL_COLOR_ARRAY);
    glVertexPointer(3, GL_FLOAT, 0, mesh.verts.data());
    glNormalPointer(   GL_FLOAT, 0, mesh.normals.data());
    glColorPointer (3, GL_FLOAT, 0, mesh.colors.data());
    glDrawElements(GL_TRIANGLES, (GLsizei)mesh.indices.size(),
                   GL_UNSIGNED_INT, mesh.indices.data());
    glDisableClientState(GL_COLOR_ARRAY);
    glDisableClientState(GL_NORMAL_ARRAY);
    glDisableClientState(GL_VERTEX_ARRAY);

    glPopMatrix();
}

// ── OnPaint ───────────────────────────────────────────────────────────────────
void TrackGLCanvas::OnPaint(wxPaintEvent&)
{
    wxPaintDC dc(this);
    if (!SetCurrent(*m_ctx)) return;
    if (!m_glInit) InitGL();
    SetupCamera();
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    DrawGrid();
    DrawBaseboard();
    // Draw scenery first (behind/under track)
    for (const auto& inst : m_sceneryInstances)
        DrawSceneryInstance(inst);
    // Draw bridge supports
    if (!m_supportMesh.verts.empty()) {
        glPushMatrix();
        glEnableClientState(GL_VERTEX_ARRAY);
        glEnableClientState(GL_NORMAL_ARRAY);
        glEnableClientState(GL_COLOR_ARRAY);
        glVertexPointer(3, GL_FLOAT, 0, m_supportMesh.verts.data());
        glNormalPointer(GL_FLOAT, 0, m_supportMesh.normals.data());
        glColorPointer(3, GL_FLOAT, 0, m_supportMesh.colors.data());
        glDrawElements(GL_TRIANGLES, (GLsizei)m_supportMesh.indices.size(),
                       GL_UNSIGNED_INT, m_supportMesh.indices.data());
        glDisableClientState(GL_VERTEX_ARRAY);
        glDisableClientState(GL_NORMAL_ARRAY);
        glDisableClientState(GL_COLOR_ARRAY);
        glPopMatrix();
    }

    // Opaque polygon shapes (100% opacity). Drawn as ordinary solid geometry
    // -- depth writes on, no blending -- so the shape's own faces occlude each
    // other and it renders as a closed box with a proper roof. Colours are
    // RGBA, but every alpha here is 1.0, so no blending is needed.
    if (!m_shapeMeshOpaque.verts.empty()) {
        glPushMatrix();
        glEnableClientState(GL_VERTEX_ARRAY);
        glEnableClientState(GL_NORMAL_ARRAY);
        glEnableClientState(GL_COLOR_ARRAY);
        glVertexPointer(3, GL_FLOAT, 0, m_shapeMeshOpaque.verts.data());
        glNormalPointer(GL_FLOAT, 0, m_shapeMeshOpaque.normals.data());
        glColorPointer(4, GL_FLOAT, 0, m_shapeMeshOpaque.colors.data());
        glDrawElements(GL_TRIANGLES, (GLsizei)m_shapeMeshOpaque.indices.size(),
                       GL_UNSIGNED_INT, m_shapeMeshOpaque.indices.data());
        glDisableClientState(GL_VERTEX_ARRAY);
        glDisableClientState(GL_NORMAL_ARRAY);
        glDisableClientState(GL_COLOR_ARRAY);
        glPopMatrix();
    }

    // Draw track on top — polygon offset ensures track wins any depth ties
    glEnable(GL_POLYGON_OFFSET_FILL);
    glPolygonOffset(-1.0f, -1.0f);
    for (const auto& inst : m_instances)
        DrawInstance(inst);
    glDisable(GL_POLYGON_OFFSET_FILL);
    // Tunnels drawn AFTER track so the loaf covers the rails it spans.
    DrawTunnels();

    // ── Transparent polygon shapes ────────────────────────────────────────
    // Only shapes below 100% opacity reach here; solid ones were drawn above
    // with the opaque geometry. Drawn LAST, after every opaque surface,
    // because they are alpha-blended:
    // a transparent surface has to composite over what is already in the
    // framebuffer, so whatever it should show through must be drawn first.
    //
    // glDepthMask(GL_FALSE) leaves depth TESTING on (so a shape is still
    // correctly hidden behind solid geometry in front of it) but stops it
    // WRITING depth -- otherwise the first shape drawn would occlude any
    // shape behind it, and overlapping shapes would punch holes in each other.
    if (!m_shapeMesh.verts.empty()) {
        glEnable(GL_BLEND);
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
        glDepthMask(GL_FALSE);

        glPushMatrix();
        glEnableClientState(GL_VERTEX_ARRAY);
        glEnableClientState(GL_NORMAL_ARRAY);
        glEnableClientState(GL_COLOR_ARRAY);
        glVertexPointer(3, GL_FLOAT, 0, m_shapeMesh.verts.data());
        glNormalPointer(GL_FLOAT, 0, m_shapeMesh.normals.data());
        glColorPointer(4, GL_FLOAT, 0, m_shapeMesh.colors.data());   // RGBA
        glDrawElements(GL_TRIANGLES, (GLsizei)m_shapeMesh.indices.size(),
                       GL_UNSIGNED_INT, m_shapeMesh.indices.data());
        glDisableClientState(GL_VERTEX_ARRAY);
        glDisableClientState(GL_NORMAL_ARRAY);
        glDisableClientState(GL_COLOR_ARRAY);
        glPopMatrix();

        glDepthMask(GL_TRUE);
        glDisable(GL_BLEND);
    }

    SwapBuffers();
}

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

void TrackGLCanvas::OnMouseDown(wxMouseEvent& e) {
    m_lastMouse=e.GetPosition(); m_ldown=e.LeftIsDown(); m_rdown=e.RightIsDown();
    CaptureMouse(); SetFocus(); e.Skip();
}
void TrackGLCanvas::OnMouseUp(wxMouseEvent& e) {
    m_ldown=m_rdown=false; if(HasCapture()) ReleaseMouse(); e.Skip();
}
void TrackGLCanvas::OnMouseMove(wxMouseEvent& e) {
    wxPoint p=e.GetPosition();
    int dx=p.x-m_lastMouse.x, dy=p.y-m_lastMouse.y;
    m_lastMouse=p;
    if (m_ldown) {
        m_rotZ+=dx*0.5f; m_rotX+=dy*0.5f;
        m_rotX=std::max(-89.f,std::min(89.f,m_rotX));
    }
    if (m_rdown) {
        float rad=m_rotZ*(float)M_PI/180.f;
        m_panX+=( dx*std::cos(rad)+dy*std::sin(rad))*m_zoom/900.f;
        m_panY+=(-dx*std::sin(rad)+dy*std::cos(rad))*m_zoom/900.f;
    }
    if (m_ldown||m_rdown) Refresh();
    e.Skip();
}
void TrackGLCanvas::OnMouseWheel(wxMouseEvent& e) {
    m_zoom *= (e.GetWheelRotation()>0) ? 0.88f : 1.0f/0.88f;
    m_zoom=std::max(50.f,std::min(m_zoom,15000.f));
    Refresh();
}
void TrackGLCanvas::OnKeyDown(wxKeyEvent& e) {
    if (e.GetKeyCode()=='R'||e.GetKeyCode()=='r') {
        m_rotX=35.f; m_rotZ=30.f; m_panX=m_panY=0.f; Refresh();
    }
    e.Skip();
}

// ── View3DFrame ───────────────────────────────────────────────────────────────
View3DFrame::View3DFrame(wxWindow* parent, const MC::CanvasState& state)
    : wxFrame(parent, wxID_ANY, "3D View - Track Designer",
              wxDefaultPosition, wxSize(1000,700),
              wxDEFAULT_FRAME_STYLE | wxFRAME_FLOAT_ON_PARENT)
{
    CreateStatusBar()->SetStatusText(
        "Left drag: rotate  |  Right drag: pan  |  Scroll: zoom  |  R: reset view");
    m_canvas = new TrackGLCanvas(this, state);
    wxBoxSizer* s = new wxBoxSizer(wxVERTICAL);
    s->Add(m_canvas, 1, wxEXPAND);
    SetSizer(s);
    Show();
}

void View3DFrame::RefreshLayout(const MC::CanvasState& state) {
    m_canvas->RebuildMesh(state);
    m_canvas->Refresh();
}
