main
Colby Knox 1.1.0: every molecule gets large machine pins on the 32 mm grid; gallery rebuilt; corrected notes on kicad-to-molecule c794385 4d ago
//! KiCad .kicad_pcb lane.
//!
//! The writer patches the source text by byte span: the outer outline goes, a new
//! rectangle and four machine pins come in, and every other byte stays as it was.
//! The pin footprint is generated from the plan spec in the dialect the board was
//! saved in (KiCad 7, 8, or 9+), so a KiCad 8 board never receives KiCad 10 tokens.

use crate::embed;
use crate::model::{self, Board, Circle, Component, Pad, PadShape, Seg, Shape, Side, Via};
use crate::plan::{BBox, Plan};
use crate::sexpr::{self, Node};
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::path::Path;

pub const PIN_STEP_MEDIUM: &[u8] = include_bytes!("../../assets/pins/MachinePinMediumShort.step");
pub const PIN_STEP_LARGE: &[u8] = include_bytes!("../../assets/pins/MachinePinLargeShort.step");

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Dialect {
    /// KiCad 7 and older: fp_text, tstamp.
    V7,
    /// KiCad 8: property fields, uuid.
    V8,
    /// KiCad 9 and newer: embedded files.
    V9,
}

impl Dialect {
    pub fn from_version(v: u64) -> Dialect {
        if v < 20240108 {
            Dialect::V7
        } else if v < 20241229 {
            Dialect::V8
        } else {
            Dialect::V9
        }
    }
    pub fn label(self) -> &'static str {
        match self {
            Dialect::V7 => "kicad7",
            Dialect::V8 => "kicad8",
            Dialect::V9 => "kicad9+",
        }
    }
}

pub fn pin_step(footprint: &str) -> &'static [u8] {
    if footprint.contains("Large") { PIN_STEP_LARGE } else { PIN_STEP_MEDIUM }
}

fn version_of(root: &Node) -> u64 {
    root.get("version").and_then(|n| n.arg(0)).and_then(|s| s.parse().ok()).unwrap_or(20241229)
}

const GR: [&str; 6] = ["gr_line", "gr_arc", "gr_circle", "gr_rect", "gr_poly", "gr_curve"];
const FP_GR: [&str; 6] = ["fp_line", "fp_arc", "fp_circle", "fp_rect", "fp_poly", "fp_curve"];

/// One graphic item as shapes, in the item's own frame (Y-down, not flipped).
fn shapes_of(n: &Node) -> Vec<Shape> {
    let w = n.get("stroke").and_then(|s| s.num("width")).or_else(|| n.num("width")).unwrap_or(0.1);
    let filled = n.get("fill").and_then(|f| f.arg(0)).is_some_and(|v| v == "solid" || v == "yes");
    let seg = |a: (f64, f64), b: (f64, f64), mid| Shape::Seg(Seg { x1: a.0, y1: a.1, x2: b.0, y2: b.1, width: w, mid });
    let head = n.head();
    let kind = &head[3..];
    match kind {
        "line" => match (n.xy("start"), n.xy("end")) {
            (Some(a), Some(b)) => vec![seg(a, b, None)],
            _ => vec![],
        },
        "arc" => match (n.xy("start"), n.xy("mid"), n.xy("end")) {
            (Some(a), Some(m), Some(b)) => vec![seg(a, b, Some(m))],
            // KiCad 6 style: start = center, end = arc start, angle = sweep.
            (Some(c), None, Some(s)) => {
                let sweep = n.num("angle").unwrap_or(90.0);
                let m = model::arc_mid_from_center(c.0, c.1, s.0, s.1, -sweep);
                let e = model::arc_mid_from_center(c.0, c.1, s.0, s.1, -2.0 * sweep);
                vec![seg(s, e, Some(m))]
            }
            _ => vec![],
        },
        "circle" => match (n.xy("center"), n.xy("end")) {
            (Some(c), Some(e)) => vec![Shape::Circle(Circle { cx: c.0, cy: c.1, r: (e.0 - c.0).hypot(e.1 - c.1), width: w, fill: filled })],
            _ => vec![],
        },
        "rect" => match (n.xy("start"), n.xy("end")) {
            (Some(a), Some(b)) => {
                let p = vec![a, (b.0, a.1), b, (a.0, b.1)];
                if filled { vec![Shape::Poly(p)] } else { ring(&p, w) }
            }
            _ => vec![],
        },
        "poly" => {
            let p = pts(n);
            if p.len() < 2 { vec![] } else if filled { vec![Shape::Poly(p)] } else { ring(&p, w) }
        }
        "curve" => {
            // Bezier: approximate by its control polygon endpoints for extents.
            let p = pts(n);
            p.windows(2).map(|q| seg(q[0], q[1], None)).collect()
        }
        _ => vec![],
    }
}

