app
Board to Molecule
Public Made by Adomby adom
Turn any KiCad, Fusion/EAGLE, or Altium board into an Adom molecule: wings plus MP1-MP4 machine pins, written back in the board's own format.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
//! 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);
}
}