123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
//! Self-render a whole `.kicad_pcb` to a themed, INTERACTIVE SVG.
//!
//! kicad-cli's `pcb export svg` is FLAT and ANONYMOUS — no way to know which
//! path is which net/part. We parse the board ourselves and tag every copper
//! element with `data-net`, every pad with `data-net`/`data-ref`/`data-pad`, and
//! every component hotspot with `data-ref`/`data-side`. That identity is what
//! makes real net highlighting, per-component hover, and per-side layer raising
//! possible.
//!
//! Layers are split by side (F.Cu/B.Cu, F.Fab/B.Fab, F.SilkS/B.SilkS) so the
//! frontend can raise the back of the board to the front to select bottom parts.
//! Component OUTLINES come from the footprints' F.Fab/B.Fab graphics.
//!
//! Board coords are Y-DOWN (page mm), same as SVG → no global flip.

use regex::Regex;
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Write as _;

// ── Parsed model ───────────────────────────────────────────────────────────

// These are `pub(crate)` so sibling parser modules (eagle.rs for Fusion/EAGLE,
// altium.rs for .PcbDoc) can build the SAME neutral model. Everything downstream
// — overlay, net highlighting, hover, self-render — is then EDA-agnostic.
pub(crate) struct Track { pub x1: f64, pub y1: f64, pub x2: f64, pub y2: f64, pub width: f64, pub layer: String, pub net: u32 }
pub(crate) struct Via { pub x: f64, pub y: f64, pub size: f64, pub drill: f64, pub net: u32 }
pub(crate) struct Arc { pub d: String, pub layer: String, pub width: f64, pub net: u32 }
pub(crate) struct Pad {
    pub reference: String, pub num: String, pub net: u32,
    pub x: f64, pub y: f64, pub rot: f64, pub w: f64, pub h: f64,
    pub shape: String, pub drill: f64, pub mount: String, pub side: char,
}
pub(crate) struct ZonePoly { pub d: String, pub layer: String, pub net: u32 }
/// A graphic stroke on a fab / silk / courtyard / edge layer. `d` is an SVG path.
pub(crate) struct Gfx { pub d: String, pub width: f64, pub closed_fill: bool }

#[derive(Serialize, Clone)]
pub struct Comp {
    pub reference: String,
    pub value: String,
    pub footprint: String,
    pub side: String,
    pub mpn: String,
    pub lcsc: String,
    pub x: f64,
    pub y: f64,
    pub bbox: [f64; 4],
    pub pad_count: usize,
}

#[derive(Default)]
pub struct Board {
    pub nets: HashMap<u32, String>,
    pub(crate) tracks: Vec<Track>,
    pub(crate) vias: Vec<Via>,
    pub(crate) arcs: Vec<Arc>,
    pub(crate) pads: Vec<Pad>,
    pub(crate) zones: Vec<ZonePoly>,
    pub(crate) edges: Vec<Gfx>,
    pub(crate) fab_f: Vec<Gfx>, pub(crate) fab_b: Vec<Gfx>,
    pub(crate) silk_f: Vec<Gfx>, pub(crate) silk_b: Vec<Gfx>,
    pub(crate) crtyd_f: Vec<Gfx>, pub(crate) crtyd_b: Vec<Gfx>,
    pub(crate) txt_f: Vec<String>, pub(crate) txt_b: Vec<String>, // pre-rendered <text> on F/B silk
    pub comps: Vec<Comp>,
}

// ── S-expr helpers ──────────────────────────────────────────────────────────