fn pts(n: &Node) -> Vec<(f64, f64)> {
    n.get("pts").map(|p| p.all("xy").filter_map(|x| { let v = x.nums(); (v.len() >= 2).then(|| (v[0], v[1])) }).collect()).unwrap_or_default()
}

fn ring(p: &[(f64, f64)], w: f64) -> Vec<Shape> {
    (0..p.len())
        .map(|i| {
            let (a, b) = (p[i], p[(i + 1) % p.len()]);
            Shape::Seg(Seg { x1: a.0, y1: a.1, x2: b.0, y2: b.1, width: w, mid: None })
        })
        .collect()
}

/// Map a shape from a Y-down frame (optionally footprint-local) to board Y-up.
fn place(s: &Shape, fx: f64, fy: f64, frot: f64) -> Shape {
    let t = |x: f64, y: f64| {
        let (rx, ry) = model::rot(x, -y, frot);
        (rx + fx, ry - fy)
    };
    match s {
        Shape::Seg(g) => {
            let (x1, y1) = t(g.x1, g.y1);
            let (x2, y2) = t(g.x2, g.y2);
            Shape::Seg(Seg { x1, y1, x2, y2, width: g.width, mid: g.mid.map(|m| t(m.0, m.1)) })
        }
        Shape::Circle(c) => {
            let (cx, cy) = t(c.cx, c.cy);
            Shape::Circle(Circle { cx, cy, ..c.clone() })
        }
        Shape::Poly(p) => Shape::Poly(p.iter().map(|&(x, y)| t(x, y)).collect()),
    }
}

/// Outline bbox in mm, Y up.
pub fn outline_bbox(path: &Path) -> Result<BBox> {
    let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    let root = sexpr::parse(&text)?;
    let edge: Vec<Shape> = edge_items(&root).iter().flat_map(|(_, s)| s.clone()).collect();
    let b = model::bbox_of_shapes(&edge);
    anyhow::ensure!(!b.is_empty(), "no Edge.Cuts geometry found in {}", path.display());
    Ok(BBox { xmin: b.xmin, xmax: b.xmax, ymin: -b.ymax, ymax: -b.ymin })
}

/// Top-level Edge.Cuts items with their shapes (Y-down).
fn edge_items(root: &Node) -> Vec<(&Node, Vec<Shape>)> {
    root.lists().filter(|n| GR.contains(&n.head()) && n.layer() == "Edge.Cuts").map(|n| (n, shapes_of(n))).collect()
}

/// Byte spans of the outer outline: every connected chain of top-level Edge.Cuts
/// segments that touches all four sides of the outline bbox, plus any single closed
/// shape (rect, poly, circle, curve) whose own extent is the whole outline. Inner
/// cutouts such as slots and mounting-hole rings stay.
fn outer_outline_spans(root: &Node) -> (Vec<(usize, usize)>, BBox) {
    let items = edge_items(root);
    let all: Vec<Shape> = items.iter().flat_map(|(_, s)| s.clone()).collect();
    let bb = model::bbox_of_shapes(&all);
    let eps = 0.5;
    let touches = |b: &BBox| -> [bool; 4] {
        [(b.xmin - bb.xmin).abs() < eps, (b.xmax - bb.xmax).abs() < eps, (b.ymin - bb.ymin).abs() < eps, (b.ymax - bb.ymax).abs() < eps]
    };
    let mut spans = vec![];
    // Closed single shapes.
    let mut chain_idx = vec![];
    for (i, (n, s)) in items.iter().enumerate() {
        match n.head() {
            "gr_line" | "gr_arc" => chain_idx.push(i),
            _ => {
                if touches(&model::bbox_of_shapes(s)).iter().all(|&t| t) {
                    spans.push((n.start, n.end));
                }
            }
        }
    }
    // Union-find over line/arc endpoints.
    let mut parent: Vec<usize> = (0..chain_idx.len()).collect();
    fn find(p: &mut Vec<usize>, mut i: usize) -> usize {
        while p[i] != i {
            p[i] = p[p[i]];
            i = p[i];
        }
        i
    }
    let key = |x: f64, y: f64| ((x / 0.01).round() as i64, (y / 0.01).round() as i64);
    let mut seen = std::collections::HashMap::new();
    for (k, &i) in chain_idx.iter().enumerate() {
        if let Some(Shape::Seg(s)) = items[i].1.first() {
            for p in [key(s.x1, s.y1), key(s.x2, s.y2)] {
                match seen.get(&p) {
                    Some(&o) => {
                        let (a, b) = (find(&mut parent, k), find(&mut parent, o));
                        parent[a] = b;
                    }
                    None => {
                        seen.insert(p, k);
                    }
                }
            }
        }
    }
    let mut groups: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
    for k in 0..chain_idx.len() {
        let r = find(&mut parent, k);
        groups.entry(r).or_default().push(k);
    }
    for g in groups.values() {
        let shapes: Vec<Shape> = g.iter().flat_map(|&k| items[chain_idx[k]].1.clone()).collect();
        if touches(&model::bbox_of_shapes(&shapes)).iter().all(|&t| t) {
            spans.extend(g.iter().map(|&k| (items[chain_idx[k]].0.start, items[chain_idx[k]].0.end)));
        }
    }
    spans.sort();
    (spans, bb)
}

