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.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati…
0009b41
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
//! Planning for the copper edit verbs that `ipc::route_edit` commits live:
//! `kicad_modify_track` (width, layer or endpoints of existing tracks),
//! `kicad_route_diff_pair` (two coupled paths offset from one centerline) and
//! `kicad_copy_routing` (tracks and vias in a box, translated, nets remapped). Pure: every
//! plan carries the candidate board text the DRC preflight checks, and nothing here talks
//! to KiCad.
//!
//! Coordinates are millimetres in KiCad's board frame (+x right, +y DOWN).
use std::collections::BTreeMap;
use serde::Serialize;
use serde_json::{json, Value};
use crate::pcb::{self, Board, NetFormat, NetRef, Plan, PlanSegment, PlanVia, RoutingError};
use crate::project_rules;
type R<T> = Result<T, RoutingError>;
/// Most copper items one call may create or change (one undo step).
pub const MAX_ITEMS: usize = 1024;
// ---------------------------------------------------------------------------
// Board text helpers
// ---------------------------------------------------------------------------
/// Byte spans of the root list's direct children, quotes respected.
pub fn top_level_spans(text: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let (mut depth, mut quoted, mut escaped, mut start) = (0i32, false, false, 0usize);
for (i, c) in text.char_indices() {
if quoted {
if escaped {
escaped = false
} else if c == '\\' {
escaped = true
} else if c == '"' {
quoted = false
}
continue;
}
match c {
'"' => quoted = true,
'(' => {
depth += 1;
if depth == 2 {
start = i;
}
}
')' => {
if depth == 2 {
spans.push((start, i + 1));
}
depth -= 1;
}
_ => {}
}
}
spans
}
/// The span of the top-level item of one of `kinds` carrying this uuid.
pub fn item_span(text: &str, kinds: &[&str], id: &str) -> Option<(usize, usize)> {
let a = format!("(uuid \"{id}\")");
let b = format!("(tstamp \"{id}\")");
top_level_spans(text).into_iter().find(|(s, e)| {
let item = &text[*s..*e];
kinds.iter().any(|k| item.starts_with(&format!("({k}")) && item[k.len() + 1..].starts_with(|c: char| c.is_whitespace())) && (item.contains(&a) || item.contains(&b))
})
}
/// A net this board can write, from a name (KiCad 10) or a number (older boards).
pub fn net_ref(board: &Board, number: Option<i64>, name: Option<&str>) -> NetRef {
let name = name.map(str::to_string).or_else(|| number.and_then(|n| board.nets.iter().find(|x| x.number == Some(n)).map(|x| x.name.clone()))).unwrap_or_default();
let number = number.or_else(|| board.nets.iter().find(|x| x.name == name).and_then(|x| x.number));
NetRef { format: board.net_format, number, name: name.clone(), key: pcb::net_key(board, number, Some(&name)) }
}
fn named_net(board: &Board, name: &str) -> R<NetRef> {
let hits: Vec<&pcb::Net> = board.nets.iter().filter(|n| n.name == name).collect();
if hits.len() != 1 {
let mut near: Vec<&str> = board.nets.iter().map(|n| n.name.as_str()).filter(|n| n.to_lowercase().contains(&name.to_lowercase())).collect();
near.truncate(20);
return Err(RoutingError::new("unknown_net", format!("No unique net named '{name}'")).with("candidates", json!(near)));
}
Ok(net_ref(board, hits[0].number, Some(name)))
}
fn point(v: &Value, pads: &[pcb::Pad], label: &str) -> R<(f64, f64)> {
pcb::resolve_point(v, pads).map(|(p, _)| p).map_err(|e| RoutingError::new(e.get("errorCode").and_then(Value::as_str).unwrap_or("bad_point"), format!("{label}: {}", e.get("error").and_then(Value::as_str).unwrap_or("bad point"))))
}
fn path_length(segments: &[PlanSegment]) -> f64 {
segments.iter().map(|s| pcb::dist((s.start[0], s.start[1]), (s.end[0], s.end[1]))).sum()
}
fn r4(v: f64) -> f64 {
(v * 10_000.0).round() / 10_000.0
}
// ---------------------------------------------------------------------------
// kicad_modify_track
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct TrackGeom {
pub start: [f64; 2],
pub end: [f64; 2],
pub width: f64,
pub layer: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct TrackChange {
pub id: String,
pub net: String,
pub before: TrackGeom,
pub after: TrackGeom,
pub changed: Vec<&'static str>,
}
/// Read `changes: [{id, width?, layer?, start?, end?}]`, or `itemIds` (or `id`) with a
/// shared `width` / `layer` (and `start` / `end` for a single id). Endpoints take [x,y],
/// {x,y} or a pad name. Returns the changes and the candidate board text with every
/// changed segment rewritten in place (same uuid, same net).
pub fn plan_track_changes(board: &Board, text: &str, args: &Value) -> R<(Vec<TrackChange>, String)> {
let rows: Vec<Value> = if let Some(Value::Array(c)) = args.get("changes") {
c.clone()
} else {
let ids: Vec<String> = match (args.get("itemIds"), args.get("id")) {
(Some(Value::Array(a)), _) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
(_, Some(Value::String(s))) => vec![s.clone()],
_ => Vec::new(),
};
if ids.len() > 1 && (args.get("start").is_some() || args.get("end").is_some()) {
return Err(RoutingError::new("invalid_changes", "start and end apply to one track; use changes:[{id,start,end}] for several"));
}
ids.into_iter().map(|id| json!({"id": id, "width": args.get("width"), "layer": args.get("layer"), "start": args.get("start"), "end": args.get("end")})).collect()
};
if rows.is_empty() || rows.len() > MAX_ITEMS {
return Err(RoutingError::new("invalid_changes", format!("Pass changes:[{{id, width?, layer?, start?, end?}}] (1..{MAX_ITEMS}) or itemIds with width/layer")));
}
let pads = pcb::pads(board);
let copper = pcb::copper_layers(board);
let mut changes: Vec<TrackChange> = Vec::new();
let mut out = text.to_string();
for row in &rows {
let id = row.get("id").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| RoutingError::new("invalid_changes", "every change needs the track id (kicad_routing_state segments[].uuid)"))?;
if changes.iter().any(|c| c.id == id) {
return Err(RoutingError::new("invalid_changes", format!("track {id} appears twice")));
}
let Some(seg) = board.segments.iter().find(|s| s.uuid.as_deref() == Some(id)) else {
let is_arc = item_span(text, &["arc"], id).is_some();
let is_via = board.vias.iter().any(|v| v.uuid.as_deref() == Some(id));
let why = if is_arc { "is an arc; only straight tracks are modified (remove and route it again)" } else if is_via { "is a via, not a track" } else { "is not a track on this board" };
return Err(RoutingError::new("unknown_track", format!("{id} {why}")).with("mutated", json!(false)));
};
let (Some(s), Some(e), Some(w), Some(l)) = (&seg.start, &seg.end, seg.width, &seg.layer) else {
return Err(RoutingError::new("unknown_track", format!("{id} has incomplete geometry")));
};
let before = TrackGeom { start: [s.x, s.y], end: [e.x, e.y], width: w, layer: l.clone() };
let mut after = before.clone();
let mut changed = Vec::new();
if let Some(v) = row.get("width").filter(|v| !v.is_null()) {
after.width = pcb::number(Some(v), "width", true)?;
}
if let Some(v) = row.get("layer").filter(|v| !v.is_null()) {
let layer = v.as_str().ok_or_else(|| RoutingError::new("unknown_layer", "layer is a copper layer name"))?;
if !copper.iter().any(|c| c == layer) {
return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {layer}")).with("copperLayers", json!(copper)));
}
after.layer = layer.to_string();
}
if let Some(v) = row.get("start").filter(|v| !v.is_null()) {
let p = point(v, &pads, "start")?;
after.start = [pcb::round6(p.0), pcb::round6(p.1)];
}
if let Some(v) = row.get("end").filter(|v| !v.is_null()) {
let p = point(v, &pads, "end")?;
after.end = [pcb::round6(p.0), pcb::round6(p.1)];
}
if after.start == after.end {
return Err(RoutingError::new("zero_length", format!("track {id} would have zero length")));
}
if after.width != before.width {
changed.push("width");
}
if after.layer != before.layer {
changed.push("layer");
}
if after.start != before.start {
changed.push("start");
}
if after.end != before.end {
changed.push("end");
}
if changed.is_empty() {
return Err(RoutingError::new("no_change", format!("track {id} already has that geometry")).with("track", json!(before)).with("mutated", json!(false)));
}
let span = item_span(&out, &["segment"], id).ok_or_else(|| RoutingError::new("unknown_track", format!("{id} was not found in the board text")))?;
let old = &out[span.0..span.1];
if old.contains("(locked yes)") || old.contains("(locked)") {
if args.get("allowLocked") != Some(&json!(true)) {
return Err(RoutingError::new("track_locked", format!("track {id} is locked; pass allowLocked:true to change it anyway")).with("mutated", json!(false)));
}
}
let net = net_ref(board, seg.net, seg.net_name.as_deref());
let mut block = pcb::segment_sexpr((after.start[0], after.start[1]), (after.end[0], after.end[1]), after.width, &after.layer, &net);
if let Some(i) = block.find("(uuid \"") {
let j = block[i + 7..].find('"').map(|j| i + 7 + j).unwrap_or(block.len());
block.replace_range(i + 7..j, id);
}
out.replace_range(span.0..span.1, block.trim_start());
changes.push(TrackChange { id: id.to_string(), net: net.name.clone(), before, after, changed });
}
Ok((changes, out))
}
// ---------------------------------------------------------------------------
// kicad_route_diff_pair
// ---------------------------------------------------------------------------
/// The P and N nets of a pair named by its base (`USB_D` -> `USB_D+`/`USB_D-`, `_P`/`_N`,
/// `P`/`N`, `_DP`/`_DN`), or the partner of a P net.
pub fn pair_nets(board: &Board, args: &Value) -> R<(String, String)> {
let has = |n: &str| board.nets.iter().any(|x| x.name == n);
let p = args.get("netP").and_then(Value::as_str);
let n = args.get("netN").and_then(Value::as_str);
if let (Some(p), Some(n)) = (p, n) {
return Ok((p.to_string(), n.to_string()));
}
const SUFFIXES: &[(&str, &str)] = &[("+", "-"), ("_P", "_N"), ("_DP", "_DN"), ("P", "N"), ("_p", "_n")];
if let Some(p) = p {
for (a, b) in SUFFIXES {
if let Some(base) = p.strip_suffix(a) {
let cand = format!("{base}{b}");
if has(&cand) {
return Ok((p.to_string(), cand));
}
}
}
return Err(RoutingError::new("pair_not_found", format!("no N partner found for {p}; pass netN")));
}
if let Some(base) = args.get("pair").and_then(Value::as_str) {
for (a, b) in SUFFIXES {
let (cp, cn) = (format!("{base}{a}"), format!("{base}{b}"));
if has(&cp) && has(&cn) {
return Ok((cp, cn));
}
}
return Err(RoutingError::new("pair_not_found", format!("no P/N nets found for the pair {base} (tried +/-, _P/_N, _DP/_DN, P/N)")));
}
Err(RoutingError::new("missing_net", "Pass netP and netN, netP alone, or pair (the base name)"))
}
/// Offset a polyline sideways by `d[i]` at vertex i (positive = left of the direction of
/// travel, in the y-down frame). Interior vertices use the miter of the two adjacent
/// normals; a corner sharper than about 130 degrees of turn is refused.
pub fn offset_path(points: &[(f64, f64)], d: &[f64]) -> R<Vec<(f64, f64)>> {
let n = points.len();
let normal = |a: (f64, f64), b: (f64, f64)| -> R<(f64, f64)> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
return Err(RoutingError::new("invalid_geometry", "the centerline repeats a point"));
}
Ok((dy / len, -dx / len))
};
let mut out = Vec::with_capacity(n);
for i in 0..n {
let (nx, ny) = if i == 0 {
normal(points[0], points[1])?
} else if i == n - 1 {
normal(points[n - 2], points[n - 1])?
} else {
let a = normal(points[i - 1], points[i])?;
let b = normal(points[i], points[i + 1])?;
let (mx, my) = (a.0 + b.0, a.1 + b.1);
let ml = (mx * mx + my * my).sqrt();
if ml < 0.85 {
return Err(RoutingError::new("corner_too_sharp", format!("the centerline turns too sharply at point {i}; split the corner into two 45-degree bends")));
}
let (ux, uy) = (mx / ml, my / ml);
let scale = 1.0 / (ux * a.0 + uy * a.1);
(ux * scale, uy * scale)
};
out.push((pcb::round6(points[i].0 + nx * d[i]), pcb::round6(points[i].1 + ny * d[i])));
}
Ok(out)
}
#[derive(Clone, Debug)]
pub struct DiffPairPlan {
pub p: Plan,
pub n: Plan,
pub width: f64,
pub gap: f64,
pub via_gap: f64,
pub length_p: f64,
pub length_n: f64,
pub source: &'static str,
}
impl DiffPairPlan {
pub fn public(&self) -> Value {
let skew = r4((self.length_p - self.length_n).abs());
json!({
"netP": self.p.net_name, "netN": self.n.net_name,
"width": self.width, "gap": self.gap, "viaGap": self.via_gap, "sizesFrom": self.source,
"p": self.p.public(), "n": self.n.public(),
"lengthP": r4(self.length_p), "lengthN": r4(self.length_n), "skewMm": skew,
"longer": if skew == 0.0 { Value::Null } else if self.length_p > self.length_n { json!(self.p.net_name) } else { json!(self.n.net_name) },
"viaPairs": self.p.vias.len(),
})
}
pub fn blocks(&self) -> Vec<String> {
self.p.blocks.iter().chain(self.n.blocks.iter()).cloned().collect()
}
}
/// Plan both halves of a pair: `centerline: [[x,y] | {x,y,layer?}, ...]` (a `layer` on a
/// point changes layer there, with a via pair), `gap` and `width` (default: the P net's
/// netclass diff pair values from the project, else 0.25 / 0.2), `pSide: left|right`,
/// optional `pStart`, `nStart`, `pEnd`, `nEnd` pads joined to the offset ends, and the
/// usual `layer`, `viaSize`, `viaDrill`. Each half goes through `pcb::plan`, the planner
/// `kicad_route_net` uses, so its pad, net and layer checks hold for both.
pub fn plan_diff_pair(board: &Board, project: Option<&Value>, args: &Value) -> R<DiffPairPlan> {
let (net_p, net_n) = pair_nets(board, args)?;
if net_p == net_n {
return Err(RoutingError::new("invalid_pair", "netP and netN must differ"));
}
named_net(board, &net_p)?;
named_net(board, &net_n)?;
let class = project.map(|p| project_rules::class_for_net(p, &net_p));
let from_class = |k: &str| class.as_ref().and_then(|c| c.get(k)).and_then(Value::as_f64).filter(|v| *v > 0.0);
let source = if class.is_some() && args.get("gap").is_none() && args.get("width").is_none() { "netclass" } else if args.get("gap").is_some() && args.get("width").is_some() { "arguments" } else if class.is_some() { "arguments+netclass" } else { "defaults" };
let width = match args.get("width") {
Some(v) => pcb::number(Some(v), "width", true)?,
None => from_class("diff_pair_width").unwrap_or(0.2),
};
let gap = match args.get("gap") {
Some(v) => pcb::number(Some(v), "gap", true)?,
None => from_class("diff_pair_gap").unwrap_or(0.25),
};
let via_size = pcb::number(Some(args.get("viaSize").unwrap_or(&json!(0.6))), "viaSize", true)?;
let via_drill = pcb::number(Some(args.get("viaDrill").unwrap_or(&json!(0.3))), "viaDrill", true)?;
let via_gap = match args.get("viaGap") {
Some(v) => pcb::number(Some(v), "viaGap", true)?,
None => from_class("diff_pair_via_gap").unwrap_or(gap),
};
let pads = pcb::pads(board);
let line = args.get("centerline").and_then(Value::as_array).filter(|a| (2..=256).contains(&a.len())).ok_or_else(|| RoutingError::new("invalid_path", "centerline needs 2..256 points: [x,y] or {x,y,layer?}"))?;
let mut pts = Vec::with_capacity(line.len());
let mut layers: Vec<Option<String>> = Vec::with_capacity(line.len());
for (i, spec) in line.iter().enumerate() {
if spec.get("pad").is_some() || spec.is_string() {
return Err(RoutingError::new("invalid_path", "centerline points are coordinates; join pads with pStart/nStart/pEnd/nEnd"));
}
pts.push(point(spec, &pads, &format!("centerline[{i}]"))?);
layers.push(spec.get("layer").and_then(Value::as_str).map(str::to_string));
}
let half = (gap + width) / 2.0;
let half_via = half.max((via_size + via_gap) / 2.0);
let sign = match args.get("pSide").and_then(Value::as_str) {
None | Some("left") => 1.0,
Some("right") => -1.0,
Some(o) => return Err(RoutingError::new("invalid_side", format!("pSide is left or right, not {o}"))),
};
let d: Vec<f64> = (0..pts.len()).map(|i| if layers[i].is_some() { half_via } else { half }).collect();
let left = offset_path(&pts, &d)?;
let right = offset_path(&pts, &d.iter().map(|v| -v).collect::<Vec<_>>())?;
let (p_pts, n_pts) = if sign > 0.0 { (left, right) } else { (right, left) };
let build = |net: &str, offs: &[(f64, f64)], start: Option<&Value>, end: Option<&Value>| -> R<Plan> {
let mut path: Vec<Value> = Vec::new();
if let Some(s) = start {
path.push(s.clone());
}
for (i, p) in offs.iter().enumerate() {
let mut v = json!({"x": p.0, "y": p.1});
if let Some(l) = &layers[i] {
v["layer"] = json!(l);
}
path.push(v);
}
if let Some(e) = end {
path.push(e.clone());
}
let mut a = json!({"net": net, "points": path, "width": width, "viaSize": via_size, "viaDrill": via_drill});
if let Some(l) = args.get("layer") {
a["layer"] = l.clone();
}
pcb::plan(board, &a).map_err(|e| {
let mut e2 = RoutingError::new(&e.code, format!("{net}: {}", e.message));
e2.detail = e.detail;
e2
})
};
let p = build(&net_p, &p_pts, args.get("pStart"), args.get("pEnd"))?;
let n = build(&net_n, &n_pts, args.get("nStart"), args.get("nEnd"))?;
if p.segments.len() + p.vias.len() + n.segments.len() + n.vias.len() > MAX_ITEMS {
return Err(RoutingError::new("route_too_large", format!("Limit each call to {MAX_ITEMS} copper items")));
}
let (length_p, length_n) = (path_length(&p.segments), path_length(&n.segments));
Ok(DiffPairPlan { p, n, width, gap, via_gap, length_p, length_n, source })
}
// ---------------------------------------------------------------------------
// kicad_copy_routing
// ---------------------------------------------------------------------------
#[derive(Clone, Debug)]
pub struct CopyPlan {
pub plans: Vec<Plan>,
pub offset: (f64, f64),
pub net_map: BTreeMap<String, String>,
pub skipped: Vec<Value>,
pub source_ids: Vec<String>,
pub ref_map: BTreeMap<String, String>,
}
impl CopyPlan {
pub fn blocks(&self) -> Vec<String> {
self.plans.iter().flat_map(|p| p.blocks.iter().cloned()).collect()
}
pub fn public(&self) -> Value {
json!({
"offset": {"dx": self.offset.0, "dy": self.offset.1},
"netMap": self.net_map, "refMap": self.ref_map,
"segmentCount": self.plans.iter().map(|p| p.segments.len()).sum::<usize>(),
"viaCount": self.plans.iter().map(|p| p.vias.len()).sum::<usize>(),
"sourceItemIds": self.source_ids,
"skipped": self.skipped,
"byNet": self.plans.iter().map(|p| p.public()).collect::<Vec<_>>(),
})
}
}
/// `*` in `from` captures; the capture fills the `*` in `to`. Without a star, a plain
/// substring replacement.
pub fn map_ref(reference: &str, from: &str, to: &str) -> Option<String> {
match from.split_once('*') {
Some((pre, post)) => {
if reference.len() >= pre.len() + post.len() && reference.starts_with(pre) && reference.ends_with(post) {
let mid = &reference[pre.len()..reference.len() - post.len()];
Some(if to.contains('*') { to.replacen('*', mid, 1) } else { format!("{to}{mid}") })
} else {
None
}
}
None => reference.contains(from).then(|| reference.replacen(from, to, 1)),
}
}
fn rect_arg(v: Option<&Value>) -> R<(f64, f64, f64, f64)> {
let v = v.ok_or_else(|| RoutingError::new("missing_arg", "source is the box to copy from: {minX, minY, maxX, maxY} in mm"))?;
let g = |k: &str| pcb::number(v.get(k), k, false);
let (a, b, c, d) = (g("minX")?, g("minY")?, g("maxX")?, g("maxY")?);
if c <= a || d <= b {
return Err(RoutingError::new("invalid_geometry", "source box needs maxX > minX and maxY > minY"));
}
Ok((a, b, c, d))
}
/// Copy every track (both ends inside) and via (centre inside) of `source` by `offset`
/// ({dx, dy} or [dx, dy]). Nets: `netMap` {from: to} wins; else `refMap` {"U1": "U3"} or
/// `refPattern` {from: "CH1_*", to: "CH2_*"} pairs footprints and maps every source pad's
/// net to the same pad number's net on its partner. With a remap in play an item whose
/// net has no mapping is skipped (`keepUnmappedNets:true` keeps its net).
pub fn plan_copy(board: &Board, args: &Value) -> R<CopyPlan> {
let (x0, y0, x1, y1) = rect_arg(args.get("source"))?;
let off = args.get("offset").ok_or_else(|| RoutingError::new("missing_arg", "offset is {dx, dy} in mm"))?;
let (dx, dy) = match off {
Value::Array(a) if a.len() == 2 => (pcb::number(a.first(), "dx", false)?, pcb::number(a.get(1), "dy", false)?),
_ => (pcb::number(off.get("dx"), "dx", false)?, pcb::number(off.get("dy"), "dy", false)?),
};
if dx == 0.0 && dy == 0.0 {
return Err(RoutingError::new("invalid_geometry", "offset is zero: the copy would sit on the original"));
}
let layers: Option<Vec<String>> = args.get("layers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
// footprint pairs
let mut ref_map: BTreeMap<String, String> = BTreeMap::new();
if let Some(m) = args.get("refMap").and_then(Value::as_object) {
for (k, v) in m {
if let Some(t) = v.as_str() {
ref_map.insert(k.clone(), t.to_string());
}
}
}
if let Some(p) = args.get("refPattern") {
let from = p.get("from").and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_ref_pattern", "refPattern is {from: \"CH1_*\", to: \"CH2_*\"}"))?;
let to = p.get("to").and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_ref_pattern", "refPattern is {from, to}"))?;
for fp in &board.footprints {
if let Some(t) = map_ref(&fp.reference, from, to) {
if t != fp.reference && board.footprints.iter().any(|f| f.reference == t) {
ref_map.entry(fp.reference.clone()).or_insert(t);
}
}
}
if ref_map.is_empty() {
return Err(RoutingError::new("no_ref_pairs", format!("refPattern {from} -> {to} pairs no two footprints on this board")));
}
}
let pads = pcb::pads(board);
let mut net_map: BTreeMap<String, String> = BTreeMap::new();
let mut conflicts: Vec<Value> = Vec::new();
for (src, dst) in &ref_map {
if !board.footprints.iter().any(|f| &f.reference == dst) {
return Err(RoutingError::new("unknown_footprint", format!("refMap target {dst} is not on this board")));
}
for p in pads.iter().filter(|p| &p.reference == src && !p.net_name.is_empty()) {
let Some(q) = pads.iter().find(|q| &q.reference == dst && q.pad == p.pad) else { continue };
match net_map.get(&p.net_name) {
Some(existing) if existing != &q.net_name => conflicts.push(json!({"net": p.net_name, "maps": [existing, q.net_name], "via": format!("{src}.{} -> {dst}.{}", p.pad, q.pad)})),
Some(_) => {}
None => {
if !q.net_name.is_empty() {
net_map.insert(p.net_name.clone(), q.net_name.clone());
}
}
}
}
}
if let Some(m) = args.get("netMap").and_then(Value::as_object) {
for (k, v) in m {
if let Some(t) = v.as_str() {
named_net(board, t)?;
net_map.insert(k.clone(), t.to_string());
}
}
}
if !conflicts.is_empty() && args.get("netMap").is_none() {
return Err(RoutingError::new("net_map_conflict", "the footprint pairs map one source net to more than one target net").with("conflicts", json!(conflicts)).with("_hint", json!("Pass netMap {from: to} for the nets named in conflicts; it overrides the pad-derived mapping.")));
}
let remapping = !ref_map.is_empty() || args.get("netMap").is_some();
let keep = args.get("keepUnmappedNets").and_then(Value::as_bool).unwrap_or(!remapping);
let inside = |x: f64, y: f64| x >= x0 - 1e-9 && x <= x1 + 1e-9 && y >= y0 - 1e-9 && y <= y1 + 1e-9;
let layer_ok = |l: &str| layers.as_ref().map(|ls| ls.iter().any(|x| x == l)).unwrap_or(true);
let target_net = |name: &str| -> Option<String> {
match net_map.get(name) {
Some(t) => Some(t.clone()),
None if keep => Some(name.to_string()),
None => None,
}
};
let mut by_net: BTreeMap<String, Plan> = BTreeMap::new();
let mut skipped = Vec::new();
let mut source_ids = Vec::new();
let mut count = 0usize;
for s in &board.segments {
let (Some(a), Some(b), Some(w), Some(l)) = (&s.start, &s.end, s.width, &s.layer) else { continue };
if !(inside(a.x, a.y) && inside(b.x, b.y)) || !layer_ok(l) {
continue;
}
let name = s.net_name.clone().unwrap_or_default();
let id = s.uuid.clone().unwrap_or_default();
let Some(t) = target_net(&name) else {
skipped.push(json!({"id": id, "kind": "track", "net": name, "reason": "unmapped_net"}));
continue;
};
let net = net_ref(board, None, Some(&t));
let (na, nb) = ((pcb::round6(a.x + dx), pcb::round6(a.y + dy)), (pcb::round6(b.x + dx), pcb::round6(b.y + dy)));
let plan = by_net.entry(t.clone()).or_insert_with(|| Plan { net_name: t.clone(), segments: Vec::new(), vias: Vec::new(), blocks: Vec::new() });
plan.segments.push(PlanSegment { start: [na.0, na.1], end: [nb.0, nb.1], layer: l.clone(), width: w });
plan.blocks.push(pcb::segment_sexpr(na, nb, w, l, &net));
source_ids.push(id);
count += 1;
}
for v in &board.vias {
let (Some(p), Some(size), Some(drill)) = (&v.position, v.size, v.drill) else { continue };
if !inside(p.x, p.y) {
continue;
}
let name = v.net_name.clone().unwrap_or_default();
let id = v.uuid.clone().unwrap_or_default();
let mut ls = v.layers.clone().unwrap_or_default();
ls.sort();
if ls != ["B.Cu".to_string(), "F.Cu".to_string()] || v.kind.as_deref().map(|k| k != "through" && !k.is_empty()).unwrap_or(false) {
skipped.push(json!({"id": id, "kind": "via", "net": name, "reason": "not_a_through_via"}));
continue;
}
let Some(t) = target_net(&name) else {
skipped.push(json!({"id": id, "kind": "via", "net": name, "reason": "unmapped_net"}));
continue;
};
let net = net_ref(board, None, Some(&t));
let at = (pcb::round6(p.x + dx), pcb::round6(p.y + dy));
let plan = by_net.entry(t.clone()).or_insert_with(|| Plan { net_name: t.clone(), segments: Vec::new(), vias: Vec::new(), blocks: Vec::new() });
plan.vias.push(PlanVia { at: [at.0, at.1], layers: ["F.Cu".into(), "B.Cu".into()], size, drill });
plan.blocks.push(pcb::via_sexpr(at, size, drill, ("F.Cu", "B.Cu"), &net));
source_ids.push(id);
count += 1;
}
if count == 0 {
return Err(RoutingError::new("nothing_to_copy", "no track or via lies inside the source box").with("skipped", json!(skipped)));
}
if count > MAX_ITEMS {
return Err(RoutingError::new("route_too_large", format!("{count} items; limit each call to {MAX_ITEMS}")));
}
if board.net_format == NetFormat::Number {
for t in by_net.keys() {
if !t.is_empty() && !board.nets.iter().any(|n| &n.name == t) {
return Err(RoutingError::new("unknown_net", format!("target net {t} is not on this board")));
}
}
}
Ok(CopyPlan { plans: by_net.into_values().collect(), offset: (dx, dy), net_map, skipped, source_ids, ref_map })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offsets_keep_a_constant_distance_on_straight_runs_and_miter_at_corners() {
let pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)];
let left = offset_path(&pts, &[1.0, 1.0, 1.0]).unwrap();
// left of +x travel in a y-down frame is -y
assert_eq!(left[0], (0.0, -1.0));
assert_eq!(left[1], (11.0, -1.0));
assert_eq!(left[2], (11.0, 10.0));
let right = offset_path(&pts, &[-1.0, -1.0, -1.0]).unwrap();
assert_eq!(right[1], (9.0, 1.0));
assert!(offset_path(&[(0.0, 0.0), (10.0, 0.0), (0.0, 0.1)], &[1.0; 3]).is_err());
}
#[test]
fn reference_patterns_capture_the_star() {
assert_eq!(map_ref("CH1_R3", "CH1_*", "CH2_*").as_deref(), Some("CH2_R3"));
assert_eq!(map_ref("R13", "R1*", "R2*").as_deref(), Some("R23"));
assert_eq!(map_ref("U1", "CH1_*", "CH2_*"), None);
assert_eq!(map_ref("U1A", "1", "2").as_deref(), Some("U2A"));
}
}