fn blocks_ws<'a>(s: &'a str, tag: &str) -> Vec<&'a str> {
    let bytes = s.as_bytes();
    let needle = format!("({}", tag);
    let mut out = Vec::new();
    let mut search = 0;
    while let Some(rel) = s[search..].find(&needle) {
        let start = search + rel;
        let after = start + needle.len();
        let ok = bytes.get(after).map(|c| c.is_ascii_whitespace() || *c == b'(').unwrap_or(false);
        if !ok { search = after; continue; }
        let mut depth = 0i32;
        let mut end = start;
        for i in start..s.len() {
            match bytes[i] {
                b'(' => depth += 1,
                b')' => { depth -= 1; if depth == 0 { end = i; break; } }
                _ => {}
            }
        }
        out.push(&s[start..=end.min(s.len() - 1)]);
        search = end + 1;
    }
    out
}
fn f(cap: Option<regex::Match>) -> f64 { cap.and_then(|m| m.as_str().parse().ok()).unwrap_or(0.0) }
pub fn esc(s: &str) -> String { s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") }

// ── Parse ────────────────────────────────────────────────────────────────

pub fn parse(content: &str) -> Board {
    let net_decl = Regex::new(r##"\(net\s+(\d+)\s+"([^"]*)"\)"##).unwrap();
    let net_ref = Regex::new(r"\(net\s+(\d+)\s*\)").unwrap();
    let net_name_ref = Regex::new(r##"\(net\s+"([^"]*)"\)"##).unwrap();
    let start_re = Regex::new(r"\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let mid_re = Regex::new(r"\(mid\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let end_re = Regex::new(r"\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let center_re = Regex::new(r"\(center\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let width_re = Regex::new(r"\(width\s+(-?[\d.]+)\)").unwrap();
    let layer_re = Regex::new(r##"\(layer\s+"([^"]+)"\)"##).unwrap();
    let at_re = Regex::new(r"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\)").unwrap();
    let size_re = Regex::new(r"\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let via_size_re = Regex::new(r"\(size\s+(-?[\d.]+)\)").unwrap();
    let drill_re = Regex::new(r"\(drill\s+(-?[\d.]+)").unwrap();
    let xy_re = Regex::new(r"\(xy\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
    let pad_head = Regex::new(r##"\(pad\s+"?([^"\s)]+)"?\s+(\w+)\s+(\w+)"##).unwrap();
    let prop_re = Regex::new(r##"\(property\s+"([^"]+)"\s+"([^"]*)""##).unwrap();
    let text_layer = Regex::new(r##"\(layer\s+"([^"]+)""##).unwrap(); // loose: allows `knockout` after

    let mut b = Board::default();

    for c in net_decl.captures_iter(content) {
        b.nets.entry(c[1].parse().unwrap_or(0)).or_insert_with(|| unescape_net(&c[2]));
    }

    // KiCad 10 (generator_version 10.0, file version 20260206+) dropped numeric net ids:
    // there is no `(net <id> "<name>")` declaration table, and pads/tracks/vias/zones carry
    // `(net "<name>")` by name only. Intern those names into synthetic ids so the rest of the
    // renderer — which keys everything by u32 — keeps working unchanged.
    let mut name_to_id: HashMap<String, u32> = HashMap::new();
    for c in net_name_ref.captures_iter(content) {
        let name = unescape_net(&c[1]);
        let next = name_to_id.len() as u32 + 1;
        name_to_id.entry(name).or_insert(next);
    }
    for (name, id) in &name_to_id {
        b.nets.entry(*id).or_insert_with(|| name.clone());
    }

    // Resolve a net from a block under either schema: `(net <id>)` [KiCad <=9 reference],
    // `(net <id> "<name>")` [KiCad <=9 pad], or `(net "<name>")` [KiCad 10].
    let resolve_net = |blk: &str| -> u32 {
        if let Some(c) = net_ref.captures(blk) {
            if let Ok(v) = c[1].parse() { return v; }
        }
        if let Some(c) = net_decl.captures(blk) {
            if let Ok(v) = c[1].parse() { return v; }
        }
        if let Some(c) = net_name_ref.captures(blk) {
            if let Some(id) = name_to_id.get(&unescape_net(&c[1])) { return *id; }
        }
        0
    };

    // strip footprints so board-level track/via/zone/gr scans don't see fp_* / pads
    let no_fp = strip_all(content, "footprint");

    for blk in blocks_ws(&no_fp, "segment") {
        let (s, e) = match (start_re.captures(blk), end_re.captures(blk)) { (Some(s), Some(e)) => (s, e), _ => continue };
        b.tracks.push(Track {
            x1: f(s.get(1)), y1: f(s.get(2)), x2: f(e.get(1)), y2: f(e.get(2)),
            width: width_re.captures(blk).map(|c| f(c.get(1))).unwrap_or(0.15),
            layer: layer_re.captures(blk).map(|c| c[1].to_string()).unwrap_or_default(),
            net: resolve_net(blk),
        });
    }
    for blk in blocks_ws(&no_fp, "arc") {
        let (s, m, e) = match (start_re.captures(blk), mid_re.captures(blk), end_re.captures(blk)) {
            (Some(s), Some(m), Some(e)) => (s, m, e), _ => continue };
        b.arcs.push(Arc {
            d: arc_path((f(s.get(1)), f(s.get(2))), (f(m.get(1)), f(m.get(2))), (f(e.get(1)), f(e.get(2)))),
            width: width_re.captures(blk).map(|c| f(c.get(1))).unwrap_or(0.15),
            layer: layer_re.captures(blk).map(|c| c[1].to_string()).unwrap_or_default(),
            net: resolve_net(blk),
        });
    }
    for blk in blocks_ws(&no_fp, "via") {
        let at = match at_re.captures(blk) { Some(a) => a, None => continue };
        b.vias.push(Via {
            x: f(at.get(1)), y: f(at.get(2)),
            size: via_size_re.captures(blk).map(|c| f(c.get(1))).unwrap_or(0.6),
            drill: drill_re.captures(blk).map(|c| f(c.get(1))).unwrap_or(0.3),
            net: resolve_net(blk),
        });
    }
    for zone in blocks_ws(&no_fp, "zone") {
        let net = resolve_net(zone);
        for fp in blocks_ws(zone, "filled_polygon") {
            let layer = layer_re.captures(fp).map(|c| c[1].to_string()).unwrap_or_default();
            for pts_blk in blocks_ws(fp, "pts") {
                let pts: Vec<(f64, f64)> = xy_re.captures_iter(pts_blk).map(|c| (f(c.get(1)), f(c.get(2)))).collect();
                if pts.len() >= 3 { b.zones.push(ZonePoly { d: poly_d(&pts, true), layer: layer.clone(), net }); }
            }
        }
    }
    // board graphics (gr_*) → edge / silk
    for blk in blocks_ws(&no_fp, "gr_line") {
        let (s, e) = match (start_re.captures(blk), end_re.captures(blk)) { (Some(s), Some(e)) => (s, e), _ => continue };
        let layer = layer_re.captures(blk).map(|c| c[1].to_string()).unwrap_or_default();
        let d = format!("M {:.3} {:.3} L {:.3} {:.3}", f(s.get(1)), f(s.get(2)), f(e.get(1)), f(e.get(2)));
        route_gfx(&mut b, &layer, Gfx { d, width: width_re.captures(blk).map(|c| f(c.get(1))).unwrap_or(0.1), closed_fill: false });
    }
    for blk in blocks_ws(&no_fp, "gr_arc") {
        let (s, m, e) = match (start_re.captures(blk), mid_re.captures(blk), end_re.captures(blk)) {
            (Some(s), Some(m), Some(e)) => (s, m, e), _ => continue };
        let layer = layer_re.captures(blk).map(|c| c[1].to_string()).unwrap_or_default();
        let d = arc_path((f(s.get(1)), f(s.get(2))), (f(m.get(1)), f(m.get(2))), (f(e.get(1)), f(e.get(2))));
        route_gfx(&mut b, &layer, Gfx { d, width: width_re.captures(blk).map(|c| f(c.get(1))).unwrap_or(0.1), closed_fill: false });
    }
    // board-level silk text (gr_text): "3V3", "GND", "BOSCH: BME690\n…", etc.
    for blk in blocks_ws(&no_fp, "gr_text") {
        if blk.contains("(hide yes)") { continue; }
        let layer = text_layer.captures(blk).map(|c| c[1].to_string()).unwrap_or_default();
        if !layer.contains("SilkS") { continue; }
        let text = first_quoted(blk); if text.is_empty() { continue; }
        let at = match at_re.captures(blk) { Some(a) => a, None => continue };
        let size = size_re.captures(blk).map(|c| f(c.get(2))).unwrap_or(1.0);
        let side = if layer.starts_with("B.") { 'B' } else { 'F' };
        let anchor = text_anchor(blk);
        let snip = mk_text(f(at.get(1)), f(at.get(2)), f(at.get(3)), &text, size, blk.contains("(bold yes)"), side, anchor);
        if side == 'B' { b.txt_b.push(snip) } else { b.txt_f.push(snip) }
    }

    // ── Footprints → components, pads, and fab/silk/courtyard OUTLINES ──
    for fpblk in blocks_ws(content, "footprint") {
        let fat = match at_re.captures(fpblk) { Some(a) => a, None => continue };
        let (fx, fy, frot) = (f(fat.get(1)), f(fat.get(2)), f(fat.get(3)));
        let fside = layer_re.captures(fpblk).map(|c| c[1].to_string()).unwrap_or_else(|| "F.Cu".into());
        let bottom = fside.starts_with("B.");
        let side_c = if bottom { 'B' } else { 'F' };
        let xf = |lx: f64, ly: f64| -> (f64, f64) { let (rx, ry) = rot_pt(lx, ly, frot); (rx + fx, ry + fy) };

        let mut reference = String::new(); let mut value = String::new();
        let mut mpn = String::new(); let mut lcsc = String::new(); let mut fpname = String::new();
        for c in prop_re.captures_iter(fpblk) {
            match &c[1] {
                "Reference" => reference = c[2].to_string(),
                "Value" => value = c[2].to_string(),
                "Footprint" => fpname = c[2].to_string(),
                k if k.eq_ignore_ascii_case("MPN") || k.eq_ignore_ascii_case("Manufacturer Part Number") => mpn = c[2].to_string(),
                k if k.eq_ignore_ascii_case("LCSC") || k.eq_ignore_ascii_case("JLCPCB") => lcsc = c[2].to_string(),
                _ => {}
            }
        }
        if fpname.is_empty() { if let Some(q1) = fpblk.find('"') { if let Some(q2) = fpblk[q1 + 1..].find('"') { fpname = fpblk[q1 + 1..q1 + 1 + q2].to_string(); } } }

        // graphics helper: route a transformed stroke by its layer + side
        macro_rules! push_gfx { ($layer:expr, $g:expr) => {{
            let l: &str = $layer;
            if l.contains("Fab") { if bottom { b.fab_b.push($g) } else { b.fab_f.push($g) } }
            else if l.contains("SilkS") { if bottom { b.silk_b.push($g) } else { b.silk_f.push($g) } }
            else if l.contains("CrtYd") { if bottom { b.crtyd_b.push($g) } else { b.crtyd_f.push($g) } }
        }}; }
        for g in blocks_ws(fpblk, "fp_line") {
            let (s, e) = match (start_re.captures(g), end_re.captures(g)) { (Some(s), Some(e)) => (s, e), _ => continue };
            let (p1, p2) = (xf(f(s.get(1)), f(s.get(2))), xf(f(e.get(1)), f(e.get(2))));
            let layer = layer_re.captures(g).map(|c| c[1].to_string()).unwrap_or_default();
            push_gfx!(&layer, Gfx { d: format!("M {:.3} {:.3} L {:.3} {:.3}", p1.0, p1.1, p2.0, p2.1), width: width_re.captures(g).map(|c| f(c.get(1))).unwrap_or(0.12), closed_fill: false });
        }
        for g in blocks_ws(fpblk, "fp_rect") {
            let (s, e) = match (start_re.captures(g), end_re.captures(g)) { (Some(s), Some(e)) => (s, e), _ => continue };
            let (x1, y1, x2, y2) = (f(s.get(1)), f(s.get(2)), f(e.get(1)), f(e.get(2)));
            let corners = [xf(x1, y1), xf(x2, y1), xf(x2, y2), xf(x1, y2), xf(x1, y1)];
            let layer = layer_re.captures(g).map(|c| c[1].to_string()).unwrap_or_default();
            push_gfx!(&layer, Gfx { d: poly_d(&corners, false), width: width_re.captures(g).map(|c| f(c.get(1))).unwrap_or(0.12), closed_fill: false });
        }
        for g in blocks_ws(fpblk, "fp_poly") {
            let pts: Vec<(f64, f64)> = xy_re.captures_iter(g).map(|c| xf(f(c.get(1)), f(c.get(2)))).collect();
            if pts.len() < 2 { continue; }
            let layer = layer_re.captures(g).map(|c| c[1].to_string()).unwrap_or_default();
            let fill = layer.contains("SilkS");
            push_gfx!(&layer, Gfx { d: poly_d(&pts, true), width: width_re.captures(g).map(|c| f(c.get(1))).unwrap_or(0.12), closed_fill: fill });
        }
        for g in blocks_ws(fpblk, "fp_circle") {
            let (ce, en) = match (center_re.captures(g), end_re.captures(g)) { (Some(c), Some(e)) => (c, e), _ => continue };
            let (cx, cy) = (f(ce.get(1)), f(ce.get(2)));
            let r = ((f(en.get(1)) - cx).powi(2) + (f(en.get(2)) - cy).powi(2)).sqrt();
            let (bx, by) = xf(cx, cy);
            let d = format!("M {:.3} {:.3} A {:.3} {:.3} 0 1 0 {:.3} {:.3} A {:.3} {:.3} 0 1 0 {:.3} {:.3}", bx - r, by, r, r, bx + r, by, r, r, bx - r, by);
            let layer = layer_re.captures(g).map(|c| c[1].to_string()).unwrap_or_default();
            push_gfx!(&layer, Gfx { d, width: width_re.captures(g).map(|c| f(c.get(1))).unwrap_or(0.12), closed_fill: false });
        }
        for g in blocks_ws(fpblk, "fp_arc") {
            let (s, m, e) = match (start_re.captures(g), mid_re.captures(g), end_re.captures(g)) { (Some(s), Some(m), Some(e)) => (s, m, e), _ => continue };
            let d = arc_path(xf(f(s.get(1)), f(s.get(2))), xf(f(m.get(1)), f(m.get(2))), xf(f(e.get(1)), f(e.get(2))));
            let layer = layer_re.captures(g).map(|c| c[1].to_string()).unwrap_or_default();
            push_gfx!(&layer, Gfx { d, width: width_re.captures(g).map(|c| f(c.get(1))).unwrap_or(0.12), closed_fill: false });
        }
        // footprint silk text (fp_text), incl. ${REFERENCE} → the refdes
        for tb in blocks_ws(fpblk, "fp_text") {
            if tb.contains("(hide yes)") { continue; }
            let layer = text_layer.captures(tb).map(|c| c[1].to_string()).unwrap_or_default();
            if !layer.contains("SilkS") { continue; }
            let mut text = first_quoted(tb);
            text = text.replace("${REFERENCE}", &reference);
            if text.trim().is_empty() { continue; }
            let at = match at_re.captures(tb) { Some(a) => a, None => continue };
            let (tx, ty) = xf(f(at.get(1)), f(at.get(2)));
            let size = size_re.captures(tb).map(|c| f(c.get(2))).unwrap_or(1.0);
            let tside = if layer.starts_with("B.") { 'B' } else { 'F' };
            // NOTE: angle is ABSOLUTE in the file (KiCad bakes the footprint's rotation into
            // each text's `(at .. angle)`), so do NOT add frot — that double-counts. U1 above
            // is the proof case: a 180-rotated footprint whose upright texts are stored as 0.
            let snip = mk_text(tx, ty, f(at.get(3)), &text, size, tb.contains("(bold yes)"), tside, "middle");
            if tside == 'B' { b.txt_b.push(snip) } else { b.txt_f.push(snip) }
        }

        // pads
        let mut minx = f64::INFINITY; let mut miny = f64::INFINITY; let mut maxx = f64::NEG_INFINITY; let mut maxy = f64::NEG_INFINITY;
        let mut pad_n = 0usize;
        for pblk in blocks_ws(fpblk, "pad") {
            let head = match pad_head.captures(pblk) { Some(h) => h, None => continue };
            let (pat, psz) = match (at_re.captures(pblk), size_re.captures(pblk)) { (Some(a), Some(s)) => (a, s), _ => continue };
            let (lx, ly, prot) = (f(pat.get(1)), f(pat.get(2)), f(pat.get(3)));
            let (bx, by) = xf(lx, ly);
            let net = resolve_net(pblk);
            let (w, h) = (f(psz.get(1)), f(psz.get(2)));
            minx = minx.min(bx - w); miny = miny.min(by - h); maxx = maxx.max(bx + w); maxy = maxy.max(by + h);
            pad_n += 1;
            // Pad POSITION is footprint-local and unrotated (so `xf` applies frot above), but the
            // pad ANGLE is already ABSOLUTE in the file — KiCad bakes the footprint rotation into
            // every pad's `(at .. angle)`. Adding frot double-counts it: a 90-rotated footprint
            // rendered its hitboxes at 180, which reads as "rotated 90" because a symmetric rect
            // at 180 looks like 0 (U3/U5/L4/C6); Y1 at 50 came out at 100.
            b.pads.push(Pad { reference: reference.clone(), num: head[1].to_string(), net, x: bx, y: by, rot: prot, w, h, shape: head[3].to_string(), drill: drill_re.captures(pblk).map(|c| f(c.get(1))).unwrap_or(0.0), mount: head[2].to_string(), side: side_c });
        }
        if !minx.is_finite() { minx = fx - 0.5; miny = fy - 0.5; maxx = fx + 0.5; maxy = fy + 0.5; }
        b.comps.push(Comp { reference, value, footprint: fpname, side: if bottom { "bottom".into() } else { "top".into() }, mpn, lcsc, x: fx, y: fy, bbox: [minx, miny, maxx, maxy], pad_count: pad_n });
    }
    b
}

/// Un-escape KiCad's net-name escaping (`/` is stored as `{slash}`, etc.).
fn unescape_net(s: &str) -> String {
    s.replace("{slash}", "/").replace("{backslash}", "\\").replace("{colon}", ":")
        .replace("{dblquote}", "\"").replace("{lt}", "<").replace("{gt}", ">").replace("{tab}", " ")
}
fn first_quoted(s: &str) -> String {
    if let Some(a) = s.find('"') { if let Some(b) = s[a + 1..].find('"') { return s[a + 1..a + 1 + b].to_string(); } }
    String::new()
}
fn text_anchor(blk: &str) -> &'static str {
    if blk.contains("(justify") {
        if blk.contains("left") { return "start"; }
        if blk.contains("right") { return "end"; }
    }
    "middle"
}
fn route_gfx(b: &mut Board, layer: &str, g: Gfx) {
    if layer == "Edge.Cuts" { b.edges.push(g); }
    else if layer == "F.SilkS" { b.silk_f.push(g); }
    else if layer == "B.SilkS" { b.silk_b.push(g); }
    else if layer == "F.Fab" { b.fab_f.push(g); }
    else if layer == "B.Fab" { b.fab_b.push(g); }
}

// ── Geometry ────────────────────────────────────────────────────────────────

fn rot_pt(x: f64, y: f64, deg: f64) -> (f64, f64) {
    if deg.abs() < 1e-6 { return (x, y); }
    let a = -deg.to_radians();
    (x * a.cos() - y * a.sin(), x * a.sin() + y * a.cos())
}
fn poly_d(pts: &[(f64, f64)], close: bool) -> String {
    let mut d = String::new();
    for (i, &(x, y)) in pts.iter().enumerate() { let _ = write!(d, "{}{:.3} {:.3} ", if i == 0 { "M" } else { "L" }, x, y); }
    if close { d.push('Z'); }
    d
}
/// SVG path for a KiCad 3-point arc (start→end, single arc as before) — with the
/// sweep flag FLIPPED from the original so board-edge fillets bow the right way.
fn arc_path(s: (f64, f64), m: (f64, f64), e: (f64, f64)) -> String {
    let (ax, ay) = s; let (bx, by) = m; let (cx, cy) = e;
    let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
    if d.abs() < 1e-9 { return format!("M {:.3} {:.3} L {:.3} {:.3}", ax, ay, cx, cy); }
    let ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
    let uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
    let r = ((ax - ux).powi(2) + (ay - uy).powi(2)).sqrt();
    let cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
    let sweep = if cross < 0.0 { 0 } else { 1 }; // flipped
    format!("M {:.3} {:.3} A {:.3} {:.3} 0 0 {} {:.3} {:.3}", ax, ay, r, r, sweep, cx, cy)
}
fn collect_path_pts(d: &str, out: &mut Vec<(f64, f64)>) {
    let re = Regex::new(r"[ML]\s*(-?[\d.]+)[ ,]+(-?[\d.]+)").unwrap();
    for c in re.captures_iter(d) { if let (Ok(x), Ok(y)) = (c[1].parse::<f64>(), c[2].parse::<f64>()) { out.push((x, y)); } }
}
fn strip_all(s: &str, tag: &str) -> String {
    let mut out = s.to_string();
    for b in blocks_ws(s, tag) { out = out.replacen(b, "", 1); }
    out
}

// ── Theming ───────────────────────────────────────────────────────────────

// adom-symbol/adom-footprint palette: copper red, grey fab, cyan silk, magenta crtyd.
fn copper_color(layer: &str) -> &'static str {
    match layer { "F.Cu" => "#c83434", "B.Cu" => "#4b7bd0", "In1.Cu" => "#c89b3a", "In2.Cu" => "#3ab87a", _ => "#9a9a9a" }
}

#[derive(Serialize)]
pub struct RenderMeta {
    pub width_mm: f64, pub height_mm: f64, pub view_box: [f64; 4],
    pub net_names: HashMap<String, String>, pub components: Vec<Comp>,
    pub track_count: usize, pub via_count: usize, pub pad_count: usize,
    /// Inner copper layers in this board's stackup, front->back (e.g. ["In1.Cu","In2.Cu"]).
    /// Empty on a 2-layer board. The frontend builds its layer rows from this, so a
    /// 4/6-layer board gets inner-layer toggles and raise-to-front automatically.
    #[serde(default)]
    pub inner_layers: Vec<String>,
}

/// Board bounding box (min_x, min_y, w, h) in mm, with a 1 mm margin. Prefers
/// Edge.Cuts; falls back to component/track extent.
pub fn board_bbox(b: &Board) -> (f64, f64, f64, f64) {
    let mut pts: Vec<(f64, f64)> = vec![];
    for g in &b.edges { collect_path_pts(&g.d, &mut pts); }
    if pts.len() < 2 {
        for c in &b.comps { pts.push((c.bbox[0], c.bbox[1])); pts.push((c.bbox[2], c.bbox[3])); }
        for t in &b.tracks { pts.push((t.x1, t.y1)); pts.push((t.x2, t.y2)); }
    }
    if pts.is_empty() { pts.push((0.0, 0.0)); pts.push((10.0, 10.0)); }
    let m = 1.0;
    let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min) - m;
    let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min) - m;
    let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max) + m;
    let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max) + m;
    (min_x, min_y, max_x - min_x, max_y - min_y)
}

pub fn build_meta(b: &Board, bbox: (f64, f64, f64, f64)) -> RenderMeta {
    build_meta_with(b, bbox, Vec::new())
}

pub fn build_meta_with(b: &Board, bbox: (f64, f64, f64, f64), inner_layers: Vec<String>) -> RenderMeta {
    let net_names: HashMap<String, String> = b.nets.iter().map(|(k, v)| (k.to_string(), v.clone())).collect();
    RenderMeta {
        width_mm: bbox.2, height_mm: bbox.3, view_box: [bbox.0, bbox.1, bbox.2, bbox.3],
        net_names, components: b.comps.clone(),
        track_count: b.tracks.len(), via_count: b.vias.len(), pad_count: b.pads.len(),
        inner_layers,
    }
}

/// Top-level: authentic EDA (service-kicad) per-layer render as the visual base,
/// with our parsed geometry as the interaction overlay. Falls back to a full
/// self-render if the service is unreachable.
/// Compose an EDA-EXPORTED SVG as the visual base with our interaction overlay
/// on top. Used for Altium, whose own PDF->SVG export is published on the wiki
/// page (`render/pcb.svg`) — that render IS Altium's, so the board looks exactly
/// like Altium drew it.
///
/// The two coordinate systems are unrelated (the export is in PDF pixel space,
/// possibly with global transforms; our overlay is in board mm), so the fit is
/// done in the FRONTEND at mount time via getBBox(), which is transform-aware.
pub fn compose_with_base(b: &Board, base_svg: &str, bbox: (f64, f64, f64, f64)) -> String {
    let inner = {
        let open_end = base_svg.find("<svg").and_then(|i| base_svg[i..].find('>').map(|e| i + e + 1));
        let close = base_svg.rfind("</svg>");
        match (open_end, close) { (Some(a), Some(z)) if z > a => &base_svg[a..z], _ => "" }
    };
    let view = base_svg.find("viewBox=\"").map(|i| {
        let rest = &base_svg[i + 9..];
        rest.split('"').next().unwrap_or("0 0 100 100").to_string()
    }).unwrap_or_else(|| format!("{} {} {} {}", bbox.0, bbox.1, bbox.2, bbox.3));

    let mut s = String::new();
    let _ = write!(s, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{}" preserveAspectRatio="xMidYMid meet">"##, view);
    let _ = write!(s, r##"<g class="adom-base">{}</g>"##, inner);
    // .adom-fit is positioned by the frontend once both boxes can be measured
    let _ = write!(s, r##"<g class="adom-fit">{}</g>"##, build_overlay(b));
    s.push_str("</svg>");
    s
}

/// Bytes-level entry point. Altium `.PcbDoc` is a BINARY compound file, so it
/// can't come in through the text path at all; everything else is UTF-8 text.
pub fn render_board_bytes(bytes: &[u8]) -> (String, RenderMeta) { render_board_bytes_with(bytes, None) }

/// `base` is an optional EDA-exported SVG to use as the visual base (Altium).
pub fn render_board_bytes_with(bytes: &[u8], base: Option<&str>) -> (String, RenderMeta) {
    if crate::altium::looks_like_altium(bytes) {
        match crate::altium::parse_pcbdoc(bytes) {
            Ok(b) => {
                let bbox = board_bbox(&b);
                let meta = build_meta(&b, bbox);
                if let Some(bs) = base {
                    return (compose_with_base(&b, bs, bbox), meta);
                }
                return (render(&b).0, meta);
            }
            Err(e) => eprintln!("[adom-layout-viewer] altium parse failed ({e})"),
        }
    }
    render_board(&String::from_utf8_lossy(bytes))
}

pub fn render_board(text: &str) -> (String, RenderMeta) {
    // Fusion 360 Electronics / EAGLE `.brd`. There is no headless renderer for
    // these, so the visual is our own self-render of the same neutral model —
    // every interaction (nets, pads, hover) is identical to the KiCad path.
    if crate::eagle::looks_like_eagle(text) {
        match crate::eagle::parse_brd(text) {
            Ok(b) => {
                let bbox = board_bbox(&b);
                let meta = build_meta(&b, bbox);
                return (render(&b).0, meta);
            }
            Err(e) => eprintln!("[adom-layout-viewer] eagle/fusion parse failed ({e}); trying KiCad"),
        }
    }
    let b = parse(text);
    let bbox = board_bbox(&b);
    let meta = build_meta_with(&b, bbox, crate::kicad::inner_copper_layers(text));
    match crate::kicad::fetch_all(text) {
        Ok(layers) => (compose(&b, &layers, bbox), meta),
        Err(e) => {
            eprintln!("[adom-layout-viewer] service-kicad unavailable ({e}); self-rendering");
            (render(&b).0, meta)
        }
    }
}

/// Compose the KiCad-native per-layer SVGs (visual) + our interaction overlay.
fn compose(b: &Board, layers: &[crate::kicad::KLayer], bbox: (f64, f64, f64, f64)) -> String {
    let (mnx, mny, w, h) = bbox;
    let mut s = String::new();
    let _ = write!(s, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.3} {:.3} {:.3} {:.3}" preserveAspectRatio="xMidYMid meet">"##, mnx, mny, w, h);
    let _ = write!(s, r##"<rect class="adom-board-sub" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" rx="0.3" fill="#161b22"/>"##, mnx, mny, w, h);
    let _ = write!(s, r##"<g class="adom-flip" data-cx="{:.3}">"##, mnx + w / 2.0);
    for kl in layers {
        let _ = write!(s, r##"<g class="adom-layer-{}" data-side="{}">{}</g>"##, kl.group, kl.side, kl.inner);
    }
    s.push_str(&build_overlay(b));
    s.push_str("</g></svg>");
    s
}

/// The interaction overlay: transparent-but-hittable pads/vias (hover) and net
/// geometry (tracks/arcs/zones) that brightens to its own copper colour on the
/// active net. Everything is invisible until hovered or net-highlighted.
fn build_overlay(b: &Board) -> String {
    let mut s = String::new();
    s.push_str(r##"<g class="adom-overlay">"##);
    // zones first (bottom of the net stack, like the pour under everything)
    for z in &b.zones {
        let _ = write!(s, r##"<path class="ov-zone" d="{}" data-net="{}" fill="transparent" style="--hlc:{}"/>"##, z.d, z.net, copper_color(&z.layer));
    }
    // traces — ROUND caps/joins so a highlighted net is continuous like the PCB
    for t in &b.tracks {
        let _ = write!(s, r##"<line class="ov-trace" x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke-width="{:.3}" data-net="{}" stroke="transparent" stroke-linecap="round" style="--hlc:{}"/>"##,
            t.x1, t.y1, t.x2, t.y2, t.width.max(0.08), t.net, copper_color(&t.layer));
    }
    for a in &b.arcs {
        let _ = write!(s, r##"<path class="ov-trace" d="{}" stroke-width="{:.3}" data-net="{}" fill="none" stroke="transparent" stroke-linecap="round" stroke-linejoin="round" style="--hlc:{}"/>"##,
            a.d, a.width.max(0.08), a.net, copper_color(&a.layer));
    }
    // vias — copper ring + drilled hole (shown on select, so it reads like a real via)
    for v in &b.vias {
        let _ = write!(s, r##"<g class="adom-via" data-net="{}" style="--hlc:#c9924a"><circle class="pad-body" cx="{:.3}" cy="{:.3}" r="{:.3}" fill="transparent"/><circle class="pad-hole" cx="{:.3}" cy="{:.3}" r="{:.3}" fill="transparent" pointer-events="none"/></g>"##,
            v.net, v.x, v.y, v.size / 2.0, v.x, v.y, v.drill / 2.0);
    }
    // pads — copper body (+ drill hole for THT) so a selected pad looks like the real pad, not a blob
    for p in &b.pads {
        let (hw, hh) = (p.w / 2.0, p.h / 2.0);
        let body = match p.shape.as_str() {
            "circle" => format!(r##"<circle class="pad-body" cx="{:.3}" cy="{:.3}" r="{:.3}" fill="transparent"/>"##, p.x, p.y, hw.min(hh)),
            "oval" | "roundrect" => format!(r##"<rect class="pad-body" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" rx="{:.3}" fill="transparent"/>"##, p.x - hw, p.y - hh, p.w, p.h, hw.min(hh) * if p.shape == "oval" { 1.0 } else { 0.25 }),
            _ => format!(r##"<rect class="pad-body" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="transparent"/>"##, p.x - hw, p.y - hh, p.w, p.h),
        };
        let hole = if (p.mount == "thru_hole" || p.mount == "np_thru_hole") && p.drill > 0.0 {
            format!(r##"<circle class="pad-hole" cx="{:.3}" cy="{:.3}" r="{:.3}" fill="transparent" pointer-events="none"/>"##, p.x, p.y, p.drill / 2.0)
        } else { String::new() };
        let hlc = if p.side == 'B' { "#4d7fc4" } else { "#c83434" };
        let (ro, rc) = if p.rot.abs() > 0.01 { (format!(r##"<g transform="rotate({:.3} {:.3} {:.3})">"##, -p.rot, p.x, p.y), "</g>") } else { (String::new(), "") };
        let _ = write!(s, r##"<g class="adom-pad" data-net="{}" data-ref="{}" data-pad="{}" data-size="{:.3}×{:.3}" data-shape="{}" data-side="{}" style="--hlc:{}">{}{}{}{}</g>"##,
            p.net, esc(&p.reference), esc(&p.num), p.w, p.h, esc(&p.shape), p.side, hlc, ro, body, rc, hole);
    }
    s.push_str("</g>");
    s
}

pub fn render(b: &Board) -> (String, RenderMeta) {
    let mut pts: Vec<(f64, f64)> = vec![];
    for g in &b.edges { collect_path_pts(&g.d, &mut pts); }
    if pts.len() < 2 {
        for c in &b.comps { pts.push((c.bbox[0], c.bbox[1])); pts.push((c.bbox[2], c.bbox[3])); }
        for t in &b.tracks { pts.push((t.x1, t.y1)); pts.push((t.x2, t.y2)); }
    }
    if pts.is_empty() { pts.push((0.0, 0.0)); pts.push((10.0, 10.0)); }
    let margin = 1.0;
    let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min) - margin;
    let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min) - margin;
    let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max) + margin;
    let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max) + margin;
    let (w, h) = (max_x - min_x, max_y - min_y);

    let mut s = String::new();
    let _ = write!(s, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.3} {:.3} {:.3} {:.3}" preserveAspectRatio="xMidYMid meet" font-family="ui-sans-serif,system-ui,sans-serif">"##, min_x, min_y, w, h);
    let _ = write!(s, r##"<rect class="adom-board-sub" x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" rx="0.3" fill="#161b22"/>"##, min_x, min_y, w, h);

    // everything below the substrate lives in a flip group so "view bottom" can
    // mirror the whole board about its vertical centre (looking at the back).
    let cx = min_x + w / 2.0;
    let _ = write!(s, r##"<g class="adom-flip" data-cx="{:.3}">"##, cx);

    // zones (copper pours) split by side
    for (grp, bottom) in [("zoneB", true), ("zoneF", false)] {
        layer_open(&mut s, grp);
        for z in b.zones.iter().filter(|z| z.layer.starts_with("B.") == bottom) {
            let _ = write!(s, r##"<path d="{}" data-net="{}" fill="{}" fill-opacity="0.35" stroke="none"/>"##, z.d, z.net, copper_color(&z.layer));
        }
        s.push_str("</g>");
    }

    // copper split by side (B under F)
    for (grp, want) in [("copperB", "B.Cu"), ("copperF", "F.Cu")] {
        layer_open(&mut s, grp);
        for t in b.tracks.iter().filter(|t| t.layer == want) {
            let _ = write!(s, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke-width="{:.3}" data-net="{}" data-layer="{}" stroke="{}" stroke-linecap="round"/>"##, t.x1, t.y1, t.x2, t.y2, t.width, t.net, t.layer, copper_color(&t.layer));
        }
        for a in b.arcs.iter().filter(|a| a.layer == want) {
            let _ = write!(s, r##"<path d="{}" data-net="{}" data-layer="{}" fill="none" stroke="{}" stroke-width="{:.3}" stroke-linecap="round"/>"##, a.d, a.net, a.layer, copper_color(&a.layer), a.width);
        }
        s.push_str("</g>");
    }

    // fab (component outlines) — bottom then top (footprint fab grey)
    gfx_layer(&mut s, "fabB", &b.fab_b, "#565d66");
    gfx_layer(&mut s, "fabF", &b.fab_f, "#6e7681");

    // pads split by side (so the far side dims in top/bottom view)
    for (grp, want) in [("padB", 'B'), ("padF", 'F')] {
        layer_open(&mut s, grp);
        for p in b.pads.iter().filter(|p| p.side == want) {
            let (hw, hh) = (p.w / 2.0, p.h / 2.0);
            let body = match p.shape.as_str() {
                "circle" => format!(r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}"/>"##, p.x, p.y, hw.min(hh)),
                "oval" => format!(r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" rx="{:.3}"/>"##, p.x - hw, p.y - hh, p.w, p.h, hw.min(hh)),
                "roundrect" => format!(r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" rx="{:.3}"/>"##, p.x - hw, p.y - hh, p.w, p.h, hw.min(hh) * 0.25),
                _ => format!(r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}"/>"##, p.x - hw, p.y - hh, p.w, p.h),
            };
            let (fill, stroke) = if p.side == 'B' { ("#3d6fb5", "#5a8fd0") } else { ("#c83434", "#ef5350") };
            let (ro, rc) = if p.rot.abs() > 0.01 { (format!(r##"<g transform="rotate({:.3} {:.3} {:.3})">"##, -p.rot, p.x, p.y), "</g>") } else { (String::new(), "") };
            let _ = write!(s, r##"<g class="sr-pad" data-net="{}" data-ref="{}" data-pad="{}" data-size="{:.3}×{:.3}" data-shape="{}" data-side="{}" fill="{}" stroke="{}" stroke-width="0.03">{}{}{}</g>"##,
                p.net, esc(&p.reference), esc(&p.num), p.w, p.h, esc(&p.shape), p.side, fill, stroke, ro, body, rc);
            if (p.mount == "thru_hole" || p.mount == "np_thru_hole") && p.drill > 0.0 {
                let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="#161b22" stroke="#555" stroke-width="0.04" pointer-events="none"/>"##, p.x, p.y, p.drill / 2.0);
            }
        }
        s.push_str("</g>");
    }

    // vias (copper ring + drilled centre) — hoverable to see their net
    layer_open(&mut s, "via");
    for v in &b.vias { let _ = write!(s, r##"<g class="sr-via" data-net="{}"><circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="#c08a3e"/><circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="#161b22" stroke="#555" stroke-width="0.03"/></g>"##, v.net, v.x, v.y, v.size / 2.0, v.x, v.y, v.drill / 2.0); }
    s.push_str("</g>");

    // silk (bottom then top): thin white lines/polys + text
    layer_open(&mut s, "silkB");
    silk_paths(&mut s, &b.silk_b, "#c8ccd2");
    for t in &b.txt_b { s.push_str(t); }
    s.push_str("</g>");
    layer_open(&mut s, "silkF");
    silk_paths(&mut s, &b.silk_f, "#ffffff");
    for t in &b.txt_f { s.push_str(t); }
    s.push_str("</g>");

    // courtyard (default hidden via CSS), dashed magenta
    layer_open(&mut s, "crtyd");
    for g in b.crtyd_f.iter().chain(b.crtyd_b.iter()) {
        let _ = write!(s, r##"<path d="{}" fill="none" stroke="#ff26e2" stroke-width="{:.3}" stroke-dasharray="0.15 0.1"/>"##, g.d, g.width.max(0.05));
    }
    s.push_str("</g>");

    // board edge
    gfx_layer(&mut s, "edge", &b.edges, "#f2d060");

    // (no component hotspots — the pads themselves are the hover/click targets)

    s.push_str("</g>"); // close .adom-flip
    // The SAME interaction overlay the KiCad path uses. Without this a
    // self-rendered board (EAGLE/Fusion, or KiCad when service-kicad is down)
    // would draw fine but have no hover targets and no net highlighting.
    s.push_str(&build_overlay(b));
    s.push_str("</svg>");

    let net_names: HashMap<String, String> = b.nets.iter().map(|(k, v)| (k.to_string(), v.clone())).collect();
    let meta = RenderMeta { width_mm: w, height_mm: h, view_box: [min_x, min_y, w, h], net_names, components: b.comps.clone(), track_count: b.tracks.len(), via_count: b.vias.len(), pad_count: b.pads.len(), inner_layers: Vec::new() };
    (s, meta)
}

fn layer_open(s: &mut String, name: &str) { let _ = write!(s, r##"<g class="adom-layer-{}">"##, name); }
fn gfx_layer(s: &mut String, name: &str, items: &[Gfx], color: &str) {
    layer_open(s, name);
    for g in items {
        if g.closed_fill { let _ = write!(s, r##"<path d="{}" fill="{}" stroke="{}" stroke-width="{:.3}"/>"##, g.d, color, color, g.width.max(0.1)); }
        else { let _ = write!(s, r##"<path d="{}" fill="none" stroke="{}" stroke-width="{:.3}" stroke-linecap="round" stroke-linejoin="round"/>"##, g.d, color, g.width.max(0.08)); }
    }
    s.push_str("</g>");
}
/// Silk paths (no group wrapper) — thin, width-clamped so they don't read heavy.
fn silk_paths(s: &mut String, items: &[Gfx], color: &str) {
    for g in items {
        let wraw = if g.width > 0.0 { g.width } else { 0.1 };
        let w = wraw.clamp(0.08, 0.12);
        if g.closed_fill { let _ = write!(s, r##"<path d="{}" fill="{}" stroke="{}" stroke-width="{:.3}"/>"##, g.d, color, color, w); }
        else { let _ = write!(s, r##"<path d="{}" fill="none" stroke="{}" stroke-width="{:.3}" stroke-linecap="round" stroke-linejoin="round"/>"##, g.d, color, w); }
    }
}

/// Render one silk text element as a KNOCKOUT (like KiCad): a filled silk box
/// with the glyphs cut out via an SVG mask, so the real copper/board underneath
/// shows through the letters. Multi-line via `\n`; B-side text is mirrored about
/// its anchor so it reads correctly in bottom view.
fn mk_text(x: f64, y: f64, rot: f64, text: &str, size: f64, bold: bool, side: char, anchor: &str) -> String {
    use std::sync::atomic::{AtomicUsize, Ordering};
    static UID: AtomicUsize = AtomicUsize::new(0);
    let id = UID.fetch_add(1, Ordering::Relaxed);
    let silk = if side == 'B' { "#c8ccd2" } else { "#ffffff" };
    let weight = if bold { "700" } else { "600" };
    let fs = (size * 1.05).max(0.4);
    let lines: Vec<&str> = text.split("\\n").collect();
    let lh = fs * 1.12;
    let start = -((lines.len() as f64 - 1.0) / 2.0) * lh;
    let mut spans = String::new();
    for (i, l) in lines.iter().enumerate() {
        let dy = if i == 0 { start } else { lh };
        let _ = write!(spans, r##"<tspan x="{:.3}" dy="{:.3}">{}</tspan>"##, x, dy, esc(l));
    }
    // knockout box sized snug to the text (approx glyph metrics for a bold sans)
    let charw = fs * 0.58;
    let maxlen = lines.iter().map(|l| l.chars().count()).max().unwrap_or(1).max(1) as f64;
    let bw = maxlen * charw + fs * 0.34;
    let bh = lines.len() as f64 * lh + fs * 0.16;
    let rx0 = match anchor { "start" => x - fs * 0.16, "end" => x - bw + fs * 0.16, _ => x - bw / 2.0 };
    let ry0 = y - bh / 2.0;
    let rr = (fs * 0.22).min(bh / 2.0);
    let mut tf = format!("rotate({:.2} {:.3} {:.3})", -rot, x, y);
    if side == 'B' { tf = format!("translate({:.3} 0) scale(-1 1) {}", 2.0 * x, tf); }
    format!(concat!(
        r##"<g transform="{tf}" style="pointer-events:none">"##,
        r##"<mask id="ko{id}" maskContentUnits="userSpaceOnUse">"##,
        r##"<rect x="{rx:.3}" y="{ry:.3}" width="{bw:.3}" height="{bh:.3}" rx="{rr:.3}" fill="#fff"/>"##,
        r##"<text x="{x:.3}" y="{y:.3}" text-anchor="{a}" dominant-baseline="central" font-size="{fs:.3}" font-weight="{w}" fill="#000" font-family="ui-sans-serif,system-ui,sans-serif">{spans}</text>"##,
        r##"</mask>"##,
        r##"<rect x="{rx:.3}" y="{ry:.3}" width="{bw:.3}" height="{bh:.3}" rx="{rr:.3}" fill="{silk}" mask="url(#ko{id})"/>"##,
        r##"</g>"##,
    ), tf = tf, id = id, rx = rx0, ry = ry0, bw = bw, bh = bh, rr = rr, x = x, y = y, a = anchor, fs = fs, w = weight, spans = spans, silk = silk)
}