main
Colby Knox Merge PR #273: 0.2.9: the Altium lane on 0.2.x (courtyards, parts and project page, verified list) a8ed7d2
//! The plan board for an Altium board: the Altium bridge's read (`altium_routing_state` plus
//! `altium_pour_state`, saved as `{"source":"altium-bridge","state":...,"pours":...}`) written as the
//! `.kicad_pcb` text every stage here reads. Altium stays the board of record (see the aiflow-bridge
//! Altium backend); this is only the plan copy.
//!
//! The mapping: y is negated (Altium is y up); a component at (x, y, θ) is a footprint at (x, -y, θ)
//! with its pads local to it and their angles absolute; a bottom component's pads are written in board
//! position on B.Cu (footprint angle 0); only copper layers come along; the outline's arcs stay arcs;
//! a pour's fill is Altium's own, each island with its holes slit into it.
//!
//! Courtyards, fab outlines and polarity marks: a read that names its Altium project (`"projectDir"`,
//! the run's `altium/` folder) gives each part the courtyard, fab and silkscreen graphics of the KiCad
//! footprint its Altium
//! footprint was converted from (`pages/<MPN>/*.kicad_mod`, mapped through `project.json`), once that
//! footprint's pads are proven to sit where the Altium part's pads do. Any other part (no source
//! footprint, pads that differ, a bottom part) gets a courtyard drawn around its pads.
use serde_json::Value;
use std::collections::HashMap;
use std::fmt::Write;
use std::path::Path;

/// Pads closer than this (mm) to their KiCad source footprint's prove the two frames are the same.
const PAD_MATCH_MM: f64 = 0.02;
/// IPC-7351 nominal courtyard excess, for a courtyard drawn around the pads.
const COURTYARD_MM: f64 = 0.25;
/// The source footprint's own graphics that come along: courtyard, fab outline, and its silkscreen
/// lines, arcs, circles and polygons (pin-1 dots, cathode bars: the polarity marks). Never its texts:
/// Altium's designators are rendered from the board itself.
const MECH_LAYERS: [&str; 6] = ["F.CrtYd", "F.Fab", "B.CrtYd", "B.Fab", "F.SilkS", "B.SilkS"];

/// One KiCad footprint's pads (name, local x, local y, KiCad's y-down frame) and its raw courtyard
/// and fab items.
struct Mech { pads: Vec<(String, f64, f64)>, items: Vec<String> }

fn mech_of_footprint(text: &str) -> Option<Mech> {
    let root = crate::sx::parse(text).ok()?;
    let mut pads = Vec::new();
    for p in root.find_all("pad") {
        let (Some(name), Some(at)) = (p.atom(1), p.find("at")) else { continue };
        let (Some(x), Some(y)) = (at.number(1), at.number(2)) else { continue };
        pads.push((name.to_string(), x, y));
    }
    let items: Vec<String> = root.nodes()
        .filter(|n| matches!(n.tag.as_str(), "fp_line" | "fp_rect" | "fp_poly" | "fp_circle" | "fp_arc"))
        .filter(|n| n.find("layer").and_then(|l| l.value()).map(|l| MECH_LAYERS.contains(&l)).unwrap_or(false))
        .map(|n| text[n.start..n.end].to_string()).collect();
    Some(Mech { pads, items })
}

/// Reference -> the KiCad source footprint of its part, from `<project>/project.json`
/// (`parts.<MPN>.refs`) and the first `<project>/pages/<MPN>/*.kicad_mod`.
fn mech_by_ref(project: &Path) -> HashMap<String, std::rc::Rc<Mech>> {
    let mut out = HashMap::new();
    let Some(pj) = std::fs::read_to_string(project.join("project.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) else { return out };
    for (key, part) in pj["parts"].as_object().into_iter().flatten() {
        let Ok(rd) = std::fs::read_dir(project.join("pages").join(key)) else { continue };
        let mut mods: Vec<_> = rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_mod").unwrap_or(false)).collect();
        mods.sort();
        let Some(m) = mods.first().and_then(|f| std::fs::read_to_string(f).ok()).and_then(|t| mech_of_footprint(&t)) else { continue };
        let m = std::rc::Rc::new(m);
        for r in part["refs"].as_array().into_iter().flatten().filter_map(|r| r.as_str()) { out.insert(r.to_string(), m.clone()); }
    }
    out
}

/// Every rendered pad (name, local x, local y) has a source pad of the same name within PAD_MATCH_MM.
fn pads_match(rendered: &[(String, f64, f64)], src: &[(String, f64, f64)]) -> bool {
    !rendered.is_empty() && rendered.len() == src.len()
        && rendered.iter().all(|(n, x, y)| src.iter().any(|(sn, sx, sy)| sn == n && (sx - x).abs() < PAD_MATCH_MM && (sy - y).abs() < PAD_MATCH_MM))
}

fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }
fn is_copper(layer: &str) -> bool { layer == "F.Cu" || layer == "B.Cu" || (layer.starts_with("In") && layer.ends_with(".Cu")) }

fn q(s: &str) -> String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) }
fn n(v: f64) -> String { let r = (v * 1e6).round() / 1e6; if r == 0.0 { "0".into() } else { format!("{r}") } }