fn uid(d: Dialect) -> String {
    let u = uuid::Uuid::new_v4();
    if d == Dialect::V7 { format!("(tstamp {u})") } else { format!("(uuid \"{u}\")") }
}

fn f(v: f64) -> String {
    let s = format!("{:.6}", v);
    let s = s.trim_end_matches('0').trim_end_matches('.');
    if s == "-0" { "0".into() } else { s.into() }
}

fn gr_line(d: Dialect, a: (f64, f64), b: (f64, f64), width: f64, layer: &str) -> String {
    let u = uid(d);
    format!(
        "\t(gr_line\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(stroke\n\t\t\t(width {})\n\t\t\t(type default)\n\t\t)\n\t\t(layer \"{layer}\")\n\t\t{u}\n\t)",
        f(a.0), f(a.1), f(b.0), f(b.1), f(width)
    )
}

/// One machine-pin footprint at board position (x, y) in KiCad's Y-down frame.
fn mp_footprint(d: Dialect, rf: &str, x: f64, y: f64, front: bool, plan: &Plan, model_path: &str) -> String {
    let s = plan.spec();
    let large = s.pad_mm > 3.0;
    let (ts, tt) = if large { (1.0, 0.15) } else { (0.6, 0.1) };
    let r = s.pad_mm / 2.0;
    // Reference text sits on the board side of the pin, away from the nearest edge.
    let ty = if front { -(r + ts * 0.9) } else { r + ts * 0.9 };
    let name = format!("Adom:{}", s.footprint);
    let font = format!("(effects (font (size {ts} {ts}) (thickness {tt})))");
    let model = format!(
        "(model \"{model_path}\"\n\t\t\t(offset (xyz 0 0 0))\n\t\t\t(scale (xyz 1 1 1))\n\t\t\t(rotate (xyz 0 0 0))\n\t\t)"
    );
    let ring = |rad: f64, w: f64, layer: &str| {
        format!("(fp_circle (center 0 0) (end {} 0) (stroke (width {w}) (type solid)) (fill none) (layer \"{layer}\") {})", f(rad), uid(d))
    };
    let pad = format!(
        "(pad \"1\" thru_hole circle (at 0 0) (size {p} {p}) (drill {dr}) (layers \"*.Cu\" \"*.Mask\") {})",
        uid(d), p = f(s.pad_mm), dr = f(s.drill_mm)
    );
    let fields = if d == Dialect::V7 {
        format!(
            "\t\t(fp_text reference \"{rf}\" (at 0 {ty}) (layer \"F.SilkS\") {font} {})\n\t\t(fp_text value \"{v}\" (at 0 {vy}) (layer \"F.Fab\") hide {font} {})",
            uid(d), uid(d), ty = f(ty), v = s.footprint, vy = f(-ty)
        )
    } else {
        format!(
            "\t\t(property \"Reference\" \"{rf}\" (at 0 {ty} 0) (layer \"F.SilkS\") {} {font})\n\t\t(property \"Value\" \"{v}\" (at 0 {vy} 0) (layer \"F.Fab\") (hide yes) {} {font})",
            uid(d), uid(d), ty = f(ty), v = s.footprint, vy = f(-ty)
        )
    };
    format!(
        "\t(footprint \"{name}\"\n\t\t(layer \"F.Cu\")\n\t\t{}\n\t\t(at {} {})\n{fields}\n\t\t(attr through_hole)\n\t\t{}\n\t\t{}\n\t\t{pad}\n\t\t{model}\n\t)",
        uid(d), f(x), f(y),
        ring(r + 0.15, if large { 0.15 } else { 0.1 }, "F.SilkS"),
        ring(r + 0.25, 0.05, "F.CrtYd"),
    )
}

