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 #399: 1.0.100: update_pcb sets every reference (closes #138); the verbs ruled on standard #29
85f32a2
8d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785
//! `.kicad_pcb` reading and copper editing on disk. Port of `parsers/pcb.py` and the pure
//! parts of `handlers/route.py` (pads in board coordinates, net resolution, s-expression
//! emission, the atomic write with `.adom-bak`) plus the route planner that
//! `handlers/live_routing.py::_plan` and `handlers/route.py::handle_route` share.
//!
//! Coordinates are millimetres in KiCad's board frame: +x right, +y DOWN. Nothing here
//! talks to KiCad; the IPC group (`crate::ipc`) and the file verbs build on it.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::{json, Map, Value};
use crate::model_check::{parse_sexpr, Sx};
use crate::schematic::new_uuid;
pub const DEFAULT_WIDTH: f64 = 0.2;
pub const DEFAULT_VIA_SIZE: f64 = 0.6;
pub const DEFAULT_VIA_DRILL: f64 = 0.3;
pub const DEFAULT_LAYER: &str = "F.Cu";
/// First KiCad file format that names nets instead of numbering them (KiCad 10).
pub const NAME_BASED_NET_VERSION: i64 = 20260206;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// The Python `RoutingError`: a code, a message and extra detail keys that land in the
/// response next to `errorCode` and `error`.
#[derive(Clone, Debug, PartialEq)]
pub struct RoutingError {
pub code: String,
pub message: String,
pub detail: Map<String, Value>,
}
impl RoutingError {
pub fn new(code: &str, message: impl Into<String>) -> Self {
RoutingError { code: code.into(), message: message.into(), detail: Map::new() }
}
pub fn with(mut self, key: &str, value: Value) -> Self {
self.detail.insert(key.into(), value);
self
}
}
impl std::fmt::Display for RoutingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
// ---------------------------------------------------------------------------
// Parsed board
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Default, Serialize)]
pub struct Layer {
pub number: String,
pub name: String,
#[serde(rename = "type")]
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonical_name: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, PartialEq)]
pub struct Net {
pub number: Option<i64>,
pub name: String,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Position {
pub x: f64,
pub y: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub rotation: Option<f64>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct FpPad {
pub number: String,
#[serde(rename = "type")]
pub kind: String,
pub shape: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub x: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub y: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<f64>,
/// The pad's `(at x y angle)` angle: KiCad writes it absolute (footprint orientation included).
#[serde(skip_serializing_if = "Option::is_none")]
pub rotation: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layers: Option<Vec<String>>,
/// Present when the pad carries a (net ...) reference at all (either dialect).
#[serde(skip_serializing_if = "Option::is_none")]
pub net_number: Option<Option<i64>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub net_name: Option<String>,
}
impl FpPad {
fn has_net(&self) -> bool {
self.net_number.is_some() || self.net_name.is_some()
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Footprint {
pub footprint: String,
pub layer: Option<String>,
pub uuid: Option<String>,
pub position: Option<Position>,
pub locked: bool,
pub properties: HashMap<String, String>,
pub reference: String,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub sheetname: String,
pub sheetfile: String,
pub attr: String,
pub pad_count: usize,
pub pads: Vec<FpPad>,
/// Courtyard vertices in footprint-local mm (every `fp_line`, `fp_rect`, `fp_poly`,
/// `fp_circle` and `fp_arc` on F.CrtYd or B.CrtYd), empty when the footprint has no
/// courtyard. Not serialised: `crate::placement` turns it into a board-frame box.
#[serde(skip)]
pub courtyard: Vec<Point>,
/// The courtyard's actual outline as edges in footprint-local mm (lines, rectangle and
/// polygon sides, arcs and circles as short chords). Wiki #125: overlap is decided on
/// these, not on the bounding box of `courtyard`.
#[serde(skip)]
pub courtyard_edges: Vec<(Point, Point)>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Segment {
pub uuid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<Point>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<Point>,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub net: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub net_name: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Via {
pub uuid: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<Point>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub drill: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub net: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub net_name: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Zone {
pub uuid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub net: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub net_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i64>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Dimensions {
pub min_x: f64,
pub min_y: f64,
pub max_x: f64,
pub max_y: f64,
pub width: f64,
pub height: f64,
}
/// Which net dialect the board speaks: `Number` (KiCad <= 9, `(net 3)`) or `Name`
/// (KiCad 10, `(net "GND")`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum NetFormat {
Number,
Name,
}
impl NetFormat {
pub fn as_str(self) -> &'static str {
match self {
NetFormat::Number => "number",
NetFormat::Name => "name",
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Board {
pub file: String,
pub version: Option<String>,
pub generator: Option<String>,
pub general: Map<String, Value>,
pub paper: Option<String>,
pub title_block: Map<String, Value>,
pub layers: Vec<Layer>,
pub nets: Vec<Net>,
pub footprints: Vec<Footprint>,
pub segments: Vec<Segment>,
pub vias: Vec<Via>,
pub zones: Vec<Zone>,
pub dimensions: Option<Dimensions>,
pub net_format: NetFormat,
}
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
fn atom_f64(sx: &Sx, i: usize) -> Option<f64> {
sx.atom_at(i)?.parse::<f64>().ok()
}
fn value_f64(sx: Option<&Sx>) -> Option<f64> {
sx?.value()?.parse::<f64>().ok()
}
fn value_string(sx: Option<&Sx>) -> Option<String> {
sx?.value().map(str::to_string)
}
fn string_atoms(sx: &Sx) -> Vec<String> {
sx.as_list().map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default()
}
/// Read a `(net ...)` reference in either board format (`_net_ref`).
fn net_ref(node: Option<&Sx>, name_based: bool) -> (Option<i64>, Option<String>) {
let Some(node) = node else { return (None, None) };
let Some(first) = node.atom_at(1) else { return (None, None) };
if name_based {
return (None, Some(first.to_string()));
}
let digits = first.strip_prefix('-').unwrap_or(first);
if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {
let name = node.atom_at(2).map(str::to_string);
return (first.parse::<i64>().ok(), name);
}
(None, Some(first.to_string()))
}
/// Parse board text already in memory (`parse_pcb_text`). The routing verbs splice new
/// copper into the TEXT they hold, so re-reading the file would race their own write.
pub fn parse_pcb_text(text: &str, filepath: &str) -> Result<Board, String> {
let tree = parse_sexpr(text).ok_or_else(|| "Empty S-expression".to_string())?;
if tree.as_list().is_none() {
return Err("not an s-expression document".into());
}
Ok(resolve_net_format(extract_raw(&tree, filepath)))
}
pub fn parse_pcb(path: &Path) -> Result<Board, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
parse_pcb_text(&text, &path.to_string_lossy())
}
fn extract_raw(tree: &Sx, filepath: &str) -> Board {
let version = value_string(tree.find("version"));
let name_based = version.as_deref().and_then(|v| v.parse::<i64>().ok()).unwrap_or(0) >= NAME_BASED_NET_VERSION;
Board {
file: filepath.to_string(),
version,
generator: value_string(tree.find("generator")),
general: extract_general(tree.find("general")),
paper: value_string(tree.find("paper")),
title_block: extract_title_block(tree.find("title_block")),
layers: extract_layers(tree),
nets: extract_nets(tree, name_based),
footprints: extract_footprints(tree, name_based),
segments: extract_segments(tree, name_based),
vias: extract_vias(tree, name_based),
zones: extract_zones(tree, name_based),
dimensions: extract_dimensions(tree),
net_format: NetFormat::Number,
}
}
/// `_resolve_net_format`: a KiCad 10 board has no net table, so its net inventory is the
/// names its pads and copper mention.
fn resolve_net_format(mut data: Board) -> Board {
let numbered = !data.nets.is_empty()
|| data.footprints.iter().flat_map(|f| f.pads.iter()).any(|p| matches!(p.net_number, Some(Some(_))));
data.net_format = if numbered { NetFormat::Number } else { NetFormat::Name };
if data.nets.is_empty() {
let mut names: Vec<String> = Vec::new();
let mut push = |n: Option<&String>| {
if let Some(n) = n {
if !n.is_empty() && !names.contains(n) {
names.push(n.clone());
}
}
};
for fp in &data.footprints {
for p in &fp.pads {
push(p.net_name.as_ref());
}
}
for s in &data.segments {
push(s.net_name.as_ref());
}
for v in &data.vias {
push(v.net_name.as_ref());
}
for z in &data.zones {
push(z.net_name.as_ref());
}
data.nets = names.into_iter().map(|name| Net { number: None, name }).collect();
}
data
}
fn extract_general(node: Option<&Sx>) -> Map<String, Value> {
let mut out = Map::new();
if let Some(node) = node {
if let Some(t) = value_f64(node.find("thickness")) {
out.insert("thickness".into(), json!(t));
}
}
out
}
fn extract_title_block(node: Option<&Sx>) -> Map<String, Value> {
let mut out = Map::new();
let Some(node) = node else { return out };
for field in ["title", "date", "rev", "company"] {
if let Some(v) = value_string(node.find(field)).filter(|v| !v.is_empty()) {
out.insert(field.into(), json!(v));
}
}
for c in node.find_all("comment") {
if let (Some(n), Some(text)) = (c.atom_at(1), c.atom_at(2)) {
out.insert(format!("comment_{n}"), json!(text));
}
}
out
}
fn extract_layers(tree: &Sx) -> Vec<Layer> {
let Some(layers) = tree.find("layers").and_then(Sx::as_list) else { return Vec::new() };
layers
.iter()
.skip(1)
.filter_map(|child| {
let l = child.as_list()?;
if l.len() < 3 {
return None;
}
Some(Layer {
number: l[0].as_atom().unwrap_or("").to_string(),
name: l[1].as_atom().unwrap_or("").to_string(),
kind: l[2].as_atom().unwrap_or("").to_string(),
canonical_name: l.get(3).and_then(Sx::as_atom).map(str::to_string),
})
})
.collect()
}
fn extract_nets(tree: &Sx, name_based: bool) -> Vec<Net> {
tree.find_all("net")
.filter_map(|n| {
let (num, name) = net_ref(Some(n), name_based);
num.map(|number| Net { number: Some(number), name: name.unwrap_or_default() })
})
.collect()
}
fn extract_position(node: &Sx) -> Option<Position> {
let at = node.find("at")?;
let x = atom_f64(at, 1)?;
let y = atom_f64(at, 2)?;
Some(Position { x, y, rotation: atom_f64(at, 3) })
}
fn extract_footprints(tree: &Sx, name_based: bool) -> Vec<Footprint> {
let mut out = Vec::new();
for node in tree.find_all("footprint") {
let Some(list) = node.as_list() else { continue };
if list.len() < 2 {
continue;
}
let mut props: HashMap<String, String> = HashMap::new();
for p in node.find_all("property") {
if let (Some(k), Some(v)) = (p.atom_at(1), p.atom_at(2)) {
props.insert(k.to_string(), v.to_string());
}
}
// KiCad 7 and older keep them as (fp_text reference "R1") and (fp_text value "10k")
for t in node.find_all("fp_text") {
let key = match t.atom_at(1) {
Some("reference") => "Reference",
Some("value") => "Value",
_ => continue,
};
if let Some(v) = t.atom_at(2) {
props.entry(key.to_string()).or_insert_with(|| v.to_string());
}
}
let pads: Vec<FpPad> = node
.find_all("pad")
.filter_map(|pad| {
let l = pad.as_list()?;
if l.len() < 4 {
return None;
}
let at = pad.find("at");
let size = pad.find("size");
let (num, name) = net_ref(pad.find("net"), name_based);
let has_net = num.is_some() || name.is_some();
Some(FpPad {
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: at.and_then(|a| atom_f64(a, 1)),
y: at.and_then(|a| atom_f64(a, 2)),
width: size.and_then(|s| atom_f64(s, 1)),
height: size.and_then(|s| atom_f64(s, 2)),
rotation: at.and_then(|a| atom_f64(a, 3)),
layers: pad.find("layers").map(string_atoms),
net_number: if has_net { Some(num) } else { None },
net_name: if has_net { Some(name.unwrap_or_default()) } else { None },
})
})
.collect();
out.push(Footprint {
footprint: node.atom_at(1).unwrap_or("").to_string(),
layer: value_string(node.find("layer")),
uuid: value_string(node.find("uuid")),
position: extract_position(node),
locked: node.find("locked").is_some(),
reference: props.get("Reference").cloned().unwrap_or_default(),
value: props.get("Value").cloned().unwrap_or_default(),
properties: props,
path: value_string(node.find("path")),
sheetname: value_string(node.find("sheetname")).unwrap_or_default(),
sheetfile: value_string(node.find("sheetfile")).unwrap_or_default(),
attr: value_string(node.find("attr")).unwrap_or_default(),
pad_count: pads.len(),
pads,
courtyard: extract_courtyard(node),
courtyard_edges: extract_courtyard_edges(node),
});
}
out
}
/// The courtyard outline's vertices in footprint-local coordinates: a rectangle's four
/// corners, a circle's four extreme points, a line's ends, an arc's start, mid and end,
/// a polygon's points. A footprint with no F.CrtYd or B.CrtYd shape yields nothing.
fn extract_courtyard(fp: &Sx) -> Vec<Point> {
let mut out = Vec::new();
let on_courtyard = |node: &Sx| matches!(node.find("layer").and_then(Sx::value), Some("F.CrtYd") | Some("B.CrtYd"));
for node in fp.find_all("fp_line").chain(fp.find_all("fp_arc")) {
if !on_courtyard(node) {
continue;
}
for coord in ["start", "mid", "end"] {
if let Some(p) = point_of(node.find(coord)) {
out.push(p);
}
}
}
for node in fp.find_all("fp_rect") {
if !on_courtyard(node) {
continue;
}
if let (Some(a), Some(b)) = (point_of(node.find("start")), point_of(node.find("end"))) {
out.push(Point { x: a.x, y: a.y });
out.push(Point { x: b.x, y: a.y });
out.push(Point { x: b.x, y: b.y });
out.push(Point { x: a.x, y: b.y });
}
}
for node in fp.find_all("fp_circle") {
if !on_courtyard(node) {
continue;
}
if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) {
let r = dist((c.x, c.y), (e.x, e.y));
out.push(Point { x: c.x - r, y: c.y });
out.push(Point { x: c.x + r, y: c.y });
out.push(Point { x: c.x, y: c.y - r });
out.push(Point { x: c.x, y: c.y + r });
}
}
for node in fp.find_all("fp_poly") {
if !on_courtyard(node) {
continue;
}
if let Some(pts) = node.find("pts") {
for xy in pts.find_all("xy") {
if let (Some(x), Some(y)) = (atom_f64(xy, 1), atom_f64(xy, 2)) {
out.push(Point { x, y });
}
}
}
}
out
}
/// The courtyard outline as edges (see `Footprint::courtyard_edges`).
fn extract_courtyard_edges(fp: &Sx) -> Vec<(Point, Point)> {
let mut out: Vec<(Point, Point)> = Vec::new();
let on_courtyard = |node: &Sx| matches!(node.find("layer").and_then(Sx::value), Some("F.CrtYd") | Some("B.CrtYd"));
let pt = |x: f64, y: f64| Point { x, y };
for node in fp.find_all("fp_line") {
if on_courtyard(node) {
if let (Some(a), Some(b)) = (point_of(node.find("start")), point_of(node.find("end"))) {
out.push((a, b));
}
}
}
for node in fp.find_all("fp_arc") {
if on_courtyard(node) {
if let (Some(a), Some(m), Some(b)) = (point_of(node.find("start")), point_of(node.find("mid")), point_of(node.find("end"))) {
// every 2 degrees, like the board model: two chords cut a large arc short (wiki #133)
let pts = arc_points(&a, &m, &b);
for w in pts.windows(2) {
out.push((w[0].clone(), w[1].clone()));
}
}
}
}
for node in fp.find_all("fp_rect") {
if on_courtyard(node) {
if let (Some(a), Some(b)) = (point_of(node.find("start")), point_of(node.find("end"))) {
let c = [pt(a.x, a.y), pt(b.x, a.y), pt(b.x, b.y), pt(a.x, b.y)];
for i in 0..4 {
out.push((c[i].clone(), c[(i + 1) % 4].clone()));
}
}
}
}
for node in fp.find_all("fp_circle") {
if on_courtyard(node) {
if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) {
let r = dist((c.x, c.y), (e.x, e.y));
// 2 degrees: a 24-gon's chords cut a 24 mm ring's hole 0.2 mm short (wiki #133)
let n = 180;
let at = |k: usize| {
let t = std::f64::consts::TAU * k as f64 / n as f64;
pt(c.x + r * t.cos(), c.y + r * t.sin())
};
for k in 0..n {
out.push((at(k), at(k + 1)));
}
}
}
}
for node in fp.find_all("fp_poly") {
if on_courtyard(node) {
if let Some(pts) = node.find("pts") {
let v: Vec<Point> = pts.find_all("xy").filter_map(|xy| Some(pt(atom_f64(xy, 1)?, atom_f64(xy, 2)?))).collect();
if v.len() >= 2 {
for i in 0..v.len() {
out.push((v[i].clone(), v[(i + 1) % v.len()].clone()));
}
}
}
}
}
out
}
/// A three-point arc as points every 2 degrees from start through mid to end (start and end exact);
/// collinear points are a straight line.
pub fn arc_points(a: &Point, m: &Point, b: &Point) -> Vec<Point> {
let d = 2.0 * (a.x * (m.y - b.y) + m.x * (b.y - a.y) + b.x * (a.y - m.y));
if d.abs() < 1e-12 {
return vec![a.clone(), b.clone()];
}
let (a2, m2, b2) = (a.x * a.x + a.y * a.y, m.x * m.x + m.y * m.y, b.x * b.x + b.y * b.y);
let cx = (a2 * (m.y - b.y) + m2 * (b.y - a.y) + b2 * (a.y - m.y)) / d;
let cy = (a2 * (b.x - m.x) + m2 * (a.x - b.x) + b2 * (m.x - a.x)) / d;
let r = dist((cx, cy), (a.x, a.y));
let ang = |p: &Point| (p.y - cy).atan2(p.x - cx);
let tau = std::f64::consts::TAU;
let (s, mid, e) = (ang(a), ang(m), ang(b));
let ccw = |x: f64, y: f64| (y - x).rem_euclid(tau);
// the sweep from start to end that passes through mid, signed
let sweep = if ccw(s, mid) < ccw(s, e) { ccw(s, e) } else { -(tau - ccw(s, e)) };
let n = ((sweep.abs().to_degrees() / 2.0).ceil() as usize).max(1);
let mut out: Vec<Point> = (0..=n).map(|k| {
let t = s + sweep * k as f64 / n as f64;
Point { x: cx + r * t.cos(), y: cy + r * t.sin() }
}).collect();
out[0] = a.clone();
out[n] = b.clone();
out
}
fn point_of(node: Option<&Sx>) -> Option<Point> {
let n = node?;
Some(Point { x: atom_f64(n, 1)?, y: atom_f64(n, 2)? })
}
fn extract_segments(tree: &Sx, name_based: bool) -> Vec<Segment> {
tree.find_all("segment")
.map(|node| {
let (num, name) = net_ref(node.find("net"), name_based);
Segment {
uuid: value_string(node.find("uuid")),
start: point_of(node.find("start")),
end: point_of(node.find("end")),
width: value_f64(node.find("width")),
layer: value_string(node.find("layer")),
net: num,
net_name: name,
}
})
.collect()
}
fn extract_vias(tree: &Sx, name_based: bool) -> Vec<Via> {
tree.find_all("via")
.map(|node| {
let (num, name) = net_ref(node.find("net"), name_based);
Via {
uuid: value_string(node.find("uuid")),
kind: node.atom_at(1).map(str::to_string),
position: point_of(node.find("at")),
size: value_f64(node.find("size")),
drill: value_f64(node.find("drill")),
layers: node.find("layers").map(string_atoms),
net: num,
net_name: name,
}
})
.collect()
}
fn extract_zones(tree: &Sx, name_based: bool) -> Vec<Zone> {
tree.find_all("zone")
.map(|node| {
let (num, mut name) = net_ref(node.find("net"), name_based);
if let Some(n) = value_string(node.find("net_name")) {
name = Some(n);
}
Zone {
uuid: value_string(node.find("uuid")),
net: num,
net_name: name,
layer: value_string(node.find("layer")),
layers: node.find("layers").map(string_atoms),
name: value_string(node.find("name")),
priority: node.find("priority").and_then(Sx::value).and_then(|v| v.parse::<i64>().ok()),
}
})
.collect()
}
fn extract_dimensions(tree: &Sx) -> Option<Dimensions> {
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
let mut found = false;
let mut update = |x: f64, y: f64| {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
found = true;
};
let on_edge = |node: &Sx| node.find("layer").and_then(Sx::value) == Some("Edge.Cuts");
for tag in ["gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly"] {
for node in tree.find_all(tag) {
if !on_edge(node) {
continue;
}
for coord in ["start", "end", "mid"] {
if let Some(p) = point_of(node.find(coord)) {
update(p.x, p.y);
}
}
// an arc reaches as far as its sweep goes, a circle its radius all round (wiki #133:
// one 340 degree edge arc read only by its three points left the box 100 mm short)
if let (Some(a), Some(m), Some(b)) = (point_of(node.find("start")), point_of(node.find("mid")), point_of(node.find("end"))) {
for p in arc_points(&a, &m, &b) {
update(p.x, p.y);
}
}
if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) {
let r = dist((c.x, c.y), (e.x, e.y));
update(c.x - r, c.y - r);
update(c.x + r, c.y + r);
}
if let Some(pts) = node.find("pts") {
for xy in pts.find_all("xy") {
if let (Some(x), Some(y)) = (atom_f64(xy, 1), atom_f64(xy, 2)) {
update(x, y);
}
}
}
}
}
for fp in tree.find_all("footprint") {
let (fx, fy) = fp.find("at").map(|a| (atom_f64(a, 1).unwrap_or(0.0), atom_f64(a, 2).unwrap_or(0.0))).unwrap_or((0.0, 0.0));
for tag in ["fp_line", "fp_rect", "fp_arc"] {
for node in fp.find_all(tag) {
if !on_edge(node) {
continue;
}
for coord in ["start", "end", "center", "mid"] {
if let Some(p) = point_of(node.find(coord)) {
update(p.x + fx, p.y + fy);
}
}
}
}
}
if !found {
return None;
}
let r3 = |v: f64| (v * 1000.0).round() / 1000.0;
Some(Dimensions { min_x: r3(min_x), min_y: r3(min_y), max_x: r3(max_x), max_y: r3(max_y), width: r3(max_x - min_x), height: r3(max_y - min_y) })
}
// ---------------------------------------------------------------------------
// Numbers and geometry
// ---------------------------------------------------------------------------
/// KiCad-style trimmed float (`1.5`, not `1.500000`).
pub fn num(v: f64) -> String {
let s = format!("{v:.6}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s.is_empty() || s == "-" || s == "-0" {
"0".into()
} else {
s.to_string()
}
}
pub fn round6(v: f64) -> f64 {
(v * 1_000_000.0).round() / 1_000_000.0
}
pub fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()
}
/// Python `_number`: a finite, in-range number rounded to KiCad's nanometre precision.
/// Booleans are refused (Python's `isinstance(True, int)` trap).
pub fn number(value: Option<&Value>, label: &str, positive: bool) -> Result<f64, RoutingError> {
let v = match value {
Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
_ => return Err(RoutingError::new("invalid_geometry", format!("{label} must be a finite number"))),
};
if !v.is_finite() {
return Err(RoutingError::new("invalid_geometry", format!("{label} must be a finite number")));
}
if v.abs() > 2000.0 || (positive && v <= 0.0) {
return Err(RoutingError::new("invalid_geometry", format!("{label} is outside the supported board range")));
}
let rounded = round6(v);
if positive && rounded <= 0.0 {
return Err(RoutingError::new("invalid_geometry", format!("{label} is below KiCad nanometre precision")));
}
Ok(rounded)
}
// ---------------------------------------------------------------------------
// Nets
// ---------------------------------------------------------------------------
/// A net reference the writers can emit, in this board's dialect.
#[derive(Clone, Debug, PartialEq)]
pub struct NetRef {
pub format: NetFormat,
pub number: Option<i64>,
pub name: String,
/// The identity of the net on THIS board: its number pre-KiCad-10, else its name.
pub key: Value,
}
pub fn net_key(board: &Board, number: Option<i64>, name: Option<&str>) -> Value {
match board.net_format {
NetFormat::Number => number.map(|n| json!(n)).unwrap_or(Value::Null),
NetFormat::Name => json!(name.unwrap_or("")),
}
}
fn is_no_net(key: &Value) -> bool {
match key {
Value::Null => true,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
_ => false,
}
}
/// `_resolve_net`: a name or (pre-KiCad-10) number to a reference this board can carry.
/// Never invents one. Err carries the ready-to-return response.
pub fn resolve_net(board: &Board, spec: Option<&Value>) -> Result<NetRef, Value> {
let fmt = board.net_format;
let names: Vec<&str> = board.nets.iter().map(|n| n.name.as_str()).collect();
let spec = spec.cloned().unwrap_or(Value::Null);
let spec = if spec.is_null() { json!("") } else { spec };
match &spec {
Value::String(s) => {
if let Some(n) = board.nets.iter().find(|n| &n.name == s) {
return Ok(NetRef { format: fmt, number: n.number, name: s.clone(), key: net_key(board, n.number, Some(s)) });
}
}
Value::Number(n) => {
if let Some(f) = n.as_f64().filter(|f| f.is_finite() && *f == f.trunc()) {
let i = f as i64;
if fmt == NetFormat::Name && i == 0 {
return Ok(NetRef { format: fmt, number: None, name: String::new(), key: json!("") });
}
if fmt == NetFormat::Number {
if let Some(n) = board.nets.iter().find(|n| n.number == Some(i)) {
return Ok(NetRef { format: fmt, number: Some(i), name: n.name.clone(), key: json!(i) });
}
}
}
}
_ => {}
}
Err(json!({
"success": false, "errorCode": "unknown_net", "error": format!("No exact net matching {}", py_repr(&spec)),
"nets": names.iter().take(60).collect::<Vec<_>>(),
"_hint": "Use an exact net name. Integer net codes are supported only for pre-KiCad-10 boards.",
}))
}
/// Python `repr()` for the values that reach error messages.
fn py_repr(v: &Value) -> String {
match v {
Value::String(s) => format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),
Value::Bool(true) => "True".into(),
Value::Bool(false) => "False".into(),
Value::Null => "None".into(),
other => other.to_string(),
}
}
/// The `(net ...)` line in this board's dialect, or nothing for deliberately unconnected
/// copper on a KiCad 10 board (which has no net 0 to name).
pub fn net_sexpr(net: &NetRef) -> String {
match net.format {
NetFormat::Number => format!("\t\t(net {})\n", net.number.unwrap_or(0)),
NetFormat::Name => {
if net.name.is_empty() {
String::new()
} else {
format!("\t\t(net {})\n", serde_json::to_string(&net.name).unwrap_or_default())
}
}
}
}
/// Match the board's own copper: this net's width, else the commonest width, else 0.2.
pub fn default_width(board: &Board, net_key: Option<&Value>) -> f64 {
let commonest = |widths: Vec<f64>| -> Option<f64> {
let mut counts: Vec<(f64, usize)> = Vec::new();
for w in widths {
match counts.iter_mut().find(|(k, _)| *k == w) {
Some(c) => c.1 += 1,
None => counts.push((w, 1)),
}
}
counts.into_iter().max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal))).map(|c| c.0)
};
let on_net: Vec<f64> = board
.segments
.iter()
.filter(|s| s.width.map(|w| w != 0.0).unwrap_or(false))
.filter(|s| net_key.map(|k| net_key_of_segment(board, s) == *k).unwrap_or(true))
.filter_map(|s| s.width)
.collect();
if let Some(w) = commonest(on_net) {
return w;
}
let any: Vec<f64> = board.segments.iter().filter_map(|s| s.width).filter(|w| *w != 0.0).collect();
commonest(any).unwrap_or(DEFAULT_WIDTH)
}
fn net_key_of_segment(board: &Board, s: &Segment) -> Value {
net_key(board, s.net, s.net_name.as_deref())
}
// ---------------------------------------------------------------------------
// Pads in board coordinates
// ---------------------------------------------------------------------------
/// One pad as `kicad_board_pads` reports it: board millimetres with footprint rotation
/// applied, +y DOWN, plus its net.
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct Pad {
#[serde(rename = "ref")]
pub reference: String,
pub pad: String,
pub name: String,
pub x: f64,
pub y: f64,
pub net: Option<i64>,
#[serde(rename = "netName")]
pub net_name: String,
#[serde(rename = "netKey")]
pub net_key: Value,
#[serde(rename = "type")]
pub kind: String,
pub layers: Vec<String>,
pub side: String,
pub footprint: String,
}
/// Every pad, transformed from footprint-local into BOARD coordinates. KiCad rotates a
/// footprint's pads by the footprint orientation in a y-DOWN frame (trigo.cpp
/// RotatePoint): x' = x*cos + y*sin, y' = -x*sin + y*cos.
pub fn pads(board: &Board) -> Vec<Pad> {
let mut out = Vec::new();
for fp in &board.footprints {
let (fx, fy, rot) = fp.position.as_ref().map(|p| (p.x, p.y, p.rotation.unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));
let ang = rot.to_radians();
let (sin_a, cos_a) = ang.sin_cos();
for pad in &fp.pads {
let (Some(px), Some(py)) = (pad.x, pad.y) else { continue };
let net_number = pad.net_number.flatten();
let net_name = pad.net_name.clone().unwrap_or_default();
out.push(Pad {
reference: fp.reference.clone(),
pad: pad.number.clone(),
name: format!("{}.{}", fp.reference, pad.number),
x: round6(fx + px * cos_a + py * sin_a),
y: round6(fy - px * sin_a + py * cos_a),
net: net_number,
net_key: if pad.has_net() { net_key(board, net_number, Some(&net_name)) } else { net_key(board, None, None) },
net_name,
kind: pad.kind.clone(),
layers: pad.layers.clone().unwrap_or_default(),
side: fp.layer.clone().unwrap_or_default(),
footprint: fp.footprint.clone(),
});
}
}
out
}
/// Wiki #119, measured with KiCad's own DRC on KiCad 10.0.2 and 10.0.5: a footprint on `B.Cu` whose
/// SMD pads say `F.Cu` keeps those pads on the FRONT copper at their literal positions ("Pad 1 [N1] of R1
/// on F.Cu" in KiCad's connectivity report; back-layer tracks connect to them neither at the literal nor
/// at the mirrored spot). The bridge reads them the same way, which is right. Such a footprint is
/// almost always a part placed on the back by a tool that did not flip it the way KiCad does (KiCad's
/// own flip moves the pads to `B.*`: its StickHub demo, 123 of 123 back-side pads). This finds them so
/// replies can say so, instead of letting routing fail on copper that is really on the other side.
pub fn back_footprint_with_front_pads(fp: &Footprint) -> bool {
if fp.layer.as_deref() != Some("B.Cu") {
return false;
}
fp.pads.iter().any(|p| {
let l = p.layers.clone().unwrap_or_default();
p.kind == "smd" && l.iter().any(|x| x == "F.Cu") && !l.iter().any(|x| x == "B.Cu" || x == "*.Cu")
})
}
/// References of every footprint `back_footprint_with_front_pads` finds, for a reply warning.
pub fn back_footprints_with_front_pads(board: &Board) -> Vec<String> {
board.footprints.iter().filter(|f| back_footprint_with_front_pads(f)).map(|f| f.reference.clone()).collect()
}
pub const FRONT_PADS_HINT: &str = "These footprints sit on B.Cu but their SMD pads are on F.Cu. KiCad keeps such pads on the FRONT copper at their stored positions (checked with KiCad's own DRC on 10.0.2 and 10.0.5), so back-layer copper cannot reach them and a via is checked against them where they really are. A footprint flipped in KiCad has its pads on B.Cu; one written by another tool must flip it the same way: move every pad layer F.* to B.* and negate each pad's local x (and mirror its angle), or flip the part in KiCad (select, F).";
pub fn copper_layers(board: &Board) -> Vec<String> {
board.layers.iter().filter(|l| l.name.ends_with(".Cu")).map(|l| l.name.clone()).collect()
}
/// Is this pad actually on that copper layer? An SMD pad lives on ONE side; a track that
/// ends on it from the other side is a dangling track to DRC. Through-hole pads carry
/// the `*.Cu` wildcard.
pub fn pad_reaches(pad: Option<&Pad>, layer: &str) -> bool {
let Some(pad) = pad else { return true };
if pad.layers.is_empty() {
return true;
}
for l in &pad.layers {
if l == layer || l == "*.Cu" || (l.ends_with('*') && layer.starts_with(&l[..l.len() - 1])) {
return true;
}
}
!pad.layers.iter().any(|l| l.ends_with(".Cu") || l == "*.Cu")
}
/// The net a pad sits on, or None for a pad with no net (a mounting hole, a fiducial).
pub fn pad_net(board: &Board, pad: Option<&Pad>) -> Option<NetRef> {
let pad = pad?;
if is_no_net(&pad.net_key) {
return None;
}
Some(NetRef { format: board.net_format, number: pad.net, name: pad.net_name.clone(), key: pad.net_key.clone() })
}
/// `'R1.2' / 'R1-2' / 'R1 2'` -> that pad.
pub fn find_pad<'a>(pads: &'a [Pad], spec: &str) -> Result<&'a Pad, Value> {
let key = spec.trim().replace('-', ".").replace(' ', ".").to_uppercase();
if let Some(p) = pads.iter().find(|p| p.name.to_uppercase() == key) {
return Ok(p);
}
let reference = key.split('.').next().unwrap_or("").to_string();
let near: Vec<&str> = pads.iter().filter(|p| p.reference.to_uppercase() == reference).map(|p| p.name.as_str()).collect();
let candidates: Vec<&str> = if near.is_empty() { pads.iter().map(|p| p.name.as_str()).collect() } else { near };
Err(json!({
"success": false, "error": format!("no pad '{spec}' on this board"), "errorCode": "unknown_pad",
"candidates": candidates.iter().take(20).collect::<Vec<_>>(),
"_hint": "Pads are addressed 'REF.PAD' (e.g. 'U1.7'). kicad_board_pads lists every pad with its board coordinates.",
}))
}
/// `(x, y)` and the pad it came from. Accepts `[x,y]`, `{x,y}`, `"REF.PAD"` or `{"pad":"REF.PAD"}`.
pub fn resolve_point<'a>(spec: &Value, pads: &'a [Pad]) -> Result<((f64, f64), Option<&'a Pad>), Value> {
let as_f64 = |v: &Value| -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().parse::<f64>().ok(),
_ => None,
}
};
match spec {
Value::String(s) => {
let pad = find_pad(pads, s)?;
return Ok(((pad.x, pad.y), Some(pad)));
}
Value::Object(o) => {
if let Some(p) = o.get("pad").filter(|p| truthy(p)) {
let name = match p {
Value::String(s) => s.clone(),
other => other.to_string(),
};
let pad = find_pad(pads, &name)?;
return Ok(((pad.x, pad.y), Some(pad)));
}
if let (Some(x), Some(y)) = (o.get("x"), o.get("y")) {
if let (Some(x), Some(y)) = (as_f64(x), as_f64(y)) {
return Ok(((x, y), None));
}
}
}
Value::Array(a) if a.len() >= 2 => {
if let (Some(x), Some(y)) = (as_f64(&a[0]), as_f64(&a[1])) {
return Ok(((x, y), None));
}
}
_ => {}
}
Err(json!({
"success": false, "error": format!("could not read a point from {}", py_repr(spec)), "errorCode": "bad_point",
"_hint": "A point is [x,y] in mm, {\"x\":..,\"y\":..}, or a pad name like \"R1.2\". +y is DOWN in KiCad board coordinates.",
}))
}
fn truthy(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
// ---------------------------------------------------------------------------
// s-expression emission
// ---------------------------------------------------------------------------
pub fn segment_sexpr(a: (f64, f64), b: (f64, f64), width: f64, layer: &str, net: &NetRef) -> String {
format!(
"\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width {})\n\t\t(layer \"{}\")\n{}\t\t(uuid \"{}\")\n\t)",
num(a.0),
num(a.1),
num(b.0),
num(b.1),
num(width),
layer,
net_sexpr(net),
new_uuid()
)
}
pub fn via_sexpr(at: (f64, f64), size: f64, drill: f64, layers: (&str, &str), net: &NetRef) -> String {
format!(
"\t(via\n\t\t(at {} {})\n\t\t(size {})\n\t\t(drill {})\n\t\t(layers \"{}\" \"{}\")\n{}\t\t(uuid \"{}\")\n\t)",
num(at.0),
num(at.1),
num(size),
num(drill),
layers.0,
layers.1,
net_sexpr(net),
new_uuid()
)
}
/// Insert board items before the closing paren (where KiCad keeps copper).
/// The board text without the top-level item whose uuid is `id` (a segment or an arc).
pub fn text_without_item(text: &str, id: &str) -> Option<String> {
let root = crate::sxedit::parse(text).ok()?;
let item = root.kids.iter().find(|k| matches!(k.tag(), Some("segment" | "arc")) && k.uuid() == Some(id))?;
let mut start = item.start;
while start > 0 && matches!(text.as_bytes()[start - 1], b' ' | b'\t') {
start -= 1;
}
if start > 0 && text.as_bytes()[start - 1] == b'\n' {
start -= 1;
}
Some(format!("{}{}", &text[..start], &text[item.end..]))
}
pub fn splice(text: &str, blocks: &[String]) -> String {
let trimmed = text.trim_end();
let idx = trimmed.rfind(')').unwrap_or(trimmed.len());
format!("{}\n{}\n)\n", text[..idx].trim_end(), blocks.join("\n"))
}
// ---------------------------------------------------------------------------
// Route planning (shared by kicad_route and kicad_route_net)
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Serialize)]
pub struct PlanSegment {
pub start: [f64; 2],
pub end: [f64; 2],
pub layer: String,
pub width: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct PlanVia {
pub at: [f64; 2],
pub layers: [String; 2],
pub size: f64,
pub drill: f64,
}
/// The validated copper for one net: what the caller sees (`segments`, `vias`) and the
/// s-expression blocks the DRC snapshot and the file writer splice in.
#[derive(Clone, Debug)]
pub struct Plan {
pub net_name: String,
pub segments: Vec<PlanSegment>,
pub vias: Vec<PlanVia>,
pub blocks: Vec<String>,
}
impl Plan {
/// `{"netName", "segments", "vias"}` (everything but `blocks`).
pub fn public(&self) -> Value {
json!({"netName": self.net_name, "segments": self.segments, "vias": self.vias})
}
}
/// Pure planning and validation (`live_routing._plan`); every waypoint is checked before
/// any mutation. `args`: net, points or paths, width, layer, viaSize, viaDrill.
pub fn plan(board: &Board, args: &Value) -> Result<Plan, RoutingError> {
let netname = match args.get("net") {
Some(Value::String(s)) if !s.is_empty() => s.clone(),
_ => return Err(RoutingError::new("missing_net", "net must be an exact, nonempty net name")),
};
let matches: Vec<&Net> = board.nets.iter().filter(|n| n.name == netname).collect();
if matches.len() != 1 {
return Err(RoutingError::new("unknown_net", format!("No unique net named '{netname}'")));
}
let net = NetRef { format: board.net_format, number: matches[0].number, name: netname.clone(), key: net_key(board, matches[0].number, Some(&netname)) };
let pads = pads(board);
let copper = copper_layers(board);
let width = number(Some(args.get("width").unwrap_or(&json!(0.25))), "width", true)?;
let size = number(Some(args.get("viaSize").unwrap_or(&json!(0.6))), "viaSize", true)?;
let drill = number(Some(args.get("viaDrill").unwrap_or(&json!(0.3))), "viaDrill", true)?;
if drill >= size {
return Err(RoutingError::new("invalid_geometry", "viaDrill must be smaller than viaSize"));
}
let paths: Vec<Value> = match args.get("paths") {
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 128 => a.clone(),
Some(_) => return Err(RoutingError::new("invalid_path", "Supply points or up to 128 paths for one net")),
None => vec![args.get("points").cloned().unwrap_or(Value::Null)],
};
let mut segments = Vec::new();
let mut vias = Vec::new();
let mut blocks = Vec::new();
for path in &paths {
let Some(path) = path.as_array().filter(|p| (2..=512).contains(&p.len())) else {
return Err(RoutingError::new("invalid_path", "Each path needs 2..512 waypoints"));
};
// (point, pad, explicit layer)
let mut resolved: Vec<((f64, f64), Option<&Pad>, Option<String>)> = Vec::new();
for spec in path {
let coordinates: Option<[Option<&Value>; 2]> = match spec {
Value::Array(a) => {
if a.len() != 2 {
return Err(RoutingError::new("invalid_geometry", "Coordinates require exactly [x, y]"));
}
Some([a.first(), a.get(1)])
}
Value::Object(o) if !o.get("pad").map(truthy).unwrap_or(false) => Some([o.get("x"), o.get("y")]),
_ => None,
};
if let Some(c) = coordinates {
for v in c {
number(v, "coordinate", false)?;
}
}
let (point, pad) = resolve_point(spec, &pads).map_err(|e| {
RoutingError::new(e.get("errorCode").and_then(Value::as_str).unwrap_or("bad_point"), e.get("error").and_then(Value::as_str).unwrap_or("bad point").to_string())
})?;
let point = (number(Some(&json!(point.0)), "coordinate", false)?, number(Some(&json!(point.1)), "coordinate", false)?);
if let Some(p) = pad {
if p.net_name != netname {
return Err(RoutingError::new("net_mismatch", format!("Pad {} is not on {netname}", p.name)));
}
}
let layer = spec.get("layer").and_then(Value::as_str).map(str::to_string);
if let Some(l) = &layer {
if !copper.contains(l) {
return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {l}")));
}
}
resolved.push((point, pad, layer));
}
let mut layer: String = match args.get("layer").and_then(Value::as_str) {
Some(l) => l.to_string(),
None => resolved[0]
.2
.clone()
.or_else(|| resolved[0].1.map(|p| p.side.clone()).filter(|s| !s.is_empty()))
.unwrap_or_else(|| DEFAULT_LAYER.to_string()),
};
if !copper.contains(&layer) {
return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {layer}")));
}
for i in 0..resolved.len() {
let (point, pad, explicit) = (&resolved[i].0, resolved[i].1, resolved[i].2.clone());
let next_layer = explicit.unwrap_or_else(|| layer.clone());
if i > 0 {
let previous = resolved[i - 1].0;
if previous != *point {
segments.push(PlanSegment { start: [previous.0, previous.1], end: [point.0, point.1], layer: layer.clone(), width });
blocks.push(segment_sexpr(previous, *point, width, &layer, &net));
}
}
// A layer marker changes the outgoing layer AT this point, including the final
// waypoint (where a via can reach a back-side pad).
if next_layer != layer {
vias.push(PlanVia { at: [point.0, point.1], layers: ["F.Cu".into(), "B.Cu".into()], size, drill });
blocks.push(via_sexpr(*point, size, drill, ("F.Cu", "B.Cu"), &net));
layer = next_layer;
}
if let Some(p) = pad {
if !(p.layers.iter().any(|l| l == &layer) || p.layers.iter().any(|l| l == "*.Cu")) {
return Err(RoutingError::new("pad_layer_mismatch", format!("Pad {} has no copper on {layer}", p.name)));
}
}
}
}
if segments.is_empty() && vias.is_empty() {
return Err(RoutingError::new("zero_length", "No nonzero segments or layer transitions"));
}
if segments.len() + vias.len() > 1024 {
return Err(RoutingError::new("route_too_large", "Limit each call to 1024 copper items"));
}
Ok(Plan { net_name: netname, segments, vias, blocks })
}
// ---------------------------------------------------------------------------
// The board on disk
// ---------------------------------------------------------------------------
/// KiCad's own open-file marker, `~<name>.kicad_pcb.lck` beside the board. The one signal
/// that works with no window list at all.
pub fn lock_file(path: &Path) -> Option<PathBuf> {
let name = path.file_name()?.to_string_lossy().to_string();
let lck = path.with_file_name(format!("~{name}.lck"));
lck.exists().then_some(lck)
}
/// A lock file no KiCad holds any more (KiCad killed or crashed) blocks every file edit and makes
/// KiCad itself ask about it on the next open. Remove it only when it is certainly stale: the OS layer
/// lists no KiCad process at all, and the lock was written on this machine (a board on a shared drive
/// may be open elsewhere). Anything uncertain keeps the lock and the board_open refusal.
fn clear_stale_lock(path: &Path) {
let Some(lck) = lock_file(path) else { return };
if crate::install_kicad::kicad_running().0 != Some(false) {
return;
}
let here = std::env::var("COMPUTERNAME").or_else(|_| std::env::var("HOSTNAME")).unwrap_or_default();
let text = std::fs::read_to_string(&lck).unwrap_or_default();
let host = serde_json::from_str::<Value>(&text).ok().and_then(|v| v["hostname"].as_str().map(str::to_string));
// a lock that names no machine is KiCad's own on this one; one that names a machine must name this one
if host.map_or(true, |h| !here.is_empty() && h.eq_ignore_ascii_case(&here)) {
let _ = std::fs::remove_file(&lck);
}
}
/// A loaded board: path, the exact text on disk and the parse of it.
#[derive(Debug)]
pub struct Loaded {
pub path: PathBuf,
pub text: String,
pub board: Board,
}
/// `route._load`: read and parse a board file, or the ready-to-return error. Without
/// `read_only` an open board (lock file present) is refused before any write.
pub fn load(args: &Value, read_only: bool) -> Result<Loaded, Value> {
let raw = ["filePath", "boardPath"].iter().find_map(|k| args.get(*k).and_then(Value::as_str)).filter(|s| !s.is_empty());
let Some(raw) = raw else {
return Err(json!({
"success": false, "error": "No filePath specified", "errorCode": "missing_arg",
"_hint": "Pass the absolute path to a .kicad_pcb, e.g. {\"filePath\":\"C:/designs/foo/foo.kicad_pcb\"}. Call kicad_board_pads on it first to see pads, nets and copper layers.",
}));
};
let path = PathBuf::from(raw);
if !read_only {
clear_stale_lock(&path);
}
if !read_only && lock_file(&path).is_some() {
return Err(json!({
"success": false, "errorCode": "board_open",
"error": "This board is open in KiCad; file routing would conflict with live edits",
"_hint": "Use kicad_routing_state and kicad_route_net for live edits, or close this board before offline routing.",
}));
}
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
if !name.to_ascii_lowercase().ends_with(".kicad_pcb") {
return Err(json!({
"success": false, "error": format!("not a board file: {name}"), "errorCode": "not_a_board",
"_hint": "These verbs edit a .kicad_pcb. For a schematic there is nothing to route.",
}));
}
if !path.exists() {
return Err(json!({
"success": false, "error": format!("board not found: {}", path.display()), "errorCode": "board_not_found",
"_hint": "Absolute path, no %VAR% expansion. kicad_place_footprint can make a scratch board to route on if you have no project yet.",
}));
}
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
return Err(json!({"success": false, "error": format!("could not read board: {e}"), "errorCode": "board_unreadable", "_hint": "Is the file locked by another process?"}))
}
};
if !text.trim_start().starts_with("(kicad_pcb") {
return Err(json!({
"success": false, "error": "file does not start with '(kicad_pcb'", "errorCode": "not_a_board",
"_hint": "A legacy .brd or a truncated file. kicad_format_upgrade converts old formats.",
}));
}
let board = match parse_pcb_text(&text, &path.to_string_lossy()) {
Ok(b) => b,
Err(e) => {
return Err(json!({
"success": false, "error": format!("could not parse board: {e}"), "errorCode": "board_unparsable",
"_hint": "Our reader is not KiCad's parser. kicad_lint_board gets KiCad's own verdict.",
}))
}
};
Ok(Loaded { path, text, board })
}
/// Validate the new copper, refuse live or changed boards, back up to `<path>.adom-bak`
/// and replace the file through a unique temp file (`route._write`). Returns the backup
/// path, or the ready-to-return `board_write_refused` response.
pub fn write_board(path: &Path, new_text: &str, backup: bool, expected: Option<&str>) -> Result<Option<String>, Value> {
match write_board_inner(path, new_text, backup, expected) {
Ok(bak) => Ok(bak),
Err(e) => Err(json!({
"success": false, "error": format!("Board write refused: {e}"), "errorCode": "board_write_refused",
"_hint": "No board replacement was performed. Reinspect geometry and the live editor before retrying.",
})),
}
}
fn write_board_inner(path: &Path, new_text: &str, backup: bool, expected: Option<&str>) -> Result<Option<String>, String> {
let file = path.to_string_lossy().to_string();
let data = parse_pcb_text(new_text, &file)?;
let old_ids: Vec<String> = match expected {
Some(exp) => {
let original = parse_pcb_text(exp, &file)?;
original.segments.iter().filter_map(|s| s.uuid.clone()).chain(original.vias.iter().filter_map(|v| v.uuid.clone())).collect()
}
None => Vec::new(),
};
let is_old = |uuid: &Option<String>| uuid.as_ref().map(|u| old_ids.contains(u)).unwrap_or(false);
for s in &data.segments {
if is_old(&s.uuid) {
continue;
}
let (Some(w), Some(a), Some(b)) = (s.width, &s.start, &s.end) else {
return Err("Track coordinates must be finite and width must be positive".into());
};
if ![w, a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite()) || w <= 0.0 {
return Err("Track coordinates must be finite and width must be positive".into());
}
}
for v in &data.vias {
if is_old(&v.uuid) {
continue;
}
let (Some(size), Some(drill), Some(p)) = (v.size, v.drill, &v.position) else {
return Err("Via geometry must be finite, with 0 < drill < size".into());
};
if ![size, drill, p.x, p.y].iter().all(|x| x.is_finite()) || !(0.0 < drill && drill < size) {
return Err("Via geometry must be finite, with 0 < drill < size".into());
}
let layers = v.layers.clone().unwrap_or_default();
let mut sorted = layers.clone();
sorted.sort();
sorted.dedup();
if sorted != ["B.Cu".to_string(), "F.Cu".to_string()] {
return Err("Only through vias spanning F.Cu to B.Cu are supported".into());
}
}
if lock_file(path).is_some() {
return Err("Board was opened while planning; use live routing".into());
}
if let Some(exp) = expected {
let current = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
if current != exp {
return Err("Board changed while planning; inspect it and retry".into());
}
}
let mut bak = None;
if backup {
let b = format!("{}.adom-bak", path.to_string_lossy());
std::fs::copy(path, &b).map_err(|e| e.to_string())?;
bak = Some(b);
}
let dir = path.parent().unwrap_or(Path::new("."));
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let tmp = dir.join(format!("{name}.{}.{}.adom-tmp", std::process::id(), new_uuid().split('-').next().unwrap_or("x")));
let result = (|| -> Result<(), String> {
{
use std::io::Write;
let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
f.write_all(new_text.as_bytes()).map_err(|e| e.to_string())?;
f.sync_all().map_err(|e| e.to_string())?;
}
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
})();
let _ = std::fs::remove_file(&tmp);
result?;
Ok(bak)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
pub(crate) mod tests {
use super::*;
fn qfn_board(pad_layers: &str) -> Board {
let pad = |n: &str, x: f64, y: f64| format!("(pad \"{n}\" smd roundrect (at {x} {y}) (size 0.2 0.8) (layers {pad_layers}) (net 1 \"GND\"))");
let text = format!("(kicad_pcb (version 20250907) (generator \"t\") (net 0 \"\") (net 1 \"GND\")\n\
(footprint \"Package_DFN_QFN:QFN-56\" (layer \"B.Cu\") (at 52 153)\n\
(property \"Reference\" \"U1\" (at 0 0) (layer \"B.SilkS\"))\n {}\n {}\n))",
pad("19", -1.0, 3.44), pad("24", 1.0, 3.44));
parse_pcb_text(&text, "t.kicad_pcb").unwrap()
}
#[test]
fn a_back_side_footprint_with_front_pads_is_read_as_kicad_reads_it_and_flagged() {
// KiCad 10.0.2 and 10.0.5 keep these pads on F.Cu at the literal position (wiki #119).
let b = qfn_board("\"F.Cu\" \"F.Mask\" \"F.Paste\"");
let p = pads(&b);
let p19 = p.iter().find(|x| x.pad == "19").unwrap();
assert_eq!((p19.x, p19.y), (51.0, 156.44));
assert!(pad_reaches(Some(p19), "F.Cu") && !pad_reaches(Some(p19), "B.Cu"));
assert_eq!(back_footprints_with_front_pads(&b), vec!["U1".to_string()]);
}
#[test]
fn a_footprint_flipped_by_kicad_itself_is_read_literally() {
// KiCad's own flip writes the pads on the back layers; those positions are already right
// (StickHub demo: 123 of 123 back-side pads match their own net's copper this way).
let b = qfn_board("\"B.Cu\" \"B.Mask\" \"B.Paste\"");
let p = pads(&b);
let p19 = p.iter().find(|x| x.pad == "19").unwrap();
let p24 = p.iter().find(|x| x.pad == "24").unwrap();
assert_eq!((p19.x, p19.y), (51.0, 156.44));
assert_eq!((p24.x, p24.y), (53.0, 156.44));
assert!(pad_reaches(Some(p19), "B.Cu"));
assert!(back_footprints_with_front_pads(&b).is_empty());
}
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()
}
fn rp2040() -> (String, Board) {
let p = repo_root().join("tour-pack-rp2040/template/rp2040-breakout.kicad_pcb");
let text = std::fs::read_to_string(&p).expect("template board present");
let b = parse_pcb_text(&text, &p.to_string_lossy()).unwrap();
(text, b)
}
/// The Python `demo/routing/make_fixture.py` board: three through-hole connectors on
/// six named nets, KiCad 10 dialect.
pub(crate) fn fixture_text() -> String {
let base = std::fs::read_to_string(repo_root().join("templates/blank-board.kicad_pcb")).unwrap();
let mut blocks = String::new();
let mut footprint = |r: &str, x: f64, y: f64, pads: Vec<(String, f64, f64, String)>| {
blocks.push_str(&format!("(footprint \"RoutingFixture:{r}\" (layer \"F.Cu\") (uuid \"{}\") (at {x} {y})\n", new_uuid()));
blocks.push_str(&format!("(property \"Reference\" \"{r}\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n"));
blocks.push_str("(property \"Value\" \"ROUTING_TEST\" (at 0 -5) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n(attr through_hole exclude_from_pos_files exclude_from_bom)\n");
for (n, px, py, net) in pads {
blocks.push_str(&format!("(pad \"{n}\" thru_hole circle (at {px} {py}) (size 2 2) (drill 1) (layers \"*.Cu\" \"*.Mask\") (net \"{net}\") (uuid \"{}\"))\n", new_uuid()));
}
blocks.push_str(")\n");
};
let six = |_: ()| (1..=6).map(|i| (i.to_string(), 0.0, ((i - 1) * 5) as f64, format!("NET_{i}"))).collect::<Vec<_>>();
footprint("J1", 105.0, 85.0, six(()));
footprint("J2", 145.0, 85.0, six(()));
footprint("J3", 125.0, 116.0, vec![("1".into(), 0.0, 0.0, "NET_6".into())]);
blocks.push_str(&format!("(gr_rect (start 98 78) (end 152 122) (stroke (width 0.05) (type default)) (fill none) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", new_uuid()));
let idx = base.rfind(')').unwrap();
format!("{}{}\n)\n", &base[..idx], blocks)
}
fn test_board() -> Board {
// The tests/test_live_routing.py `board()` fixture as s-expression text (KiCad 10 dialect).
let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")
(layers (0 "F.Cu" signal) (1 "In1.Cu" signal) (2 "B.Cu" signal) (5 "F.SilkS" user))
(footprint "X:J1" (layer "F.Cu") (at 10 20 90) (property "Reference" "J1")
(pad "1" thru_hole circle (at 2 0) (size 1 1) (layers "*.Cu") (net "GND"))
(pad "2" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "VCC")))
(footprint "X:J2" (layer "B.Cu") (at 30 20) (property "Reference" "J2")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "B.Cu") (net "GND")))
(footprint "X:J3" (layer "F.Cu") (at 50 20) (property "Reference" "J3")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "123")))
)"#;
parse_pcb_text(text, "test.kicad_pcb").unwrap()
}
#[test]
fn parses_the_rp2040_template_pads_layers_and_dimensions() {
let (_, b) = rp2040();
assert_eq!(b.version.as_deref(), Some("20240108"));
assert_eq!(b.net_format, NetFormat::Number, "the template carries a (net 0 \"\") table");
assert_eq!(b.nets, vec![Net { number: Some(0), name: String::new() }]);
assert_eq!(copper_layers(&b), vec!["F.Cu", "B.Cu"]);
let all = pads(&b);
assert_eq!(all.len(), 117);
let u1 = b.footprints.iter().find(|f| f.reference == "U1").unwrap();
assert_eq!(u1.footprint, "AdomRP2040:QFN-56_AdomRP2040");
assert_eq!((u1.position.as_ref().unwrap().x, u1.position.as_ref().unwrap().y), (130.0, 110.0));
let u1_1 = all.iter().find(|p| p.name == "U1.1").unwrap();
assert_eq!((u1_1.x, u1_1.y), (130.0 - 3.4375, 110.0 - 2.6));
assert_eq!(u1_1.layers, vec!["F.Cu", "F.Mask", "F.Paste"]);
assert_eq!(u1_1.side, "F.Cu");
assert!(b.segments.is_empty() && b.vias.is_empty());
assert!(b.dimensions.is_some() || b.footprints.len() > 1);
}
#[test]
fn rp2040_add_track_and_via_round_trip_with_backup() {
let (text, b) = rp2040();
let dir = std::env::temp_dir().join(format!("adom-pcb-test-{}-{}", std::process::id(), new_uuid()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("rp2040-breakout.kicad_pcb");
std::fs::write(&path, &text).unwrap();
let net = resolve_net(&b, Some(&json!(0))).unwrap();
assert_eq!(net.format, NetFormat::Number);
let all = pads(&b);
let ((ax, ay), pad_a) = resolve_point(&json!("U1.1"), &all).unwrap();
assert!(pad_a.is_some());
let seg = segment_sexpr((ax, ay), (ax - 3.0, ay), 0.2, "F.Cu", &net);
let via = via_sexpr((ax - 3.0, ay), 0.6, 0.3, ("F.Cu", "B.Cu"), &net);
let new_text = splice(&text, &[seg, via]);
let bak = write_board(&path, &new_text, true, Some(&text)).unwrap();
assert_eq!(std::fs::read_to_string(bak.as_ref().unwrap()).unwrap(), text);
let after = parse_pcb(&path).unwrap();
assert_eq!(after.segments.len(), 1);
assert_eq!(after.vias.len(), 1);
let s = &after.segments[0];
assert_eq!(s.net, Some(0));
assert_eq!(s.layer.as_deref(), Some("F.Cu"));
assert_eq!((s.start.as_ref().unwrap().x, s.end.as_ref().unwrap().x), (ax, ax - 3.0));
let v = &after.vias[0];
assert_eq!((v.size, v.drill), (Some(0.6), Some(0.3)));
assert_eq!(v.layers.as_ref().unwrap(), &["F.Cu", "B.Cu"]);
assert!(v.uuid.is_some() && s.uuid.is_some());
// Every pad survives the splice untouched.
assert_eq!(pads(&after), all);
// A changed file is refused (the text on disk no longer matches `expected`).
let err = write_board(&path, &new_text, false, Some(&text)).unwrap_err();
assert_eq!(err["errorCode"], "board_write_refused");
// A lock file marks an open board: one written on another machine (a shared drive) is always honoured.
std::fs::write(dir.join("~rp2040-breakout.kicad_pcb.lck"), r#"{"hostname":"ANOTHER-PC","username":"someone"}"#).unwrap();
assert!(lock_file(&path).is_some());
let err = load(&json!({"filePath": path.to_string_lossy()}), false).unwrap_err();
assert_eq!(err["errorCode"], "board_open");
assert!(load(&json!({"filePath": path.to_string_lossy()}), true).is_ok());
// One left behind on this machine by a KiCad that is no longer running is stale: cleared, the edit goes ahead.
if crate::install_kicad::kicad_running().0 == Some(false) {
std::fs::write(dir.join("~rp2040-breakout.kicad_pcb.lck"), "{}").unwrap();
assert!(load(&json!({"filePath": path.to_string_lossy()}), false).is_ok());
assert!(lock_file(&path).is_none());
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn numeric_net_name_in_kicad_10_file() {
let data = parse_pcb_text("(kicad_pcb (version 20260206) (footprint \"X\" (layer \"F.Cu\") (pad \"1\" thru_hole circle (net \"123\"))))", "x").unwrap();
assert_eq!(data.net_format, NetFormat::Name);
assert_eq!(data.nets, vec![Net { number: None, name: "123".into() }]);
}
#[test]
fn numbered_dialect_reads_both_shapes() {
let data = parse_pcb_text(
"(kicad_pcb (version 20240108) (net 0 \"\") (net 3 \"GND\") (footprint \"X\" (at 1 2) (pad \"1\" smd rect (at 0 0) (net 3 \"GND\"))) (segment (start 0 0) (end 1 0) (width 0.2) (layer \"F.Cu\") (net 3)))",
"x",
)
.unwrap();
assert_eq!(data.net_format, NetFormat::Number);
assert_eq!(data.nets.len(), 2);
assert_eq!(data.segments[0].net, Some(3));
assert_eq!(data.segments[0].net_name, None);
let p = &pads(&data)[0];
assert_eq!((p.net, p.net_name.as_str(), p.net_key.clone()), (Some(3), "GND", json!(3)));
assert_eq!(resolve_net(&data, Some(&json!("123"))).unwrap_err()["errorCode"], "unknown_net");
let b = parse_pcb_text("(kicad_pcb (net 1 \"123\"))", "x").unwrap();
assert_eq!(resolve_net(&b, Some(&json!("123"))).unwrap().number, Some(1));
for spec in [json!(1.5), json!(true), json!("123x")] {
assert!(resolve_net(&b, Some(&spec)).is_err(), "{spec}");
}
assert_eq!(resolve_net(&b, Some(&json!(1))).unwrap().name, "123");
}
#[test]
fn rotated_pad_coordinates_and_final_layer_transition() {
let p = plan(&test_board(), &json!({"net": "GND", "points": ["J1.1", {"pad": "J2.1", "layer": "B.Cu"}]})).unwrap();
assert_eq!(p.segments[0].start, [10.0, 18.0]);
assert_eq!(p.vias[0].at, [30.0, 20.0]);
assert_eq!(p.vias[0].layers, ["F.Cu", "B.Cu"]);
assert_eq!(p.blocks.len(), 2);
}
#[test]
fn branches_on_one_net() {
let p = plan(&test_board(), &json!({"net": "GND", "paths": [["J1.1", [20, 18]], [[20, 18], [20, 25]]]})).unwrap();
assert_eq!(p.segments.len(), 2);
}
#[test]
fn reject_wrong_net_and_layer() {
for (args, code) in [
(json!({"net": "GND", "points": ["J1.1", "J1.2"]}), "net_mismatch"),
(json!({"net": "GND", "points": ["J1.1", "J2.1"]}), "pad_layer_mismatch"),
(json!({"net": "GND", "points": ["J1.1", {"x": 20, "y": 20, "layer": "F.SilkS"}]}), "unknown_layer"),
(json!({"net": "GND", "points": ["J1.1", "J9.1"]}), "unknown_pad"),
(json!({"net": "NOPE", "points": [[0, 0], [1, 1]]}), "unknown_net"),
(json!({"points": [[0, 0], [1, 1]]}), "missing_net"),
] {
assert_eq!(plan(&test_board(), &args).unwrap_err().code, code, "{args}");
}
}
#[test]
fn invalid_geometry_is_rejected_before_mutation() {
for extra in [
json!({"width": 0}),
json!({"width": -1}),
json!({"width": true}),
json!({"points": [[true, 0], [1, 1]]}),
json!({"points": [[0, 0, 9], [1, 1]]}),
json!({"viaDrill": 0.8, "viaSize": 0.6}),
json!({"points": [[0, 0], [5000, 0]]}),
json!({"points": [[0, 0], [0, 0]]}),
json!({"points": [[0, 0]]}),
json!({"paths": []}),
] {
let mut args = json!({"net": "GND", "points": [[0, 0], [1, 1]]});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
assert!(plan(&test_board(), &args).is_err(), "{extra}");
}
}
#[test]
fn exact_numeric_net_name() {
assert_eq!(plan(&test_board(), &json!({"net": "123", "points": [[0, 0], [1, 1]]})).unwrap().net_name, "123");
}
#[test]
fn quoted_net_serialization() {
let net = NetRef { format: NetFormat::Name, number: None, name: "a\"b\\c".into(), key: json!("a\"b\\c") };
assert!(net_sexpr(&net).contains("\"a\\\"b\\\\c\""));
let none = NetRef { format: NetFormat::Name, number: None, name: String::new(), key: json!("") };
assert_eq!(net_sexpr(&none), "");
let numbered = NetRef { format: NetFormat::Number, number: Some(4), name: "X".into(), key: json!(4) };
assert_eq!(net_sexpr(&numbered), "\t\t(net 4)\n");
}
#[test]
fn num_formats_like_kicad() {
assert_eq!(num(1.5), "1.5");
assert_eq!(num(1.0), "1");
assert_eq!(num(0.0), "0");
assert_eq!(num(-0.0000001), "0");
assert_eq!(num(105.0), "105");
assert_eq!(num(0.25), "0.25");
}
#[test]
fn fixture_routes_whole_plan_through_the_file_writer() {
let text = fixture_text();
let b = parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();
assert_eq!(b.net_format, NetFormat::Name);
assert_eq!(b.nets.len(), 6);
assert_eq!(pads(&b).len(), 13);
let p = plan(&b, &json!({"net": "NET_3", "width": 0.25, "points": ["J1.3", {"x": 112, "y": 95, "layer": "B.Cu"}, {"x": 138, "y": 95, "layer": "F.Cu"}, "J2.3"]})).unwrap();
assert_eq!(p.segments.len(), 3);
assert_eq!(p.vias.len(), 2);
assert_eq!(p.segments[1].layer, "B.Cu");
let spliced = splice(&text, &p.blocks);
let after = parse_pcb_text(&spliced, "x").unwrap();
assert_eq!(after.segments.len(), 3);
assert_eq!(after.vias.len(), 2);
assert_eq!(after.segments[0].net_name.as_deref(), Some("NET_3"));
assert_eq!(after.vias[0].net_name.as_deref(), Some("NET_3"));
assert_eq!(default_width(&after, None), 0.25);
}
#[test]
fn pad_reach_and_pad_net() {
let b = test_board();
let all = pads(&b);
let j1_2 = all.iter().find(|p| p.name == "J1.2").unwrap();
assert!(pad_reaches(Some(j1_2), "F.Cu"));
assert!(!pad_reaches(Some(j1_2), "B.Cu"));
let j1_1 = all.iter().find(|p| p.name == "J1.1").unwrap();
assert!(pad_reaches(Some(j1_1), "B.Cu"));
assert_eq!(pad_net(&b, Some(j1_1)).unwrap().name, "GND");
assert!(find_pad(&all, "j1-2").is_ok());
assert!(find_pad(&all, "J1 1").is_ok());
let err = find_pad(&all, "J1.9").unwrap_err();
assert_eq!(err["candidates"].as_array().unwrap().len(), 2);
}
}