app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #453: 1.0.106: silk batch verbs take kicad_silk_state's y-up frame; off-board text refused (closes #141)
11c4c49
2d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954
//! The EDA Bridge Standard's board model (adom/eda-bridge-standard, `schema/board.schema.json`) built
//! from `.kicad_pcb` text: the live editor's own serialisation (IPC `GetBoardAsString`) or the file.
//!
//! The rules it keeps, from the standard's `docs/board-model.md`:
//! - millimetres, absolute, y UP: KiCad's frame is y down, so every y is negated (`frame: "kicad"`
//! keeps KiCad's y). Angles need no change: KiCad measures them counterclockwise on screen, which is
//! counterclockwise in a y-up frame;
//! - copper layers by KiCad's names, `*.Cu` for a pad on every copper layer;
//! - a custom-shape pad is its bounding rectangle (`shape: "rect"`, rotation 0), never 0 x 0;
//! - net-less copper is kept with an empty net;
//! - the outline as edges with its arcs (center, radius, angle1 to angle2 counterclockwise);
//! - `unconnected` computed from the copper itself;
//! - `revision` changes only with copper, parts or the outline (silkscreen, text and fills do not move it).
//!
//! Nothing here talks to KiCad; `crate::ipc::routing_state` and the file read build on it.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde_json::{json, Value};
use crate::model_check::{parse_sexpr, Sx};
use crate::pcb::NAME_BASED_NET_VERSION;
/// Options for one build.
#[derive(Clone, Copy, Debug)]
pub struct Opts {
/// The standard's frame (y up). `false` keeps KiCad's y-down frame.
pub y_up: bool,
}
impl Default for Opts {
fn default() -> Self {
Opts { y_up: true }
}
}
type P = (f64, f64);
/// 1 nm resolution: KiCad's own unit, so nothing is rounded away. `+ 0.0` turns -0 into 0.
fn r6(v: f64) -> f64 {
(v * 1e6).round() / 1e6 + 0.0
}
fn num(sx: &Sx, i: usize) -> Option<f64> {
sx.atom_at(i).and_then(|a| a.parse::<f64>().ok())
}
/// `(tag x y ...)` as a y-up point.
fn xy_of(sx: Option<&Sx>) -> Option<P> {
let s = sx?;
Some((num(s, 1)?, -num(s, 2)?))
}
fn rotate(p: P, deg: f64) -> P {
let (s, c) = deg.to_radians().sin_cos();
(p.0 * c - p.1 * s, p.0 * s + p.1 * c)
}
fn add(a: P, b: P) -> P {
(a.0 + b.0, a.1 + b.1)
}
fn dist(a: P, b: P) -> f64 {
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
}
/// A footprint's placement: local y-up coordinates to board coordinates.
#[derive(Clone, Copy)]
struct Place {
at: P,
angle: f64,
}
impl Place {
const BOARD: Place = Place { at: (0.0, 0.0), angle: 0.0 };
fn apply(&self, p: P) -> P {
add(self.at, rotate(p, self.angle))
}
}
/// The circle through three points: (center, radius), or None when they are collinear.
fn circle3(a: P, b: P, c: P) -> Option<(P, f64)> {
let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));
if d.abs() < 1e-12 {
return None;
}
let (a2, b2, c2) = (a.0 * a.0 + a.1 * a.1, b.0 * b.0 + b.1 * b.1, c.0 * c.0 + c.1 * c.1);
let ux = (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d;
let uy = (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d;
Some(((ux, uy), dist((ux, uy), a)))
}
fn angle_deg(c: P, p: P) -> f64 {
let a = (p.1 - c.1).atan2(p.0 - c.0).to_degrees();
if a < 0.0 {
a + 360.0
} else {
a
}
}
/// Counterclockwise sweep from `a1` to `a2`, in (0, 360].
fn ccw_sweep(a1: f64, a2: f64) -> f64 {
let s = (a2 - a1).rem_euclid(360.0);
if s < 1e-9 {
360.0
} else {
s
}
}
/// A three-point arc as (center, radius, angle1, angle2) with angle1 -> angle2 counterclockwise.
#[derive(Clone, Copy, Debug)]
struct Arc3 {
center: P,
radius: f64,
a1: f64,
a2: f64,
}
fn arc_of(start: P, mid: P, end: P) -> Option<Arc3> {
let (center, radius) = circle3(start, mid, end)?;
let (s, m, e) = (angle_deg(center, start), angle_deg(center, mid), angle_deg(center, end));
// counterclockwise from start through mid to end, or the other way round
let ccw = ccw_sweep(s, m) < ccw_sweep(s, e);
let (a1, a2) = if ccw { (s, e) } else { (e, s) };
Some(Arc3 { center, radius, a1, a2 })
}
impl Arc3 {
fn point(&self, deg: f64) -> P {
let r = deg.to_radians();
(self.center.0 + self.radius * r.cos(), self.center.1 + self.radius * r.sin())
}
/// Points every `step` degrees, ends included, counterclockwise.
fn sample(&self, step: f64) -> Vec<P> {
let sweep = ccw_sweep(self.a1, self.a2);
let n = ((sweep / step).ceil() as usize).max(1);
(0..=n).map(|k| self.point(self.a1 + sweep * k as f64 / n as f64)).collect()
}
}
/// The points of a `(pts (xy ..) (arc (start)(mid)(end)) ..)` list, arcs sampled every 2 degrees.
fn pts_of(pts: Option<&Sx>) -> Vec<P> {
let mut out = Vec::new();
for c in pts.and_then(Sx::as_list).into_iter().flatten() {
match c.tag() {
Some("xy") => {
if let Some(p) = xy_of(Some(c)) {
out.push(p);
}
}
Some("arc") => {
let (Some(s), Some(m), Some(e)) = (xy_of(c.find("start")), xy_of(c.find("mid")), xy_of(c.find("end"))) else { continue };
match arc_of(s, m, e) {
Some(a) => {
let mut ps = a.sample(2.0);
// keep the arc's own direction (start to end) inside the outline
if dist(ps[0], s) > dist(*ps.last().unwrap(), s) {
ps.reverse();
}
out.extend(ps);
}
None => out.extend([s, e]),
}
}
_ => {}
}
}
out
}
fn shoelace(poly: &[P]) -> f64 {
let n = poly.len();
(0..n).map(|k| {
let (a, b) = (poly[k], poly[(k + 1) % n]);
a.0 * b.1 - b.0 * a.1
}).sum::<f64>() / 2.0
}
fn stroke_width(sx: &Sx) -> f64 {
sx.find("stroke").and_then(|s| s.find("width")).or_else(|| sx.find("width")).and_then(|w| num(w, 1)).unwrap_or(0.0)
}
fn filled(sx: &Sx) -> bool {
matches!(sx.find("fill").and_then(Sx::value), Some("yes") | Some("solid"))
}
// -- nets and layers ---------------------------------------------------------------------------
struct Ctx {
name_based: bool,
nets: HashMap<i64, String>,
/// copper layers top to bottom
copper: Vec<String>,
}
impl Ctx {
fn net(&self, node: &Sx) -> String {
if let Some(n) = node.find("net_name").and_then(Sx::value) {
return n.to_string();
}
let Some(net) = node.find("net") else { return String::new() };
if self.name_based {
return net.value().unwrap_or("").to_string();
}
if let Some(name) = net.atom_at(2) {
return name.to_string();
}
match net.value().and_then(|v| v.parse::<i64>().ok()) {
Some(n) => self.nets.get(&n).cloned().unwrap_or_default(),
None => net.value().unwrap_or("").to_string(),
}
}
fn is_copper(&self, layer: &str) -> bool {
layer.ends_with(".Cu") && (layer == "*.Cu" || layer == "F&B.Cu" || self.copper.iter().any(|l| l == layer) || layer.starts_with("In"))
}
fn index(&self, layer: &str) -> Option<usize> {
self.copper.iter().position(|l| l == layer)
}
/// A list of layer names as the copper layers it covers, top to bottom.
fn expand(&self, layers: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for l in layers {
if l == "*.Cu" {
return self.copper.clone();
}
if l == "F&B.Cu" {
for x in [self.copper.first(), self.copper.last()].into_iter().flatten() {
if !out.contains(x) {
out.push(x.clone());
}
}
} else if self.is_copper(l) && !out.contains(l) {
out.push(l.clone());
}
}
out.sort_by_key(|l| self.index(l).unwrap_or(usize::MAX));
out
}
}
fn layers_of(sx: &Sx) -> Vec<String> {
if let Some(l) = sx.find("layers") {
return l.as_list().unwrap_or(&[]).iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect();
}
sx.find("layer").and_then(Sx::value).map(|l| vec![l.to_string()]).unwrap_or_default()
}
fn board_ctx(root: &Sx) -> Ctx {
let version = root.find("version").and_then(|v| num(v, 1)).unwrap_or(0.0) as i64;
let mut nets = HashMap::new();
for n in root.find_all("net") {
if let (Some(k), Some(name)) = (n.value().and_then(|v| v.parse::<i64>().ok()), n.atom_at(2)) {
nets.insert(k, name.to_string());
}
}
let mut copper: Vec<(i64, String)> = Vec::new();
if let Some(ls) = root.find("layers").and_then(Sx::as_list) {
for l in ls.iter().skip(1) {
let (Some(id), Some(name)) = (l.tag().and_then(|t| t.parse::<i64>().ok()), l.atom_at(1)) else { continue };
if name.ends_with(".Cu") {
copper.push((id, name.to_string()));
}
}
}
// top to bottom: F.Cu, In1.Cu .. In30.Cu, B.Cu, whatever ids the file version uses
let rank = |n: &str| -> i64 {
match n {
"F.Cu" => 0,
"B.Cu" => 1000,
_ => n.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|r| r.parse::<i64>().ok()).unwrap_or(999),
}
};
copper.sort_by_key(|(_, n)| rank(n));
Ctx { name_based: version >= NAME_BASED_NET_VERSION, nets, copper: copper.into_iter().map(|(_, n)| n).collect() }
}
// -- the model, in the y-up frame ----------------------------------------------------------------
#[derive(Clone, Debug)]
struct Track {
a: P,
b: P,
width: f64,
layer: String,
net: String,
owner: Option<String>,
/// the segment's uuid: the handle edits take. Not part of the revision.
id: Option<String>,
}
#[derive(Clone, Debug)]
struct ArcT {
arc: Arc3,
width: f64,
layer: String,
net: String,
owner: Option<String>,
}
#[derive(Clone, Debug)]
struct Via {
at: P,
size: f64,
drill: f64,
layers: Vec<String>,
net: String,
kind: String,
}
#[derive(Clone, Debug)]
struct Pad {
owner: String,
name: String,
at: P,
size: P,
rotation: f64,
layer: String,
copper: Vec<String>,
drill: f64,
net: String,
shape: &'static str,
kicad_shape: String,
kind: String,
anchor: Option<P>,
/// a custom pad's real copper outline (board coordinates), when it is one drawn polygon over the anchor
copper_points: Option<Vec<P>>,
}
#[derive(Clone, Debug)]
struct Comp {
reference: String,
value: String,
at: P,
rotation: f64,
bottom: bool,
footprint: String,
has3d: bool,
attrs: Vec<String>,
}
#[derive(Clone, Debug)]
struct Poly {
net: String,
layer: String,
points: Vec<P>,
name: Option<String>,
priority: i64,
hatched: bool,
/// the copper KiCad poured on this layer, each island's outline (holes slit in)
fills: Vec<Vec<P>>,
owner: Option<String>,
graphic: bool,
/// the zone's uuid (one zone on several layers is one id), None for a graphic
id: Option<String>,
}
#[derive(Clone, Debug)]
struct Keepout {
layers: Vec<String>,
points: Vec<P>,
rules: BTreeMap<String, String>,
owner: Option<String>,
}
#[derive(Clone, Debug)]
enum EdgeKind {
Line,
Arc(Arc3),
}
#[derive(Clone, Debug)]
struct Edge {
a: P,
b: P,
kind: EdgeKind,
}
struct Model {
tracks: Vec<Track>,
arcs: Vec<ArcT>,
vias: Vec<Via>,
pads: Vec<Pad>,
comps: Vec<Comp>,
polys: Vec<Poly>,
keepouts: Vec<Keepout>,
/// loops of edges; [0] is the outer outline when there is one
loops: Vec<Vec<Edge>>,
loose_edges: usize,
copper: Vec<String>,
}
/// The board model as JSON (the standard's shape) from `.kicad_pcb` text.
pub fn build(text: &str, file: Option<&str>, opts: &Opts) -> Result<Value, String> {
let (root, repaired) = parse_board(text)?;
let m = model(&root);
let revision = revision(&m);
let mut v = to_json(&m, file, &revision, opts);
if repaired > 0 {
v["repairedOnRead"] = json!({"teardropFilterRatio": repaired, "why": "KiCad 9 wrote (curved_edges no)filter_ratio 0.9) without the opening parenthesis; KiCad's own reader accepts it, so this read does too. Saving the board in KiCad 10 rewrites it correctly."});
}
Ok(v)
}
/// The standard board model of a `.kicad_pcb` on disk (KiCad need not run; unsaved edits in an open
/// editor are not in the file).
pub fn read_file(path: &str, opts: &Opts) -> Result<Value, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("could not read {path}: {e}"))?;
build(&text, Some(path), opts)
}
/// The standard revision of a board text: a hash of its copper, parts and outline.
pub fn revision_of_text(text: &str) -> Result<String, String> {
Ok(revision(&model(&parse_board(text)?.0)))
}
/// Where the first top-level list closes: (byte offset just past it, line), quotes respected.
fn root_end(text: &str) -> Option<(usize, usize)> {
let (mut depth, mut quoted, mut escaped, mut line) = (0i64, false, false, 1usize);
for (i, c) in text.char_indices() {
if c == '\n' {
line += 1;
}
if quoted {
if escaped { escaped = false } else if c == '\\' { escaped = true } else if c == '"' { quoted = false }
continue;
}
match c {
'"' => quoted = true,
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some((i + 1, line));
}
}
_ => {}
}
}
None
}
/// Parse a board, refusing text whose root list closes before the end of the file (the rest would be
/// silently dropped). One known KiCad 9 writer bug is repaired first; returns how many were.
fn parse_board(text: &str) -> Result<(Sx, usize), String> {
let early = |t: &str| root_end(t).filter(|(end, _)| !t[*end..].trim().is_empty());
let mut owned = None;
let mut repaired = 0;
if early(text).is_some() && text.contains(")filter_ratio ") {
repaired = text.matches(")filter_ratio ").count();
owned = Some(text.replace(")filter_ratio ", ")(filter_ratio "));
}
let t = owned.as_deref().unwrap_or(text);
if let Some((_, line)) = early(t) {
return Err(format!("the board's parentheses close the kicad_pcb list at line {line}, before the end of the file: the file is damaged (open and save it in KiCad, or restore a backup)"));
}
let root = parse_sexpr(t).ok_or("empty board text")?;
if root.tag() != Some("kicad_pcb") {
return Err("not a .kicad_pcb (no kicad_pcb root)".into());
}
Ok((root, repaired))
}
fn model(root: &Sx) -> Model {
let cx = board_ctx(root);
let mut m = Model { tracks: vec![], arcs: vec![], vias: vec![], pads: vec![], comps: vec![], polys: vec![], keepouts: vec![], loops: vec![], loose_edges: 0, copper: cx.copper.clone() };
let mut edges: Vec<Edge> = Vec::new();
for item in root.as_list().unwrap_or(&[]).iter().skip(1) {
match item.tag() {
Some("segment") => {
let (Some(a), Some(b)) = (xy_of(item.find("start")), xy_of(item.find("end"))) else { continue };
let layer = item.find("layer").and_then(Sx::value).unwrap_or("").to_string();
m.tracks.push(Track { a, b, width: item.find("width").and_then(|w| num(w, 1)).unwrap_or(0.0), layer, net: cx.net(item), owner: None, id: item.find("uuid").and_then(Sx::value).map(str::to_string) });
}
Some("arc") => {
let (Some(s), Some(mid), Some(e)) = (xy_of(item.find("start")), xy_of(item.find("mid")), xy_of(item.find("end"))) else { continue };
let width = item.find("width").and_then(|w| num(w, 1)).unwrap_or(0.0);
let layer = item.find("layer").and_then(Sx::value).unwrap_or("").to_string();
match arc_of(s, mid, e) {
Some(arc) => m.arcs.push(ArcT { arc, width, layer, net: cx.net(item), owner: None }),
None => m.tracks.push(Track { a: s, b: e, width, layer, net: cx.net(item), owner: None, id: None }),
}
}
Some("via") => {
let Some(at) = xy_of(item.find("at")) else { continue };
let kind = item.atom_at(1).filter(|k| matches!(*k, "blind" | "micro" | "through" | "buried")).unwrap_or("through").to_string();
let layers = cx.expand(&layers_of(item));
let span = match (layers.first(), layers.last()) {
(Some(a), Some(b)) => vec![a.clone(), b.clone()],
_ => vec![cx.copper.first().cloned().unwrap_or("F.Cu".into()), cx.copper.last().cloned().unwrap_or("B.Cu".into())],
};
m.vias.push(Via { at, size: item.find("size").and_then(|w| num(w, 1)).unwrap_or(0.0), drill: item.find("drill").and_then(|w| num(w, 1)).unwrap_or(0.0), layers: span, net: cx.net(item), kind });
}
Some("footprint") | Some("module") => footprint(&cx, item, &mut m, &mut edges),
Some("zone") => zone(&cx, item, None, &mut m),
Some(t) if t.starts_with("gr_") => graphic(&cx, item, Place::BOARD, None, &mut m, &mut edges),
_ => {}
}
}
let (loops, loose) = chain(edges);
m.loops = loops;
m.loose_edges = loose;
m
}
fn footprint(cx: &Ctx, fp: &Sx, m: &mut Model, edges: &mut Vec<Edge>) {
let at = fp.find("at");
let place = Place { at: xy_of(at).unwrap_or((0.0, 0.0)), angle: at.and_then(|a| num(a, 3)).unwrap_or(0.0) };
let layer = fp.find("layer").and_then(Sx::value).unwrap_or("F.Cu");
let reference = fp.find_all("property").find(|p| p.value() == Some("Reference")).and_then(|p| p.atom_at(2))
.or_else(|| fp.find_all("fp_text").find(|t| t.value() == Some("reference")).and_then(|t| t.atom_at(2)))
.unwrap_or("").to_string();
let value = fp.find_all("property").find(|p| p.value() == Some("Value")).and_then(|p| p.atom_at(2))
.or_else(|| fp.find_all("fp_text").find(|t| t.value() == Some("value")).and_then(|t| t.atom_at(2)))
.unwrap_or("").to_string();
// a model counts unless it is hidden: (hide yes) since KiCad 8, a bare `hide` atom before
let has3d = fp.find_all("model").any(|md| {
let hidden = md.find("hide").map(|h| h.value() != Some("no")).unwrap_or(false) || md.as_list().unwrap_or(&[]).iter().any(|a| a.as_atom() == Some("hide"));
!hidden
});
let attrs = fp.find("attr").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default();
m.comps.push(Comp { reference: reference.clone(), value, at: place.at, rotation: place.angle, bottom: layer == "B.Cu", footprint: fp.value().unwrap_or("").to_string(), has3d, attrs });
for pad in fp.find_all("pad") {
if let Some(p) = pad_of(cx, pad, &place, &reference) {
m.pads.push(p);
}
}
for c in fp.as_list().unwrap_or(&[]).iter() {
match c.tag() {
Some(t) if t.starts_with("fp_") && t != "fp_text" => graphic(cx, c, place, Some(&reference), m, edges),
Some("zone") => zone(cx, c, Some(&reference), m),
_ => {}
}
}
}
fn pad_of(cx: &Ctx, pad: &Sx, place: &Place, owner: &str) -> Option<Pad> {
let name = pad.value().unwrap_or("").to_string();
let kind = pad.atom_at(2).unwrap_or("").to_string();
let kshape = pad.atom_at(3).unwrap_or("").to_string();
let at = pad.find("at")?;
let local = xy_of(Some(at))?;
// a pad's angle in the board file is absolute (footprint angle included)
let angle = num(at, 3).unwrap_or(0.0);
let center = place.apply(local);
let size = pad.find("size").map(|s| (num(s, 1).unwrap_or(0.0), num(s, 2).unwrap_or(0.0))).unwrap_or((0.0, 0.0));
let drill = pad.find("drill").map(|d| {
// (drill d) or (drill oval w h): the smaller is the hole's width
let vals: Vec<f64> = d.as_list().unwrap_or(&[]).iter().skip(1).filter_map(Sx::as_atom).filter_map(|a| a.parse().ok()).collect();
vals.iter().cloned().fold(f64::MAX, f64::min)
}).filter(|d| *d < f64::MAX).unwrap_or(0.0);
let copper = cx.expand(&layers_of(pad));
let npth = kind == "np_thru_hole";
if copper.is_empty() && !npth {
return None;
}
let layer = if copper.len() > 1 || npth || layers_of(pad).iter().any(|l| l == "*.Cu") { "*.Cu".to_string() } else { copper[0].clone() };
let copper = if npth && copper.is_empty() { cx.copper.clone() } else { copper };
let net = if npth { String::new() } else { cx.net(pad) };
let base = Pad { owner: owner.to_string(), name, at: center, size, rotation: angle, layer, copper, drill, net, shape: "rect", kicad_shape: kshape.clone(), kind, anchor: None, copper_points: None };
Some(match kshape.as_str() {
"circle" => Pad { shape: "circle", ..base },
"oval" => Pad { shape: "round", ..base },
"roundrect" => Pad { shape: "roundrect", ..base },
"trapezoid" => {
// its widest extent: the size plus the delta on each side
let d = pad.find("rect_delta").map(|r| (num(r, 1).unwrap_or(0.0).abs(), num(r, 2).unwrap_or(0.0).abs())).unwrap_or((0.0, 0.0));
Pad { size: (size.0 + d.1, size.1 + d.0), ..base }
}
"custom" => custom_pad(pad, base, center, size, angle),
_ => base,
})
}
/// A custom pad as its bounding rectangle: the anchor and every primitive, rotated with the pad.
fn custom_pad(pad: &Sx, base: Pad, center: P, size: P, angle: f64) -> Pad {
let anchor_round = pad.find("options").and_then(|o| o.find("anchor")).and_then(Sx::value) == Some("circle");
let (hx, hy) = (size.0 / 2.0, size.1 / 2.0);
let mut local: Vec<P> = if anchor_round {
(0..16).map(|k| { let a = (k as f64 * 22.5f64).to_radians(); (hx * a.cos(), hx * a.sin()) }).collect()
} else {
vec![(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)]
};
let mut grow: f64 = 0.0;
for prim in pad.find("primitives").and_then(Sx::as_list).into_iter().flatten().skip(1) {
let w = stroke_width(prim);
grow = grow.max(w / 2.0);
match prim.tag() {
Some("gr_poly") => local.extend(pts_of(prim.find("pts"))),
Some("gr_line") | Some("gr_rect") => local.extend([xy_of(prim.find("start")), xy_of(prim.find("end"))].into_iter().flatten()),
Some("gr_circle") => {
if let (Some(c), Some(e)) = (xy_of(prim.find("center")), xy_of(prim.find("end"))) {
let r = dist(c, e);
local.extend([(c.0 - r, c.1 - r), (c.0 + r, c.1 + r), (c.0 - r, c.1 + r), (c.0 + r, c.1 - r)]);
}
}
Some("gr_arc") => {
if let (Some(s), Some(mid), Some(e)) = (xy_of(prim.find("start")), xy_of(prim.find("mid")), xy_of(prim.find("end"))) {
match arc_of(s, mid, e) { Some(a) => local.extend(a.sample(2.0)), None => local.extend([s, e]) }
}
}
Some("gr_curve") | Some("gr_bbox") => local.extend(pts_of(prim.find("pts"))),
_ => {}
}
}
// the real outline, when it is exact: one filled polygon primitive that covers the anchor (the usual
// custom pad); several shapes would need a union, so they keep only the rectangle
let polys: Vec<&Sx> = pad.find("primitives").and_then(Sx::as_list).into_iter().flatten().skip(1).collect();
let copper_points = match polys.as_slice() {
[one] if one.tag() == Some("gr_poly") && (filled(one) || one.find("fill").is_none()) => {
let pts = pts_of(one.find("pts"));
let anchor = if anchor_round { vec![(0.0, 0.0)] } else { vec![(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)] };
let covers = pts.len() >= 3 && anchor.iter().all(|a| in_poly(*a, &pts) || pts.iter().enumerate().any(|(k, q)| dist_seg(*a, *q, pts[(k + 1) % pts.len()]) < 1e-6));
covers.then(|| pts.iter().map(|q| add(center, rotate(*q, angle))).collect())
}
_ => None,
};
let world: Vec<P> = local.iter().map(|p| add(center, rotate(*p, angle))).collect();
let (x0, x1) = world.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));
let (y0, y1) = world.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));
Pad {
at: ((x0 + x1) / 2.0, (y0 + y1) / 2.0),
size: (x1 - x0 + 2.0 * grow, y1 - y0 + 2.0 * grow),
rotation: 0.0,
shape: "rect",
anchor: Some(center),
copper_points,
..base
}
}
fn zone(cx: &Ctx, z: &Sx, owner: Option<&str>, m: &mut Model) {
let layers = layers_of(z);
let points = pts_of(z.find("polygon").and_then(|p| p.find("pts")));
if let Some(k) = z.find("keepout") {
let rules = k.as_list().unwrap_or(&[]).iter().skip(1).filter_map(|r| Some((r.tag()?.to_string(), r.value()?.to_string()))).collect();
m.keepouts.push(Keepout { layers, points, rules, owner: owner.map(str::to_string) });
return;
}
let copper = cx.expand(&layers);
if copper.is_empty() {
return;
}
let net = cx.net(z);
let name = z.find("name").and_then(Sx::value).map(str::to_string);
let priority = z.find("priority").and_then(|p| num(p, 1)).unwrap_or(0.0) as i64;
let hatched = z.find("fill").and_then(|f| f.find("mode")).and_then(Sx::value) == Some("hatch");
let id = z.find("uuid").or_else(|| z.find("tstamp")).and_then(Sx::value).map(str::to_string);
let mut fills: HashMap<String, Vec<Vec<P>>> = HashMap::new();
for f in z.find_all("filled_polygon") {
let layer = f.find("layer").and_then(Sx::value).map(str::to_string).unwrap_or_else(|| copper[0].clone());
let pts = pts_of(f.find("pts"));
if pts.len() >= 3 {
fills.entry(layer).or_default().push(pts);
}
}
for layer in copper {
let f = fills.remove(&layer).unwrap_or_default();
m.polys.push(Poly { net: net.clone(), layer, points: points.clone(), name: name.clone(), priority, hatched, fills: f, owner: owner.map(str::to_string), graphic: false, id: id.clone() });
}
}
/// A board (`gr_*`) or footprint (`fp_*`) graphic: on a copper layer it is copper (a track, an arc
/// or a filled polygon); on Edge.Cuts it is part of the outline.
fn graphic(cx: &Ctx, g: &Sx, place: Place, owner: Option<&str>, m: &mut Model, edges: &mut Vec<Edge>) {
let layer = g.find("layer").and_then(Sx::value).unwrap_or("");
let edge = layer == "Edge.Cuts";
if !edge && !cx.is_copper(layer) {
return;
}
let kind = g.tag().unwrap_or("")[3..].to_string();
let p = |tag: &str| xy_of(g.find(tag)).map(|v| place.apply(v));
let width = stroke_width(g);
let net = cx.net(g);
let owner_s = owner.map(str::to_string);
let mut lines: Vec<(P, P)> = Vec::new();
let mut arcs: Vec<Arc3> = Vec::new();
let mut poly: Option<Vec<P>> = None;
match kind.as_str() {
"line" => {
if let (Some(a), Some(b)) = (p("start"), p("end")) {
lines.push((a, b));
}
}
"arc" => {
if let (Some(s), Some(mid), Some(e)) = (p("start"), p("mid"), p("end")) {
match arc_of(s, mid, e) { Some(a) => arcs.push(a), None => lines.push((s, e)) }
} else if let (Some(c), Some(s), Some(sweep)) = (p("start"), p("end"), g.find("angle").and_then(|a| num(a, 1))) {
// KiCad 5: start is the centre, end the arc's start, angle the sweep (clockwise on screen)
let r = dist(c, s);
let a0 = angle_deg(c, s);
let a1 = a0 - sweep;
let (lo, hi) = if sweep >= 0.0 { (a1, a0) } else { (a0, a1) };
arcs.push(Arc3 { center: c, radius: r, a1: lo.rem_euclid(360.0), a2: hi.rem_euclid(360.0) });
}
}
"circle" => {
if let (Some(c), Some(e)) = (p("center"), p("end")) {
let r = dist(c, e);
if filled(g) && !edge {
poly = Some((0..180).map(|k| { let a = (k as f64 * 2.0).to_radians(); (c.0 + r * a.cos(), c.1 + r * a.sin()) }).collect());
} else {
arcs.push(Arc3 { center: c, radius: r, a1: 0.0, a2: 360.0 });
}
}
}
"rect" => {
if let (Some(s), Some(e)) = (xy_of(g.find("start")), xy_of(g.find("end"))) {
let corners: Vec<P> = [s, (e.0, s.1), e, (s.0, e.1)].iter().map(|q| place.apply(*q)).collect();
if filled(g) && !edge {
poly = Some(corners);
} else {
for k in 0..4 {
lines.push((corners[k], corners[(k + 1) % 4]));
}
}
}
}
"poly" => {
let pts: Vec<P> = pts_of(g.find("pts")).into_iter().map(|q| place.apply(q)).collect();
if pts.len() >= 3 && filled(g) && !edge {
poly = Some(pts);
} else {
for k in 0..pts.len() {
// an outline polygon closes itself; a copper one does too
if pts.len() >= 3 || k + 1 < pts.len() {
lines.push((pts[k], pts[(k + 1) % pts.len()]));
}
}
}
}
"curve" => {
let c: Vec<P> = pts_of(g.find("pts")).into_iter().map(|q| place.apply(q)).collect();
if c.len() == 4 {
let bez = |t: f64| {
let u = 1.0 - t;
let k = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
(c.iter().zip(k).map(|(p, w)| p.0 * w).sum(), c.iter().zip(k).map(|(p, w)| p.1 * w).sum())
};
let pts: Vec<P> = (0..=32).map(|k| bez(k as f64 / 32.0)).collect();
for w in pts.windows(2) {
lines.push((w[0], w[1]));
}
}
}
_ => {}
}
if edge {
for (a, b) in lines {
edges.push(Edge { a, b, kind: EdgeKind::Line });
}
for a in arcs {
let full = ccw_sweep(a.a1, a.a2) >= 360.0 - 1e-9;
let (s, e) = (a.point(a.a1), a.point(if full { a.a1 } else { a.a2 }));
edges.push(Edge { a: s, b: e, kind: EdgeKind::Arc(a) });
}
return;
}
for (a, b) in lines {
m.tracks.push(Track { a, b, width, layer: layer.to_string(), net: net.clone(), owner: owner_s.clone(), id: None });
}
for a in arcs {
m.arcs.push(ArcT { arc: a, width, layer: layer.to_string(), net: net.clone(), owner: owner_s.clone() });
}
if let Some(points) = poly {
m.polys.push(Poly { net, layer: layer.to_string(), fills: vec![points.clone()], points, name: None, priority: 0, hatched: false, owner: owner_s, graphic: true, id: None });
}
}
/// Chain the Edge.Cuts edges into closed loops. The loop of largest area comes first (the outline);
/// the others are cutouts. Edges that close no loop are counted, not guessed.
fn chain(mut edges: Vec<Edge>) -> (Vec<Vec<Edge>>, usize) {
const TOL: f64 = 0.01;
let mut loops: Vec<Vec<Edge>> = Vec::new();
let mut loose = 0;
while let Some(first) = edges.pop() {
if dist(first.a, first.b) < 1e-9 {
// a full circle closes on itself
loops.push(vec![first]);
continue;
}
let mut lp = vec![first];
loop {
let tail = lp.last().unwrap().b;
if dist(tail, lp[0].a) < TOL && lp.len() > 1 {
break;
}
let next = edges.iter().enumerate()
.map(|(i, e)| (i, dist(e.a, tail).min(dist(e.b, tail))))
.filter(|(_, d)| *d < TOL)
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.map(|(i, _)| i);
let Some(i) = next else { break };
let mut e = edges.swap_remove(i);
if dist(e.a, tail) > dist(e.b, tail) {
std::mem::swap(&mut e.a, &mut e.b);
}
lp.push(e);
}
let closed = lp.len() > 1 && dist(lp.last().unwrap().b, lp[0].a) < TOL;
if closed {
loops.push(lp);
} else {
loose += lp.len();
}
}
let area = |lp: &Vec<Edge>| shoelace(&ring(lp)).abs();
loops.sort_by(|a, b| area(b).partial_cmp(&area(a)).unwrap_or(std::cmp::Ordering::Equal));
// the outline counterclockwise, as the y-up frame reads it, starting at its lowest-left vertex: the
// same loop whatever order the file lists its pieces in (KiCad's live serialisation and the saved
// file differ)
for lp in loops.iter_mut() {
if shoelace(&ring(lp)) < 0.0 {
lp.reverse();
for e in lp.iter_mut() {
std::mem::swap(&mut e.a, &mut e.b);
}
}
let key = |e: &Edge| ((e.a.0 * 1e4).round() as i64, (e.a.1 * 1e4).round() as i64);
if let Some(first) = (0..lp.len()).min_by_key(|i| key(&lp[*i])) {
lp.rotate_left(first);
}
}
(loops, loose)
}
/// A loop as a polygon, arcs sampled every 2 degrees in the loop's direction.
fn ring(lp: &[Edge]) -> Vec<P> {
let mut out = Vec::new();
for e in lp {
match &e.kind {
EdgeKind::Line => out.push(e.a),
EdgeKind::Arc(a) => {
let mut ps = a.sample(2.0);
if dist(ps[0], e.a) > dist(*ps.last().unwrap(), e.a) {
ps.reverse();
}
ps.pop();
out.extend(ps);
}
}
}
out
}
// -- companions: stack-up and poured copper ----------------------------------------------------
/// KiCad's own defaults when a board has no stack-up: what its Board Setup shows for a new board.
const DEFAULT_COPPER_MM: f64 = 0.035;
const DEFAULT_THICKNESS_MM: f64 = 1.6;
const DEFAULT_DK: f64 = 4.5;
/// The stack-up (the standard's `layer_stack`): the copper layers top to bottom, each with the
/// dielectric BELOW it (thickness, Dk, material), the masks and the board thickness. A board saved
/// without a stack-up gets KiCad's defaults, said so (`fromDefaults`).
pub fn stackup(text: &str) -> Result<Value, String> {
let (root, _) = parse_board(text)?;
let cx = board_ctx(&root);
let m = model(&root);
// KiCad's layer type per copper layer (signal, power, mixed, jumper) and its user name
let mut kinds: HashMap<String, (String, Option<String>)> = HashMap::new();
for l in root.find("layers").and_then(Sx::as_list).into_iter().flatten().skip(1) {
if let (Some(name), Some(kind)) = (l.atom_at(1), l.atom_at(2)) {
kinds.insert(name.to_string(), (kind.to_string(), l.atom_at(3).map(str::to_string)));
}
}
let general_mm = root.find("general").and_then(|g| g.find("thickness")).and_then(|t| num(t, 1));
let stack = root.find("setup").and_then(|s| s.find("stackup"));
// (name, type, thickness mm, material, Dk) in board order
let mut rows: Vec<(String, String, f64, Option<String>, Option<f64>)> = Vec::new();
for l in stack.and_then(Sx::as_list).into_iter().flatten().filter(|c| c.tag() == Some("layer")) {
let name = l.value().unwrap_or("").to_string();
let kind = l.find("type").and_then(Sx::value).unwrap_or("").to_string();
// a dielectric with sublayers: (thickness a) (material ..) (epsilon_r ..) addsublayer (thickness b) ..
let items = l.as_list().unwrap_or(&[]);
let (mut total, mut dk_sum, mut mats): (f64, f64, Vec<String>) = (0.0, 0.0, Vec::new());
let any_dk = items.iter().any(|it| it.tag() == Some("epsilon_r"));
let mut cur_t: Option<f64> = None;
let mut cur_dk: Option<f64> = None;
let flush = |t: Option<f64>, dk: Option<f64>, total: &mut f64, dk_sum: &mut f64| {
if let Some(t) = t {
*total += t;
*dk_sum += t * dk.unwrap_or(DEFAULT_DK);
}
};
for it in items.iter().skip(2) {
match (it.tag(), it.as_atom()) {
(Some("thickness"), _) => cur_t = num(it, 1),
(Some("epsilon_r"), _) => cur_dk = num(it, 1),
(Some("material"), _) => { if let Some(mt) = it.value() { if !mats.iter().any(|x| x == mt) { mats.push(mt.to_string()) } } }
(_, Some("addsublayer")) => { flush(cur_t.take(), cur_dk.take(), &mut total, &mut dk_sum); }
_ => {}
}
}
flush(cur_t, cur_dk, &mut total, &mut dk_sum);
// a dielectric without a Dk gets FR4's; a mask only says one when the file does
let dk = if total > 0.0 && (kind == "core" || kind == "prepreg" || kind.contains("Mask") && any_dk) { Some(dk_sum / total) } else { None };
rows.push((name, kind, total, if mats.is_empty() { None } else { Some(mats.join(" + ")) }, dk));
}
let from_defaults = !rows.iter().any(|r| r.1 == "copper");
if from_defaults {
// KiCad's default build: copper of 35 um, the core shared out evenly between the copper layers
let n = cx.copper.len().max(2);
let thick = general_mm.unwrap_or(DEFAULT_THICKNESS_MM);
let diel = (thick - n as f64 * DEFAULT_COPPER_MM) / (n - 1) as f64;
rows.clear();
for (i, l) in cx.copper.iter().enumerate() {
rows.push((l.clone(), "copper".into(), DEFAULT_COPPER_MM, None, None));
if i + 1 < cx.copper.len() {
rows.push((format!("dielectric {}", i + 1), if i == 0 { "core".into() } else { "prepreg".into() }, diel, Some("FR4".into()), Some(DEFAULT_DK)));
}
}
}
// the plane's net: the net of the most pour area on that layer
let plane_net = |layer: &str| -> Option<String> {
let mut by: BTreeMap<&str, f64> = BTreeMap::new();
for g in m.polys.iter().filter(|g| g.layer == layer && !g.net.is_empty()) {
*by.entry(&g.net).or_default() += shoelace(&g.points).abs();
}
by.into_iter().max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|(n, _)| n.to_string())
};
let mut layers = Vec::new();
let (mut planes, mut mids) = (0, 0);
let copper_rows: Vec<usize> = rows.iter().enumerate().filter(|(_, r)| r.1 == "copper").map(|(i, _)| i).collect();
for (k, &i) in copper_rows.iter().enumerate() {
let (name, _, t, _, _) = &rows[i];
let (ktype, user) = kinds.get(name).cloned().unwrap_or(("signal".into(), None));
let plane = ktype == "power";
let mut j = json!({"name": name, "kind": if plane { "plane" } else { "signal" }, "kicadType": ktype, "copperUm": r6(t * 1000.0)});
if let Some(u) = user {
j["userName"] = json!(u);
}
if name.starts_with("In") {
if plane {
planes += 1;
j["plane"] = json!(planes);
} else {
mids += 1;
j["mid"] = json!(name.trim_start_matches("In").trim_end_matches(".Cu").parse::<i64>().unwrap_or(mids));
}
}
if plane {
if let Some(n) = plane_net(name) {
j["net"] = json!(n);
}
}
// the dielectric between this copper and the next: every non-copper row in between
if let Some(&next) = copper_rows.get(k + 1) {
let diel: Vec<&(String, String, f64, Option<String>, Option<f64>)> = rows[i + 1..next].iter().filter(|r| r.2 > 0.0).collect();
let um: f64 = diel.iter().map(|r| r.2).sum::<f64>() * 1000.0;
let dk = if um > 0.0 { diel.iter().map(|r| r.2 * r.4.unwrap_or(DEFAULT_DK)).sum::<f64>() * 1000.0 / um } else { DEFAULT_DK };
j["dielectricUm"] = json!(r6(um));
j["dk1000"] = json!((dk * 1000.0).round());
let mats: Vec<String> = diel.iter().filter_map(|r| r.3.clone()).collect();
if !mats.is_empty() {
j["material"] = json!(mats.join(" / "));
}
j["dielectricKinds"] = json!(diel.iter().map(|r| r.1.clone()).collect::<Vec<_>>());
}
layers.push(j);
}
let sum_mm: f64 = rows.iter().map(|r| r.2).sum();
let mask = |side: &str| rows.iter().find(|r| r.1 == format!("{side} Solder Mask")).map(|r| json!({"um": r6(r.2 * 1000.0), "dk1000": r.4.map(|d| (d * 1000.0).round())}));
let finish = stack.and_then(|s| s.find("copper_finish")).and_then(Sx::value);
Ok(json!({
"copperLayers": layers.len(),
"totalUm": r6(general_mm.unwrap_or(sum_mm) * 1000.0),
"stackSumUm": r6(sum_mm * 1000.0),
"layers": layers,
"masks": {"top": mask("Top"), "bottom": mask("Bottom")},
"copperFinish": finish,
"fromDefaults": from_defaults,
}))
}
/// Poured copper (the standard's `pour_state`): every zone on every copper layer with the copper
/// KiCad actually poured. KiCad stores each island with its holes joined to the outline by
/// zero-width cuts (`fractured`): its area and a point-in-polygon test are exact as they stand.
pub fn pours(text: &str, opts: &Opts, geometry: bool) -> Result<Value, String> {
let (root, _) = parse_board(text)?;
let m = model(&root);
let mut by_layer: BTreeMap<String, f64> = BTreeMap::new();
let mut zones = Vec::new();
let mut unfilled = Vec::new();
for (k, g) in m.polys.iter().enumerate().filter(|(_, g)| !g.graphic) {
let area: f64 = g.fills.iter().map(|f| shoelace(f).abs()).sum();
*by_layer.entry(g.layer.clone()).or_default() += area;
let mut z = json!({"net": g.net, "name": g.name, "layer": g.layer, "pourIndex": k, "priority": g.priority,
"filledMm2": r6(area), "filled": !g.fills.is_empty(), "islands": g.fills.len(), "hatched": g.hatched});
if let Some(o) = &g.owner {
z["ref"] = json!(o);
}
if let Some(id) = &g.id {
z["id"] = json!(id);
}
if geometry {
z["outline"] = json!(g.points.iter().map(|p| pa(*p, opts)).collect::<Vec<_>>());
z["fills"] = json!(g.fills.iter().map(|f| json!({"outline": f.iter().map(|p| pa(*p, opts)).collect::<Vec<_>>(), "holes": [], "fractured": true})).collect::<Vec<_>>());
}
if g.fills.is_empty() {
unfilled.push(json!({"net": g.net, "name": g.name, "layer": g.layer, "pourIndex": k}));
}
zones.push(z);
}
Ok(json!({
"zones": zones, "unfilled": unfilled,
"filledAreaByLayerMm2": by_layer.into_iter().map(|(l, a)| (l, json!(r6(a)))).collect::<serde_json::Map<String, Value>>(),
"revision": revision(&m),
}))
}
// -- core reads derived from the model ----------------------------------------------------------
/// The standard's core read verbs that are views of the board model (`board_summary`, `list_nets`,
/// `list_designators`, `copper_layers`, `layers_in_use`, `component_data`, `component_pins`), from a
/// model `build` returned. Positions in mm in the model's frame (Altium answers mils; the standard's
/// conventions are mm).
pub fn derive(model: &Value, view: &str, args: &Value) -> Result<Value, (String, String)> {
let arr = |k: &str| model[k].as_array().cloned().unwrap_or_default();
let (pads, comps) = (arr("pads"), arr("components"));
let f = |v: &Value| v.as_f64().unwrap_or(0.0);
let copper: Vec<String> = model["copperLayers"].as_array().into_iter().flatten().filter_map(|l| l.as_str().map(str::to_string)).collect();
// copper pieces per layer: tracks, arcs, vias (every layer they span), pads, pours
let in_use = || {
let mut n: BTreeMap<String, usize> = copper.iter().map(|l| (l.clone(), 0)).collect();
let mut bump = |l: &str| { if let Some(c) = n.get_mut(l) { *c += 1 } };
for k in ["tracks", "arcs", "polygons"] { for t in arr(k) { bump(t["layer"].as_str().unwrap_or("")) } }
for p in &pads {
match p["layer"].as_str().unwrap_or("") {
"*.Cu" => { let ls: Vec<String> = p["layers"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or(copper.clone()); for l in ls { bump(&l) } }
l => bump(l),
}
}
for v in arr("vias") {
let (a, b) = (copper.iter().position(|l| v["layers"][0] == l.as_str()), copper.iter().position(|l| v["layers"][1] == l.as_str()));
if let (Some(a), Some(b)) = (a, b) { for l in &copper[a.min(b)..=a.max(b)] { bump(l) } }
}
let mut v: Vec<(String, usize)> = n.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1));
v
};
let want = |k: &str| args.get(k).or_else(|| args.get("ref")).and_then(Value::as_str).map(str::to_string);
let find_comp = |d: &str| comps.iter().find(|c| c["ref"].as_str().map_or(false, |r| r.eq_ignore_ascii_case(d))).cloned();
let pins_of = |d: &str| -> Vec<Value> { pads.iter().filter(|p| p["ref"].as_str().map_or(false, |r| r.eq_ignore_ascii_case(d))).map(|p| json!({
"name": p["name"], "net": p["net"], "x": p["x"], "y": p["y"], "layer": p["layer"], "w": p["sizeX"], "h": p["sizeY"], "rotation": p["rotation"], "hole": p["drill"], "shape": p["shape"]})).collect() };
let comp_row = |c: &Value| {
let d = c["ref"].as_str().unwrap_or("");
let ps: Vec<&Value> = pads.iter().filter(|p| p["ref"] == c["ref"]).collect();
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &ps {
let (hx, hy) = (f(&p["sizeX"]) / 2.0, f(&p["sizeY"]) / 2.0);
let r = f(&p["rotation"]).to_radians();
let (ex, ey) = (hx * r.cos().abs() + hy * r.sin().abs(), hx * r.sin().abs() + hy * r.cos().abs());
x0 = x0.min(f(&p["x"]) - ex); x1 = x1.max(f(&p["x"]) + ex); y0 = y0.min(f(&p["y"]) - ey); y1 = y1.max(f(&p["y"]) + ey);
}
let mut nets: Vec<&str> = ps.iter().filter_map(|p| p["net"].as_str()).filter(|n| !n.is_empty()).collect();
nets.sort(); nets.dedup();
json!({"designator": d, "pattern": c["pattern"], "footprint": c["footprint"], "comment": c["value"], "side": c["side"], "bottom": c["side"] == "bottom",
"x": c["x"], "y": c["y"], "rotation": c["rotation"], "padsBox": if ps.is_empty() { Value::Null } else { json!([r6(x0), r6(y0), r6(x1), r6(y1)]) },
"w": if ps.is_empty() { json!(0.0) } else { json!(r6(x1 - x0)) }, "h": if ps.is_empty() { json!(0.0) } else { json!(r6(y1 - y0)) },
"pads": ps.len(), "nets": nets, "attributes": c["attributes"], "has3d": c["has3d"]})
};
Ok(match view {
"list_nets" => {
let mut by: BTreeMap<&str, usize> = model["nets"].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n, 0)).collect();
for p in &pads { if let Some(c) = by.get_mut(p["net"].as_str().unwrap_or("")) { *c += 1 } }
json!({"count": by.len(), "nets": by.iter().map(|(n, c)| json!({"name": n, "pads": c})).collect::<Vec<_>>()})
}
// the standard's net_connections: every pad with its net and owner, and per net its span and airline length
// (a minimum spanning tree over its pads, the length a perfect router would need)
"net_connections" => {
let want = args.get("net").and_then(Value::as_str);
let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(4000) as usize;
let mil = |v: &Value| (f(v) / 0.0254 * 100.0).round() / 100.0;
let mut by: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();
let mut rows = Vec::new();
for p in &pads {
let net = p["net"].as_str().unwrap_or("");
if net.is_empty() || want.map_or(false, |w| !w.eq_ignore_ascii_case(net)) {
continue;
}
by.entry(net.to_string()).or_default().push((f(&p["x"]), f(&p["y"])));
rows.push(json!({"net": net, "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "xMil": mil(&p["x"]), "yMil": mil(&p["y"])}));
}
if let Some(w) = want {
if by.is_empty() {
return Err(("unknown_net".into(), format!("no pad is on net {w}")));
}
}
let count = rows.len();
rows.truncate(limit);
let nets: Vec<Value> = by.iter().map(|(n, pts)| {
let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));
let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));
// Prim's minimum spanning tree over the pads
let mut inside = vec![false; pts.len()];
let mut best = vec![f64::MAX; pts.len()];
let mut total = 0.0;
best[0] = 0.0;
for _ in 0..pts.len() {
let Some(i) = (0..pts.len()).filter(|i| !inside[*i]).min_by(|a, b| best[*a].total_cmp(&best[*b])) else { break };
inside[i] = true;
total += best[i];
for j in 0..pts.len() {
if !inside[j] {
best[j] = best[j].min(((pts[i].0 - pts[j].0).powi(2) + (pts[i].1 - pts[j].1).powi(2)).sqrt());
}
}
}
json!({"net": n, "pads": pts.len(), "minXMil": mil(&json!(x0)), "minYMil": mil(&json!(y0)), "maxXMil": mil(&json!(x1)), "maxYMil": mil(&json!(y1)), "airlineMm": r6(total)})
}).collect();
json!({"data": {"count": count, "pads": rows, "nets": nets}, "truncated": count > limit})
}
"list_designators" => {
let mut d: Vec<&str> = comps.iter().filter_map(|c| c["ref"].as_str()).collect();
d.sort_by_key(|r| natural_key(r));
json!({"count": d.len(), "designators": d})
}
"copper_layers" => json!({"count": copper.len(), "layers": copper.iter().enumerate().map(|(i, l)| json!({"name": l, "index": i})).collect::<Vec<_>>()}),
"layers_in_use" => {
let u = in_use();
json!({"copperLayersInUse": u.iter().filter(|(_, n)| *n > 0).map(|(l, n)| json!({"name": l, "primitives": n})).collect::<Vec<_>>(),
"copperLayersEmpty": u.iter().filter(|(_, n)| *n == 0).map(|(l, _)| l).collect::<Vec<_>>()})
}
"board_summary" => {
let u = in_use();
// in stack order, top to bottom
let used: Vec<&String> = copper.iter().filter(|l| u.iter().any(|(m, n)| m == *l && *n > 0)).collect();
let o: Vec<(f64, f64)> = model["outline"].as_array().into_iter().flatten().map(|p| (f(&p[0]), f(&p[1]))).collect();
let size = if o.len() >= 3 {
let mut pts = o.clone();
for e in model["outlineEdges"].as_array().into_iter().flatten().filter(|e| e["kind"] == "arc") {
let a = Arc3 { center: (f(&e["center"][0]), f(&e["center"][1])), radius: f(&e["radius"]), a1: f(&e["angle1"]), a2: f(&e["angle2"]) };
pts.extend(a.sample(2.0));
}
let b = bbox_of(&pts, 0.0);
json!({"x0": r6(b.0), "y0": r6(b.1), "x1": r6(b.2), "y1": r6(b.3), "widthMm": r6(b.2 - b.0), "heightMm": r6(b.3 - b.1), "areaMm2": r6(shoelace(&ring_of(model)).abs())})
} else { Value::Null };
let top = comps.iter().filter(|c| c["side"] == "top").count();
json!({"summary": {"board": model["file"], "components": comps.len(), "componentsTop": top, "componentsBottom": comps.len() - top,
"nets": model["counts"]["nets"], "pads": pads.len(), "copperLayers": copper.len(), "copperLayersInUse": used.len(), "copperLayersInUseNames": used,
"unconnected": model["unconnected"], "counts": model["counts"], "outline": size, "revision": model["revision"]}})
}
"component_data" => match want("designator") {
Some(d) => match find_comp(&d) {
Some(c) => json!({"component": comp_row(&c)}),
None => return Err(("designator_not_found".into(), format!("no part {d} on the board"))),
},
None => {
let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize;
json!({"count": comps.len(), "truncated": comps.len() > limit, "components": comps.iter().take(limit).map(comp_row).collect::<Vec<_>>()})
}
},
"component_pins" => {
let Some(d) = want("designator") else { return Err(("bad_args".into(), "designator is required (e.g. U1)".into())) };
if find_comp(&d).is_none() { return Err(("designator_not_found".into(), format!("no part {d} on the board"))) }
let mut pins = pins_of(&d);
pins.sort_by_key(|p| natural_key(p["name"].as_str().unwrap_or("")));
json!({"designator": d, "count": pins.len(), "pins": pins})
}
other => return Err(("bad_args".into(), format!("no view named {other}"))),
})
}
/// The outline as a polygon (arcs sampled) from a model's `outlineEdges`.
fn ring_of(model: &Value) -> Vec<P> {
let f = |v: &Value| v.as_f64().unwrap_or(0.0);
let edges: Vec<Edge> = model["outlineEdges"].as_array().into_iter().flatten().map(|e| {
let (a, b) = ((f(&e["start"][0]), f(&e["start"][1])), (f(&e["end"][0]), f(&e["end"][1])));
let kind = if e["kind"] == "arc" { EdgeKind::Arc(Arc3 { center: (f(&e["center"][0]), f(&e["center"][1])), radius: f(&e["radius"]), a1: f(&e["angle1"]), a2: f(&e["angle2"]) }) } else { EdgeKind::Line };
Edge { a, b, kind }
}).collect();
ring(&edges)
}
/// Designators and pin names in natural order: R2 before R10, A2 before A10.
fn natural_key(s: &str) -> (String, u64, String) {
let head: String = s.chars().take_while(|c| !c.is_ascii_digit()).collect();
let digits: String = s[head.len()..].chars().take_while(|c| c.is_ascii_digit()).collect();
let rest = s[head.len() + digits.len()..].to_string();
(head, digits.parse().unwrap_or(0), rest)
}
// -- the edit verbs in the standard's frame ----------------------------------------------------
/// The edit verbs whose coordinates `frame:"standard"` turns from y-up into KiCad's y-down.
pub const FRAME_VERBS: &[&str] = &["kicad_route_net", "kicad_move_footprint", "kicad_add_zone", "kicad_apply_zone_plan", "kicad_add_via", "kicad_add_track", "kicad_route",
// wiki #141: kicad_silk_state reports y up by default, so its positions go back with frame:"standard"
"kicad_silk_text_batch", "kicad_silk_fields_batch", "kicad_silk_graphics_batch"];
fn flip_point(v: &mut Value) {
match v {
Value::Array(a) if a.len() >= 2 && a[0].is_number() && a[1].is_number() => {
a[1] = json!(-a[1].as_f64().unwrap_or(0.0) + 0.0);
}
Value::Object(o) => {
if let Some(y) = o.get("y").and_then(Value::as_f64) {
o.insert("y".into(), json!(-y + 0.0));
}
}
_ => {}
}
}
fn flip_list(v: Option<&mut Value>) {
if let Some(Value::Array(a)) = v {
a.iter_mut().for_each(flip_point);
}
}
/// An edit verb's arguments given in the standard's frame (`frame:"standard"`, y up, what
/// `kicad_routing_state {model:"standard"}` reports) rewritten in KiCad's frame (y down), which is
/// what the verbs take. Waypoints, polygons, `at`, `start`, `end` and `x`/`y` (also in `refs`) have
/// their y negated; pad names and angles stay as they are (KiCad's angles are counterclockwise as
/// seen, the standard's too). `frame` absent or "kicad" leaves the arguments alone.
pub fn args_to_kicad_frame(args: &Value) -> Result<Value, String> {
match args.get("frame").and_then(Value::as_str) {
None | Some("kicad") => return Ok(args.clone()),
Some("standard") | Some("yup") => {}
Some(other) => return Err(format!("frame must be \"standard\" (y up, as kicad_routing_state {{model:\"standard\"}} reports) or \"kicad\" (y down), not \"{other}\"")),
}
let mut a = args.clone();
if let Some(o) = a.as_object_mut() {
o.remove("frame");
o.insert("_fromFrame".into(), json!("standard"));
}
flip_list(a.get_mut("points"));
if let Some(Value::Array(paths)) = a.get_mut("paths") {
for p in paths.iter_mut() {
flip_list(Some(p));
}
}
flip_list(a.get_mut("polygon"));
for k in ["at", "start", "end"] {
if let Some(v) = a.get_mut(k) {
flip_point(v);
}
}
flip_point(&mut a);
for k in ["refs", "zones", "create", "update"] {
if let Some(Value::Array(items)) = a.get_mut(k) {
for it in items.iter_mut() {
flip_point(it);
flip_list(it.get_mut("polygon"));
// the silk graphics rows (wiki #141)
flip_list(it.get_mut("points"));
}
}
}
Ok(a)
}
// -- connectivity from the copper ------------------------------------------------------------------
fn dist_seg(p: P, a: P, b: P) -> f64 {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let l2 = dx * dx + dy * dy;
let t = if l2 < 1e-18 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };
dist(p, (a.0 + t * dx, a.1 + t * dy))
}
fn seg_seg(a: P, b: P, c: P, d: P) -> f64 {
let cross = |o: P, p: P, q: P| (p.0 - o.0) * (q.1 - o.1) - (p.1 - o.1) * (q.0 - o.0);
let (d1, d2, d3, d4) = (cross(c, d, a), cross(c, d, b), cross(a, b, c), cross(a, b, d));
if ((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0)) {
return 0.0;
}
dist_seg(a, c, d).min(dist_seg(b, c, d)).min(dist_seg(c, a, b)).min(dist_seg(d, a, b))
}
fn in_poly(p: P, poly: &[P]) -> bool {
let mut inside = false;
let n = poly.len();
for k in 0..n {
let (a, b) = (poly[k], poly[(k + n - 1) % n]);
if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 {
inside = !inside;
}
}
inside
}
type BBox = (f64, f64, f64, f64);
fn bbox_of(pts: &[P], grow: f64) -> BBox {
let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in pts {
b = (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1));
}
(b.0 - grow, b.1 - grow, b.2 + grow, b.3 + grow)
}
fn overlap(a: &BBox, b: &BBox) -> bool {
a.0 <= b.2 && b.0 <= a.2 && a.1 <= b.3 && b.1 <= a.3
}
/// One piece of copper for the connectivity walk: a capsule (a track, a via, a round or oval pad: a
/// segment with a radius) or a polygon (a rectangular pad, a poured island), on a set of layers.
#[derive(Clone)]
enum Shape {
Capsule { a: P, b: P, r: f64 },
Poly { pts: Vec<P> },
}
#[derive(Clone)]
struct Cu {
shape: Shape,
/// copper layer indexes
layers: Vec<usize>,
bbox: BBox,
}
const EPS: f64 = 1e-4;
fn touch(x: &Cu, y: &Cu) -> bool {
if !overlap(&x.bbox, &y.bbox) || !x.layers.iter().any(|l| y.layers.contains(l)) {
return false;
}
match (&x.shape, &y.shape) {
(Shape::Capsule { a, b, r }, Shape::Capsule { a: c, b: d, r: s }) => seg_seg(*a, *b, *c, *d) <= r + s + EPS,
(Shape::Capsule { a, b, r }, Shape::Poly { pts }) | (Shape::Poly { pts }, Shape::Capsule { a, b, r }) => {
in_poly(*a, pts) || (0..pts.len()).any(|k| seg_seg(*a, *b, pts[k], pts[(k + 1) % pts.len()]) <= r + EPS)
}
(Shape::Poly { pts: p }, Shape::Poly { pts: q }) => {
in_poly(p[0], q) || in_poly(q[0], p)
|| (0..p.len()).any(|i| (0..q.len()).any(|j| seg_seg(p[i], p[(i + 1) % p.len()], q[j], q[(j + 1) % q.len()]) <= EPS))
}
}
}
fn pad_cu(p: &Pad, idx: &dyn Fn(&str) -> Option<usize>) -> Cu {
let layers: Vec<usize> = p.copper.iter().filter_map(|l| idx(l)).collect();
let (hx, hy) = (p.size.0 / 2.0, p.size.1 / 2.0);
let shape = if p.shape == "round" || p.shape == "circle" {
let (r, half) = if hx >= hy { (hy, (hx - hy, 0.0)) } else { (hx, (0.0, hy - hx)) };
let d = rotate(half, p.rotation);
Shape::Capsule { a: (p.at.0 - d.0, p.at.1 - d.1), b: add(p.at, d), r }
} else {
Shape::Poly { pts: [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)].iter().map(|q| add(p.at, rotate(*q, p.rotation))).collect() }
};
let bbox = shape_bbox(&shape);
Cu { shape, layers, bbox }
}
fn shape_bbox(s: &Shape) -> BBox {
match s {
Shape::Capsule { a, b, r } => bbox_of(&[*a, *b], *r),
Shape::Poly { pts } => bbox_of(pts, 0.0),
}
}
fn capsule(a: P, b: P, r: f64, layers: Vec<usize>) -> Cu {
let shape = Shape::Capsule { a, b, r };
Cu { bbox: shape_bbox(&shape), shape, layers }
}
/// What the connectivity walk found.
struct Connectivity {
unconnected: usize,
split: Vec<Value>,
/// (net, layer, a point on it, area mm2), y up
isolated: Vec<(String, String, P, f64)>,
}
/// Connections still to make, from the copper: per net, (groups of touching copper) - 1. Pours count
/// with the copper KiCad poured (or the outline drawn when the zone is not filled). A group that is
/// only poured copper is an isolated island, dead copper KiCad warns about, not a connection to make:
/// it is listed under `isolatedCopper` and not counted. A stray track or via still counts.
fn unrouted(m: &Model) -> Connectivity {
let idx = |l: &str| m.copper.iter().position(|c| c == l);
let all: Vec<usize> = (0..m.copper.len()).collect();
// each piece of copper with what it is: Some(pad name), None for a track or via, Pour(layer)
#[derive(Clone)]
enum What { Pad(String), Wire, Pour(String, f64, P) }
let mut by_net: BTreeMap<&str, Vec<(Cu, What)>> = BTreeMap::new();
for p in &m.pads {
if !p.net.is_empty() {
by_net.entry(&p.net).or_default().push((pad_cu(p, &idx), What::Pad(format!("{}.{}", p.owner, p.name))));
}
}
for t in &m.tracks {
if t.net.is_empty() { continue }
if let Some(l) = idx(&t.layer) {
by_net.entry(&t.net).or_default().push((capsule(t.a, t.b, t.width / 2.0, vec![l]), What::Wire));
}
}
for a in &m.arcs {
if a.net.is_empty() { continue }
if let Some(l) = idx(&a.layer) {
let pts = a.arc.sample(2.0);
for w in pts.windows(2) {
by_net.entry(&a.net).or_default().push((capsule(w[0], w[1], a.width / 2.0, vec![l]), What::Wire));
}
}
}
for v in &m.vias {
if v.net.is_empty() { continue }
let (lo, hi) = (v.layers.first().and_then(|l| idx(l)).unwrap_or(0), v.layers.last().and_then(|l| idx(l)).unwrap_or(all.len().saturating_sub(1)));
by_net.entry(&v.net).or_default().push((capsule(v.at, v.at, v.size / 2.0, (lo.min(hi)..=lo.max(hi)).collect()), What::Wire));
}
for g in &m.polys {
if g.net.is_empty() { continue }
let Some(l) = idx(&g.layer) else { continue };
let islands: Vec<&Vec<P>> = if g.fills.is_empty() { vec![&g.points] } else { g.fills.iter().collect() };
for pts in islands {
if pts.len() >= 3 {
by_net.entry(&g.net).or_default().push((Cu { bbox: bbox_of(pts, 0.0), shape: Shape::Poly { pts: pts.clone() }, layers: vec![l] }, What::Pour(g.layer.clone(), shoelace(pts).abs(), pts[0])));
}
}
}
let mut out = Connectivity { unconnected: 0, split: Vec::new(), isolated: Vec::new() };
for (net, items) in by_net {
let n = items.len();
let mut parent: Vec<usize> = (0..n).collect();
fn find(p: &mut [usize], x: usize) -> usize {
let mut r = x;
while p[r] != r { r = p[r]; }
let mut y = x;
while p[y] != r { let nx = p[y]; p[y] = r; y = nx; }
r
}
for i in 0..n {
for j in (i + 1)..n {
let (a, b) = (find(&mut parent, i), find(&mut parent, j));
if a != b && touch(&items[i].0, &items[j].0) {
parent[a] = b;
}
}
}
let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for i in 0..n {
let r = find(&mut parent, i);
groups.entry(r).or_default().push(i);
}
let mut live: Vec<Vec<String>> = Vec::new();
for members in groups.values() {
let only_pour = members.iter().all(|i| matches!(items[*i].1, What::Pour(..)));
if only_pour {
// pours join only on a shared layer, so an island is on one layer
let (mut layer, mut area, mut at) = (String::new(), 0.0, (0.0, 0.0));
for i in members {
if let What::Pour(l, a, p) = &items[*i].1 {
if layer.is_empty() { layer = l.clone(); at = *p; }
area += a;
}
}
out.isolated.push((net.to_string(), layer, at, area));
continue;
}
live.push(members.iter().filter_map(|i| if let What::Pad(p) = &items[*i].1 { Some(p.clone()) } else { None }).collect());
}
if live.len() > 1 {
out.unconnected += live.len() - 1;
// each piece by the pads it holds (a few each): what to join
let pieces: Vec<Value> = live.iter().map(|pads| json!({"pads": pads.iter().take(6).collect::<Vec<_>>(), "padCount": pads.len()})).collect();
out.split.push(json!({"net": net, "groups": live.len(), "pieces": pieces}));
}
}
out
}
// -- revision and JSON ---------------------------------------------------------------------------
/// FNV-1a 64, the reference bridge's revision hash: stable across builds and machines.
fn fnv(s: &str) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
format!("{h:016x}")
}
/// Copper, parts and outline, canonical (y-up, 1 nm): fills, text and silkscreen are left out, so a
/// refill or a moved designator does not make a reader's revision stale.
fn revision(m: &Model) -> String {
let id = Opts { y_up: true };
let mut canon = String::new();
// each kind's items sorted, so the order a serialisation lists them in does not matter
for v in [tracks_json(m, &id), arcs_json(m, &id), vias_json(m, &id), pads_json(m, &id), comps_json(m, &id), polys_json(m, &id, false), edges_json(m.loops.first().map(Vec::as_slice).unwrap_or(&[]), &id)] {
let mut items: Vec<String> = v.as_array().map(|a| a.iter().map(Value::to_string).collect()).unwrap_or_default();
items.sort_unstable();
canon.push_str(&items.join(","));
canon.push('|');
}
fnv(&canon)
}
fn pj(p: P, o: &Opts) -> Value {
json!({"x": r6(p.0), "y": r6(if o.y_up { p.1 } else { -p.1 })})
}
fn pa(p: P, o: &Opts) -> Value {
json!([r6(p.0), r6(if o.y_up { p.1 } else { -p.1 })])
}
/// Angles are KiCad's own in both frames: counterclockwise as seen on screen, which is counterclockwise
/// in the standard's y-up frame.
fn ang(a: f64) -> f64 {
r6(a)
}
fn with_owner(mut v: Value, owner: &Option<String>) -> Value {
if let Some(o) = owner {
v["ref"] = json!(o);
}
v
}
/// tracks_json with each free segment's id, which edit_tracks and kicad_modify_track take. The revision reads
/// tracks_json, so an id changes no revision.
fn tracks_with_ids(m: &Model, o: &Opts) -> Value {
let mut v = tracks_json(m, o);
if let Some(rows) = v.as_array_mut() {
for (row, t) in rows.iter_mut().zip(&m.tracks) {
if let Some(id) = &t.id {
row["id"] = json!(id);
}
}
}
v
}
fn tracks_json(m: &Model, o: &Opts) -> Value {
Value::Array(m.tracks.iter().map(|t| with_owner(json!({"start": pj(t.a, o), "end": pj(t.b, o), "width": r6(t.width), "layer": t.layer, "net": t.net}), &t.owner)).collect())
}
fn arc_angles(a: &Arc3) -> (f64, f64) {
(ang(a.a1), ang(a.a2))
}
fn arcs_json(m: &Model, o: &Opts) -> Value {
Value::Array(m.arcs.iter().map(|a| {
let (s, e) = arc_angles(&a.arc);
with_owner(json!({"center": pj(a.arc.center, o), "radius": r6(a.arc.radius), "startAngle": s, "endAngle": e,
"start": pj(a.arc.point(a.arc.a1), o), "end": pj(a.arc.point(a.arc.a2), o), "width": r6(a.width), "layer": a.layer, "net": a.net}), &a.owner)
}).collect())
}
fn vias_json(m: &Model, o: &Opts) -> Value {
Value::Array(m.vias.iter().map(|v| {
let mut j = json!({"position": pj(v.at, o), "size": r6(v.size), "drill": r6(v.drill), "layers": v.layers, "net": v.net});
if v.kind != "through" {
j["kind"] = json!(v.kind);
}
j
}).collect())
}
fn pads_json(m: &Model, o: &Opts) -> Value {
Value::Array(m.pads.iter().map(|p| {
let mut j = json!({"ref": p.owner, "name": p.name, "x": r6(p.at.0), "y": r6(if o.y_up { p.at.1 } else { -p.at.1 }),
"sizeX": r6(p.size.0), "sizeY": r6(p.size.1), "rotation": ang(p.rotation), "layer": p.layer, "drill": r6(p.drill), "net": p.net,
"shape": p.shape, "padType": p.kind});
if p.kicad_shape != p.shape {
j["kicadShape"] = json!(p.kicad_shape);
}
if let Some(a) = p.anchor {
j["anchor"] = pj(a, o);
}
if let Some(c) = &p.copper_points {
j["copperPoints"] = json!(c.iter().map(|q| pa(*q, o)).collect::<Vec<_>>());
}
if p.layer == "*.Cu" && p.copper.len() != m.copper.len() {
j["layers"] = json!(p.copper);
}
j
}).collect())
}
fn comps_json(m: &Model, o: &Opts) -> Value {
Value::Array(m.comps.iter().map(|c| {
let pattern = c.footprint.rsplit(':').next().unwrap_or("").to_string();
json!({"ref": c.reference, "x": r6(c.at.0), "y": r6(if o.y_up { c.at.1 } else { -c.at.1 }), "rotation": ang(c.rotation),
"side": if c.bottom { "bottom" } else { "top" }, "pattern": pattern, "footprint": c.footprint, "value": c.value,
"has3d": c.has3d, "attributes": c.attrs})
}).collect())
}
fn polys_json(m: &Model, o: &Opts, with_fills: bool) -> Value {
Value::Array(m.polys.iter().map(|g| {
let mut j = json!({"net": g.net, "layer": g.layer, "points": g.points.iter().map(|p| pa(*p, o)).collect::<Vec<_>>()});
if g.graphic {
j["kind"] = json!("graphic");
} else {
j["kind"] = json!("zone");
j["priority"] = json!(g.priority);
if let Some(n) = &g.name {
j["name"] = json!(n);
}
if with_fills {
j["filled"] = json!(!g.fills.is_empty());
j["filledMm2"] = json!(r6(g.fills.iter().map(|f| shoelace(f).abs()).sum::<f64>()));
j["hatched"] = json!(g.hatched);
}
}
with_owner(j, &g.owner)
}).collect())
}
fn edges_json(lp: &[Edge], o: &Opts) -> Value {
Value::Array(lp.iter().map(|e| match &e.kind {
EdgeKind::Line => json!({"kind": "line", "start": pa(e.a, o), "end": pa(e.b, o)}),
EdgeKind::Arc(a) => {
let (a1, a2) = arc_angles(a);
json!({"kind": "arc", "start": pa(e.a, o), "end": pa(e.b, o), "center": pa(a.center, o), "radius": r6(a.radius), "angle1": a1, "angle2": a2})
}
}).collect())
}
fn to_json(m: &Model, file: Option<&str>, revision: &str, o: &Opts) -> Value {
let outer: &[Edge] = m.loops.first().map(Vec::as_slice).unwrap_or(&[]);
let mut nets: BTreeSet<&str> = BTreeSet::new();
nets.extend(m.pads.iter().map(|p| p.net.as_str()));
nets.extend(m.tracks.iter().map(|t| t.net.as_str()));
nets.extend(m.arcs.iter().map(|t| t.net.as_str()));
nets.extend(m.vias.iter().map(|t| t.net.as_str()));
nets.extend(m.polys.iter().map(|t| t.net.as_str()));
nets.remove("");
let conn = unrouted(m);
let unfilled: Vec<Value> = m.polys.iter().filter(|g| !g.graphic && g.fills.is_empty()).map(|g| json!({"net": g.net, "layer": g.layer, "name": g.name})).collect();
let mut out = json!({
"units": "mm",
"frame": if o.y_up { "standard" } else { "kicad" },
"file": file, "boardId": file, "revision": revision,
"counts": {"tracks": m.tracks.len(), "arcs": m.arcs.len(), "vias": m.vias.len(), "pads": m.pads.len(),
"components": m.comps.len(), "polygons": m.polys.len(), "nets": nets.len()},
"unconnected": conn.unconnected, "splitNets": conn.split,
"nets": nets.iter().collect::<Vec<_>>(),
"copperLayers": m.copper,
"tracks": tracks_with_ids(m, o), "arcs": arcs_json(m, o), "vias": vias_json(m, o), "pads": pads_json(m, o),
"components": comps_json(m, o), "polygons": polys_json(m, o, true),
"outline": outer.iter().map(|e| pa(e.a, o)).collect::<Vec<_>>(),
"outlineEdges": edges_json(outer, o),
});
if m.loops.len() > 1 {
out["cutouts"] = Value::Array(m.loops[1..].iter().map(|lp| edges_json(lp, o)).collect());
}
if !m.keepouts.is_empty() {
out["keepouts"] = Value::Array(m.keepouts.iter().map(|k| with_owner(json!({"layers": k.layers, "points": k.points.iter().map(|p| pa(*p, o)).collect::<Vec<_>>(), "rules": k.rules}), &k.owner)).collect());
}
if !conn.isolated.is_empty() {
out["isolatedCopper"] = json!(conn.isolated.iter().map(|(n, l, at, a)| json!({"net": n, "layer": l, "at": pj(*at, o), "areaMm2": r6(*a)})).collect::<Vec<_>>());
}
if !unfilled.is_empty() {
out["unfilledZones"] = json!(unfilled);
}
if m.loose_edges > 0 {
out["outlineOpenEdges"] = json!(m.loose_edges);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn board(body: &str) -> String {
format!("(kicad_pcb (version 20260206) (generator \"pcbnew\")\n (layers (0 \"F.Cu\" signal) (4 \"In1.Cu\" signal) (6 \"In2.Cu\" signal) (2 \"B.Cu\" signal) (25 \"Edge.Cuts\" user))\n{body})")
}
const RECT_OUTLINE: &str = "(gr_rect (start 0 0) (end 40 20) (stroke (width 0.1) (type solid)) (fill no) (layer \"Edge.Cuts\"))";
fn std_model(body: &str) -> Value {
build(&board(body), Some("C:/t/t.kicad_pcb"), &Opts::default()).unwrap()
}
#[test]
fn y_is_negated_and_layers_keep_kicad_names() {
let v = std_model(&format!("{RECT_OUTLINE}(segment (start 1 2) (end 5 2) (width 0.25) (layer \"In1.Cu\") (net \"A\"))"));
assert_eq!(v["units"], "mm");
assert_eq!(v["tracks"][0]["start"], json!({"x": 1.0, "y": -2.0}));
assert_eq!(v["tracks"][0]["layer"], "In1.Cu");
assert_eq!(v["copperLayers"], json!(["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]));
let k = build(&board("(segment (start 1 2) (end 5 2) (width 0.25) (layer \"F.Cu\") (net \"A\"))"), None, &Opts { y_up: false }).unwrap();
assert_eq!(k["tracks"][0]["start"], json!({"x": 1.0, "y": 2.0}));
assert_eq!(k["frame"], "kicad");
}
#[test]
fn rounded_outline_keeps_its_arcs_counterclockwise() {
// a 10 x 10 board with one rounded corner (radius 1) at (10, 10) in KiCad's frame
let v = std_model("(gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\"))(gr_line (start 10 0) (end 10 9) (layer \"Edge.Cuts\"))\
(gr_arc (start 10 9) (mid 9.707107 9.707107) (end 9 10) (layer \"Edge.Cuts\"))(gr_line (start 9 10) (end 0 10) (layer \"Edge.Cuts\"))(gr_line (start 0 10) (end 0 0) (layer \"Edge.Cuts\"))");
let edges = v["outlineEdges"].as_array().unwrap();
assert_eq!(edges.len(), 5);
let arc = edges.iter().find(|e| e["kind"] == "arc").unwrap();
assert!((arc["center"][0].as_f64().unwrap() - 9.0).abs() < 1e-5 && (arc["center"][1].as_f64().unwrap() + 9.0).abs() < 1e-5, "{arc}");
assert!((arc["radius"].as_f64().unwrap() - 1.0).abs() < 1e-5);
// y-up: the corner is the bottom-right, the quarter from 270 to 360
assert!((arc["angle1"].as_f64().unwrap() - 270.0).abs() < 1e-3, "{arc}");
assert!((arc["angle2"].as_f64().unwrap() - 0.0).abs() < 1e-3 || (arc["angle2"].as_f64().unwrap() - 360.0).abs() < 1e-3);
assert_eq!(v["outline"].as_array().unwrap().len(), 5);
assert!(shoelace(&v["outline"].as_array().unwrap().iter().map(|p| (p[0].as_f64().unwrap(), p[1].as_f64().unwrap())).collect::<Vec<_>>()) > 0.0, "counterclockwise");
}
#[test]
fn a_round_board_is_one_full_arc() {
let v = std_model("(gr_circle (center 20 20) (end 30 20) (layer \"Edge.Cuts\") (fill no))");
assert_eq!(v["outlineEdges"].as_array().unwrap().len(), 1);
assert_eq!(v["outlineEdges"][0]["kind"], "arc");
assert_eq!(v["outlineEdges"][0]["radius"], json!(10.0));
assert_eq!(v["outlineEdges"][0]["angle2"], json!(360.0));
}
#[test]
fn pads_are_placed_and_rotated_with_their_footprint() {
// footprint at (10, 10) rotated 90 (counterclockwise on screen); pad 1 at local (1, 0)
let v = std_model("(footprint \"lib:R_0603\" (layer \"F.Cu\") (at 10 10 90) (property \"Reference\" \"R1\") \
(pad \"1\" smd roundrect (at 1 0 90) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\") (net \"A\")) \
(pad \"2\" thru_hole circle (at -1 0 90) (size 1.6 1.6) (drill 0.8) (layers \"*.Cu\" \"*.Mask\") (net \"B\")))");
let p1 = &v["pads"][0];
// local +x turned 90 counterclockwise points up the screen: KiCad (10, 9), y-up (10, -9)
assert_eq!((p1["x"].as_f64().unwrap(), p1["y"].as_f64().unwrap()), (10.0, -9.0));
assert_eq!(p1["rotation"], json!(90.0));
assert_eq!(p1["layer"], "F.Cu");
assert_eq!(p1["shape"], "roundrect");
assert_eq!(p1["ref"], "R1");
let p2 = &v["pads"][1];
assert_eq!(p2["layer"], "*.Cu");
assert_eq!(p2["drill"], json!(0.8));
assert_eq!(v["components"][0]["pattern"], "R_0603");
assert_eq!(v["components"][0]["side"], "top");
}
#[test]
fn a_custom_pad_is_its_bounding_rectangle() {
// pic_programmer's custom pad: anchor 0.3 x 0.3, a pentagon from x -0.5 to 1, y -0.75 to 0.75
let v = std_model("(footprint \"x:y\" (layer \"B.Cu\") (at 100 50) (property \"Reference\" \"JP1\") \
(pad \"1\" smd custom (at -0.725 0) (size 0.3 0.3) (layers \"B.Cu\" \"B.Mask\") (net \"VCC\") (options (clearance outline) (anchor rect)) \
(primitives (gr_poly (pts (xy 1 0) (xy 0.5 -0.75) (xy -0.5 -0.75) (xy -0.5 0.75) (xy 0.5 0.75)) (width 0) (fill yes)))))");
let p = &v["pads"][0];
assert_eq!(p["shape"], "rect");
assert_eq!(p["kicadShape"], "custom");
assert_eq!(p["rotation"], json!(0.0));
assert!((p["sizeX"].as_f64().unwrap() - 1.5).abs() < 1e-9, "{p}");
assert!((p["sizeY"].as_f64().unwrap() - 1.5).abs() < 1e-9);
assert!((p["x"].as_f64().unwrap() - (100.0 - 0.725 + 0.25)).abs() < 1e-9);
assert_eq!(p["anchor"], json!({"x": 99.275, "y": -50.0}));
assert_eq!(p["layer"], "B.Cu");
assert_eq!(v["components"][0]["side"], "bottom");
// its real outline: the pentagon, which covers the anchor
let c = p["copperPoints"].as_array().unwrap();
assert_eq!(c.len(), 5);
assert_eq!(c[0], json!([100.0 - 0.725 + 1.0, -50.0]));
}
#[test]
fn net_less_copper_is_kept_and_not_counted_as_unconnected() {
let v = std_model("(gr_line (start 0 0) (end 5 0) (stroke (width 0.2)) (layer \"F.Cu\"))\
(zone (net 0) (net_name \"\") (layer \"B.Cu\") (polygon (pts (xy 0 0) (xy 5 0) (xy 5 5))))");
assert_eq!(v["tracks"][0]["net"], "");
assert_eq!(v["polygons"][0]["net"], "");
assert_eq!(v["unconnected"], json!(0));
}
#[test]
fn unconnected_is_counted_from_the_copper() {
let pads = "(footprint \"x:y\" (layer \"F.Cu\") (at 0 0) (property \"Reference\" \"U1\") \
(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"N\")) \
(pad \"2\" smd rect (at 10 0) (size 1 1) (layers \"F.Cu\") (net \"N\")) \
(pad \"3\" smd rect (at 20 0) (size 1 1) (layers \"F.Cu\") (net \"N\")))";
let open = std_model(pads);
assert_eq!(open["unconnected"], json!(2));
assert_eq!(open["splitNets"][0]["groups"], json!(3));
assert_eq!(open["splitNets"][0]["pieces"][2]["pads"], json!(["U1.3"]));
// a track from pad 1 to pad 2, a via, and a back-side track from the via to pad 3: pad 3 is
// SMD on F.Cu, so the back track does not reach it
let half = std_model(&format!("{pads}(segment (start 0 0) (end 10 0) (width 0.2) (layer \"F.Cu\") (net \"N\"))\
(via (at 15 0) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net \"N\"))(segment (start 15 0) (end 20 0) (width 0.2) (layer \"B.Cu\") (net \"N\"))"));
assert_eq!(half["unconnected"], json!(2), "the via and the back track are their own piece");
let done = std_model(&format!("{pads}(segment (start 0 0) (end 10 0) (width 0.2) (layer \"F.Cu\") (net \"N\"))(arc (start 10 0) (mid 15 -5) (end 20 0) (width 0.2) (layer \"F.Cu\") (net \"N\"))"));
assert_eq!(done["unconnected"], json!(0));
assert_eq!(done["arcs"][0]["center"], json!({"x": 15.0, "y": 0.0}));
// through the arc's top: y-up, the arc runs counterclockwise from 0 to 180
assert_eq!((done["arcs"][0]["startAngle"].as_f64().unwrap(), done["arcs"][0]["endAngle"].as_f64().unwrap()), (0.0, 180.0));
}
#[test]
fn a_pour_joins_pads_through_its_spokes() {
// the fill leaves a hole round pad 2's centre (a thermal gap) but a spoke reaches its edge
let v = std_model("(footprint \"x:y\" (layer \"F.Cu\") (at 0 0) (property \"Reference\" \"U1\") \
(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"G\")) (pad \"2\" smd rect (at 10 0) (size 1 1) (layers \"F.Cu\") (net \"G\")))\
(zone (net \"G\") (layer \"F.Cu\") (polygon (pts (xy -2 -2) (xy 12 -2) (xy 12 2) (xy -2 2)))\
(filled_polygon (layer \"F.Cu\") (pts (xy -2 -2) (xy 9.6 -2) (xy 9.6 -0.1) (xy 9.8 -0.1) (xy 9.8 0.1) (xy 9.4 0.1) (xy 9.4 -2) (xy -2 -2) (xy -2 2) (xy 9.4 2) (xy 9.4 0.2) (xy -2 0.2))))");
assert_eq!(v["unconnected"], json!(0), "{}", v["splitNets"]);
assert_eq!(v["polygons"][0]["filled"], json!(true));
}
#[test]
fn revision_moves_with_copper_not_with_silkscreen() {
let base = format!("{RECT_OUTLINE}(segment (start 1 2) (end 5 2) (width 0.25) (layer \"F.Cu\") (net \"A\"))");
let r0 = revision_of_text(&board(&base)).unwrap();
let silk = revision_of_text(&board(&format!("{base}(gr_text \"hello\" (at 3 3) (layer \"F.SilkS\"))"))).unwrap();
assert_eq!(r0, silk);
let moved = revision_of_text(&board(&base.replace("(end 5 2)", "(end 6 2)"))).unwrap();
assert_ne!(r0, moved);
assert_eq!(r0.len(), 16);
}
#[test]
fn kicad9_teardrop_writer_bug_is_repaired_and_other_damage_refused() {
let pad = "(footprint \"x:y\" (layer \"F.Cu\") (at 0 0) (property \"Reference\" \"R1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"A\") (teardrops (curved_edges no)filter_ratio 0.9) (enabled yes))))";
let v = build(&board(&format!("{pad}{pad}")), None, &Opts::default()).unwrap();
assert_eq!(v["counts"]["components"], json!(2));
assert_eq!(v["repairedOnRead"]["teardropFilterRatio"], json!(2));
let e = build(&board("(segment (start 0 0) (end 1 0)))) (segment (start 0 0) (end 1 0))"), None, &Opts::default()).unwrap_err();
assert!(e.contains("damaged"), "{e}");
}
#[test]
fn an_isolated_pour_island_is_not_a_connection_to_make() {
let v = std_model("(footprint \"x:y\" (layer \"F.Cu\") (at 0 0) (property \"Reference\" \"U1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"G\")))\
(zone (net \"G\") (layer \"F.Cu\") (polygon (pts (xy -2 -2) (xy 30 -2) (xy 30 2) (xy -2 2)))\
(filled_polygon (layer \"F.Cu\") (pts (xy -2 -2) (xy 5 -2) (xy 5 2) (xy -2 2))) (filled_polygon (layer \"F.Cu\") (pts (xy 20 -2) (xy 30 -2) (xy 30 2) (xy 20 2))))");
assert_eq!(v["unconnected"], json!(0));
assert_eq!(v["isolatedCopper"], json!([{"net": "G", "layer": "F.Cu", "at": {"x": 20.0, "y": 2.0}, "areaMm2": 40.0}]));
}
#[test]
fn revision_and_outline_do_not_depend_on_the_order_items_are_listed() {
let lines = ["(gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\"))", "(gr_line (start 10 0) (end 10 10) (layer \"Edge.Cuts\"))", "(gr_line (start 10 10) (end 0 10) (layer \"Edge.Cuts\"))", "(gr_line (start 0 10) (end 0 0) (layer \"Edge.Cuts\"))",
"(segment (start 1 1) (end 2 1) (width 0.2) (layer \"F.Cu\") (net \"A\"))", "(segment (start 3 1) (end 4 1) (width 0.2) (layer \"F.Cu\") (net \"A\"))"];
let a = std_model(&lines.concat());
let mut rev = lines.to_vec();
rev.reverse();
let b = std_model(&rev.concat());
assert_eq!(a["revision"], b["revision"]);
assert_eq!(a["outlineEdges"], b["outlineEdges"]);
assert_eq!(a["outline"][0], json!([0.0, -10.0]), "the lowest-left vertex first");
}
#[test]
fn stackup_reads_copper_and_the_dielectric_below_each_layer() {
let text = "(kicad_pcb (version 20241229) (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (4 \"In1.Cu\" power \"GND\") (2 \"B.Cu\" signal))\
(setup (stackup (layer \"F.Mask\" (type \"Top Solder Mask\") (thickness 0.01)) (layer \"F.Cu\" (type \"copper\") (thickness 0.035))\
(layer \"dielectric 1\" (type \"prepreg\") (thickness 0.1) (material \"FR4\") (epsilon_r 4.4) addsublayer (thickness 0.1) (material \"FR4\") (epsilon_r 4.6))\
(layer \"In1.Cu\" (type \"copper\") (thickness 0.0175)) (layer \"dielectric 2\" (type \"core\") (thickness 1.2) (material \"FR4\") (epsilon_r 4.5))\
(layer \"B.Cu\" (type \"copper\") (thickness 0.035)) (copper_finish \"ENIG\")))\
(zone (net \"GND\") (layer \"In1.Cu\") (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10)))))";
let v = stackup(text).unwrap();
assert_eq!(v["copperLayers"], json!(3));
assert_eq!(v["fromDefaults"], json!(false));
let l = v["layers"].as_array().unwrap();
assert_eq!(l[0]["copperUm"], json!(35.0));
assert_eq!(l[0]["dielectricUm"], json!(200.0));
assert_eq!(l[0]["dk1000"], json!(4500.0), "the sublayers' Dk weighted by thickness");
assert_eq!(l[1]["kind"], "plane");
assert_eq!(l[1]["net"], "GND");
assert_eq!(l[1]["plane"], json!(1));
assert!(l[2].get("dielectricUm").is_none(), "nothing below the bottom layer");
assert_eq!(v["totalUm"], json!(1600.0));
assert_eq!(v["copperFinish"], "ENIG");
let d = stackup("(kicad_pcb (version 20241229) (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)))").unwrap();
assert_eq!(d["fromDefaults"], json!(true));
assert!((d["layers"][0]["dielectricUm"].as_f64().unwrap() - 1530.0).abs() < 1e-6);
}
#[test]
fn net_connections_lists_pads_and_each_nets_airline_length() {
let m = json!({"components": [], "copperLayers": ["F.Cu", "B.Cu"], "nets": ["A", "B"], "pads": [
{"ref": "U1", "name": "1", "x": 0.0, "y": 0.0, "net": "A"}, {"ref": "R1", "name": "2", "x": 3.0, "y": 0.0, "net": "A"},
{"ref": "C1", "name": "1", "x": 3.0, "y": 4.0, "net": "A"}, {"ref": "U1", "name": "2", "x": 1.0, "y": 1.0, "net": "B"},
{"ref": "H1", "name": "", "x": 9.0, "y": 9.0, "net": ""}]});
let v = derive(&m, "net_connections", &json!({})).unwrap();
assert_eq!(v["data"]["count"], 4, "pads on no net are left out: {v}");
let a = v["data"]["nets"].as_array().unwrap().iter().find(|n| n["net"] == "A").unwrap().clone();
assert_eq!((a["pads"].as_u64(), a["airlineMm"].as_f64(), a["maxYMil"].as_f64()), (Some(3), Some(7.0), Some(157.48)), "{a}");
assert_eq!(v["data"]["pads"][1]["pad"], "R1.2");
let only = derive(&m, "net_connections", &json!({"net": "b"})).unwrap();
assert_eq!(only["data"]["count"], 1);
assert_eq!(derive(&m, "net_connections", &json!({"net": "Z"})).unwrap_err().0, "unknown_net");
}
#[test]
fn pours_report_the_copper_poured() {
let v = pours(&board("(zone (net \"G\") (name \"gnd\") (layer \"F.Cu\") (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10)))\
(filled_polygon (layer \"F.Cu\") (pts (xy 0 0) (xy 4 0) (xy 4 5) (xy 0 5))))(zone (net \"G\") (layer \"B.Cu\") (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))))"), &Opts::default(), true).unwrap();
assert_eq!(v["zones"][0]["filledMm2"], json!(20.0));
assert_eq!(v["zones"][0]["fills"][0]["outline"][1], json!([4.0, 0.0]));
assert_eq!(v["filledAreaByLayerMm2"]["F.Cu"], json!(20.0));
assert_eq!(v["unfilled"][0]["layer"], "B.Cu");
}
#[test]
fn edit_arguments_in_the_standard_frame_turn_into_kicads() {
let a = args_to_kicad_frame(&json!({"frame": "standard", "net": "GND", "points": ["J1.1", [10.0, -20.0], {"x": 12.0, "y": -21.0, "layer": "B.Cu"}],
"paths": [[[1.0, -2.0]]], "polygon": [[0.0, -1.0]], "at": [3.0, -4.0], "x": 5.0, "y": -6.0, "rotation": 90.0,
"refs": [{"ref": "U1", "x": 1.0, "y": -2.0}], "zones": [{"polygon": [[7.0, -8.0]]}]})).unwrap();
assert_eq!(a["points"], json!(["J1.1", [10.0, 20.0], {"x": 12.0, "y": 21.0, "layer": "B.Cu"}]));
assert_eq!(a["paths"], json!([[[1.0, 2.0]]]));
assert_eq!((a["polygon"][0][1].clone(), a["at"][1].clone(), a["y"].clone(), a["rotation"].clone()), (json!(1.0), json!(4.0), json!(6.0), json!(90.0)));
assert_eq!(a["refs"][0]["y"], json!(2.0));
assert_eq!(a["zones"][0]["polygon"][0], json!([7.0, 8.0]));
assert!(a.get("frame").is_none());
let same = json!({"y": 3.0});
assert_eq!(args_to_kicad_frame(&same).unwrap(), same);
assert!(args_to_kicad_frame(&json!({"frame": "metric"})).is_err());
}
/// Wiki #141: a text's position from kicad_silk_state (y up) goes back to the silk batch verbs as is.
#[test]
fn silk_batch_rows_in_the_standard_frame_turn_into_kicads() {
assert!(FRAME_VERBS.contains(&"kicad_silk_text_batch") && FRAME_VERBS.contains(&"kicad_silk_fields_batch") && FRAME_VERBS.contains(&"kicad_silk_graphics_batch"));
let a = args_to_kicad_frame(&json!({"frame": "standard", "expectedRevision": "r",
"update": [{"id": "t", "x": 120.0, "y": -135.92, "rotation": 90.0}, {"id": "g", "kind": "line", "points": [[1.0, -2.0], [3.0, -4.0]]}],
"create": [{"text": "J1", "x": 1.0, "y": -5.0}], "remove": ["x"]})).unwrap();
assert_eq!(a["update"][0]["y"], json!(135.92));
assert_eq!(a["update"][0]["rotation"], json!(90.0));
assert_eq!(a["update"][1]["points"], json!([[1.0, 2.0], [3.0, 4.0]]));
assert_eq!(a["create"][0]["y"], json!(5.0));
assert_eq!(a["remove"], json!(["x"]));
assert_eq!(a["_fromFrame"], json!("standard"));
}
#[test]
fn core_reads_are_views_of_the_model() {
let m = std_model(&format!("{RECT_OUTLINE}(footprint \"lib:R_0603\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"R10\") (property \"Value\" \"10k\")\
(pad \"1\" smd rect (at -1 0) (size 1 1) (layers \"F.Cu\") (net \"A\")) (pad \"2\" smd rect (at 1 0) (size 1 1) (layers \"F.Cu\") (net \"B\")))\
(footprint \"lib:R_0603\" (layer \"B.Cu\") (at 20 10) (property \"Reference\" \"R2\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"B.Cu\") (net \"A\")))\
(segment (start 9 10) (end 20 10) (width 0.2) (layer \"B.Cu\") (net \"A\"))"));
let d = derive(&m, "list_designators", &json!({})).unwrap();
assert_eq!(d["designators"], json!(["R2", "R10"]));
let n = derive(&m, "list_nets", &json!({})).unwrap();
assert_eq!(n["nets"], json!([{"name": "A", "pads": 2}, {"name": "B", "pads": 1}]));
let s = derive(&m, "board_summary", &json!({})).unwrap();
assert_eq!(s["summary"]["componentsBottom"], json!(1));
assert_eq!(s["summary"]["outline"]["widthMm"], json!(40.0));
assert_eq!(s["summary"]["outline"]["areaMm2"], json!(800.0));
assert_eq!(s["summary"]["copperLayersInUseNames"], json!(["F.Cu", "B.Cu"]));
let c = derive(&m, "component_data", &json!({"designator": "r10"})).unwrap();
assert_eq!(c["component"]["w"], json!(3.0));
assert_eq!(c["component"]["nets"], json!(["A", "B"]));
assert_eq!(c["component"]["comment"], "10k");
let p = derive(&m, "component_pins", &json!({"designator": "R10"})).unwrap();
assert_eq!(p["pins"][1]["x"], json!(11.0));
assert_eq!(derive(&m, "component_pins", &json!({"designator": "U9"})).unwrap_err().0, "designator_not_found");
}
#[test]
fn numbered_nets_of_older_files_resolve_to_names() {
let text = "(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)) (net 0 \"\") (net 1 \"GND\")\
(segment (start 0 0) (end 1 0) (width 0.2) (layer \"B.Cu\") (net 1))\
(zone (net 1) (net_name \"GND\") (layers \"F&B.Cu\") (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1)))))";
let v = build(text, None, &Opts::default()).unwrap();
assert_eq!(v["tracks"][0]["net"], "GND");
assert_eq!(v["copperLayers"], json!(["F.Cu", "B.Cu"]));
assert_eq!(v["polygons"].as_array().unwrap().len(), 2, "a two-layer zone is one polygon per layer");
}
}