/// Join each hole into its outline with a zero-width slit, as KiCad's filled polygons are written
/// (one outline per island, no separate holes).
fn fracture(outline: &[(f64, f64)], holes: &[Vec<(f64, f64)>]) -> Vec<(f64, f64)> {
    let mut poly = outline.to_vec();
    for h in holes.iter().filter(|h| h.len() >= 3) {
        let (mut bi, mut bj, mut bd) = (0, 0, f64::MAX);
        for (i, p) in poly.iter().enumerate() {
            for (j, q) in h.iter().enumerate() {
                let d = (p.0 - q.0).hypot(p.1 - q.1);
                if d < bd { bd = d; bi = i; bj = j; }
            }
        }
        let mut out: Vec<(f64, f64)> = poly[..=bi].to_vec();
        out.extend(h[bj..].iter().cloned());
        out.extend(h[..=bj].iter().cloned());
        out.extend(poly[bi..].iter().cloned());
        poly = out;
    }
    poly
}

/// Is this text an Altium bridge board read (`board-live.altium.json`)?
pub fn is_altium(text: &str) -> bool { text.trim_start().starts_with('{') && text.contains("\"source\":\"altium-bridge\"") }

/// The plan board for an Altium board: the bridge's read (`{"source":"altium-bridge", "state":
/// altium_routing_state, "pours": altium_pour_state}`) written as `.kicad_pcb` text, y negated.
pub fn render_plan_board(src: &str) -> Result<String, String> {
    let d: Value = serde_json::from_str(src).map_err(|e| format!("not an Altium board read (JSON): {e}"))?;
    if d["source"].as_str() != Some("altium-bridge") { return Err("not an Altium board read: save altium_routing_state and altium_pour_state as {\"source\":\"altium-bridge\",\"state\":...,\"pours\":...}".into()); }
    let st = &d["state"];
    if st["success"].as_bool() != Some(true) { return Err(format!("the Altium read failed: {}", st["error"])); }
    let yy = |v: f64| -v;
    let arr = |k: &str| st[k].as_array().cloned().unwrap_or_default();
    let mech = d["projectDir"].as_str().map(|p| mech_by_ref(Path::new(p))).unwrap_or_default();

    // nets: numbered from 1 in name order
    let mut names: Vec<String> = st["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
    for k in ["tracks", "vias", "polygons"] { for o in arr(k) { if let Some(s) = o["net"].as_str().filter(|s| !s.is_empty()) { if !names.iter().any(|x| x == s) { names.push(s.to_string()); } } } }
    names.sort();
    let idx = |s: &str| names.iter().position(|x| x == s).map(|i| i + 1).unwrap_or(0);
    let net_pad = |s: &str| if s.is_empty() { String::new() } else { format!("(net {} {})", idx(s), q(s)) };
    let net_id = |s: &str| format!("(net {})", idx(s));

    let mut inner: Vec<String> = Vec::new();
    for k in ["tracks", "arcs"] { for o in arr(k) { if let Some(l) = o["layer"].as_str().filter(|l| l.starts_with("In")) { if !inner.contains(&l.to_string()) { inner.push(l.to_string()); } } } }
    inner.sort();

    let mut o = String::new();
    o.push_str("(kicad_pcb (version 20241229) (generator \"adom-aiflow-altium\") (generator_version \"1\")\n  (general (thickness 1.6))\n  (paper \"A4\")\n  (layers (0 \"F.Cu\" signal)");
    for (k, l) in inner.iter().enumerate() { let _ = write!(o, " ({} {} signal)", 4 + 2 * k, q(l)); }
    o.push_str(" (2 \"B.Cu\" signal) (9 \"F.Adhes\" user) (11 \"B.Adhes\" user) (13 \"F.Paste\" user) (15 \"B.Paste\" user) (5 \"F.SilkS\" user) (7 \"B.SilkS\" user) (1 \"F.Mask\" user) (3 \"B.Mask\" user) (25 \"Edge.Cuts\" user) (31 \"F.CrtYd\" user) (29 \"B.CrtYd\" user) (35 \"F.Fab\" user) (33 \"B.Fab\" user))\n");
    o.push_str("  (setup (pad_to_mask_clearance 0))\n  (net 0 \"\")\n");
    for (k, s) in names.iter().enumerate() { let _ = writeln!(o, "  (net {} {})", k + 1, q(s)); }

    // footprints: each component with its pads; pad positions are local to the footprint (KiCad
    // frame), pad angles absolute. A bottom component is written with its pads already in board
    // position (footprint angle 0) on B.Cu, as the EAGLE renderer does.
    let pads = arr("pads");
    // the silkscreen as Altium has it (altium_silk_state): each part's designator and comment, and the free
    // overlay texts and lines; a board read without it renders the references hidden, as before
    let silk = &d["silk"];
    let silk_of = |r: &str| silk["components"].as_array().and_then(|a| a.iter().find(|c| c["ref"].as_str() == Some(r)).cloned());
    for c in arr("components") {
        let r = c["ref"].as_str().unwrap_or("?");
        let (cx, cy, rot) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]));
        let bottom = c["side"].as_str() == Some("bottom");
        let (fx, fy, fa, side) = (cx, yy(cy), if bottom { 0.0 } else { rot }, if bottom { "B.Cu" } else { "F.Cu" });
        // a shown text at its box centre, in the footprint's own frame (KiCad keeps it there, angle absolute)
        let local = |t: &Value| -> (f64, f64, f64, f64) {
            let b = &t["box"];
            let (bx, by) = ((f(&b[0]) + f(&b[2])) / 2.0, (f(&b[1]) + f(&b[3])) / 2.0);
            let (dx, dy) = (bx - cx, by - cy);
            let (lx, ly) = if bottom { (dx, -dy) } else { let a = rot.to_radians(); let (ux, uy) = (dx * a.cos() + dy * a.sin(), -dx * a.sin() + dy * a.cos()); (ux, -uy) };
            (lx, ly, f(&t["rotation"]), f(&t["size"]).max(0.3))
        };
        let silk_layer = if bottom { "B.SilkS" } else { "F.SilkS" };
        let sc = silk_of(r);
        let prop = |key: &str, text: &str, t: Option<&Value>, fab: &str| -> String {
            match t.filter(|t| t["shown"].as_bool() == Some(true)) {
                Some(t) => { let (lx, ly, a, s) = local(t); format!("(property {} {} (at {} {} {}) (layer {}) (effects (font (size {} {}) (thickness {}))))", q(key), q(text), n(lx), n(ly), n(a), q(silk_layer), n(s), n(s), n(s / 7.0)) }
                None => format!("(property {} {} (at 0 0 0) (layer {}) (hide yes))", q(key), q(text), q(fab)),
            }
        };
        let _ = writeln!(o, "  (footprint {} (layer {side}) (at {} {} {})\n    {}\n    {}\n    (attr smd)",
            q(&format!("Altium:{}", c["pattern"].as_str().unwrap_or(""))), n(fx), n(fy), n(fa),
            prop("Reference", r, sc.as_ref().map(|s| &s["name"]), "F.SilkS"),
            prop("Value", sc.as_ref().and_then(|s| s["comment"]["text"].as_str()).unwrap_or(""), sc.as_ref().map(|s| &s["comment"]), "F.Fab"));
        let mut drawn: Vec<(String, f64, f64)> = Vec::new();
        let mut reach: Option<(f64, f64, f64, f64)> = None;
        for p in pads.iter().filter(|p| p["ref"].as_str() == Some(r)) {
            let (dx, dy) = (f(&p["x"]) - cx, f(&p["y"]) - cy);
            let (lx, ly) = if bottom { (dx, -dy) } else {
                let a = rot.to_radians();
                let (ux, uy) = (dx * a.cos() + dy * a.sin(), -dx * a.sin() + dy * a.cos());
                (ux, -uy)
            };
            let ang = if bottom { -f(&p["rotation"]) } else { f(&p["rotation"]) };
            let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"]));
            let drill = f(&p["drill"]);
            drawn.push((p["name"].as_str().unwrap_or("").to_string(), lx, ly));
            let h = sx.max(sy) / 2.0;
            reach = Some(match reach { None => (lx - h, ly - h, lx + h, ly + h), Some((a, b, c, e)) => (a.min(lx - h), b.min(ly - h), c.max(lx + h), e.max(ly + h)) });
            let layer = p["layer"].as_str().unwrap_or("F.Cu");
            let pn = net_pad(p["net"].as_str().unwrap_or(""));
            let name = p["name"].as_str().unwrap_or("");
            let (shape, extra) = match p["shape"].as_str().unwrap_or("rect") {
                "round" | "circle" if (sx - sy).abs() < 1e-6 => ("circle", String::new()),
                "round" | "circle" => ("oval", String::new()),
                "roundrect" => ("roundrect", " (roundrect_rratio 0.25)".to_string()),
                _ => ("rect", String::new()),
            };
            if drill > 0.0 {
                let _ = writeln!(o, "    (pad {} thru_hole {shape} (at {} {} {}) (size {} {}) (drill {}) (layers \"*.Cu\" \"*.Mask\"){extra} {pn})", q(name), n(lx), n(ly), n(ang), n(sx), n(sy), n(drill));
            } else {
                let l = if layer == "B.Cu" { "B" } else { "F" };
                let _ = writeln!(o, "    (pad {} smd {shape} (at {} {} {}) (size {} {}) (layers \"{l}.Cu\" \"{l}.Paste\" \"{l}.Mask\"){extra} {pn})", q(name), n(lx), n(ly), n(ang), n(sx), n(sy));
            }
        }
        // courtyard and fab: the source footprint's own, when its pads prove the frame; else a
        // courtyard around the pads (the mechanical checks need one on every part with pads)
        match mech.get(r).filter(|m| !bottom && pads_match(&drawn, &m.pads)) {
            Some(m) => for it in &m.items { let _ = writeln!(o, "    {it}"); },
            None => if let Some((a, b, c, e)) = reach {
                let crt = if bottom { "B.CrtYd" } else { "F.CrtYd" };
                let _ = writeln!(o, "    (fp_rect (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer {}))",
                    n(a - COURTYARD_MM), n(b - COURTYARD_MM), n(c + COURTYARD_MM), n(e + COURTYARD_MM), q(crt));
            },
        }
        o.push_str("  )\n");
    }

    // free pads (no component: mounting holes, fiducials): one footprint each, so their copper and
    // holes are obstacles like any pad
    for (k, p) in pads.iter().filter(|p| p["ref"].as_str().unwrap_or("").is_empty()).enumerate() {
        let (sx, sy, drill) = (f(&p["sizeX"]), f(&p["sizeY"]), f(&p["drill"]));
        let pn = net_pad(p["net"].as_str().unwrap_or(""));
        let shape = if (sx - sy).abs() < 1e-6 && matches!(p["shape"].as_str(), Some("round") | Some("circle")) { "circle" } else { "rect" };
        let pad = if drill > 0.0 { format!("(pad {} thru_hole {shape} (at 0 0 {}) (size {} {}) (drill {}) (layers \"*.Cu\" \"*.Mask\") {pn})", q(p["name"].as_str().unwrap_or("")), n(f(&p["rotation"])), n(sx), n(sy), n(drill)) }
                  else { format!("(pad {} smd {shape} (at 0 0 {}) (size {} {}) (layers \"F.Cu\" \"F.Paste\" \"F.Mask\") {pn})", q(p["name"].as_str().unwrap_or("")), n(f(&p["rotation"])), n(sx), n(sy)) };
        let _ = writeln!(o, "  (footprint \"Altium:FreePad\" (layer F.Cu) (at {} {} 0)\n    (property \"Reference\" {} (at 0 0 0) (layer \"F.SilkS\") (hide yes))\n    (attr smd)\n    {pad}\n  )", n(f(&p["x"])), n(yy(f(&p["y"]))), q(&format!("FREE{}", k + 1)));
    }

    // free silkscreen: texts at their box centre (a bottom text mirrored, as it prints), and lines
    for t in silk["texts"].as_array().cloned().unwrap_or_default() {
        let layer = match t["side"].as_str() { Some("top") => "F.SilkS", Some("bottom") => "B.SilkS", _ => continue };
        let b = &t["box"];
        let (bx, by) = ((f(&b[0]) + f(&b[2])) / 2.0, (f(&b[1]) + f(&b[3])) / 2.0);
        let s = f(&t["size"]).max(0.3);
        let mirror = if layer == "B.SilkS" { " (justify mirror)" } else { "" };
        let _ = writeln!(o, "  (gr_text {} (at {} {} {}) (layer \"{layer}\") (effects (font (size {} {}) (thickness {})){mirror}))", q(t["text"].as_str().unwrap_or("")), n(bx), n(yy(by)), n(f(&t["rotation"])), n(s), n(s), n(s / 7.0));
    }
    for l in silk["lines"].as_array().cloned().unwrap_or_default() {
        let layer = match l["side"].as_str() { Some("top") => "F.SilkS", Some("bottom") => "B.SilkS", _ => continue };
        let _ = writeln!(o, "  (gr_line (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (layer \"{layer}\"))", n(f(&l["x1"])), n(yy(f(&l["y1"]))), n(f(&l["x2"])), n(yy(f(&l["y2"]))), n(f(&l["width"]).max(0.1)));
    }

    // outline: lines and arcs (an Altium arc runs counterclockwise from angle1 to angle2, y up)
    let edges = arr("outlineEdges");
    if edges.is_empty() { return Err("no board outline: define the board shape in Altium first (altium_board_shape)".into()); }
    for e in &edges {
        let (a, b) = ((f(&e["start"][0]), f(&e["start"][1])), (f(&e["end"][0]), f(&e["end"][1])));
        if e["kind"].as_str() == Some("arc") {
            let (cx, cy, r) = (f(&e["center"][0]), f(&e["center"][1]), f(&e["radius"]));
            let (a1, a2) = (f(&e["angle1"]), f(&e["angle2"]));
            let sweep = (a2 - a1).rem_euclid(360.0);
            let m = (a1 + sweep / 2.0).to_radians();
            let (mx, my) = (cx + r * m.cos(), cy + r * m.sin());
            let _ = writeln!(o, "  (gr_arc (start {} {}) (mid {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(a.0), n(yy(a.1)), n(mx), n(yy(my)), n(b.0), n(yy(b.1)));
        } else {
            let _ = writeln!(o, "  (gr_line (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(a.0), n(yy(a.1)), n(b.0), n(yy(b.1)));
        }
    }

    // copper already on the board
    for t in arr("tracks") {
        let l = t["layer"].as_str().unwrap_or("");
        if !is_copper(l) { continue; }
        let _ = writeln!(o, "  (segment (start {} {}) (end {} {}) (width {}) (layer {}) {})", n(f(&t["start"]["x"])), n(yy(f(&t["start"]["y"]))), n(f(&t["end"]["x"])), n(yy(f(&t["end"]["y"]))), n(f(&t["width"]).max(0.05)), q(l), net_id(t["net"].as_str().unwrap_or("")));
    }
    for a in arr("arcs") {
        let l = a["layer"].as_str().unwrap_or("");
        if !is_copper(l) { continue; }
        let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));
        let (a1, a2) = (f(&a["startAngle"]), f(&a["endAngle"]));
        let sweep = (a2 - a1).rem_euclid(360.0);
        if sweep < 1e-6 { continue; }
        let pt = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());
        let (s, m, e) = (pt(a1), pt(a1 + sweep / 2.0), pt(a1 + sweep));
        let _ = writeln!(o, "  (arc (start {} {}) (mid {} {}) (end {} {}) (width {}) (layer {}) {})", n(s.0), n(yy(s.1)), n(m.0), n(yy(m.1)), n(e.0), n(yy(e.1)), n(f(&a["width"]).max(0.05)), q(l), net_id(a["net"].as_str().unwrap_or("")));
    }
    for v in arr("vias") {
        let _ = writeln!(o, "  (via (at {} {}) (size {}) (drill {}) (layers \"F.Cu\" \"B.Cu\") {})", n(f(&v["position"]["x"])), n(yy(f(&v["position"]["y"]))), n(f(&v["size"])), n(f(&v["drill"])), net_id(v["net"].as_str().unwrap_or("")));
    }
    // pours: the bridge's pour read (with Altium's own fill) when there is one, else the outlines
    let zones = d["pours"]["zones"].as_array().cloned();
    let pts = |v: &Value| -> Vec<(f64, f64)> { v.as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect() };
    let xy = |ps: &[(f64, f64)]| ps.iter().map(|p| format!("(xy {} {})", n(p.0), n(yy(p.1)))).collect::<Vec<_>>().join(" ");
    let zone_list: Vec<(String, String, Vec<(f64, f64)>, Vec<Vec<(f64, f64)>>)> = match zones {
        Some(zs) => zs.iter().map(|z| {
            let fills: Vec<Vec<(f64, f64)>> = z["fills"].as_array().cloned().unwrap_or_default().iter()
                .map(|fl| fracture(&pts(&fl["outline"]), &fl["holes"].as_array().cloned().unwrap_or_default().iter().map(|h| pts(h)).collect::<Vec<_>>())).collect();
            (z["net"].as_str().unwrap_or("").to_string(), z["layer"].as_str().unwrap_or("F.Cu").to_string(), pts(&z["outline"]), fills)
        }).collect(),
        None => arr("polygons").iter().map(|g| (g["net"].as_str().unwrap_or("").to_string(), g["layer"].as_str().unwrap_or("F.Cu").to_string(), pts(&g["points"]), Vec::new())).collect(),
    };
    for (net, layer, outline, fills) in zone_list {
        if !is_copper(&layer) || outline.len() < 3 { continue; }
        let _ = write!(o, "  (zone (net {}) (net_name {}) (layer {}) (hatch edge 0.5) (priority 0) (connect_pads (clearance 0.2)) (min_thickness 0.2) (fill yes (thermal_gap 0.3) (thermal_bridge_width 0.3))\n    (polygon (pts {}))", idx(&net), q(&net), q(&layer), xy(&outline));
        for fl in fills.iter().filter(|fl| fl.len() >= 3) { let _ = write!(o, "\n    (filled_polygon (layer {}) (pts {}))", q(&layer), xy(fl)); }
        o.push_str(")\n");
    }
    o.push_str(")\n");
    Ok(o)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A real board: AIFLOW_ALTIUM_FIXTURE names a saved altium_routing_state reply.
    #[test]
    #[ignore]
    fn renders_a_real_altium_read() {
        let p = std::env::var("AIFLOW_ALTIUM_FIXTURE").expect("AIFLOW_ALTIUM_FIXTURE");
        let st: Value = serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap();
        let src = serde_json::json!({"source": "altium-bridge", "state": st, "pours": null}).to_string();
        let text = render_plan_board(&src).unwrap();
        std::fs::write(std::env::temp_dir().join("altium-real.kicad_pcb"), &text).unwrap();
        let b = crate::Board::from_text("real.kicad_pcb", text).unwrap();
        let n_pads = st["pads"].as_array().unwrap().len();
        eprintln!("pads {} (read {n_pads}), footprints {}, tracks {}, vias {}, zones {}, nets {}", b.pads.len(), b.footprints.len(), b.tracks().len(), b.vias().len(), b.zones().len(), b.nets.len());
        // every pad of a component comes through, where Altium has it
        for p in st["pads"].as_array().unwrap().iter().filter(|p| !p["ref"].as_str().unwrap_or("").is_empty()).take(200) {
            let key = format!("{}.{}", p["ref"].as_str().unwrap(), p["name"].as_str().unwrap());
            let got = b.pads.iter().find(|q| q.key == key && (q.x - p["x"].as_f64().unwrap()).abs() < 1e-3 && (q.y + p["y"].as_f64().unwrap()).abs() < 1e-3);
            assert!(got.is_some(), "pad {key} not at ({}, {})", p["x"], -p["y"].as_f64().unwrap());
        }
    }
    /// A two-pad part: its source footprint's courtyard, fab and silk come along when the pads prove the
    /// frame; a part whose source pads differ gets a courtyard around its pads instead.
    #[test]
    fn courtyards_come_from_the_source_footprint_or_around_the_pads() {
        let dir = std::env::temp_dir().join(format!("altium-mech-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join("pages/R1MPN")).unwrap();
        std::fs::create_dir_all(dir.join("pages/C1MPN")).unwrap();
        std::fs::write(dir.join("project.json"), r#"{"parts": {"R1MPN": {"refs": ["R1"]}, "C1MPN": {"refs": ["C1"]}}}"#).unwrap();
        let fp = |pad2x: f64| format!(r#"(footprint "X" (layer "F.Cu")
  (fp_rect (start -1 -0.5) (end 1 0.5) (stroke (width 0.05) (type solid)) (layer "F.CrtYd"))
  (fp_rect (start -0.5 -0.25) (end 0.5 0.25) (stroke (width 0.1) (type solid)) (layer "F.Fab"))
  (fp_circle (center -1.2 0) (end -1.1 0) (stroke (width 0.1) (type solid)) (layer "F.SilkS"))
  (fp_text user "keep out of the render" (at 0 0) (layer "F.SilkS"))
  (pad "1" smd rect (at -0.5 0) (size 0.5 0.5) (layers "F.Cu"))
  (pad "2" smd rect (at {pad2x} 0) (size 0.5 0.5) (layers "F.Cu")))"#);
        std::fs::write(dir.join("pages/R1MPN/r.kicad_mod"), fp(0.5)).unwrap();
        std::fs::write(dir.join("pages/C1MPN/c.kicad_mod"), fp(0.9)).unwrap();   // pads differ from the board's
        let comp = |r: &str, x: f64| serde_json::json!({"ref": r, "x": x, "y": 0.0, "rotation": 0.0, "side": "top"});
        let pad = |r: &str, n: &str, x: f64| serde_json::json!({"ref": r, "name": n, "x": x, "y": 0.0, "rotation": 0.0, "sizeX": 0.5, "sizeY": 0.5, "shape": "rect", "layer": "F.Cu", "net": ""});
        let st = serde_json::json!({"success": true, "nets": [], "components": [comp("R1", 0.0), comp("C1", 5.0)],
            "pads": [pad("R1", "1", -0.5), pad("R1", "2", 0.5), pad("C1", "1", 4.5), pad("C1", "2", 5.5)], "outlineEdges": [
            {"kind": "line", "start": [-3.0, -3.0], "end": [8.0, -3.0]}, {"kind": "line", "start": [8.0, -3.0], "end": [8.0, 3.0]},
            {"kind": "line", "start": [8.0, 3.0], "end": [-3.0, 3.0]}, {"kind": "line", "start": [-3.0, 3.0], "end": [-3.0, -3.0]}]});
        let src = serde_json::json!({"source": "altium-bridge", "state": st, "pours": null, "projectDir": dir.display().to_string()}).to_string();
        let text = render_plan_board(&src).unwrap();
        let b = crate::Board::from_text("t.kicad_pcb", text.clone()).unwrap();
        let fp_of = |r: &str| b.root.find_all("footprint").find(|f| crate::reference_of(f).as_deref() == Some(r)).unwrap().clone();
        let layers = |r: &str| { let f = fp_of(r); f.nodes().filter(|n| n.tag.starts_with("fp_")).filter_map(|n| n.find("layer").and_then(|l| l.value()).map(str::to_string)).collect::<Vec<_>>() };
        assert_eq!(layers("R1"), vec!["F.CrtYd", "F.Fab", "F.SilkS"], "R1's source graphics, no source text");
        assert_eq!(layers("C1"), vec!["F.CrtYd"], "C1: only a courtyard around its pads");
        assert!(text.contains("(fp_rect (start -1 -0.5) (end 1 0.5) (stroke (width 0.05)"), "R1's courtyard verbatim");
        assert!(text.contains("(fp_rect (start -1 -0.5) (end 1 0.5) (stroke (width 0.05) (type solid)) (layer \"F.CrtYd\"))") || text.matches("F.CrtYd").count() == 2);
        assert!(!text.contains("keep out of the render"));
        let _ = std::fs::remove_dir_all(&dir);
    }
    #[test]
    fn a_hole_is_slit_into_its_outline() {
        let p = fracture(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], &[vec![(4.0, 4.0), (6.0, 4.0), (6.0, 6.0), (4.0, 6.0)]]);
        assert_eq!(p.len(), 4 + 4 + 2);
    }
}