/// Write the molecule. Returns a JSON summary of what changed.
pub fn write_molecule(src: &Path, plan: &Plan, out: &Path) -> Result<Value> {
    let text = std::fs::read_to_string(src).with_context(|| format!("read {}", src.display()))?;
    let root = sexpr::parse(&text)?;
    let d = Dialect::from_version(version_of(&root));
    let (spans, _) = outer_outline_spans(&root);
    let s = plan.spec();
    let embed = d >= Dialect::V9;
    let step_name = format!("{}.step", s.footprint);
    let model_path = if embed { format!("kicad-embed://{step_name}") } else { format!("${{KIPRJMOD}}/3dmodels/{step_name}") };

    // Kicad frame is Y-down: flip the plan on the way out.
    let n = &plan.new;
    let (x0, x1, y0, y1) = (n.xmin, n.xmax, -n.ymax, -n.ymin);
    let mut add = vec![
        gr_line(d, (x0, y0), (x1, y0), 0.1, "Edge.Cuts"),
        gr_line(d, (x1, y0), (x1, y1), 0.1, "Edge.Cuts"),
        gr_line(d, (x1, y1), (x0, y1), 0.1, "Edge.Cuts"),
        gr_line(d, (x0, y1), (x0, y0), 0.1, "Edge.Cuts"),
    ];
    for p in &plan.pins {
        add.push(mp_footprint(d, &p.r#ref, p.x, -p.y, p.role.starts_with("front"), plan, &model_path));
    }
    if plan.vcut {
        for l in plan.vcut_lines() {
            add.push(gr_line(d, (l[0], -l[1]), (l[2], -l[3]), 0.2, "Cmts.User"));
        }
    }

    // Embedded pin model: into an existing top-level (embedded_files) or a new one.
    let existing = root.get("embedded_files");
    let mut embed_insert: Option<(usize, String)> = None;
    if embed {
        let entry = embed::file_entry(&step_name, pin_step(s.footprint), "\t\t");
        match existing {
            Some(ef) if ef.all("file").any(|f| f.get("name").and_then(|n| n.arg(0)) == Some(&step_name)) => {}
            Some(ef) => embed_insert = Some((ef.end - 1, format!("\n{entry}\n\t"))),
            None => add.push(format!("\t(embedded_files\n{entry}\n\t)")),
        }
    }

    // Assemble: drop outline spans (with the whitespace before each), insert before the root ')'.
    let mut cuts: Vec<(usize, usize, String)> = spans
        .iter()
        .map(|&(a, b)| {
            let a2 = text[..a].trim_end_matches([' ', '\t']).len();
            let a2 = if text[..a2].ends_with('\n') { a2 - 1 } else { a2 };
            (a2, b, String::new())
        })
        .collect();
    if let Some((at, s)) = embed_insert {
        cuts.push((at, at, s));
    }
    let close = root.end - 1;
    cuts.push((close, close, format!("{}\n", add.iter().map(|a| format!("\n{a}")).collect::<String>())));
    cuts.sort_by_key(|c| (c.0, c.1));
    let mut outs = String::with_capacity(text.len() + 1 << 16);
    let mut last = 0;
    for (a, b, ins) in &cuts {
        outs.push_str(&text[last..*a]);
        outs.push_str(ins);
        last = *b;
    }
    outs.push_str(&text[last..]);
    std::fs::write(out, &outs).with_context(|| format!("write {}", out.display()))?;

    let mut step_files = vec![];
    if !embed {
        let dir = out.parent().unwrap_or(Path::new(".")).join("3dmodels");
        std::fs::create_dir_all(&dir)?;
        std::fs::write(dir.join(&step_name), pin_step(s.footprint))?;
        step_files.push(dir.join(&step_name).display().to_string());
    }
    Ok(json!({
        "dialect": d.label(),
        "outline_items_removed": spans.len(),
        "pins_added": plan.pins.len(),
        "pin_model": if embed { "embedded (kicad-embed://)" } else { "3dmodels/ folder beside the board" },
        "step_files": step_files,
        "bytes_in": text.len(),
        "bytes_out": outs.len(),
    }))
}

/// Make a copy whose machine-pin models are embedded, for headless 3D export on a
/// machine that only sees the .kicad_pcb (service-kicad). Returns the new text.
pub fn embed_pin_models(text: &str) -> Result<String> {
    let root = sexpr::parse(text)?;
    let mut out = text.to_string();
    let mut names = vec![];
    for fp in ["MachinePinMediumShort", "MachinePinLargeShort"] {
        let from = format!("${{KIPRJMOD}}/3dmodels/{fp}.step");
        if out.contains(&from) {
            out = out.replace(&from, &format!("kicad-embed://{fp}.step"));
            names.push(fp);
        } else if out.contains(&format!("kicad-embed://{fp}.step")) && root.get("embedded_files").is_none() {
            names.push(fp);
        }
    }
    if names.is_empty() {
        return Ok(out);
    }
    let entries: Vec<String> = names.iter().map(|fp| embed::file_entry(&format!("{fp}.step"), pin_step(fp), "\t\t")).collect();
    let close = out.rfind(')').context("malformed board")?;
    out.insert_str(close, &format!("\t(embedded_files\n{}\n\t)\n", entries.join("\n")));
    Ok(out)
}

fn pad_shape(s: &str) -> PadShape {
    match s {
        "circle" => PadShape::Circle,
        "oval" => PadShape::Oval,
        "roundrect" => PadShape::RoundRect,
        _ => PadShape::Rect,
    }
}

/// Preview model (Y-up mm).
pub fn read(path: &Path) -> Result<Board> {
    let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    let root = sexpr::parse(&text)?;
    let mut b = Board::new("kicad");
    b.meta.insert("dialect".into(), Dialect::from_version(version_of(&root)).label().into());
    for n in root.lists() {
        let h = n.head();
        if GR.contains(&h) {
            let shapes = shapes_of(n).iter().map(|s| place(s, 0.0, 0.0, 0.0)).collect::<Vec<_>>();
            match n.layer() {
                "Edge.Cuts" => b.outline.extend(shapes),
                "F.SilkS" => b.silk_top.extend(shapes),
                "B.SilkS" => b.silk_bottom.extend(shapes),
                _ => {}
            }
        } else if h == "segment" || h == "arc" {
            let (Some(a), Some(e)) = (n.xy("start"), n.xy("end")) else { continue };
            let seg = Seg { x1: a.0, y1: -a.1, x2: e.0, y2: -e.1, width: n.num("width").unwrap_or(0.2), mid: n.xy("mid").map(|m| (m.0, -m.1)) };
            match n.layer() {
                "F.Cu" => b.tracks_top.push(seg),
                "B.Cu" => b.tracks_bottom.push(seg),
                _ => {}
            }
        } else if h == "via" {
            if let Some(at) = n.xy("at") {
                b.vias.push(Via { x: at.0, y: -at.1, dia: n.num("size").unwrap_or(0.6), drill: n.num("drill").unwrap_or(0.3) });
            }
        } else if h == "zone" {
            for fp in n.all("filled_polygon") {
                let p: Vec<(f64, f64)> = pts(fp).into_iter().map(|(x, y)| (x, -y)).collect();
                match fp.layer() {
                    "F.Cu" => b.pours_top.push(p),
                    "B.Cu" => b.pours_bottom.push(p),
                    _ => {}
                }
            }
        } else if h == "footprint" || h == "module" {
            let at = n.get("at").map(|a| a.nums()).unwrap_or_default();
            let (fx, fy, frot) = (at.first().copied().unwrap_or(0.0), at.get(1).copied().unwrap_or(0.0), at.get(2).copied().unwrap_or(0.0));
            let side = if n.layer() == "B.Cu" { Side::Bottom } else { Side::Top };
            let rf = n.all("property").find(|p| p.arg(0) == Some("Reference")).and_then(|p| p.arg(1))
                .or_else(|| n.all("fp_text").find(|p| p.arg(0) == Some("reference")).and_then(|p| p.arg(1)))
                .unwrap_or("?").to_string();
            let value = n.all("property").find(|p| p.arg(0) == Some("Value")).and_then(|p| p.arg(1))
                .or_else(|| n.all("fp_text").find(|p| p.arg(0) == Some("value")).and_then(|p| p.arg(1)))
                .unwrap_or("").to_string();
            let mut c = Component { r#ref: rf, x: fx, y: -fy, rot: frot, side, value, package: n.arg(0).unwrap_or("").to_string(), pads: vec![], bodies: vec![] };
            for g in n.lists().filter(|g| FP_GR.contains(&g.head())) {
                let shapes = shapes_of(g).iter().map(|s| place(s, fx, fy, frot)).collect::<Vec<_>>();
                match g.layer() {
                    "F.SilkS" => b.silk_top.extend(shapes),
                    "B.SilkS" => b.silk_bottom.extend(shapes),
                    "Edge.Cuts" => b.outline.extend(shapes),
                    _ => {}
                }
            }
            for p in n.all("pad") {
                let pat = p.get("at").map(|a| a.nums()).unwrap_or_default();
                let (lx, ly) = (pat.first().copied().unwrap_or(0.0), pat.get(1).copied().unwrap_or(0.0));
                let (rx, ry) = model::rot(lx, -ly, frot);
                let size = p.get("size").map(|s| s.nums()).unwrap_or_default();
                let layers = p.layers();
                let kind = p.arg(1).unwrap_or("smd");
                if kind == "np_thru_hole" {
                    continue;
                }
                let drill = if kind == "thru_hole" { p.get("drill").map(|d| d.nums()).and_then(|v| v.first().copied()).unwrap_or(0.0) } else { 0.0 };
                let pside = if layers.iter().any(|l| *l == "B.Cu") && !layers.iter().any(|l| *l == "F.Cu" || *l == "*.Cu") { Side::Bottom } else { Side::Top };
                c.pads.push(Pad {
                    x: fx + rx,
                    y: -fy + ry,
                    w: size.first().copied().unwrap_or(1.0),
                    h: size.get(1).copied().unwrap_or(1.0),
                    shape: pad_shape(p.arg(2).unwrap_or("rect")),
                    rot: pat.get(2).copied().unwrap_or(frot),
                    drill,
                    side: pside,
                    name: p.arg(0).unwrap_or("").to_string(),
                });
            }
            b.components.push(c);
        }
    }
    b.meta.insert("components".into(), b.components.len().to_string());
    Ok(b)
}

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

    const BOARD8: &str = "(kicad_pcb (version 20240108) (generator \"pcbnew\")\n\t(gr_rect (start 10 10) (end 30 20) (stroke (width 0.1) (type default)) (fill none) (layer \"Edge.Cuts\") (uuid \"a\"))\n\t(gr_circle (center 20 15) (end 21 15) (stroke (width 0.1) (type default)) (fill none) (layer \"Edge.Cuts\") (uuid \"b\"))\n)\n";

    #[test]
    fn rect_outline_replaced_hole_kept_v8_tokens() {
        let dir = std::env::temp_dir().join(format!("b2m-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let src = dir.join("b.kicad_pcb");
        std::fs::write(&src, BOARD8).unwrap();
        let bb = outline_bbox(&src).unwrap();
        assert_eq!((bb.w(), bb.h()), (20.0, 10.0));
        let plan = make_plan("kicad", "b", bb, false);
        let out = dir.join("m.kicad_pcb");
        let sum = write_molecule(&src, &plan, &out).unwrap();
        assert_eq!(sum["outline_items_removed"], 1);
        let t = std::fs::read_to_string(&out).unwrap();
        assert!(t.contains("gr_circle"), "inner hole must stay");
        assert!(!t.contains("gr_rect"));
        assert!(!t.contains("jumpers") && !t.contains("embedded_"), "no KiCad 9/10 tokens in a KiCad 8 board");
        assert!(t.contains("${KIPRJMOD}/3dmodels/MachinePinLargeShort.step"));
        let nb = outline_bbox(&out).unwrap();
        assert!((nb.w() - plan.new.w()).abs() < 1e-6 && (nb.h() - plan.new.h()).abs() < 1e-6);
        sexpr::parse(&t).unwrap();
        let _ = std::fs::remove_dir_all(&dir);
    }
}