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
John Lauer
1.0.20: NATIVE (Rust) build. 1.0.20: reservePlanes defaults to false (ESC fixture: reserved 30 unconnected, open 3), re…
6f8e15e
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656
//! Specctra DSN writer and SES reader for the Freerouting engine of `kicad_autoroute`.
//!
//! The writer follows pcbnew's own exporter (KiCad `pcbnew/specctra_export.cpp`): units
//! are micrometres with `(resolution um 10)` declaring a tenth of a micrometre of precision
//! (so a clearance can be `200.1`), the y axis is flipped (`dsn_y = -board_y`), pins are `REF-PAD`, the
//! through via is `Via[0-1]_<size>:<drill>_um`, and existing copper goes into `(wiring)`
//! as protected wires so the router keeps it. Where this writer is simpler than pcbnew:
//!
//! - one `(image)` per footprint INSTANCE, placed `front 0`, with every pin already in the
//! footprint's board-frame orientation. Rotation and side are baked into the pins and
//! padstacks, so the DSN place semantics (counter-clockwise, mirrored back side) never
//! come into play;
//! - circle and rect pads are exact (rect as a rotated polygon); oval, roundrect and every
//! other shape are written as the rect of their bounding size and listed in
//! `approximatedPads`, which is conservative (never smaller than the real pad);
//! - the boundary is the largest closed loop on Edge.Cuts (arcs and circles as segments),
//! else the outline's bounding box, else the pad bounding box plus a margin (`notes`
//! says which).
//!
//! The parser header says `host_cad "KiCad's Pcbnew"` on purpose: this IS pcbnew's
//! dialect, and Freerouting keys compatibility behaviour off that string (any other host
//! is treated as a pre-6 KiCad export with a warning).
//!
//! The reader takes Freerouting's `.ses` (`routes > network_out > net > wire | via`) back
//! to millimetre segments and through vias in KiCad's frame, honouring the session's own
//! `(resolution ...)` (Freerouting writes tenths of a micrometre there, ten times the DSN
//! numbers) and the `(string_quote ")` parser quirk.
use std::collections::BTreeMap;
use std::path::Path;
use serde::Serialize;
use serde_json::Value;
use crate::model_check::{parse_sexpr, Sx};
use crate::pcb::{self, Board, PlanSegment, PlanVia};
/// KiCad's routing defaults when the board has no project file beside it.
pub const DEFAULT_CLEARANCE_MM: f64 = 0.2;
pub const DEFAULT_TRACK_MM: f64 = 0.25;
pub const DEFAULT_VIA_SIZE_MM: f64 = 0.8;
pub const DEFAULT_VIA_DRILL_MM: f64 = 0.4;
/// DSN numbers are micrometres, written with at most one decimal (`(resolution um 10)`).
const UM_PER_MM: f64 = 1000.0;
/// Endpoints closer than this (mm) are the same point when chaining the outline.
const CHAIN_TOL: f64 = 0.01;
// ---------------------------------------------------------------------------
// Rules
// ---------------------------------------------------------------------------
/// One netclass as the DSN carries it: mm.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct NetClass {
pub name: String,
pub clearance: f64,
#[serde(rename = "trackWidth")]
pub track_width: f64,
#[serde(rename = "viaSize")]
pub via_size: f64,
#[serde(rename = "viaDrill")]
pub via_drill: f64,
/// Exact net names assigned to this class (resolved against the board's nets).
pub nets: Vec<String>,
}
/// The Default class plus every other class that owns at least one net, and where the
/// numbers came from.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Rules {
pub clearance: f64,
#[serde(rename = "trackWidth")]
pub track_width: f64,
#[serde(rename = "viaSize")]
pub via_size: f64,
#[serde(rename = "viaDrill")]
pub via_drill: f64,
pub classes: Vec<NetClass>,
pub source: String,
}
impl Default for Rules {
fn default() -> Self {
Rules { clearance: DEFAULT_CLEARANCE_MM, track_width: DEFAULT_TRACK_MM, via_size: DEFAULT_VIA_SIZE_MM, via_drill: DEFAULT_VIA_DRILL_MM, classes: Vec::new(), source: "kicad-defaults".into() }
}
}
impl Rules {
/// The class a net belongs to (Default when unassigned).
pub fn class_for(&self, net: &str) -> (f64, f64, f64, f64) {
for c in &self.classes {
if c.nets.iter().any(|n| n == net) {
return (c.clearance, c.track_width, c.via_size, c.via_drill);
}
}
(self.clearance, self.track_width, self.via_size, self.via_drill)
}
}
/// Rules for a board: the sibling `.kicad_pro`'s Default netclass (floored by the
/// board's minimum constraints), plus any other class that owns a net on this board.
/// KiCad's defaults when there is no project file or it does not say.
pub fn rules_for(board_path: &Path, board: &Board) -> Rules {
let mut rules = Rules::default();
let pro = board_path.with_extension("kicad_pro");
let Some(doc) = std::fs::read_to_string(&pro).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) else {
return rules;
};
let f = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|x| x.is_finite() && *x > 0.0);
let mins = &doc["board"]["design_settings"]["rules"];
let min_clearance = f(mins.get("min_clearance")).unwrap_or(0.0);
let min_track = f(mins.get("min_track_width")).unwrap_or(0.0);
let min_via = f(mins.get("min_via_diameter")).unwrap_or(0.0);
let min_hole = f(mins.get("min_through_hole_diameter")).unwrap_or(0.0);
let classes = doc["net_settings"]["classes"].as_array().cloned().unwrap_or_default();
let default = classes.iter().find(|c| c["name"].as_str() == Some("Default"));
if let Some(d) = default {
rules.clearance = f(d.get("clearance")).unwrap_or(rules.clearance);
rules.track_width = f(d.get("track_width")).unwrap_or(rules.track_width);
rules.via_size = f(d.get("via_diameter")).unwrap_or(rules.via_size);
rules.via_drill = f(d.get("via_drill")).unwrap_or(rules.via_drill);
rules.source = pro.to_string_lossy().replace('\\', "/");
}
rules.clearance = rules.clearance.max(min_clearance);
rules.track_width = rules.track_width.max(min_track);
rules.via_size = rules.via_size.max(min_via);
rules.via_drill = rules.via_drill.max(min_hole);
if rules.via_drill >= rules.via_size {
rules.via_drill = (rules.via_size / 2.0 * 1000.0).round() / 1000.0;
}
// Net to class: KiCad 8+ exact assignments, KiCad 7 patterns (exact or a trailing `*`).
let mut assigned: BTreeMap<String, String> = BTreeMap::new();
if let Some(map) = doc["net_settings"]["netclass_assignments"].as_object() {
for (net, class) in map {
if let Some(c) = class.as_str() {
assigned.insert(net.clone(), c.to_string());
}
}
}
if let Some(pats) = doc["net_settings"]["netclass_patterns"].as_array() {
for p in pats {
let (Some(pattern), Some(class)) = (p["pattern"].as_str(), p["netclass"].as_str()) else { continue };
for n in &board.nets {
if n.name.is_empty() || assigned.contains_key(&n.name) {
continue;
}
let hit = match pattern.strip_suffix('*') {
Some(prefix) => n.name.starts_with(prefix),
None => n.name == pattern,
};
if hit {
assigned.insert(n.name.clone(), class.to_string());
}
}
}
}
for c in &classes {
let Some(name) = c["name"].as_str().filter(|n| *n != "Default") else { continue };
let nets: Vec<String> = assigned.iter().filter(|(n, cl)| cl.as_str() == name && board.nets.iter().any(|b| &b.name == *n)).map(|(n, _)| n.clone()).collect();
if nets.is_empty() {
continue;
}
let mut nc = NetClass {
name: name.to_string(),
clearance: f(c.get("clearance")).unwrap_or(rules.clearance).max(min_clearance),
track_width: f(c.get("track_width")).unwrap_or(rules.track_width).max(min_track),
via_size: f(c.get("via_diameter")).unwrap_or(rules.via_size).max(min_via),
via_drill: f(c.get("via_drill")).unwrap_or(rules.via_drill).max(min_hole),
nets,
};
if nc.via_drill >= nc.via_size {
nc.via_drill = (nc.via_size / 2.0 * 1000.0).round() / 1000.0;
}
rules.classes.push(nc);
}
rules
}
// ---------------------------------------------------------------------------
// Units, quoting, geometry
// ---------------------------------------------------------------------------
fn u(mm: f64) -> String {
let tenths = (mm * UM_PER_MM * 10.0).round();
if tenths % 10.0 == 0.0 {
format!("{}", (tenths / 10.0) as i64)
} else {
format!("{:.1}", tenths / 10.0)
}
}
/// A board point (mm, +y down) as DSN units with the y axis flipped.
fn pt(x: f64, y: f64) -> String {
format!("{} {}", u(x), u(-y))
}
/// Quote the way pcbnew does: bare when every character is safe, else in double quotes.
pub fn q(s: &str) -> String {
let safe = |c: char| c.is_ascii_alphanumeric() || "_-./+:@#$&%!~^*?=<>|[]".contains(c);
if !s.is_empty() && s.chars().all(safe) {
s.to_string()
} else {
format!("\"{}\"", s.replace('"', "'"))
}
}
/// KiCad's pad rotation in a y-down frame (trigo.cpp RotatePoint): the same formula
/// `pcb::pads` uses for footprint rotation.
fn rotate(x: f64, y: f64, deg: f64) -> (f64, f64) {
let (s, c) = deg.to_radians().sin_cos();
(x * c + y * s, -x * s + y * c)
}
fn atom_f64(sx: &Sx, i: usize) -> Option<f64> {
sx.atom_at(i)?.parse::<f64>().ok()
}
fn xy(node: Option<&Sx>) -> Option<(f64, f64)> {
let n = node?;
Some((atom_f64(n, 1)?, atom_f64(n, 2)?))
}
fn via_name(size: f64, drill: f64) -> String {
format!("Via[0-1]_{}:{}_um", (size * 1000.0).round() as i64, (drill * 1000.0).round() as i64)
}
/// `Via[0-1]_800:400_um` -> (0.8, 0.4).
pub fn via_dims(name: &str) -> Option<(f64, f64)> {
let rest = name.strip_prefix("Via[")?;
let (_, rest) = rest.split_once("]_")?;
let rest = rest.strip_suffix("_um")?;
let (a, b) = rest.split_once(':')?;
Some((a.parse::<f64>().ok()? / 1000.0, b.parse::<f64>().ok()? / 1000.0))
}
// ---------------------------------------------------------------------------
// Board outline
// ---------------------------------------------------------------------------
/// Arc through three points as a polyline (start and end inclusive).
fn arc_points(a: (f64, f64), m: (f64, f64), b: (f64, f64)) -> Vec<(f64, f64)> {
let (ax, ay) = a;
let (mx, my) = m;
let (bx, by) = b;
let d = 2.0 * (ax * (my - by) + mx * (by - ay) + bx * (ay - my));
if d.abs() < 1e-9 {
return vec![a, b];
}
let a2 = ax * ax + ay * ay;
let m2 = mx * mx + my * my;
let b2 = bx * bx + by * by;
let cx = (a2 * (my - by) + m2 * (by - ay) + b2 * (ay - my)) / d;
let cy = (a2 * (bx - mx) + m2 * (ax - bx) + b2 * (mx - ax)) / d;
let r = ((ax - cx).powi(2) + (ay - cy).powi(2)).sqrt();
let t0 = (ay - cy).atan2(ax - cx);
let tm = (my - cy).atan2(mx - cx);
let t1 = (by - cy).atan2(bx - cx);
let two_pi = std::f64::consts::TAU;
let ccw = |from: f64, to: f64| ((to - from) % two_pi + two_pi) % two_pi;
// Sweep counter-clockwise from start unless the mid point is not on that way.
let (sweep, sign) = if ccw(t0, tm) <= ccw(t0, t1) { (ccw(t0, t1), 1.0) } else { (ccw(t1, t0), -1.0) };
let n = ((sweep.to_degrees() / 5.0).ceil() as usize).clamp(4, 180);
let mut out = Vec::with_capacity(n + 1);
for i in 0..=n {
let t = t0 + sign * sweep * (i as f64) / (n as f64);
out.push((cx + r * t.cos(), cy + r * t.sin()));
}
out[0] = a;
out[n] = b;
out
}
fn circle_points(c: (f64, f64), r: f64) -> Vec<(f64, f64)> {
let n = 64;
let mut out: Vec<(f64, f64)> = (0..n).map(|i| {
let t = std::f64::consts::TAU * (i as f64) / (n as f64);
(c.0 + r * t.cos(), c.1 + r * t.sin())
}).collect();
out.push(out[0]);
out
}
/// Every Edge.Cuts primitive as a polyline in board mm (footprint ones transformed).
fn edge_polylines(tree: &Sx) -> Vec<Vec<(f64, f64)>> {
let mut out = Vec::new();
let on_edge = |node: &Sx| node.find("layer").and_then(Sx::value) == Some("Edge.Cuts");
let mut collect = |node: &Sx, tag: &str, tf: &dyn Fn((f64, f64)) -> (f64, f64)| {
if !on_edge(node) {
return;
}
let poly: Option<Vec<(f64, f64)>> = (|| match tag {
"gr_line" | "fp_line" => Some(vec![tf(xy(node.find("start"))?), tf(xy(node.find("end"))?)]),
"gr_rect" | "fp_rect" => {
let (x1, y1) = xy(node.find("start"))?;
let (x2, y2) = xy(node.find("end"))?;
Some(vec![tf((x1, y1)), tf((x2, y1)), tf((x2, y2)), tf((x1, y2)), tf((x1, y1))])
}
"gr_arc" | "fp_arc" => {
let a = xy(node.find("start"))?;
let b = xy(node.find("end"))?;
match xy(node.find("mid")) {
Some(m) => Some(arc_points(a, m, b).into_iter().map(tf).collect()),
None => Some(vec![tf(a), tf(b)]),
}
}
"gr_circle" | "fp_circle" => {
let c = xy(node.find("center"))?;
let e = xy(node.find("end"))?;
Some(circle_points(c, pcb::dist(c, e)).into_iter().map(tf).collect())
}
"gr_poly" | "fp_poly" => {
let pts = node.find("pts")?;
let mut v: Vec<(f64, f64)> = pts.find_all("xy").filter_map(|p| Some(tf((atom_f64(p, 1)?, atom_f64(p, 2)?)))).collect();
if v.len() >= 3 {
v.push(v[0]);
}
Some(v)
}
_ => None,
})()
.filter(|v| v.len() >= 2);
if let Some(p) = poly {
out.push(p);
}
};
for tag in ["gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly"] {
for node in tree.find_all(tag) {
collect(node, tag, &|p| p);
}
}
for fp in tree.find_all("footprint") {
let (fx, fy, rot) = fp.find("at").map(|a| (atom_f64(a, 1).unwrap_or(0.0), atom_f64(a, 2).unwrap_or(0.0), atom_f64(a, 3).unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));
let tf = move |p: (f64, f64)| {
let (x, y) = rotate(p.0, p.1, rot);
(fx + x, fy + y)
};
for tag in ["fp_line", "fp_rect", "fp_arc", "fp_circle", "fp_poly"] {
for node in fp.find_all(tag) {
collect(node, tag, &tf);
}
}
}
out
}
fn same_point(a: (f64, f64), b: (f64, f64)) -> bool {
pcb::dist(a, b) <= CHAIN_TOL
}
fn polygon_area(p: &[(f64, f64)]) -> f64 {
let mut s = 0.0;
for i in 0..p.len() {
let (x1, y1) = p[i];
let (x2, y2) = p[(i + 1) % p.len()];
s += x1 * y2 - x2 * y1;
}
s.abs() / 2.0
}
/// Chain the outline primitives into closed loops and keep the largest one.
fn outline_loop(mut pieces: Vec<Vec<(f64, f64)>>) -> Option<Vec<(f64, f64)>> {
let mut loops: Vec<Vec<(f64, f64)>> = Vec::new();
while let Some(mut chain) = pieces.pop() {
loop {
let head = chain[0];
let tail = chain[chain.len() - 1];
if chain.len() >= 4 && same_point(head, tail) {
chain.pop();
loops.push(chain);
break;
}
let mut joined = false;
for i in 0..pieces.len() {
let p = &pieces[i];
let (ph, pt_) = (p[0], p[p.len() - 1]);
if same_point(tail, ph) {
let p = pieces.remove(i);
chain.extend(p.into_iter().skip(1));
joined = true;
} else if same_point(tail, pt_) {
let p = pieces.remove(i);
chain.extend(p.into_iter().rev().skip(1));
joined = true;
} else if same_point(head, pt_) {
let mut p = pieces.remove(i);
p.pop();
p.extend(chain.iter().copied());
chain = p;
joined = true;
} else if same_point(head, ph) {
let p = pieces.remove(i);
let mut p: Vec<(f64, f64)> = p.into_iter().rev().collect();
p.pop();
p.extend(chain.iter().copied());
chain = p;
joined = true;
}
if joined {
break;
}
}
if !joined {
break; // an open chain: not an outline
}
}
}
loops.into_iter().filter(|l| l.len() >= 3 && polygon_area(l) > 0.0).max_by(|a, b| polygon_area(a).partial_cmp(&polygon_area(b)).unwrap_or(std::cmp::Ordering::Equal))
}
// ---------------------------------------------------------------------------
// The writer
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Default, Serialize)]
pub struct DsnStats {
pub components: usize,
pub pins: usize,
pub nets: usize,
/// Nets the router is asked to connect (all nets with 2+ pins, or the requested subset).
#[serde(rename = "routedNets")]
pub routed_nets: Vec<String>,
/// Requested nets the board does not have.
#[serde(rename = "unknownNets")]
pub unknown_nets: Vec<String>,
#[serde(rename = "existingWires")]
pub existing_wires: usize,
#[serde(rename = "existingVias")]
pub existing_vias: usize,
#[serde(rename = "boundaryPoints")]
pub boundary_points: usize,
#[serde(rename = "boundarySource")]
pub boundary_source: String,
pub layers: Vec<String>,
/// `REF.PAD (shape)` for every pad written as its bounding rect.
#[serde(rename = "approximatedPads")]
pub approximated_pads: Vec<String>,
pub planes: usize,
pub keepouts: usize,
/// Inner layers written as `(type power)`: the router only drops vias into them.
#[serde(rename = "powerLayers")]
pub power_layers: Vec<String>,
/// Inner layers that ARE planes (declared power, or half the board covered by one zone),
/// whether or not they were reserved.
#[serde(rename = "planeLayers")]
pub plane_layers: Vec<String>,
pub notes: Vec<String>,
}
pub struct Dsn {
pub text: String,
pub stats: DsnStats,
/// The padstack the Default class routes with.
pub via_padstack: String,
}
struct PadDef {
number: String,
kind: String,
shape: String,
x: f64,
y: f64,
rot: f64,
w: f64,
h: f64,
drill: Option<f64>,
offset: (f64, f64),
layers: Vec<String>,
net: String,
/// A custom pad's drawn copper, as points in the pad's own frame (before its rotation).
/// Empty for every other shape. `custom_exact` is false when a primitive kind was not
/// understood, so the pad is reported as approximated.
primitives: Vec<(f64, f64)>,
custom_exact: bool,
}
/// Points of a custom pad's `(primitives ...)`: filled polygons and rects exactly, circles
/// as 16-gons, stroked lines and arcs by their end points widened by half the stroke. The
/// caller takes the convex hull, which is a superset of the real copper (wiki #93: writing
/// only the 1x1 mm anchor let Freerouting route across the MOSFETs' 4.75 mm drain tabs).
fn custom_primitives(pad: &Sx) -> (Vec<(f64, f64)>, bool) {
let mut pts = Vec::new();
let mut exact = true;
let Some(prims) = pad.find("primitives") else { return (pts, true) };
for p in prims.as_list().into_iter().flatten().skip(1) {
let width = p.find("width").and_then(|w| atom_f64(w, 1)).unwrap_or(0.0);
let grow = |pts: &mut Vec<(f64, f64)>, x: f64, y: f64| {
let r = width / 2.0;
if r > 0.0 {
pts.extend([(x - r, y - r), (x + r, y - r), (x + r, y + r), (x - r, y + r)]);
} else {
pts.push((x, y));
}
};
match p.tag() {
Some("gr_poly") => {
for q in p.find("pts").into_iter().flat_map(|l| l.find_all("xy")) {
if let (Some(x), Some(y)) = (atom_f64(q, 1), atom_f64(q, 2)) {
grow(&mut pts, x, y);
}
}
}
Some("gr_rect") => {
if let (Some((x1, y1)), Some((x2, y2))) = (xy(p.find("start")), xy(p.find("end"))) {
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
grow(&mut pts, x, y);
}
}
}
Some("gr_circle") => {
if let (Some((cx, cy)), Some((ex, ey))) = (xy(p.find("center")), xy(p.find("end"))) {
let r = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt() + width / 2.0;
// Circumscribed 16-gon, so the polygon contains the circle.
let rr = r / (std::f64::consts::PI / 16.0).cos();
for k in 0..16 {
let a = k as f64 * std::f64::consts::PI / 8.0;
pts.push((cx + rr * a.cos(), cy + rr * a.sin()));
}
}
}
Some("gr_line") | Some("gr_arc") | Some("gr_curve") | Some("gr_bbox") => {
exact = false;
for tag in ["start", "mid", "end"] {
if let Some((x, y)) = xy(p.find(tag)) {
grow(&mut pts, x, y);
}
}
}
_ => exact = false,
}
}
(pts, exact)
}
/// Andrew's monotone chain; returns the hull counter-clockwise, no repeated first point.
fn convex_hull(mut pts: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal).then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9);
if pts.len() < 3 {
return pts;
}
let cross = |o: (f64, f64), a: (f64, f64), b: (f64, f64)| (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0);
let mut lower: Vec<(f64, f64)> = Vec::new();
for &p in &pts {
while lower.len() >= 2 && cross(lower[lower.len() - 2], lower[lower.len() - 1], p) <= 0.0 {
lower.pop();
}
lower.push(p);
}
let mut upper: Vec<(f64, f64)> = Vec::new();
for &p in pts.iter().rev() {
while upper.len() >= 2 && cross(upper[upper.len() - 2], upper[upper.len() - 1], p) <= 0.0 {
upper.pop();
}
upper.push(p);
}
lower.pop();
upper.pop();
lower.extend(upper);
lower
}
fn net_name_of(node: Option<&Sx>, board: &Board) -> String {
let Some(node) = node else { return String::new() };
let Some(first) = node.atom_at(1) else { return String::new() };
if let Some(name) = node.atom_at(2) {
return name.to_string();
}
if board.net_format == pcb::NetFormat::Number {
if let Ok(n) = first.parse::<i64>() {
return board.nets.iter().find(|x| x.number == Some(n)).map(|x| x.name.clone()).unwrap_or_default();
}
}
first.to_string()
}
fn pad_defs(fp: &Sx, board: &Board) -> Vec<PadDef> {
fp.find_all("pad")
.filter_map(|pad| {
let at = pad.find("at")?;
let size = pad.find("size");
let drill = pad.find("drill");
let (drill_d, offset) = match drill {
Some(d) => {
let dia = if d.atom_at(1) == Some("oval") { match (atom_f64(d, 2), atom_f64(d, 3)) { (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b) } } else { atom_f64(d, 1) };
(dia, xy(d.find("offset")).unwrap_or((0.0, 0.0)))
}
None => (None, (0.0, 0.0)),
};
Some(PadDef {
number: pad.atom_at(1).unwrap_or("").to_string(),
kind: pad.atom_at(2).unwrap_or("").to_string(),
shape: pad.atom_at(3).unwrap_or("").to_string(),
x: atom_f64(at, 1)?,
y: atom_f64(at, 2)?,
rot: atom_f64(at, 3).unwrap_or(0.0),
w: size.and_then(|s| atom_f64(s, 1)).unwrap_or(0.0),
h: size.and_then(|s| atom_f64(s, 2)).unwrap_or(0.0),
drill: drill_d,
offset,
layers: pad.find("layers").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default(),
net: net_name_of(pad.find("net"), board),
primitives: if pad.atom_at(3) == Some("custom") { custom_primitives(pad).0 } else { Vec::new() },
custom_exact: pad.atom_at(3) != Some("custom") || custom_primitives(pad).1,
})
})
.collect()
}
/// The copper layers a pad's `(layers ...)` list reaches, in board order.
fn pad_copper(layers: &[String], copper: &[String]) -> Vec<String> {
let mut out = Vec::new();
for c in copper {
let hit = layers.iter().any(|l| l == c || l == "*.Cu" || (l.ends_with("*.Cu") && c.starts_with(&l[..l.len() - 4])) || (l == "F&B.Cu" && (c == "F.Cu" || c == "B.Cu")));
if hit {
out.push(c.clone());
}
}
out
}
fn layer_tag(on: &[String], copper: &[String]) -> String {
if on.len() == copper.len() {
"A".into()
} else if on.len() == 1 && on[0] == "F.Cu" {
"T".into()
} else if on.len() == 1 && on[0] == "B.Cu" {
"B".into()
} else {
format!("L{}", on.iter().map(|l| copper.iter().position(|c| c == l).unwrap_or(0).to_string()).collect::<Vec<_>>().join("-"))
}
}
fn fmt_mm_um(v: f64) -> i64 {
(v * 1000.0).round() as i64
}
/// Write the DSN for `board` (parsed from `text`). `nets`: route only these (None: every
/// net with two or more pins); the other nets' pads and copper stay as obstacles.
pub fn write_dsn(board: &Board, text: &str, name: &str, rules: &Rules, nets: Option<&[String]>) -> Result<Dsn, String> {
write_dsn_with(board, text, name, rules, nets, true)
}
/// `reserve_planes`: write plane layers as `(type power)` (wiki #93). False lets the router
/// use them for signals, which completes more of a dense board at the cost of cutting
/// through the planes; the caller chooses, the reply says which it got. Measured on the ESC
/// G431 fixture (2026-09-18): reserved left 30 connections unrouted, open left 3, with the
/// same six clearance errors, so kicad_autoroute defaults to open and reports the crossings.
pub fn write_dsn_with(board: &Board, text: &str, name: &str, rules: &Rules, nets: Option<&[String]>, reserve_planes: bool) -> Result<Dsn, String> {
let tree = parse_sexpr(text).ok_or("empty board text")?;
let copper = pcb::copper_layers(board);
if copper.is_empty() {
return Err("the board has no copper layers".into());
}
let mut stats = DsnStats { layers: copper.clone(), ..Default::default() };
// ---- which nets get routed
let board_nets: Vec<&str> = board.nets.iter().map(|n| n.name.as_str()).filter(|n| !n.is_empty()).collect();
let mut unknown: Vec<String> = Vec::new();
let wanted: Option<Vec<String>> = nets.map(|list| {
let mut ok = Vec::new();
for n in list {
if board_nets.contains(&n.as_str()) {
if !ok.contains(n) {
ok.push(n.clone());
}
} else {
unknown.push(n.clone());
}
}
ok
});
stats.unknown_nets = unknown;
let routed = |net: &str| -> bool {
!net.is_empty() && wanted.as_ref().map(|w| w.iter().any(|x| x == net)).unwrap_or(true)
};
// ---- components, images, padstacks, pins per net
let mut padstacks: BTreeMap<String, String> = BTreeMap::new();
let mut images = String::new();
let mut placement = String::new();
let mut pins_by_net: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut seen_refs: BTreeMap<String, usize> = BTreeMap::new();
for (index, fp) in tree.find_all("footprint").enumerate() {
let reference = fp.find_all("property").find(|p| p.atom_at(1) == Some("Reference")).and_then(|p| p.atom_at(2)).unwrap_or("").to_string();
let mut image_name = if reference.is_empty() { format!("FP{index}") } else { reference.clone() };
let n = seen_refs.entry(image_name.clone()).or_insert(0);
*n += 1;
if *n > 1 {
image_name = format!("{image_name}#{}", *n);
stats.notes.push(format!("duplicate reference {reference}: pins named {image_name}"));
}
let (fx, fy, rot) = fp.find("at").map(|a| (atom_f64(a, 1).unwrap_or(0.0), atom_f64(a, 2).unwrap_or(0.0), atom_f64(a, 3).unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));
let pads = pad_defs(fp, board);
let mut pin_lines = String::new();
let mut pin_counts: BTreeMap<String, usize> = BTreeMap::new();
for pad in &pads {
let on = pad_copper(&pad.layers, &copper);
let is_npth = pad.kind == "np_thru_hole";
// A pad with no copper is a hole (obstacle) or a paste/mask-only pad (nothing).
let (shape, w, h, on) = if on.is_empty() {
match (is_npth, pad.drill) {
(true, Some(d)) if d > 0.0 => ("circle".to_string(), d, d, copper.clone()),
_ => continue,
}
} else {
(pad.shape.clone(), pad.w, pad.h, on)
};
if w <= 0.0 || h <= 0.0 {
continue;
}
// A custom pad is written as the hull of its anchor and drawn copper: a superset,
// so the router cannot cross it; exact enough to leave off the approximated list
// when every primitive was understood.
let is_custom = shape == "custom" && !pad.primitives.is_empty();
let exact = shape == "circle" || shape == "rect" || (is_custom && pad.custom_exact);
if !exact {
stats.approximated_pads.push(format!("{}.{} ({shape})", reference, pad.number));
}
let tag = layer_tag(&on, &copper);
let rot_key = if shape == "circle" { 0 } else { (pad.rot.rem_euclid(360.0) * 10.0).round() as i64 };
let (ox, oy) = pad.offset;
let mut offset_key = if ox == 0.0 && oy == 0.0 { String::new() } else { format!("_o{}x{}", fmt_mm_um(ox), fmt_mm_um(oy)) };
// Two custom pads with the same anchor size but different copper (a MOSFET's gate
// and drain) must not share one padstack.
let hull: Vec<(f64, f64)> = if is_custom {
let mut all = pad.primitives.clone();
all.extend([(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)]);
convex_hull(all)
} else {
Vec::new()
};
if is_custom {
let mut hash: u64 = 1469598103934665603;
for (x, y) in &hull {
for v in [(*x * 1000.0).round() as i64, (*y * 1000.0).round() as i64] {
hash ^= v as u64;
hash = hash.wrapping_mul(1099511628211);
}
}
offset_key.push_str(&format!("_p{:08x}", hash as u32));
}
let kind_name = match shape.as_str() {
"circle" => "Round",
"rect" => "Rect",
"oval" => "Oval",
"roundrect" => "RoundRect",
"trapezoid" => "Trapz",
"custom" => "Custom",
other => other,
};
let ps_name = if shape == "circle" {
format!("{kind_name}[{tag}]Pad_{}_um{offset_key}", fmt_mm_um(w))
} else if rot_key == 0 {
format!("{kind_name}[{tag}]Pad_{}x{}_um{offset_key}", fmt_mm_um(w), fmt_mm_um(h))
} else {
format!("{kind_name}[{tag}]Pad_{}x{}_r{}_um{offset_key}", fmt_mm_um(w), fmt_mm_um(h), rot_key as f64 / 10.0)
};
padstacks.entry(ps_name.clone()).or_insert_with(|| {
let mut s = format!(" (padstack {}\n", q(&ps_name));
for layer in &on {
if shape == "circle" {
s.push_str(&format!(" (shape (circle {} {} {} {}))\n", layer, u(w), u(ox), u(-oy)));
} else {
// Corners in the pad's own orientation (absolute in a board file),
// y flipped afterwards. Oval, roundrect and the rest use the rect.
let rect = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)];
let corners: &[(f64, f64)] = if is_custom { &hull } else { &rect };
let pts: Vec<String> = corners.iter().map(|(cx, cy)| {
let (rx, ry) = rotate(*cx, *cy, pad.rot);
pt(rx + ox, ry + oy)
}).collect();
s.push_str(&format!(" (shape (polygon {} 0 {}))\n", layer, pts.join(" ")));
}
}
s.push_str(" (attach off)\n )\n");
s
});
// Pin id: REF-PAD, made unique when a footprint repeats a pad number.
let c = pin_counts.entry(pad.number.clone()).or_insert(0);
*c += 1;
let base = if pad.number.is_empty() { "NP".to_string() } else { pad.number.clone() };
let pin_id = if *c == 1 && !pad.number.is_empty() { base } else { format!("{base}{}{}", if pad.number.is_empty() { "" } else { "@" }, *c) };
let (px, py) = rotate(pad.x, pad.y, rot);
pin_lines.push_str(&format!(" (pin {} {} {})\n", q(&ps_name), q(&pin_id), pt(px, py)));
stats.pins += 1;
if routed(&pad.net) {
pins_by_net.entry(pad.net.clone()).or_default().push(format!("{image_name}-{pin_id}"));
}
}
images.push_str(&format!(" (image {}\n{} )\n", q(&image_name), pin_lines));
placement.push_str(&format!(" (component {}\n (place {} {} front 0)\n )\n", q(&image_name), q(&image_name), pt(fx, fy)));
stats.components += 1;
}
// ---- via padstacks: the rules' vias plus every size already on the board
let default_via = via_name(rules.via_size, rules.via_drill);
let mut via_names: Vec<String> = vec![default_via.clone()];
for c in &rules.classes {
let n = via_name(c.via_size, c.via_drill);
if !via_names.contains(&n) {
via_names.push(n);
}
}
let via_ps = |name: &str, size: f64| {
let mut s = format!(" (padstack {}\n", q(name));
for layer in &copper {
s.push_str(&format!(" (shape (circle {} {}))\n", layer, u(size)));
}
s.push_str(" (attach off)\n )\n");
s
};
padstacks.insert(default_via.clone(), via_ps(&default_via, rules.via_size));
for c in &rules.classes {
let n = via_name(c.via_size, c.via_drill);
padstacks.entry(n.clone()).or_insert_with(|| via_ps(&n, c.via_size));
}
// ---- existing copper as protected wiring
let net_of = |num: Option<i64>, name: Option<&str>| -> String {
match board.net_format {
pcb::NetFormat::Name => name.unwrap_or("").to_string(),
pcb::NetFormat::Number => name.map(str::to_string).or_else(|| num.and_then(|n| board.nets.iter().find(|x| x.number == Some(n)).map(|x| x.name.clone()))).unwrap_or_default(),
}
};
let mut wiring = String::new();
for s in &board.segments {
let (Some(a), Some(b), Some(w), Some(layer)) = (&s.start, &s.end, s.width, &s.layer) else { continue };
if !copper.contains(layer) {
continue;
}
let net = net_of(s.net, s.net_name.as_deref());
let net_part = if net.is_empty() { String::new() } else { format!(" (net {})", q(&net)) };
wiring.push_str(&format!(" (wire (path {} {} {} {}){} (type protect))\n", layer, u(w), pt(a.x, a.y), pt(b.x, b.y), net_part));
stats.existing_wires += 1;
}
for v in &board.vias {
let (Some(p), Some(size)) = (&v.position, v.size) else { continue };
let drill = v.drill.unwrap_or(size / 2.0);
let name = via_name(size, drill);
padstacks.entry(name.clone()).or_insert_with(|| via_ps(&name, size));
let net = net_of(v.net, v.net_name.as_deref());
let net_part = if net.is_empty() { String::new() } else { format!(" (net {})", q(&net)) };
wiring.push_str(&format!(" (via {} {}{} (type protect))\n", q(&name), pt(p.x, p.y), net_part));
stats.existing_vias += 1;
}
// ---- zones: copper zones as planes, rule areas as keepouts
let mut planes = String::new();
// Largest copper-plane polygon per layer, in mm^2, to decide which inner layers are planes.
let mut plane_area: BTreeMap<String, f64> = BTreeMap::new();
for z in tree.find_all("zone") {
let mut layers: Vec<String> = z.find("layers").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default();
if let Some(l) = z.find("layer").and_then(Sx::value) {
layers.push(l.to_string());
}
let layers: Vec<String> = layers.iter().flat_map(|l| pad_copper(std::slice::from_ref(l), &copper)).collect();
if layers.is_empty() {
continue;
}
let Some(pts) = z.find("polygon").and_then(|p| p.find("pts")) else { continue };
let coords: Vec<(f64, f64)> = pts.find_all("xy").filter_map(|p| Some((atom_f64(p, 1)?, atom_f64(p, 2)?))).collect();
let points: Vec<String> = coords.iter().map(|(x, y)| pt(*x, *y)).collect();
if points.len() < 3 {
continue;
}
let keepout = z.find("keepout").map(|k| k.find("tracks").and_then(Sx::value) == Some("not_allowed") || k.find("vias").and_then(Sx::value) == Some("not_allowed")).unwrap_or(false);
let net = z.find("net_name").and_then(Sx::value).map(str::to_string).unwrap_or_else(|| net_name_of(z.find("net"), board));
for layer in &layers {
if keepout {
planes.push_str(&format!(" (keepout \"\" (polygon {} 0 {}))\n", layer, points.join(" ")));
stats.keepouts += 1;
} else if !net.is_empty() {
planes.push_str(&format!(" (plane {} (polygon {} 0 {}))\n", q(&net), layer, points.join(" ")));
stats.planes += 1;
let a = polygon_area(&coords);
let e = plane_area.entry(layer.clone()).or_insert(0.0);
*e = e.max(a);
}
}
}
// ---- boundary
let pieces = edge_polylines(&tree);
let (boundary, source): (Vec<(f64, f64)>, &str) = match outline_loop(pieces.clone()) {
Some(l) => (l, "Edge.Cuts"),
None => {
let flat: Vec<(f64, f64)> = pieces.iter().flatten().copied().collect();
let (points, source) = if !flat.is_empty() {
(flat, "Edge.Cuts bounding box (outline is not one closed loop)")
} else {
let pads = pcb::pads(board);
(pads.iter().map(|p| (p.x, p.y)).collect(), "pad bounding box plus 5 mm (no Edge.Cuts outline)")
};
if points.is_empty() {
return Err("no Edge.Cuts outline and no pads: nothing to route inside".into());
}
let margin = if source.starts_with("pad") { 5.0 } else { 0.0 };
let (mut x1, mut y1, mut x2, mut y2) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for (x, y) in points {
x1 = x1.min(x);
y1 = y1.min(y);
x2 = x2.max(x);
y2 = y2.max(y);
}
(vec![(x1 - margin, y1 - margin), (x2 + margin, y1 - margin), (x2 + margin, y2 + margin), (x1 - margin, y2 + margin)], source)
}
};
stats.boundary_points = boundary.len();
stats.boundary_source = source.to_string();
if source != "Edge.Cuts" {
stats.notes.push(format!("boundary: {source}"));
}
// Wiki #93: an inner layer that IS a plane must not carry signal traces. Freerouting
// honours `(type power)` by only dropping vias into it; written as `signal`, it happily
// routed /nRST and /HSE_OUT across the +3V3 plane. A layer counts as a plane when KiCad
// declares it `power`, or when one copper zone on it covers at least half the board.
// Outer layers never do: parts sit there, and a full GND pour on B.Cu is still routable.
let board_area = polygon_area(&boundary);
let outer = [copper.first().cloned(), copper.last().cloned()];
for layer in &copper {
if outer.iter().any(|o| o.as_deref() == Some(layer.as_str())) {
continue;
}
let declared = board.layers.iter().any(|l| &l.name == layer && l.kind == "power");
let covered = board_area > 0.0 && plane_area.get(layer).map(|a| *a >= 0.5 * board_area).unwrap_or(false);
if declared || covered {
stats.plane_layers.push(layer.clone());
}
if reserve_planes && (declared || covered) {
stats.power_layers.push(layer.clone());
} else if declared || covered {
stats.notes.push(format!("{layer} is a plane but reservePlanes is false: signals may cross it"));
}
}
if !stats.power_layers.is_empty() {
stats.notes.push(format!("plane layers reserved (type power, vias only): {}", stats.power_layers.join(", ")));
}
let mut boundary_pts: Vec<String> = boundary.iter().map(|(x, y)| pt(*x, *y)).collect();
boundary_pts.push(boundary_pts[0].clone());
// ---- network
let mut network = String::new();
let mut class_default_nets: Vec<String> = Vec::new();
let mut class_nets: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (net, pins) in &pins_by_net {
if pins.len() < 2 {
continue;
}
network.push_str(&format!(" (net {}\n (pins {})\n )\n", q(net), pins.iter().map(|p| q(p)).collect::<Vec<_>>().join(" ")));
stats.routed_nets.push(net.clone());
match rules.classes.iter().find(|c| c.nets.iter().any(|n| n == net)) {
Some(c) => class_nets.entry(c.name.clone()).or_default().push(net.clone()),
None => class_default_nets.push(net.clone()),
}
}
stats.nets = board_nets.len();
// Clearance carries pcbnew's 0.1 um safety margin so KiCad's own DRC never sees an
// exact-touch case as a violation.
let clearance_u = |mm: f64| u(mm + 0.0001);
network.push_str(&format!(
" (class kicad_default {}\n (circuit (use_via {}))\n (rule (width {}) (clearance {}))\n )\n",
class_default_nets.iter().map(|n| q(n)).collect::<Vec<_>>().join(" "),
q(&default_via),
u(rules.track_width),
clearance_u(rules.clearance)
));
for c in &rules.classes {
let Some(list) = class_nets.get(&c.name) else { continue };
network.push_str(&format!(
" (class {} {}\n (circuit (use_via {}))\n (rule (width {}) (clearance {}))\n )\n",
q(&c.name),
list.iter().map(|n| q(n)).collect::<Vec<_>>().join(" "),
q(&via_name(c.via_size, c.via_drill)),
u(c.track_width),
clearance_u(c.clearance)
));
}
// ---- assemble
let mut out = String::new();
out.push_str(&format!("(pcb {}\n", q(&format!("{name}.dsn"))));
let host_version = tree.find("generator_version").and_then(Sx::value).filter(|v| v.split('.').next().and_then(|m| m.parse::<u32>().ok()).map(|m| m >= 6).unwrap_or(false)).unwrap_or("10.0");
out.push_str(&format!(" (parser\n (string_quote \")\n (space_in_quoted_tokens on)\n (host_cad \"KiCad's Pcbnew\")\n (host_version \"{host_version}\")\n )\n"));
out.push_str(" (resolution um 10)\n (unit um)\n");
out.push_str(" (structure\n");
for (i, layer) in copper.iter().enumerate() {
let kind = if stats.power_layers.contains(layer) { "power" } else { "signal" };
out.push_str(&format!(" (layer {}\n (type {kind})\n (property\n (index {i})\n )\n )\n", layer));
}
out.push_str(&format!(" (boundary\n (path pcb 0 {})\n )\n", boundary_pts.join(" ")));
out.push_str(&planes);
for n in &via_names {
out.push_str(&format!(" (via {})\n", q(n)));
}
out.push_str(&format!(" (rule\n (width {})\n (clearance {})\n (clearance {} (type default_smd))\n (clearance {} (type smd_smd))\n )\n", u(rules.track_width), clearance_u(rules.clearance), clearance_u(rules.clearance), u(rules.clearance / 4.0)));
out.push_str(" )\n");
out.push_str(&format!(" (placement\n{} )\n", placement));
out.push_str(" (library\n");
out.push_str(&images);
for (_, def) in &padstacks {
out.push_str(def);
}
out.push_str(" )\n");
out.push_str(&format!(" (network\n{} )\n", network));
if !wiring.is_empty() {
out.push_str(&format!(" (wiring\n{} )\n", wiring));
}
out.push_str(")\n");
Ok(Dsn { text: out, stats, via_padstack: default_via })
}
// ---------------------------------------------------------------------------
// SES reader
// ---------------------------------------------------------------------------
/// Specctra tokenizer: parentheses, quoted strings (no escapes), bare atoms, and the
/// `(string_quote ")` quirk where the quote character itself is the atom.
pub fn parse_specctra(text: &str) -> Result<Sx, String> {
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
let mut stack: Vec<Vec<Sx>> = vec![Vec::new()];
let mut quote = '"';
let mut last_atom_was_string_quote = false;
while i < chars.len() {
let c = chars[i];
if c.is_whitespace() {
i += 1;
continue;
}
if c == '(' {
stack.push(Vec::new());
i += 1;
last_atom_was_string_quote = false;
continue;
}
if c == ')' {
let list = stack.pop().ok_or("unbalanced ')'")?;
let parent = stack.last_mut().ok_or("unbalanced ')'")?;
parent.push(Sx::List(list));
i += 1;
last_atom_was_string_quote = false;
continue;
}
if last_atom_was_string_quote {
// `(string_quote ")`: the next non-blank character is the atom.
quote = c;
stack.last_mut().unwrap().push(Sx::Atom(c.to_string()));
i += 1;
last_atom_was_string_quote = false;
continue;
}
if c == quote {
let mut j = i + 1;
let mut s = String::new();
while j < chars.len() && chars[j] != quote {
s.push(chars[j]);
j += 1;
}
if j >= chars.len() {
return Err("unterminated quoted string".into());
}
stack.last_mut().unwrap().push(Sx::Atom(s));
i = j + 1;
continue;
}
let mut j = i;
let mut s = String::new();
while j < chars.len() && !chars[j].is_whitespace() && chars[j] != '(' && chars[j] != ')' {
s.push(chars[j]);
j += 1;
}
last_atom_was_string_quote = s == "string_quote";
stack.last_mut().unwrap().push(Sx::Atom(s));
i = j;
}
if stack.len() != 1 {
return Err("unbalanced '('".into());
}
let mut top = stack.pop().unwrap();
match top.len() {
1 => Ok(top.pop().unwrap()),
0 => Err("empty session".into()),
_ => Ok(Sx::List(top)),
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SesWire {
pub net: String,
pub layer: String,
/// mm
pub width: f64,
/// mm, KiCad frame (+y down)
pub points: Vec<[f64; 2]>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SesVia {
pub net: String,
pub padstack: String,
pub at: [f64; 2],
/// mm, from the session's `library_out` or the padstack name.
pub size: Option<f64>,
pub drill: Option<f64>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct Session {
pub wires: Vec<SesWire>,
pub vias: Vec<SesVia>,
pub unit: String,
pub resolution: f64,
pub nets: Vec<String>,
}
fn unit_mm(unit: &str) -> Option<f64> {
Some(match unit {
"um" => 0.001,
"mm" => 1.0,
"cm" => 10.0,
"mil" => 0.0254,
"inch" | "in" => 25.4,
_ => return None,
})
}
/// Walk every list in the tree, depth first.
fn walk<'a>(node: &'a Sx, f: &mut dyn FnMut(&'a Sx)) {
if let Some(list) = node.as_list() {
f(node);
for c in list {
walk(c, f);
}
}
}
pub fn read_ses(text: &str) -> Result<Session, String> {
let tree = parse_specctra(text)?;
if tree.tag() != Some("session") {
return Err(format!("not a Specctra session file (top-level tag {:?})", tree.tag()));
}
let routes = tree.find("routes").ok_or("session has no (routes) section")?;
// The routes section's own resolution wins; fall back to placement or the top level.
let res_node = routes.find("resolution").or_else(|| tree.find("placement").and_then(|p| p.find("resolution"))).or_else(|| tree.find("resolution"));
let (unit, resolution) = match res_node {
Some(r) => (r.atom_at(1).unwrap_or("um").to_string(), atom_f64(r, 2).unwrap_or(1.0)),
None => ("um".to_string(), 10.0),
};
let per_unit = unit_mm(&unit).ok_or_else(|| format!("unknown session unit {unit}"))?;
let to_mm = |v: f64| pcb::round6(v / resolution * per_unit) + 0.0;
// Via sizes from library_out (circle diameter) and drills from the padstack names.
let mut via_sizes: BTreeMap<String, f64> = BTreeMap::new();
if let Some(lib) = routes.find("library_out") {
for ps in lib.find_all("padstack") {
let Some(name) = ps.atom_at(1) else { continue };
let mut best: Option<f64> = None;
for shape in ps.find_all("shape") {
if let Some(circle) = shape.find("circle") {
if let Some(d) = atom_f64(circle, 2) {
best = Some(best.map_or(d, |b: f64| b.max(d)));
}
}
}
if let Some(d) = best {
via_sizes.insert(name.to_string(), to_mm(d));
}
}
}
let mut session = Session { unit: unit.clone(), resolution, ..Default::default() };
let network = routes.find("network_out").ok_or("session has no (network_out) section")?;
for net_node in network.find_all("net") {
let net = net_node.atom_at(1).unwrap_or("").to_string();
if !session.nets.contains(&net) {
session.nets.push(net.clone());
}
let mut items: Vec<&Sx> = Vec::new();
walk(net_node, &mut |n| {
if matches!(n.tag(), Some("wire") | Some("via")) {
items.push(n);
}
});
for item in items {
let item_net = item.find("net").and_then(Sx::value).map(str::to_string).unwrap_or_else(|| net.clone());
match item.tag() {
Some("wire") => {
let Some(path) = item.find("path").or_else(|| item.find("polyline_path")) else { continue };
let Some(layer) = path.atom_at(1) else { continue };
let Some(width) = atom_f64(path, 2) else { continue };
let nums: Vec<f64> = path.as_list().map(|l| l.iter().skip(3).filter_map(Sx::as_atom).filter_map(|a| a.parse::<f64>().ok()).collect()).unwrap_or_default();
let mut points: Vec<[f64; 2]> = Vec::new();
for pair in nums.chunks(2) {
if pair.len() == 2 {
let p = [to_mm(pair[0]), to_mm(-pair[1])];
if points.last() != Some(&p) {
points.push(p);
}
}
}
if points.len() >= 2 {
session.wires.push(SesWire { net: item_net, layer: layer.to_string(), width: to_mm(width), points });
}
}
Some("via") => {
let Some(padstack) = item.atom_at(1) else { continue };
let (Some(x), Some(y)) = (atom_f64(item, 2), atom_f64(item, 3)) else { continue };
let dims = via_dims(padstack);
session.vias.push(SesVia {
net: item_net,
padstack: padstack.to_string(),
at: [to_mm(x), to_mm(-y)],
size: via_sizes.get(padstack).copied().or(dims.map(|d| d.0)),
drill: dims.map(|d| d.1),
});
}
_ => {}
}
}
}
Ok(session)
}
/// The copper of one net as the applier lays it: `PlanSegment`/`PlanVia` in mm.
#[derive(Clone, Debug, Default, Serialize)]
pub struct NetCopper {
pub net: String,
pub segments: Vec<PlanSegment>,
pub vias: Vec<PlanVia>,
/// Session items identical to copper already on the board (protected wiring echoed back).
#[serde(rename = "skippedExisting")]
pub skipped_existing: usize,
/// segments widened to the net class minimum because the router returned them thinner (wiki #93)
pub widened: usize,
}
fn same_xy(a: [f64; 2], b: [f64; 2]) -> bool {
(a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4
}
/// Session wires and vias as per-net copper, KiCad frame, deduplicated against the copper
/// the board already had, restricted to `nets` when given. Vias span the outer copper pair
/// (`F.Cu`, `B.Cu`) as `pcb::write_board` requires.
pub fn copper_for(session: &Session, board: &Board, nets: Option<&[String]>, rules: &Rules) -> Vec<NetCopper> {
let copper = pcb::copper_layers(board);
let mut out: Vec<NetCopper> = Vec::new();
fn entry(out: &mut Vec<NetCopper>, net: &str) -> usize {
match out.iter().position(|c| c.net == net) {
Some(i) => i,
None => {
out.push(NetCopper { net: net.to_string(), ..Default::default() });
out.len() - 1
}
}
}
let wanted = |net: &str| !net.is_empty() && board.nets.iter().any(|n| n.name == net) && nets.map(|w| w.iter().any(|x| x == net)).unwrap_or(true);
let existing_seg = |a: [f64; 2], b: [f64; 2], layer: &str| -> bool {
board.segments.iter().any(|s| {
let (Some(sa), Some(sb)) = (&s.start, &s.end) else { return false };
s.layer.as_deref() == Some(layer) && ((same_xy([sa.x, sa.y], a) && same_xy([sb.x, sb.y], b)) || (same_xy([sa.x, sa.y], b) && same_xy([sb.x, sb.y], a)))
})
};
let existing_via = |at: [f64; 2]| -> bool { board.vias.iter().any(|v| v.position.as_ref().map(|p| same_xy([p.x, p.y], at)).unwrap_or(false)) };
for w in &session.wires {
if !wanted(&w.net) || !copper.contains(&w.layer) {
continue;
}
let i = entry(&mut out, &w.net);
for pair in w.points.windows(2) {
let (a, b) = (pair[0], pair[1]);
if same_xy(a, b) {
continue;
}
if existing_seg(a, b, &w.layer) {
out[i].skipped_existing += 1;
continue;
}
// wiki #93: Freerouting hands back widths a hair under what the DSN asked for
// (0.1874 where the class says 0.2), and KiCad's DRC then calls every one of them a
// track_width error. The class width is already floored at the board minimum, so a
// returned wire never lands thinner than the board allows; we count what we widened.
let (_, class_w, _, _) = rules.class_for(&w.net);
let width = if w.width + 1e-6 < class_w { out[i].widened += 1; class_w } else { w.width };
out[i].segments.push(PlanSegment { start: a, end: b, layer: w.layer.clone(), width });
}
}
for v in &session.vias {
if !wanted(&v.net) {
continue;
}
let i = entry(&mut out, &v.net);
if existing_via(v.at) {
out[i].skipped_existing += 1;
continue;
}
let (_, _, rs, rd) = rules.class_for(&v.net);
let size = v.size.unwrap_or(rs);
let mut drill = v.drill.unwrap_or(rd);
if drill >= size {
drill = pcb::round6(size / 2.0);
}
out[i].vias.push(PlanVia { at: v.at, layers: ["F.Cu".into(), "B.Cu".into()], size, drill });
}
out.retain(|c| !c.segments.is_empty() || !c.vias.is_empty());
out
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::pcb::tests::fixture_text;
fn fixture() -> (String, Board) {
let text = fixture_text();
let board = pcb::parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();
(text, board)
}
#[test]
fn fixture_dsn_has_boundary_layers_pins_nets_and_rules() {
let (text, board) = fixture();
let rules = Rules::default();
let dsn = write_dsn(&board, &text, "live-routing", &rules, None).unwrap();
let t = &dsn.text;
assert!(t.starts_with("(pcb live-routing.dsn\n (parser\n (string_quote \")\n (space_in_quoted_tokens on)\n (host_cad \"KiCad's Pcbnew\")\n (host_version \"10.0\")"), "{t}");
assert!(t.contains("(resolution um 10)"));
assert!(t.contains("(layer F.Cu\n (type signal)\n (property\n (index 0)"));
assert!(t.contains("(layer B.Cu\n (type signal)\n (property\n (index 1)"));
// Edge.Cuts rect (98,78)-(152,122): y flipped, closed.
assert!(t.contains("(boundary\n (path pcb 0 98000 -78000 152000 -78000 152000 -122000 98000 -122000 98000 -78000)"), "{t}");
assert_eq!(dsn.stats.boundary_source, "Edge.Cuts");
assert_eq!(dsn.stats.components, 3);
assert_eq!(dsn.stats.pins, 13);
assert_eq!(dsn.stats.routed_nets, (1..=6).map(|i| format!("NET_{i}")).collect::<Vec<_>>());
// 2 mm through-hole circle on both layers, pins in board frame relative to the origin.
assert!(t.contains("(padstack Round[A]Pad_2000_um\n (shape (circle F.Cu 2000 0 0))\n (shape (circle B.Cu 2000 0 0))\n (attach off)"));
assert!(t.contains("(image J1\n (pin Round[A]Pad_2000_um 1 0 0)\n (pin Round[A]Pad_2000_um 2 0 -5000)"));
assert!(t.contains("(component J1\n (place J1 105000 -85000 front 0)"));
assert!(t.contains("(net NET_6\n (pins J1-6 J2-6 J3-1)"));
assert!(t.contains("(via Via[0-1]_800:400_um)"));
assert!(t.contains("(class kicad_default NET_1 NET_2 NET_3 NET_4 NET_5 NET_6\n (circuit (use_via Via[0-1]_800:400_um))\n (rule (width 250) (clearance 200.1))"));
assert!(!t.contains("(wiring"), "no copper yet, no wiring section");
assert!(dsn.stats.approximated_pads.is_empty());
assert_eq!(dsn.via_padstack, "Via[0-1]_800:400_um");
}
#[test]
fn net_subset_leaves_other_pads_as_obstacles_and_keeps_existing_copper() {
let (text, board) = fixture();
let net = pcb::resolve_net(&board, Some(&serde_json::json!("NET_1"))).unwrap();
let seg = pcb::segment_sexpr((105.0, 85.0), (112.0, 85.0), 0.25, "F.Cu", &net);
let via = pcb::via_sexpr((112.0, 85.0), 0.6, 0.3, ("F.Cu", "B.Cu"), &net);
let text = pcb::splice(&text, &[seg, via]);
let board = pcb::parse_pcb_text(&text, "x").unwrap();
let dsn = write_dsn(&board, &text, "x", &Rules::default(), Some(&["NET_2".into(), "NOPE".into()])).unwrap();
assert_eq!(dsn.stats.routed_nets, vec!["NET_2"]);
assert_eq!(dsn.stats.unknown_nets, vec!["NOPE"]);
assert!(dsn.text.contains("(net NET_2\n (pins J1-2 J2-2)"));
assert!(!dsn.text.contains("(net NET_1\n"));
assert_eq!(dsn.stats.pins, 13, "every pad is still an obstacle");
assert!(dsn.text.contains("(wire (path F.Cu 250 105000 -85000 112000 -85000) (net NET_1) (type protect))"));
assert!(dsn.text.contains("(via Via[0-1]_600:300_um 112000 -85000 (net NET_1) (type protect))"));
assert!(dsn.text.contains("(padstack Via[0-1]_600:300_um\n"));
assert_eq!((dsn.stats.existing_wires, dsn.stats.existing_vias), (1, 1));
}
#[test]
fn rect_pads_rotate_and_ovals_are_declared_approximate() {
let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")
(layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
(gr_line (start 0 0) (end 30 0) (layer "Edge.Cuts"))
(gr_line (start 30 0) (end 30 20) (layer "Edge.Cuts"))
(gr_arc (start 30 20) (mid 15 25) (end 0 20) (layer "Edge.Cuts"))
(gr_line (start 0 20) (end 0 0) (layer "Edge.Cuts"))
(footprint "X:R1" (layer "F.Cu") (at 10 10 90) (property "Reference" "R1")
(pad "1" smd rect (at -1 0 90) (size 1 2) (layers "F.Cu" "F.Paste" "F.Mask") (net "A"))
(pad "2" smd oval (at 1 0 90) (size 1 2) (layers "F.Cu") (net "B")))
(footprint "X:U1" (layer "B.Cu") (at 20 10) (property "Reference" "U1")
(pad "1" smd roundrect (at 0 0) (size 1 1) (layers "B.Cu") (net "A"))
(pad "2" thru_hole circle (at 2 0) (size 1.5 1.5) (drill 0.8) (layers "*.Cu" "*.Mask") (net "B"))
(pad "" np_thru_hole circle (at 4 0) (size 2 2) (drill 2) (layers "*.Cu" "*.Mask")))
(zone (net "A") (net_name "A") (layer "B.Cu") (polygon (pts (xy 0 0) (xy 5 0) (xy 5 5))))
(zone (net "") (net_name "") (layers "F.Cu" "B.Cu") (keepout (tracks not_allowed) (vias allowed)) (polygon (pts (xy 20 0) (xy 25 0) (xy 25 5))))
)"#;
let board = pcb::parse_pcb_text(text, "x").unwrap();
let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();
let t = &dsn.text;
// Pin 1 of R1: local (-1,0) rotated 90 -> (0, 1) in a y-down frame -> DSN y = -1 mm.
assert!(t.contains("(pin Rect[T]Pad_1000x2000_r90_um 1 0 -1000)"), "{t}");
assert!(t.contains("(pin Oval[T]Pad_1000x2000_r90_um 2 0 1000)"), "{t}");
// A 1x2 rect at 90 degrees is a 2x1 polygon: corners at x = +-1 mm, y = +-0.5 mm.
assert!(t.contains("(padstack Rect[T]Pad_1000x2000_r90_um\n (shape (polygon F.Cu 0 -1000 -500 -1000 500 1000 500 1000 -500))"), "{t}");
assert!(t.contains("(pin Round[A]Pad_2000_um NP1 4000 0)"), "an unnamed hole still gets a pin id: {t}");
assert!(t.contains("(padstack RoundRect[B]Pad_1000x1000_um\n (shape (polygon B.Cu 0 -500 500 500 500 500 -500 -500 -500))"), "{t}");
assert!(t.contains("(padstack Round[A]Pad_2000_um\n (shape (circle F.Cu 2000 0 0))"), "NPTH hole is an obstacle on every layer: {t}");
assert_eq!(dsn.stats.approximated_pads, vec!["R1.2 (oval)", "U1.1 (roundrect)"]);
assert!(t.contains("(net A\n (pins R1-1 U1-1)"));
assert!(t.contains("(net B\n (pins R1-2 U1-2)"));
assert!(t.contains("(plane A (polygon B.Cu 0 0 0 5000 0 5000 -5000))"));
assert!(t.contains("(keepout \"\" (polygon F.Cu 0 20000 0 25000 0 25000 -5000))"));
assert!(t.contains("(keepout \"\" (polygon B.Cu 0 20000 0 25000 0 25000 -5000))"));
assert_eq!((dsn.stats.planes, dsn.stats.keepouts), (1, 2));
// Three lines and an arc chain into one loop; the arc became segments.
assert_eq!(dsn.stats.boundary_source, "Edge.Cuts");
assert!(dsn.stats.boundary_points > 10, "{}", dsn.stats.boundary_points);
assert!(t.contains("(place U1 20000 -10000 front 0)"));
}
// Wiki #93: Freerouting routed /nRST and /HSE_OUT across the ESC's +3V3 plane because
// every layer was declared `signal`. A plane layer is now `power` (vias only).
#[test]
fn inner_plane_layers_are_reserved_as_power_and_outer_pours_are_not() {
let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")
(layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (8 "In3.Cu" power) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
(gr_rect (start 0 0) (end 40 20) (layer "Edge.Cuts"))
(footprint "X:R1" (layer "F.Cu") (at 10 10) (property "Reference" "R1")
(pad "1" smd rect (at -1 0) (size 1 1) (layers "F.Cu") (net "GND"))
(pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "SIG")))
(footprint "X:R2" (layer "F.Cu") (at 30 10) (property "Reference" "R2")
(pad "1" smd rect (at -1 0) (size 1 1) (layers "F.Cu") (net "GND"))
(pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "SIG")))
(zone (net "GND") (net_name "GND") (layer "In1.Cu") (polygon (pts (xy 0 0) (xy 40 0) (xy 40 20) (xy 0 20))))
(zone (net "GND") (net_name "GND") (layer "In2.Cu") (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10))))
(zone (net "GND") (net_name "GND") (layer "B.Cu") (polygon (pts (xy 0 0) (xy 40 0) (xy 40 20) (xy 0 20))))
)"#;
let board = pcb::parse_pcb_text(text, "x").unwrap();
let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();
let t = &dsn.text;
let kind_of = |layer: &str| {
let at = t.find(&format!("(layer {layer}\n")).unwrap_or_else(|| panic!("{layer} missing: {t}"));
t[at..].lines().nth(1).unwrap().trim().to_string()
};
// Full-board zone on an inner layer: a plane, so vias only.
assert_eq!(kind_of("In1.Cu"), "(type power)");
// A quarter-board zone is a local pour, not a plane: still routable.
assert_eq!(kind_of("In2.Cu"), "(type signal)");
// KiCad itself declares In3 a power layer.
assert_eq!(kind_of("In3.Cu"), "(type power)");
// A full GND pour on an OUTER layer never reserves it: parts sit there.
assert_eq!(kind_of("B.Cu"), "(type signal)");
assert_eq!(kind_of("F.Cu"), "(type signal)");
assert_eq!(dsn.stats.power_layers, vec!["In1.Cu", "In3.Cu"]);
assert!(dsn.stats.notes.iter().any(|n| n.contains("In1.Cu, In3.Cu")), "{:?}", dsn.stats.notes);
}
// Wiki #93: the ESC's MOSFET drain pad is a 1x1 mm anchor plus a 4.75 x 4.61 mm drawn
// polygon. Writing only the anchor let Freerouting route /DRV_SHA straight across +VBAT.
#[test]
fn custom_pads_are_written_as_the_hull_of_their_real_copper() {
let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")
(layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
(gr_rect (start 0 0) (end 40 20) (layer "Edge.Cuts"))
(footprint "X:Q1" (layer "F.Cu") (at 10 10) (property "Reference" "Q1")
(pad "1" smd custom (at -1.05 0) (size 1 1) (layers "F.Cu") (net "G")
(options (clearance outline) (anchor rect))
(primitives (gr_rect (start -0.5 -0.3) (end 0.2 0.3) (width 0) (fill yes))))
(pad "5" smd custom (at 1.05 0) (size 1 1) (layers "F.Cu") (net "D")
(options (clearance outline) (anchor rect))
(primitives (gr_poly (pts (xy -2.375 -2.305) (xy 2.375 -2.305) (xy 2.375 -1.37) (xy 1.375 -1.37) (xy 1.375 1.37) (xy 2.375 1.37) (xy 2.375 2.305) (xy -2.375 2.305)) (width 0) (fill yes)))))
(footprint "X:R1" (layer "F.Cu") (at 30 10) (property "Reference" "R1")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "D"))
(pad "2" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "G")))
)"#;
let board = pcb::parse_pcb_text(text, "x").unwrap();
let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();
let t = &dsn.text;
// The drain pad's padstack reaches the polygon's full extent (+-2375 um in x, +-2305
// in y), not the 1x1 mm anchor's +-500.
let drain = t.lines().find(|l| l.contains("(pin Custom") && l.trim_end().ends_with(" 5 1050 0)")).unwrap_or_else(|| panic!("{t}"));
let ps = drain.split_whitespace().nth(1).unwrap();
let at = t.find(&format!("(padstack {ps}\n")).unwrap();
let shape = t[at..].lines().nth(1).unwrap();
assert!(shape.contains("-2375") && shape.contains("2375") && shape.contains("2305"), "{shape}");
// Gate and drain share an anchor size but not copper, so not a padstack.
let gate = t.lines().find(|l| l.contains("(pin Custom") && l.contains(" 1 -1050 0)")).unwrap_or_else(|| panic!("{t}"));
assert_ne!(gate.split_whitespace().nth(1), drain.split_whitespace().nth(1), "{t}");
// Every primitive was understood, so neither pad is reported as approximated.
assert!(dsn.stats.approximated_pads.is_empty(), "{:?}", dsn.stats.approximated_pads);
// The hull is a superset: every polygon vertex lies on or inside it.
let hull = convex_hull(vec![(-2.375, -2.305), (2.375, -2.305), (2.375, -1.37), (1.375, -1.37), (1.375, 1.37), (2.375, 1.37), (2.375, 2.305), (-2.375, 2.305)]);
assert_eq!(hull.len(), 4, "the notch is filled in: {hull:?}");
}
#[test]
fn no_outline_falls_back_to_pads_with_a_note() {
let text = r#"(kicad_pcb (version 20260206) (layers (0 "F.Cu" signal) (2 "B.Cu" signal))
(footprint "X:R1" (layer "F.Cu") (at 10 10) (property "Reference" "R1")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "A"))))"#;
let board = pcb::parse_pcb_text(text, "x").unwrap();
let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();
assert!(dsn.stats.boundary_source.starts_with("pad bounding box"));
assert!(dsn.text.contains("(path pcb 0 5000 -5000 15000 -5000 15000 -15000 5000 -15000 5000 -5000)"), "{}", dsn.text);
assert_eq!(dsn.stats.notes.len(), 1);
}
#[test]
fn quoting_matches_pcbnew() {
assert_eq!(q("NET_1"), "NET_1");
assert_eq!(q("/SDA"), "/SDA");
assert_eq!(q("Net-(R1-Pad1)"), "\"Net-(R1-Pad1)\"");
assert_eq!(q("a b"), "\"a b\"");
assert_eq!(q(""), "\"\"");
assert_eq!(via_dims("Via[0-1]_800:400_um"), Some((0.8, 0.4)));
assert_eq!(via_dims("Round[A]Pad_2000_um"), None);
}
#[test]
fn rules_come_from_the_project_file_or_defaults() {
let dir = std::env::temp_dir().join(format!("adom-dsn-rules-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (text, board) = fixture();
let pcb_path = dir.join("b.kicad_pcb");
std::fs::write(&pcb_path, &text).unwrap();
let r = rules_for(&pcb_path, &board);
assert_eq!(r, Rules::default());
std::fs::write(dir.join("b.kicad_pro"), r#"{
"board": {"design_settings": {"rules": {"min_clearance": 0.15, "min_track_width": 0.3}}},
"net_settings": {
"classes": [
{"name": "Default", "clearance": 0.1, "track_width": 0.2, "via_diameter": 0.7, "via_drill": 0.35},
{"name": "Power", "clearance": 0.3, "track_width": 0.5, "via_diameter": 1.0, "via_drill": 0.5},
{"name": "Unused", "clearance": 0.3, "track_width": 0.5}
],
"netclass_assignments": {"NET_6": "Power"},
"netclass_patterns": [{"netclass": "Power", "pattern": "NET_5"}]
}}"#).unwrap();
let r = rules_for(&pcb_path, &board);
assert_eq!((r.clearance, r.track_width, r.via_size, r.via_drill), (0.15, 0.3, 0.7, 0.35));
assert_eq!(r.classes.len(), 1);
assert_eq!(r.classes[0].name, "Power");
assert_eq!(r.classes[0].nets, vec!["NET_5", "NET_6"]);
assert_eq!(r.class_for("NET_6"), (0.3, 0.5, 1.0, 0.5));
assert_eq!(r.class_for("NET_1"), (0.15, 0.3, 0.7, 0.35));
let dsn = write_dsn(&board, &text, "b", &r, None).unwrap();
assert!(dsn.text.contains("(class Power NET_5 NET_6\n (circuit (use_via Via[0-1]_1000:500_um))\n (rule (width 500) (clearance 300.1))"), "{}", dsn.text);
assert!(dsn.text.contains("(class kicad_default NET_1 NET_2 NET_3 NET_4\n"));
let _ = std::fs::remove_dir_all(&dir);
}
const SES: &str = r#"(session "live-routing.ses"
(base_design "live-routing.dsn")
(placement
(resolution um 10)
(component J1
(place J1 105000 -85000 front 0)
)
)
(was_is
)
(routes
(resolution um 10)
(parser
(string_quote ")
(space_in_quoted_tokens on)
(host_cad "Adom KiCad Bridge")
(host_version "1.0.0")
)
(library_out
(padstack "Via[0-1]_800:400_um"
(shape
(circle F.Cu 8000 0 0)
)
(shape
(circle B.Cu 8000 0 0)
)
(attach off)
)
)
(network_out
(net NET_3
(wire
(path F.Cu 2500
1050000 -950000
1120000 -950000
)
)
(via "Via[0-1]_800:400_um" 1120000 -950000
)
(wire
(path B.Cu 2500
1120000 -950000
1380000 -950000
1380000 -950000
)
)
)
(net "Net-(R1-Pad1)"
(wire (path F.Cu 2000 0 0 10000 -10000) (type protect))
)
)
)
)"#;
#[test]
fn reads_freerouting_session_wires_and_vias_back_to_mm() {
let s = read_ses(SES).unwrap();
assert_eq!((s.unit.as_str(), s.resolution), ("um", 10.0));
assert_eq!(s.nets, vec!["NET_3", "Net-(R1-Pad1)"]);
assert_eq!(s.wires.len(), 3);
assert_eq!(s.wires[0], SesWire { net: "NET_3".into(), layer: "F.Cu".into(), width: 0.25, points: vec![[105.0, 95.0], [112.0, 95.0]] });
assert_eq!(s.wires[1].points, vec![[112.0, 95.0], [138.0, 95.0]], "duplicate point dropped");
assert_eq!(s.wires[2], SesWire { net: "Net-(R1-Pad1)".into(), layer: "F.Cu".into(), width: 0.2, points: vec![[0.0, 0.0], [1.0, 1.0]] });
assert_eq!(s.vias, vec![SesVia { net: "NET_3".into(), padstack: "Via[0-1]_800:400_um".into(), at: [112.0, 95.0], size: Some(0.8), drill: Some(0.4) }]);
let (_, board) = fixture();
let copper = copper_for(&s, &board, None, &Rules::default());
assert_eq!(copper.len(), 1, "the unknown net's wire is dropped by the net filter: {copper:?}");
assert_eq!(copper[0].net, "NET_3");
assert_eq!(copper[0].segments.len(), 2);
assert_eq!(copper[0].vias[0].layers, ["F.Cu", "B.Cu"]);
assert_eq!((copper[0].vias[0].size, copper[0].vias[0].drill), (0.8, 0.4));
// Copper already on the board is skipped, and a net subset filters.
let net = pcb::resolve_net(&board, Some(&serde_json::json!("NET_3"))).unwrap();
let text = pcb::splice(&fixture_text(), &[pcb::segment_sexpr((112.0, 95.0), (105.0, 95.0), 0.25, "F.Cu", &net)]);
let board2 = pcb::parse_pcb_text(&text, "x").unwrap();
let copper = copper_for(&s, &board2, Some(&["NET_3".into()]), &Rules::default());
assert_eq!(copper[0].segments.len(), 1);
assert_eq!(copper[0].skipped_existing, 1);
assert!(copper_for(&s, &board2, Some(&["NET_1".into()]), &Rules::default()).is_empty());
}
#[test]
fn router_wire_thinner_than_the_class_is_widened_to_it() {
// wiki #93: Freerouting answers 0.1874 mm where the DSN asked for the class width, and
// KiCad's DRC calls every such segment a track_width error. The applier widens it back.
let (_, board) = fixture();
let thin = Session {
unit: "um".into(),
resolution: 10.0,
nets: vec!["NET_3".into()],
wires: vec![
SesWire { net: "NET_3".into(), layer: "F.Cu".into(), width: 0.1874, points: vec![[105.0, 95.0], [112.0, 95.0]] },
SesWire { net: "NET_3".into(), layer: "F.Cu".into(), width: 0.25, points: vec![[112.0, 95.0], [138.0, 95.0]] },
],
..Default::default()
};
let copper = copper_for(&thin, &board, None, &Rules::default());
assert_eq!(copper.len(), 1);
assert_eq!(copper[0].widened, 1, "only the thin one is widened: {copper:?}");
assert_eq!(copper[0].segments[0].width, Rules::default().track_width);
assert_eq!(copper[0].segments[1].width, 0.25, "a wire at the class width is left alone");
}
#[test]
fn specctra_tokenizer_handles_string_quote_and_bad_input() {
let sx = parse_specctra("(a (string_quote \") (b \"x y\") c)").unwrap();
assert_eq!(sx.find("string_quote").unwrap().value(), Some("\""));
assert_eq!(sx.find("b").unwrap().value(), Some("x y"));
assert!(parse_specctra("(a (b)").is_err());
assert!(parse_specctra("(a))").is_err());
assert!(read_ses("(pcb x)").is_err());
assert!(read_ses("(session x (routes (resolution parsec 1) (network_out)))").is_err());
}
}