app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
Colby Knox
Merge PR #273: 0.2.9: the Altium lane on 0.2.x (courtyards, parts and project page, verified list)
a8ed7d2
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
//! 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);
}
}