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 #454: 1.0.107: kicad_move_footprint refuses unknown arguments (PR #70) without refusing real calls
ae67b65
2d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992
//! Footprint placement on a parsed board: every footprint's courtyard as a box in board
//! coordinates, the courtyard overlap and board outline checks, the minimum-spanning-tree
//! ratsnest estimate, and the move plan that `ipc::move_footprint` commits through the
//! KiCad IPC API. Pure: nothing here talks to KiCad, so every rule is tested without it.
//!
//! Coordinates are millimetres in KiCad's board frame, +x right, +y DOWN, the same frame
//! `pcb::pads` reports. Rotation is degrees, KiCad's sign (counter-clockwise on screen).
//!
//! What "courtyard" means here: the axis-aligned box around the footprint's F.CrtYd or
//! B.CrtYd shapes after rotation. For the usual rectangular courtyard at a multiple of
//! 90 degrees that is exact; at other angles it is the conservative enclosing box. A
//! footprint with no courtyard gets its pad extent plus `COURTYARD_FALLBACK_MARGIN`
//! and says so (`courtyardSource`). Boxes on different sides never conflict, which is
//! KiCad's own courtyard rule (F.CrtYd against F.CrtYd, B.CrtYd against B.CrtYd).
use serde::Serialize;
use serde_json::{json, Value};
use crate::pcb::{self, Board, Footprint, RoutingError};
/// Margin around the pad extent when a footprint has no courtyard shape.
pub const COURTYARD_FALLBACK_MARGIN: f64 = 0.25;
/// Above this many footprints `state` omits per-pad nets unless `detail:true`.
pub const DETAIL_LIMIT: usize = 200;
/// Most footprints one `kicad_move_footprint` call may move (one undo step).
pub const MAX_MOVES: usize = 256;
/// Two boxes that merely touch do not overlap.
const EPS: f64 = 1e-6;
// ---------------------------------------------------------------------------
// Geometry
// ---------------------------------------------------------------------------
/// An axis-aligned box in board millimetres.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct Rect {
#[serde(rename = "minX")]
pub min_x: f64,
#[serde(rename = "minY")]
pub min_y: f64,
#[serde(rename = "maxX")]
pub max_x: f64,
#[serde(rename = "maxY")]
pub max_y: f64,
}
impl Rect {
pub fn from_points<I: IntoIterator<Item = (f64, f64)>>(points: I) -> Option<Rect> {
let mut r: Option<Rect> = None;
for (x, y) in points {
r = Some(match r {
None => Rect { min_x: x, min_y: y, max_x: x, max_y: y },
Some(b) => Rect { min_x: b.min_x.min(x), min_y: b.min_y.min(y), max_x: b.max_x.max(x), max_y: b.max_y.max(y) },
});
}
r
}
pub fn expand(self, margin: f64) -> Rect {
Rect { min_x: self.min_x - margin, min_y: self.min_y - margin, max_x: self.max_x + margin, max_y: self.max_y + margin }
}
pub fn width(&self) -> f64 {
self.max_x - self.min_x
}
pub fn height(&self) -> f64 {
self.max_y - self.min_y
}
/// Strict overlap: a shared edge or corner is not an overlap.
pub fn overlaps(&self, o: &Rect) -> bool {
self.min_x < o.max_x - EPS && o.min_x < self.max_x - EPS && self.min_y < o.max_y - EPS && o.min_y < self.max_y - EPS
}
/// `o` lies entirely inside this box (touching the edge counts as inside).
pub fn contains(&self, o: &Rect) -> bool {
o.min_x >= self.min_x - EPS && o.max_x <= self.max_x + EPS && o.min_y >= self.min_y - EPS && o.max_y <= self.max_y + EPS
}
fn rounded(self) -> Rect {
let r = |v: f64| (v * 1000.0).round() / 1000.0;
Rect { min_x: r(self.min_x), min_y: r(self.min_y), max_x: r(self.max_x), max_y: r(self.max_y) }
}
}
/// Footprint-local to board coordinates, the way `pcb::pads` does it (KiCad rotates in
/// a y-down frame: x' = x cos + y sin, y' = -x sin + y cos).
pub fn to_board(origin: (f64, f64), rotation_deg: f64, local: (f64, f64)) -> (f64, f64) {
let (sin_a, cos_a) = rotation_deg.to_radians().sin_cos();
(origin.0 + local.0 * cos_a + local.1 * sin_a, origin.1 - local.0 * sin_a + local.1 * cos_a)
}
/// The board outline box: the extent of every Edge.Cuts item.
pub fn outline(board: &Board) -> Option<Rect> {
board.dimensions.as_ref().map(|d| Rect { min_x: d.min_x, min_y: d.min_y, max_x: d.max_x, max_y: d.max_y })
}
/// The courtyard box a footprint would have at `origin` and `rotation`, and where it
/// came from: `courtyard`, `pads+0.25mm` (no courtyard shape) or `position` (no pads
/// either, a 1 mm box around the anchor).
pub fn courtyard_at(fp: &Footprint, origin: (f64, f64), rotation: f64) -> (Rect, &'static str) {
if !fp.courtyard.is_empty() {
let pts = fp.courtyard.iter().map(|p| to_board(origin, rotation, (p.x, p.y)));
if let Some(r) = Rect::from_points(pts) {
return (r.rounded(), "courtyard");
}
}
let mut corners: Vec<(f64, f64)> = Vec::new();
for pad in &fp.pads {
let (Some(px), Some(py)) = (pad.x, pad.y) else { continue };
let hw = pad.width.unwrap_or(0.0) / 2.0;
let hh = pad.height.unwrap_or(0.0) / 2.0;
for (dx, dy) in [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)] {
corners.push(to_board(origin, rotation, (px + dx, py + dy)));
}
}
match Rect::from_points(corners) {
Some(r) => (r.expand(COURTYARD_FALLBACK_MARGIN).rounded(), "pads+0.25mm"),
None => (Rect { min_x: origin.0, min_y: origin.1, max_x: origin.0, max_y: origin.1 }.expand(0.5).rounded(), "position"),
}
}
fn side_of(layer: Option<&str>) -> String {
match layer {
Some("B.Cu") => "B.Cu".into(),
_ => "F.Cu".into(),
}
}
// ---------------------------------------------------------------------------
// Placements
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct PadNet {
pub pad: String,
pub net: String,
}
/// One footprint as `kicad_placement_state` reports it.
#[derive(Clone, Debug, Serialize)]
pub struct Placement {
#[serde(rename = "ref")]
pub reference: String,
pub value: String,
pub footprint: String,
pub uuid: Option<String>,
pub side: String,
pub x: f64,
pub y: f64,
pub rotation: f64,
pub courtyard: Rect,
#[serde(rename = "courtyardSource")]
pub courtyard_source: &'static str,
#[serde(rename = "padCount")]
pub pad_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub pads: Option<Vec<PadNet>>,
pub locked: bool,
/// None when the board has no Edge.Cuts outline.
#[serde(rename = "insideOutline")]
pub inside_outline: Option<bool>,
/// The courtyard's real outline in board mm (wiki #125); empty when the footprint has
/// no courtyard shape, in which case the box decides.
#[serde(skip)]
pub outline: Vec<Edge>,
}
/// One courtyard edge in board mm.
pub type Edge = ((f64, f64), (f64, f64));
/// A footprint's courtyard edges placed at `origin` and `rotation`.
pub fn courtyard_edges_at(fp: &Footprint, origin: (f64, f64), rotation: f64) -> Vec<Edge> {
fp.courtyard_edges.iter().map(|(a, b)| (to_board(origin, rotation, (a.x, a.y)), to_board(origin, rotation, (b.x, b.y)))).collect()
}
fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
(b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
}
/// Two segments cross through each other's interior. Touching at an end, or running along
/// each other, is not a crossing: courtyards may share an edge.
fn segments_cross(p: Edge, q: Edge) -> bool {
const T: f64 = 1e-5;
let d1 = orient(q.0, q.1, p.0);
let d2 = orient(q.0, q.1, p.1);
let d3 = orient(p.0, p.1, q.0);
let d4 = orient(p.0, p.1, q.1);
((d1 > T && d2 < -T) || (d1 < -T && d2 > T)) && ((d3 > T && d4 < -T) || (d3 < -T && d4 > T))
}
/// Even-odd point-in-outline over a set of edges that together close one or more loops.
fn inside(pt: (f64, f64), edges: &[Edge]) -> bool {
let mut odd = false;
for &((x1, y1), (x2, y2)) in edges {
if (y1 > pt.1) != (y2 > pt.1) {
let x = x1 + (pt.1 - y1) * (x2 - x1) / (y2 - y1);
if x > pt.0 {
odd = !odd;
}
}
}
odd
}
fn on_boundary(pt: (f64, f64), edges: &[Edge]) -> bool {
edges.iter().any(|&(a, b)| {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((pt.0 - a.0) * dx + (pt.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let (cx, cy) = (a.0 + t * dx, a.1 + t * dy);
(pt.0 - cx).powi(2) + (pt.1 - cy).powi(2) < 1e-10
})
}
/// Points that sample an outline's region: its corners, the middle of each edge, and a point a hair
/// inside the outline beside each edge's middle (wiki #133). Not the centre of the corners: a ring
/// courtyard's centre is in its hole, and a ray from there skims the polygon's vertices.
fn probes(e: &[Edge]) -> Vec<(f64, f64)> {
const D: f64 = 1e-4;
let mut v: Vec<(f64, f64)> = Vec::with_capacity(e.len() * 3);
for &(a, b) in e {
let m = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);
v.push(a);
v.push(m);
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let l = (dx * dx + dy * dy).sqrt();
if l > 1e-9 {
let n = (-dy / l * D, dx / l * D);
for q in [(m.0 + n.0, m.1 + n.1), (m.0 - n.0, m.1 - n.1)] {
if inside(q, e) {
v.push(q);
}
}
}
}
v
}
/// Do two courtyard outlines overlap with some area: an edge of one crosses an edge of the
/// other, or a sample point of one lies strictly inside the other. Sharing an edge or a
/// corner is not an overlap.
pub fn outlines_overlap(a: &[Edge], b: &[Edge]) -> bool {
for &ea in a {
for &eb in b {
if segments_cross(ea, eb) {
return true;
}
}
}
let strictly_in = |pt: (f64, f64), outer: &[Edge]| !on_boundary(pt, outer) && inside(pt, outer);
probes(a).into_iter().any(|p| strictly_in(p, b)) || probes(b).into_iter().any(|p| strictly_in(p, a))
}
fn placement_of(fp: &Footprint, outline: Option<&Rect>) -> Placement {
let (x, y, rotation) = 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 (courtyard, courtyard_source) = courtyard_at(fp, (x, y), rotation);
Placement {
reference: fp.reference.clone(),
value: fp.value.clone(),
footprint: fp.footprint.clone(),
uuid: fp.uuid.clone(),
side: side_of(fp.layer.as_deref()),
x,
y,
rotation,
courtyard,
courtyard_source,
pad_count: fp.pads.len(),
pads: Some(fp.pads.iter().map(|p| PadNet { pad: p.number.clone(), net: p.net_name.clone().unwrap_or_default() }).collect()),
locked: fp.locked,
inside_outline: outline.map(|o| o.contains(&courtyard)),
outline: courtyard_edges_at(fp, (x, y), rotation),
}
}
/// Every footprint's placement, in file order.
pub fn placements(board: &Board) -> Vec<Placement> {
let outline = outline(board);
board.footprints.iter().map(|fp| placement_of(fp, outline.as_ref())).collect()
}
/// A courtyard collision between two footprints on the same side.
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct Overlap {
pub a: String,
pub b: String,
pub side: String,
}
/// Every pair of same-side footprints whose courtyard boxes overlap, `a` < `b`.
pub fn overlaps(places: &[Placement]) -> Vec<Overlap> {
let mut out = Vec::new();
for i in 0..places.len() {
for j in (i + 1)..places.len() {
let (p, q) = (&places[i], &places[j]);
// The box is the broad phase; when both have a real courtyard outline, the
// outlines decide (wiki #125: a stepped TSSOP courtyard is not its box).
if p.side == q.side && p.courtyard.overlaps(&q.courtyard) && (p.outline.is_empty() || q.outline.is_empty() || outlines_overlap(&p.outline, &q.outline)) {
let (a, b) = if ref_key(&p.reference) <= ref_key(&q.reference) { (&p.reference, &q.reference) } else { (&q.reference, &p.reference) };
out.push(Overlap { a: a.clone(), b: b.clone(), side: p.side.clone() });
}
}
}
out.sort_by(|x, y| ref_key(&x.a).cmp(&ref_key(&y.a)).then_with(|| ref_key(&x.b).cmp(&ref_key(&y.b))));
out
}
/// Natural order for references: the letters, then the number (C2 before C10).
pub fn ref_key(r: &str) -> (String, u64, String) {
let letters: String = r.chars().take_while(|c| !c.is_ascii_digit()).collect();
let rest = &r[letters.len()..];
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
(letters.to_uppercase(), digits.parse().unwrap_or(0), rest[digits.len()..].to_string())
}
// ---------------------------------------------------------------------------
// Ratsnest
// ---------------------------------------------------------------------------
/// Length of the Euclidean minimum spanning tree over `points` (Prim, O(n^2)).
pub fn mst_length(points: &[(f64, f64)]) -> f64 {
if points.len() < 2 {
return 0.0;
}
let n = points.len();
let mut in_tree = vec![false; n];
let mut best = vec![f64::INFINITY; n];
best[0] = 0.0;
let mut total = 0.0;
for _ in 0..n {
let mut u = usize::MAX;
for i in 0..n {
if !in_tree[i] && (u == usize::MAX || best[i] < best[u]) {
u = i;
}
}
in_tree[u] = true;
total += best[u];
for v in 0..n {
if !in_tree[v] {
let d = pcb::dist(points[u], points[v]);
if d < best[v] {
best[v] = d;
}
}
}
}
total
}
/// One net as the placement verbs report it: which footprints it touches and the
/// ratsnest estimate (MST over its pad centres).
#[derive(Clone, Debug, Serialize)]
pub struct NetPlacement {
pub name: String,
#[serde(rename = "padCount")]
pub pad_count: usize,
pub refs: Vec<String>,
#[serde(rename = "mstLengthMm")]
pub mst_length_mm: f64,
}
/// Every named net with the footprints it touches and its MST length, plus the total
/// over all nets. Nets are ordered by pad count, then name.
pub fn nets(board: &Board) -> (Vec<NetPlacement>, f64) {
let pads = pcb::pads(board);
let mut names: Vec<&str> = pads.iter().map(|p| p.net_name.as_str()).filter(|n| !n.is_empty()).collect();
names.sort();
names.dedup();
let mut out = Vec::with_capacity(names.len());
let mut total = 0.0;
for name in names {
let on_net: Vec<&pcb::Pad> = pads.iter().filter(|p| p.net_name == name).collect();
let mut refs: Vec<String> = on_net.iter().map(|p| p.reference.clone()).collect();
refs.sort_by_key(|r| ref_key(r));
refs.dedup();
let pts: Vec<(f64, f64)> = on_net.iter().map(|p| (p.x, p.y)).collect();
let mst = (mst_length(&pts) * 1000.0).round() / 1000.0;
total += mst;
out.push(NetPlacement { name: name.to_string(), pad_count: on_net.len(), refs, mst_length_mm: mst });
}
out.sort_by(|a, b| b.pad_count.cmp(&a.pad_count).then_with(|| a.name.cmp(&b.name)));
(out, (total * 1000.0).round() / 1000.0)
}
// ---------------------------------------------------------------------------
// The state and validate reports (the caller adds revision and source)
// ---------------------------------------------------------------------------
/// `kicad_placement_state` without the transport fields. Per-pad nets are omitted above
/// `DETAIL_LIMIT` footprints unless `detail`.
pub fn state(board: &Board, detail: bool) -> Value {
let mut places = placements(board);
let omitted = places.len() > DETAIL_LIMIT && !detail;
if omitted {
for p in &mut places {
p.pads = None;
}
}
let outline = outline(board);
let (nets, total) = nets(board);
let collisions = overlaps(&places);
let outside: Vec<&str> = places.iter().filter(|p| p.inside_outline == Some(false)).map(|p| p.reference.as_str()).collect();
let parked = parked(&places, outline.as_ref());
json!({
"outline": outline,
"layers": board.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"copperLayers": pcb::copper_layers(board),
"footprintCount": places.len(),
"footprints": places,
"padNetsOmitted": omitted,
"nets": nets,
"netCount": nets.len(),
"ratsnest": {"totalMm": total, "method": "minimum spanning tree over pad centres per net"},
"courtyardOverlapCount": collisions.len(),
"outsideOutline": outside,
"parked": parked,
})
}
/// Footprints sitting in the parking area: outside the outline and entirely to its
/// right, where `tools/make_placement_fixture.py` puts them. None without an outline.
pub fn parked(places: &[Placement], outline: Option<&Rect>) -> Vec<String> {
let Some(o) = outline else { return Vec::new() };
places.iter().filter(|p| p.inside_outline == Some(false) && p.courtyard.min_x >= o.max_x - EPS).map(|p| p.reference.clone()).collect()
}
/// `kicad_placement_validate` without the DRC block: overlaps, footprints outside the
/// outline, parked footprints and the ratsnest total.
pub fn validate(board: &Board) -> Value {
let places = placements(board);
let outline = outline(board);
let collisions = overlaps(&places);
let outside: Vec<&str> = places.iter().filter(|p| p.inside_outline == Some(false)).map(|p| p.reference.as_str()).collect();
let parked = parked(&places, outline.as_ref());
let (_, total) = nets(board);
let no_courtyard: Vec<&str> = places.iter().filter(|p| p.courtyard_source != "courtyard").map(|p| p.reference.as_str()).collect();
json!({
"outline": outline,
"footprintCount": places.len(),
"courtyardOverlaps": collisions,
"courtyardOverlapCount": collisions.len(),
"outsideOutline": outside,
"parked": parked,
"withoutCourtyard": no_courtyard,
"ratsnest": {"totalMm": total, "method": "minimum spanning tree over pad centres per net"},
"placed": collisions.is_empty() && outside.is_empty(),
})
}
/// The DRC violation types that describe placement rather than routing: KiCad's own
/// courtyard, edge, silk, hole and pad-clearance checks.
pub const PLACEMENT_DRC_TYPES: &[&str] = &[
"courtyards_overlap",
"malformed_courtyard",
"missing_courtyard",
"copper_edge_clearance",
"silk_edge_clearance",
"silk_over_copper",
"silk_overlap",
"hole_clearance",
"holes_co_located",
"drilled_holes_too_close",
"items_not_allowed",
"clearance",
"shorting_items",
"footprint_type_mismatch",
];
/// A DRC report (`ipc::drc_snapshot`'s shape) reduced to what placement cares about: the
/// placement-class violations, the counts, and the truncation flags carried through.
pub fn drc_filter(report: &Value) -> Value {
let all: Vec<Value> = report.get("violations").and_then(Value::as_array).cloned().unwrap_or_default();
let is_placement = |v: &Value| v.get("type").and_then(Value::as_str).map(|t| PLACEMENT_DRC_TYPES.contains(&t)).unwrap_or(false);
let placement: Vec<Value> = all.iter().filter(|v| is_placement(v)).cloned().collect();
let placement_errors = placement.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("error")).count();
let mut out = json!({
"available": true,
"source": report.get("source").cloned().unwrap_or(Value::Null),
"errors": report.get("errors").cloned().unwrap_or(json!(0)),
"warnings": report.get("warnings").cloned().unwrap_or(json!(0)),
"unconnected": report.get("unconnected").cloned().unwrap_or(json!(0)),
"placementViolations": placement,
"placementViolationCount": placement.len(),
"placementErrors": placement_errors,
"otherViolationCount": all.len() - placement.len(),
"placementTypes": PLACEMENT_DRC_TYPES,
"placementClean": placement_errors == 0,
"projectRulesCopied": report.get("projectRulesCopied").cloned().unwrap_or(json!([])),
"zonesRefilled": report.get("zonesRefilled").cloned().unwrap_or(json!(false)),
});
for k in ["unconnectedReportMayBeTruncated", "unconnectedCountIsLowerBound", "violationReportMayBeTruncated", "possiblyTruncatedViolationTypes"] {
if let Some(v) = report.get(k) {
out[k] = v.clone();
}
}
out
}
/// The revision of a board that is not open in an editor: the SHA-256 of the file text.
/// Only comparable with itself (the editor's serialisation differs from the file).
pub fn file_revision(text: &str) -> String {
crate::pcm::sha256_hex(text.as_bytes())
}
// ---------------------------------------------------------------------------
// Move plan
// ---------------------------------------------------------------------------
/// One footprint's requested move, checked against the rest of the board.
#[derive(Clone, Debug, Serialize)]
pub struct Move {
#[serde(rename = "ref")]
pub reference: String,
pub uuid: String,
pub before: Pose,
pub after: Pose,
#[serde(rename = "courtyardBefore")]
pub courtyard_before: Rect,
#[serde(rename = "courtyardAfter")]
pub courtyard_after: Rect,
#[serde(rename = "courtyardSource")]
pub courtyard_source: &'static str,
pub flipped: bool,
/// The courtyard outline at the new pose (board mm), for the exact overlap check.
#[serde(skip)]
pub outline_after: Vec<Edge>,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct Pose {
pub x: f64,
pub y: f64,
pub rotation: f64,
pub side: String,
}
/// The checked plan: the moves and the check report the response carries.
#[derive(Clone, Debug)]
pub struct MovePlan {
pub moves: Vec<Move>,
pub checks: Value,
pub undo_label: String,
}
fn side_arg(v: Option<&Value>, current: &str) -> Result<String, RoutingError> {
match v {
None | Some(Value::Null) => Ok(current.to_string()),
Some(Value::String(s)) => match s.trim().to_ascii_lowercase().as_str() {
"f.cu" | "f" | "front" | "top" => Ok("F.Cu".into()),
"b.cu" | "b" | "back" | "bottom" => Ok("B.Cu".into()),
other => Err(RoutingError::new("invalid_side", format!("side must be F.Cu or B.Cu, not '{other}'"))),
},
Some(_) => Err(RoutingError::new("invalid_side", "side must be F.Cu or B.Cu")),
}
}
fn normalize_deg(d: f64) -> f64 {
let mut r = d % 360.0;
if r <= -180.0 {
r += 360.0;
}
if r > 180.0 {
r -= 360.0;
}
if r == -0.0 {
0.0
} else {
r
}
}
/// The keys `kicad_move_footprint` reads at the top level (wiki PR #70).
pub const MOVE_KEYS: &[&str] = &[
"filePath", "socketPath", "expectedRevision", "ref", "x", "y",
"rotation", "side", "refs", "dryRun", "allowOverlap",
"allowOutside", "allowLocked", "save", "frame", "ipcTimeoutMs",
];
/// The keys of one `refs[]` entry.
pub const MOVE_ENTRY_KEYS: &[&str] = &["ref", "x", "y", "rotation", "side"];
/// Keys any verb may carry that are not about the move: the asker's reason and identity, which
/// Adom Bridge and AI clients put in the arguments of every call. Keys starting with `_` are the
/// bridge's own notes (`_fromFrame` after a `frame:"standard"` call), also accepted.
const CALL_KEYS: &[&str] = &["reason", "caller", "aiThread"];
fn move_key_allowed(key: &str) -> bool {
MOVE_KEYS.contains(&key) || CALL_KEYS.contains(&key) || key.starts_with('_')
}
/// Only the arguments a move reads (plus the call's reason and the bridge's `_` notes), for a
/// verb that hands its own arguments on to `kicad_move_footprint` (`kicad_align_parts`).
pub fn move_args_only(args: &Value) -> Value {
match args.as_object() {
Some(o) => Value::Object(o.iter().filter(|(k, _)| move_key_allowed(k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
None => args.clone(),
}
}
/// Validate the *whole* move payload before resolving geometry or opening the live
/// editor. JSON getters otherwise silently treat typos such as `rot` as an omitted
/// `rotation`, which can move copper-connected parts without rotating their pads.
///
/// Includes the standard board-frame and IPC fields. Batch entries have their
/// own much smaller schema so typos inside `refs` fail the same way.
pub fn validate_move_arguments(args: &Value) -> Result<(), RoutingError> {
fn check(value: &Value, allowed: &[&str], top: bool, context: &str) -> Result<(), RoutingError> {
let map = value.as_object().ok_or_else(|| {
RoutingError::new("invalid_arguments", format!("{context} must be a JSON object"))
.with("mutated", json!(false))
})?;
for key in map.keys() {
let ok = if top { move_key_allowed(key) } else { allowed.contains(&key.as_str()) };
if !ok {
let suggested = if key == "rot" || key == "angle" {
Some("rotation")
} else {
allowed.iter().find(|candidate| {
candidate.to_lowercase().starts_with(&key.to_lowercase())
|| key.to_lowercase().starts_with(&candidate.to_lowercase())
}).copied()
};
let explanation = match suggested {
Some(want) => format!("Unknown {context} argument '{key}'; did you mean '{want}'?"),
None => format!("Unknown {context} argument '{key}'"),
};
let mut err = RoutingError::new("unknown_argument", explanation)
.with("argument", json!(key))
.with("allowedKeys", json!(allowed))
.with("mutated", json!(false));
if let Some(want) = suggested {
err = err.with("suggestedKey", json!(want));
}
return Err(err);
}
}
Ok(())
}
check(args, MOVE_KEYS, true, "move")?;
if let Some(Value::Array(entries)) = args.get("refs") {
for (index, entry) in entries.iter().enumerate() {
check(entry, MOVE_ENTRY_KEYS, false, &format!("refs[{index}]"))?;
}
}
Ok(())
}
/// `kicad_move_footprint`'s argument reading and checks. `ref` with `x`, `y`, `rotation?`,
/// `side?`, or `refs: [{ref, x, y, rotation?, side?}, ...]` (1..256). Refuses an unknown
/// or locked footprint, a side change (`side_change_not_supported`: a flip is separate
/// work), a courtyard collision (`courtyard_overlap`, unless `allowOverlap`) and a
/// courtyard outside the outline (`outside_outline`, unless `allowOutside`). Nothing is
/// written here; the caller commits `plan.moves` as one undo step.
pub fn plan_moves(board: &Board, args: &Value) -> Result<MovePlan, RoutingError> {
validate_move_arguments(args)?;
let items: Vec<Value> = match args.get("refs") {
Some(Value::Array(a)) if !a.is_empty() && a.len() <= MAX_MOVES => a.clone(),
Some(_) => return Err(RoutingError::new("invalid_refs", format!("refs must list 1..{MAX_MOVES} moves, each {{ref, x, y, rotation?, side?}}"))),
None => {
if args.get("ref").and_then(Value::as_str).map(|s| !s.trim().is_empty()).unwrap_or(false) {
vec![args.clone()]
} else {
return Err(RoutingError::new("missing_ref", "Pass ref with x and y (and rotation, side), or refs:[{ref,x,y,...}] for a batch"));
}
}
};
let all = placements(board);
let board_outline = outline(board);
let allow_overlap = args.get("allowOverlap") == Some(&json!(true));
let allow_outside = args.get("allowOutside") == Some(&json!(true));
let allow_locked = args.get("allowLocked") == Some(&json!(true));
let mut moves: Vec<Move> = Vec::with_capacity(items.len());
for item in &items {
let Some(reference) = item.get("ref").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
return Err(RoutingError::new("missing_ref", "Every move needs a ref"));
};
let want = reference.to_uppercase();
let Some((fp, current)) = board.footprints.iter().zip(all.iter()).find(|(f, _)| f.reference.to_uppercase() == want) else {
let mut candidates: Vec<&str> = all.iter().map(|p| p.reference.as_str()).collect();
candidates.sort_by_key(|r| ref_key(r));
return Err(RoutingError::new("unknown_footprint", format!("No footprint with reference '{reference}'")).with("candidates", json!(candidates.iter().take(40).collect::<Vec<_>>())));
};
if moves.iter().any(|m| m.reference == current.reference) {
return Err(RoutingError::new("invalid_refs", format!("'{}' appears twice in refs", current.reference)));
}
let Some(uuid) = fp.uuid.clone().filter(|u| !u.is_empty()) else {
return Err(RoutingError::new("footprint_without_uuid", format!("Footprint {} has no uuid; the editor cannot address it", current.reference)));
};
if current.locked && !allow_locked {
return Err(RoutingError::new("footprint_locked", format!("Footprint {} is locked; pass allowLocked:true to move it anyway", current.reference)).with("mutated", json!(false)));
}
let x = pcb::number(item.get("x"), "x", false)?;
let y = pcb::number(item.get("y"), "y", false)?;
let rotation = match item.get("rotation") {
None | Some(Value::Null) => current.rotation,
Some(v) => normalize_deg(pcb::number(Some(v), "rotation", false)?),
};
let side = side_arg(item.get("side"), ¤t.side)?;
if side != current.side {
return Err(side_change_error(¤t.reference, ¤t.side, &side));
}
let (courtyard_after, courtyard_source) = courtyard_at(fp, (x, y), rotation);
moves.push(Move {
reference: current.reference.clone(),
uuid,
before: Pose { x: current.x, y: current.y, rotation: current.rotation, side: current.side.clone() },
after: Pose { x, y, rotation, side: side.clone() },
courtyard_before: current.courtyard,
courtyard_after,
courtyard_source,
flipped: side != current.side,
outline_after: courtyard_edges_at(fp, (x, y), rotation),
});
}
// The board as it would be: moved footprints at their new pose, the rest as they are.
let mut proposed: Vec<Placement> = Vec::with_capacity(all.len());
for p in &all {
match moves.iter().find(|m| m.reference == p.reference) {
Some(m) => {
let mut q = p.clone();
q.x = m.after.x;
q.y = m.after.y;
q.rotation = m.after.rotation;
q.side = m.after.side.clone();
q.courtyard = m.courtyard_after;
q.outline = m.outline_after.clone();
q.inside_outline = board_outline.as_ref().map(|o| o.contains(&m.courtyard_after));
proposed.push(q);
}
None => proposed.push(p.clone()),
}
}
let moved: Vec<&str> = moves.iter().map(|m| m.reference.as_str()).collect();
let collisions: Vec<Overlap> = overlaps(&proposed).into_iter().filter(|o| moved.contains(&o.a.as_str()) || moved.contains(&o.b.as_str())).collect();
let outside: Vec<String> = proposed.iter().filter(|p| moved.contains(&p.reference.as_str()) && p.inside_outline == Some(false)).map(|p| p.reference.clone()).collect();
let checks = json!({
"courtyardOverlaps": collisions,
"outsideOutline": outside,
"outline": board_outline,
"outlineChecked": board_outline.is_some(),
"ok": collisions.is_empty() && outside.is_empty(),
});
if !collisions.is_empty() && !allow_overlap {
let mut offending: Vec<String> = collisions.iter().flat_map(|o| [o.a.clone(), o.b.clone()]).filter(|r| !moved.contains(&r.as_str())).collect();
offending.sort_by_key(|r| ref_key(r));
offending.dedup();
return Err(RoutingError::new("courtyard_overlap", format!("The requested position collides with {} other footprint(s); nothing was moved", offending.len()))
.with("overlaps", json!(collisions))
.with("offendingRefs", json!(offending))
.with("checks", checks)
.with("mutated", json!(false))
.with("_hint", json!("Courtyard boxes must not overlap on the same side. Pick a free spot (kicad_placement_state lists every courtyard), or pass allowOverlap:true to place anyway and fix it afterwards.")));
}
if !outside.is_empty() && !allow_outside {
return Err(RoutingError::new("outside_outline", format!("{} footprint(s) would lie outside the board outline; nothing was moved", outside.len()))
.with("outsideRefs", json!(outside))
.with("checks", checks)
.with("mutated", json!(false))
.with("_hint", json!("The courtyard must fit inside the Edge.Cuts box (checks.outline). Pass allowOutside:true to park a footprint off the board on purpose.")));
}
let mut names: Vec<&str> = moved.clone();
names.sort_by_key(|r| ref_key(r));
let undo_label = if names.len() == 1 { format!("Adom: place {}", names[0]) } else if names.len() <= 4 { format!("Adom: place {}", names.join(", ")) } else { format!("Adom: place {} footprints ({}, ...)", names.len(), names[..3].join(", ")) };
Ok(MovePlan { moves, checks, undo_label })
}
/// The public shape of a plan: `moves` plus `checks`.
pub fn plan_json(plan: &MovePlan) -> Value {
json!({
"moves": plan.moves,
"movedRefs": plan.moves.iter().map(|m| m.reference.clone()).collect::<Vec<_>>(),
"checks": plan.checks,
"undoLabel": plan.undo_label,
})
}
// ---------------------------------------------------------------------------
// Moving the children: the rigid-body transform applied to the IPC proto
// ---------------------------------------------------------------------------
//
// KiCad's `UpdateItems` does not modify a footprint in place. It builds a NEW footprint
// from our FootprintInstance proto (`createItemForType` then `FOOTPRINT::Deserialize`),
// removes the old one and adds the new one. Deserialize sets the anchor pose first and
// then deserialises the children (pads, fields, shapes, texts, zones, ...) with the
// ABSOLUTE board coordinates and angles they carry in the proto. So a proto with only
// `position` changed moves the anchor and leaves the body behind (seen live on KiCad
// 10.0.3: U2's anchor went into the board, every pad stayed in the parking lot). The
// bridge therefore moves every child itself, the way `FOOTPRINT::SetPosition` and
// `FOOTPRINT::SetOrientation` would: p' = N + R(nrot - orot) * (p - O) for every point,
// every absolute angle + (nrot - orot). R is KiCad's `RotatePoint` in its y-down frame
// (x' = x cos + y sin, y' = -x sin + y cos), the same matrix `to_board` uses.
//
// The children arrive as `google.protobuf.Any` payloads whose Rust types kicad-ipc-rs
// keeps private, so they are rewritten on the protobuf wire format directly: every field
// is copied through untouched except the Vector2 and Angle fields the schema below
// names, with the field numbers from kicad-ipc-rs 0.5.1's generated
// `kiapi.board.types` and `kiapi.common.types`. Sizes, pad-local offsets, custom pad
// shapes and anything unknown are left exactly as KiCad sent them.
/// The rigid-body transform of one footprint move in the proto's units (nanometres,
/// degrees): the old anchor `(ox, oy)` at `orot` to the new anchor `(nx, ny)` at `nrot`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ChildTransform {
pub ox: i64,
pub oy: i64,
pub nx: i64,
pub ny: i64,
/// `nrot - orot`, normalised to (-180, 180].
pub delta_deg: f64,
}
const ANGLE_EPS: f64 = 1e-6;
impl ChildTransform {
pub fn new(old: (i64, i64, f64), new: (i64, i64, f64)) -> ChildTransform {
ChildTransform { ox: old.0, oy: old.1, nx: new.0, ny: new.1, delta_deg: normalize_deg(new.2 - old.2) }
}
/// No rotation: points shift by an exact integer offset, angles are untouched.
pub fn is_translation(&self) -> bool {
self.delta_deg.abs() < ANGLE_EPS
}
/// A multiple of 90 degrees: rectangles and text boxes stay rectangles.
pub fn is_cardinal(&self) -> bool {
let r = self.delta_deg.rem_euclid(90.0);
r < ANGLE_EPS || r > 90.0 - ANGLE_EPS
}
fn sin_cos(&self) -> (f64, f64) {
if self.is_cardinal() {
match ((self.delta_deg / 90.0).round() as i64).rem_euclid(4) {
0 => (0.0, 1.0),
1 => (1.0, 0.0),
2 => (0.0, -1.0),
_ => (-1.0, 0.0),
}
} else {
self.delta_deg.to_radians().sin_cos()
}
}
/// A board point (nm) after the move.
pub fn point(&self, x: i64, y: i64) -> (i64, i64) {
if self.is_translation() {
return (x - self.ox + self.nx, y - self.oy + self.ny);
}
let (s, c) = self.sin_cos();
let (dx, dy) = ((x - self.ox) as f64, (y - self.oy) as f64);
(self.nx + (dx * c + dy * s).round() as i64, self.ny + (-dx * s + dy * c).round() as i64)
}
/// An absolute angle (degrees) after the move, normalised to [0, 360) the way
/// `EDA_ANGLE::Normalize` does; untouched for a pure translation.
pub fn angle(&self, deg: f64) -> f64 {
if self.is_translation() {
return deg;
}
let a = (deg + self.delta_deg).rem_euclid(360.0);
if a >= 360.0 - ANGLE_EPS {
0.0
} else {
a
}
}
}
/// A child the bridge will not move: the item's type URL and why.
#[derive(Clone, Debug, PartialEq)]
pub struct ChildError {
pub type_url: String,
pub reason: String,
}
pub const TYPE_URL_PREFIX: &str = "type.googleapis.com/";
/// The child item types `transform_child` moves (the `definition.items` of a
/// FootprintInstance), for the error message when something else turns up.
pub const CHILD_TYPES: &[&str] = &[
"kiapi.board.types.Pad",
"kiapi.board.types.BoardText",
"kiapi.board.types.Field",
"kiapi.board.types.BoardTextBox",
"kiapi.board.types.BoardGraphicShape",
"kiapi.board.types.Zone",
"kiapi.board.types.Dimension",
"kiapi.board.types.ReferenceImage",
"kiapi.board.types.Barcode",
"kiapi.board.types.Group",
"kiapi.board.types.Footprint3DModel",
];
/// One `definition.items` entry moved with its footprint: the payload bytes of the
/// `Any` with `type_url`, rewritten. Group and Footprint3DModel carry no board
/// coordinates (member ids; a footprint-local model offset) and pass through unchanged.
/// Anything else is refused rather than moved wrongly.
pub fn transform_child(xf: &ChildTransform, type_url: &str, bytes: &[u8]) -> Result<Vec<u8>, ChildError> {
let name = type_url.strip_prefix(TYPE_URL_PREFIX).unwrap_or(type_url);
let msg = match name {
"kiapi.board.types.Pad" => Msg::Pad,
"kiapi.board.types.BoardText" => Msg::BoardText,
"kiapi.board.types.Field" => Msg::Field,
"kiapi.board.types.BoardTextBox" => Msg::BoardTextBox,
"kiapi.board.types.BoardGraphicShape" => Msg::BoardGraphicShape,
"kiapi.board.types.Zone" => Msg::Zone,
"kiapi.board.types.Dimension" => Msg::Dimension,
"kiapi.board.types.ReferenceImage" => Msg::ReferenceImage,
"kiapi.board.types.Barcode" => Msg::Barcode,
"kiapi.board.types.Group" | "kiapi.board.types.Footprint3DModel" => return Ok(bytes.to_vec()),
_ => return Err(ChildError { type_url: type_url.to_string(), reason: "no transform rule for this item type".into() }),
};
rewrite(xf, msg, bytes).map_err(|reason| ChildError { type_url: type_url.to_string(), reason })
}
/// The protobuf messages the rewriter walks, by their kiapi names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Msg {
Pad,
PadStack,
BoardText,
Text,
TextAttributes,
Field,
BoardTextBox,
TextBox,
BoardGraphicShape,
GraphicShape,
Segment,
Rectangle,
Arc,
Circle,
PolySet,
Polygon,
PolyLine,
PolyLineNode,
ArcStartMidEnd,
Bezier,
Zone,
ZoneFill,
Dimension,
DimAligned,
DimOrthogonal,
DimRadial,
DimLeader,
DimCenter,
ReferenceImage,
Barcode,
}
enum Kind {
/// A `kiapi.common.types.Vector2` in board coordinates.
Point,
/// A `kiapi.common.types.Angle` in the board frame.
Angle,
/// A nested message with fields of its own to walk.
Nested(Msg),
/// Copied through verbatim.
Keep,
}
/// The schema: which fields of which message move. Everything not listed is kept.
fn kind(msg: Msg, number: u32) -> Kind {
use Msg::*;
match (msg, number) {
// Pad: position (7) is absolute in KiCad 10's PAD::Serialize; the padstack angle
// (6.6) is the pad's absolute orientation (PAD::Rotate adds to it). Per-layer
// offsets and custom shapes are pad-local and rotate with the pad.
(Pad, 6) => Kind::Nested(PadStack),
(Pad, 7) => Kind::Point,
(PadStack, 6) => Kind::Angle,
// Text: position (2), attributes.angle (3.4). attributes.size (3.14) is a size.
(BoardText, 2) => Kind::Nested(Text),
(Text, 2) => Kind::Point,
(Text, 3) => Kind::Nested(TextAttributes),
(TextAttributes, 4) => Kind::Angle,
(Field, 3) => Kind::Nested(BoardText),
// Text box: both corners plus the text angle (cardinal rotations only).
(BoardTextBox, 2) => Kind::Nested(TextBox),
(TextBox, 2) | (TextBox, 3) => Kind::Point,
(TextBox, 4) => Kind::Nested(TextAttributes),
// Graphic shapes: every geometry variant's points.
(BoardGraphicShape, 1) => Kind::Nested(GraphicShape),
(GraphicShape, 4) => Kind::Nested(Segment),
(GraphicShape, 5) => Kind::Nested(Rectangle),
(GraphicShape, 6) => Kind::Nested(Arc),
(GraphicShape, 7) => Kind::Nested(Circle),
(GraphicShape, 8) => Kind::Nested(PolySet),
(GraphicShape, 9) => Kind::Nested(Bezier),
(Segment, 1 | 2) | (Rectangle, 1 | 2) | (Arc, 1 | 2 | 3) | (Circle, 1 | 2) | (Bezier, 1 | 2 | 3 | 4) => Kind::Point,
// Polygons with holes, whose nodes are points or start-mid-end arcs.
(PolySet, 1) => Kind::Nested(Polygon),
(Polygon, 1 | 2) => Kind::Nested(PolyLine),
(PolyLine, 1) => Kind::Nested(PolyLineNode),
(PolyLineNode, 1) => Kind::Point,
(PolyLineNode, 2) => Kind::Nested(ArcStartMidEnd),
(ArcStartMidEnd, 1 | 2 | 3) => Kind::Point,
// Zones: the outline and the filled polygons per layer.
(Zone, 4) => Kind::Nested(PolySet),
(Zone, 10) => Kind::Nested(ZoneFill),
(ZoneFill, 2) => Kind::Nested(PolySet),
// Dimensions: the text and every style's points (orthogonal has its own rule).
(Dimension, 4) => Kind::Nested(Text),
(Dimension, 5) => Kind::Nested(DimAligned),
(Dimension, 6) => Kind::Nested(DimOrthogonal),
(Dimension, 7) => Kind::Nested(DimRadial),
(Dimension, 8) => Kind::Nested(DimLeader),
(Dimension, 9) => Kind::Nested(DimCenter),
(DimAligned, 1 | 2) | (DimOrthogonal, 1 | 2) | (DimRadial, 1 | 2) | (DimLeader, 1 | 2) | (DimCenter, 1 | 2) => Kind::Point,
(ReferenceImage, 3) => Kind::Point,
(Barcode, 5) => Kind::Point,
(Barcode, 6) => Kind::Angle,
_ => Kind::Keep,
}
}
// The protobuf wire format, just enough of it: tags, varints, fixed64 and
// length-delimited fields are read; fields are written back in the order found.
/// One field on the wire: its number, wire type and the raw bytes after the tag (a
/// varint's own bytes, the 8 or 4 fixed bytes, or a length-delimited payload).
pub(crate) struct WireField<'a> {
pub(crate) number: u32,
pub(crate) wire: u8,
pub(crate) data: &'a [u8],
}
fn read_varint(b: &[u8], i: &mut usize) -> Result<u64, String> {
let mut v: u64 = 0;
for shift in (0..70).step_by(7) {
let Some(&byte) = b.get(*i) else { return Err("truncated varint".into()) };
*i += 1;
if shift < 64 {
v |= ((byte & 0x7f) as u64) << shift;
}
if byte & 0x80 == 0 {
return Ok(v);
}
}
Err("varint longer than 10 bytes".into())
}
pub(crate) fn put_varint(out: &mut Vec<u8>, mut v: u64) {
while v >= 0x80 {
out.push((v as u8) | 0x80);
v >>= 7;
}
out.push(v as u8);
}
pub(crate) fn parse_fields(b: &[u8]) -> Result<Vec<WireField<'_>>, String> {
let mut i = 0;
let mut out = Vec::new();
while i < b.len() {
let tag = read_varint(b, &mut i)?;
let number = (tag >> 3) as u32;
let wire = (tag & 7) as u8;
if number == 0 {
return Err("field number 0".into());
}
let start = i;
let data = match wire {
0 => {
read_varint(b, &mut i)?;
&b[start..i]
}
1 => {
i += 8;
b.get(start..i).ok_or("truncated fixed64")?
}
5 => {
i += 4;
b.get(start..i).ok_or("truncated fixed32")?
}
2 => {
let len = read_varint(b, &mut i)? as usize;
let s = i;
i = i.checked_add(len).ok_or("bad length")?;
b.get(s..i).ok_or("truncated bytes")?
}
w => return Err(format!("unsupported wire type {w} on field {number}")),
};
out.push(WireField { number, wire, data });
}
Ok(out)
}
fn write_field(out: &mut Vec<u8>, number: u32, wire: u8, data: &[u8]) {
put_varint(out, ((number as u64) << 3) | wire as u64);
if wire == 2 {
put_varint(out, data.len() as u64);
}
out.extend_from_slice(data);
}
/// A length-delimited field (a nested message, a string, bytes).
pub(crate) fn wire_message(out: &mut Vec<u8>, number: u32, body: &[u8]) {
write_field(out, number, 2, body);
}
/// A varint field (int64, enum, bool).
pub(crate) fn wire_varint(out: &mut Vec<u8>, number: u32, v: u64) {
put_varint(out, ((number as u64) << 3) | 0);
put_varint(out, v);
}
/// A double field.
pub(crate) fn wire_double(out: &mut Vec<u8>, number: u32, v: f64) {
put_varint(out, ((number as u64) << 3) | 1);
out.extend_from_slice(&v.to_le_bytes());
}
/// `kiapi.common.types.Vector2 {x_nm = 1, y_nm = 2}`; zero coordinates are omitted, as
/// prost and C++ proto3 both omit default scalars.
pub(crate) fn vector2_bytes(x: i64, y: i64) -> Vec<u8> {
let mut out = Vec::with_capacity(22);
if x != 0 {
wire_varint(&mut out, 1, x as u64);
}
if y != 0 {
wire_varint(&mut out, 2, y as u64);
}
out
}
/// `kiapi.common.types.Angle {value_degrees = 1}`.
pub(crate) fn angle_bytes(deg: f64) -> Vec<u8> {
let mut out = Vec::with_capacity(9);
if deg != 0.0 {
wire_double(&mut out, 1, deg);
}
out
}
/// `kiapi.common.types.Distance {value_nm = 1}`.
pub(crate) fn distance_bytes(nm: i64) -> Vec<u8> {
let mut out = Vec::with_capacity(11);
if nm != 0 {
wire_varint(&mut out, 1, nm as u64);
}
out
}
pub(crate) fn varint_value(data: &[u8]) -> Result<u64, String> {
read_varint(data, &mut 0)
}
fn decode_vector2(b: &[u8]) -> Result<(i64, i64), String> {
let (mut x, mut y) = (0i64, 0i64);
for f in parse_fields(b)? {
match (f.number, f.wire) {
(1, 0) => x = varint_value(f.data)? as i64,
(2, 0) => y = varint_value(f.data)? as i64,
_ => {}
}
}
Ok((x, y))
}
fn decode_angle(b: &[u8]) -> Result<f64, String> {
let mut a = 0.0;
for f in parse_fields(b)? {
if f.number == 1 && f.wire == 1 {
a = f64::from_le_bytes(f.data.try_into().map_err(|_| "bad double".to_string())?);
}
}
Ok(a)
}
fn decode_distance(b: &[u8]) -> Result<i64, String> {
let mut d = 0i64;
for f in parse_fields(b)? {
if f.number == 1 && f.wire == 0 {
d = varint_value(f.data)? as i64;
}
}
Ok(d)
}
fn nested<'a>(f: &WireField<'a>) -> Result<&'a [u8], String> {
if f.wire == 2 {
Ok(f.data)
} else {
Err(format!("field {} is not a message (wire type {})", f.number, f.wire))
}
}
fn rewrite(xf: &ChildTransform, msg: Msg, bytes: &[u8]) -> Result<Vec<u8>, String> {
if msg == Msg::TextBox && !xf.is_cardinal() {
return Err(format!("a text box can only be rotated by a multiple of 90 degrees, not {}", xf.delta_deg));
}
let fields = parse_fields(bytes)?;
if msg == Msg::DimOrthogonal {
return rewrite_orthogonal(xf, &fields);
}
let mut out = Vec::with_capacity(bytes.len() + 16);
for f in &fields {
match kind(msg, f.number) {
Kind::Keep => write_field(&mut out, f.number, f.wire, f.data),
Kind::Point => {
let (x, y) = decode_vector2(nested(f)?)?;
let (x, y) = xf.point(x, y);
wire_message(&mut out, f.number, &vector2_bytes(x, y));
}
Kind::Angle => {
let a = decode_angle(nested(f)?)?;
wire_message(&mut out, f.number, &angle_bytes(xf.angle(a)));
}
// A rectangle rotated off the cardinal angles is no longer a rectangle; KiCad's
// EDA_SHAPE::rotate turns it into a polygon, and so does this (GraphicShape
// field 8), for the sharp-cornered case it can reproduce exactly.
Kind::Nested(Msg::Rectangle) if !xf.is_cardinal() => {
let poly = rectangle_to_polygon(xf, nested(f)?)?;
wire_message(&mut out, 8, &poly);
}
Kind::Nested(inner) => {
let body = rewrite(xf, inner, nested(f)?)?;
wire_message(&mut out, f.number, &body);
}
}
}
Ok(out)
}
fn rectangle_to_polygon(xf: &ChildTransform, bytes: &[u8]) -> Result<Vec<u8>, String> {
let (mut tl, mut br, mut radius) = ((0i64, 0i64), (0i64, 0i64), 0i64);
for f in parse_fields(bytes)? {
match f.number {
1 => tl = decode_vector2(nested(&f)?)?,
2 => br = decode_vector2(nested(&f)?)?,
3 => radius = decode_distance(nested(&f)?)?,
_ => {}
}
}
if radius != 0 {
return Err(format!("a rounded rectangle cannot be rotated by {} degrees (not a multiple of 90); KiCad approximates that with a polygon the bridge does not reproduce", xf.delta_deg));
}
let mut line = Vec::new();
for (x, y) in [(tl.0, tl.1), (br.0, tl.1), (br.0, br.1), (tl.0, br.1)] {
let (x, y) = xf.point(x, y);
let mut node = Vec::new();
wire_message(&mut node, 1, &vector2_bytes(x, y));
wire_message(&mut line, 1, &node);
}
wire_varint(&mut line, 2, 1); // closed
let mut polygon = Vec::new();
wire_message(&mut polygon, 1, &line);
let mut set = Vec::new();
wire_message(&mut set, 1, &polygon);
Ok(set)
}
const AA_X_AXIS: u64 = 1;
const AA_Y_AXIS: u64 = 2;
/// `PCB_DIM_ORTHOGONAL::Rotate`: the axis and the sign of the height follow the nearest
/// cardinal angle (exactly 45 or 135 rounds towards zero, as KiCad does).
fn rewrite_orthogonal(xf: &ChildTransform, fields: &[WireField<'_>]) -> Result<Vec<u8>, String> {
let a = xf.delta_deg;
let vertical = fields.iter().any(|f| f.number == 5 && f.wire == 0 && varint_value(f.data) == Ok(AA_Y_AXIS));
let (new_vertical, flip_height) = if a > 45.0 && a <= 135.0 {
(!vertical, vertical)
} else if a < -45.0 && a >= -135.0 {
(!vertical, !vertical)
} else if a > 135.0 || a < -135.0 {
(vertical, true)
} else {
(vertical, false)
};
let mut out = Vec::new();
let mut wrote_alignment = false;
for f in fields {
match f.number {
1 | 2 => {
let (x, y) = decode_vector2(nested(f)?)?;
let (x, y) = xf.point(x, y);
wire_message(&mut out, f.number, &vector2_bytes(x, y));
}
3 if flip_height => {
let h = decode_distance(nested(f)?)?;
wire_message(&mut out, 3, &distance_bytes(-h));
}
5 => {
wire_varint(&mut out, 5, if new_vertical { AA_Y_AXIS } else { AA_X_AXIS });
wrote_alignment = true;
}
_ => write_field(&mut out, f.number, f.wire, f.data),
}
}
if !wrote_alignment && new_vertical {
wire_varint(&mut out, 5, AA_Y_AXIS);
}
Ok(out)
}
// ---------------------------------------------------------------------------
// The post-commit proof that the body moved with the anchor
// ---------------------------------------------------------------------------
/// Read-back positions closer than this (mm) count as the same point.
pub const CHILD_TOLERANCE_MM: f64 = 0.01;
/// Did the children land where the move put them? Compared on the board KiCad
/// serialises after the commit: the courtyard box centre (`courtyardCentre`) when the
/// footprint has a courtyard, else the board position of its first pad (`firstPad`),
/// else the anchor alone (`position`, which proves nothing about children).
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct ChildCheck {
pub method: &'static str,
pub expected: [f64; 2],
pub actual: Option<[f64; 2]>,
#[serde(rename = "deltaMm")]
pub delta_mm: Option<f64>,
pub ok: bool,
}
impl ChildCheck {
pub fn describe(&self, reference: &str) -> String {
match (self.actual, self.delta_mm) {
(Some(a), Some(d)) => format!("{reference} {} expected ({}, {}) got ({}, {}), off by {d} mm", self.method, self.expected[0], self.expected[1], a[0], a[1]),
_ => format!("{reference} {} expected ({}, {}) but the read-back footprint has nothing to compare", self.method, self.expected[0], self.expected[1]),
}
}
}
/// Compare the move `m` (planned on `before`, the footprint as it was) with `after`, the
/// same footprint re-read from the editor after the commit.
pub fn check_children(m: &Move, before: &Footprint, after: &Footprint) -> ChildCheck {
let pose = |fp: &Footprint| 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 (ax, ay, arot) = pose(after);
let centre = |r: &Rect| [(r.min_x + r.max_x) / 2.0, (r.min_y + r.max_y) / 2.0];
let (method, expected, actual) = if m.courtyard_source == "courtyard" {
let (after_box, source) = courtyard_at(after, (ax, ay), arot);
("courtyardCentre", centre(&m.courtyard_after), if source == "courtyard" { Some(centre(&after_box)) } else { None })
} else if let Some(pad) = before.pads.iter().find(|p| p.x.is_some() && p.y.is_some()) {
let local = (pad.x.unwrap_or(0.0), pad.y.unwrap_or(0.0));
let e = to_board((m.after.x, m.after.y), m.after.rotation, local);
let a = after.pads.iter().find(|p| p.number == pad.number).and_then(|p| Some((p.x?, p.y?))).map(|l| to_board((ax, ay), arot, l));
("firstPad", [e.0, e.1], a.map(|p| [p.0, p.1]))
} else {
("position", [m.after.x, m.after.y], Some([ax, ay]))
};
let delta = actual.map(|a| pcb::dist((expected[0], expected[1]), (a[0], a[1])));
ChildCheck { method, expected, actual, delta_mm: delta.map(|d| (d * 1e6).round() / 1e6), ok: delta.map(|d| d <= CHILD_TOLERANCE_MM).unwrap_or(false) }
}
/// The refusal for a `side` change: a flip mirrors every child and swaps its layers, a
/// separate piece of work; the editor's own flip (F) is the way until it lands.
pub fn side_change_error(reference: &str, from: &str, to: &str) -> RoutingError {
RoutingError::new("side_change_not_supported", format!("Footprint {reference} is on {from}; moving it to {to} is a flip, which kicad_move_footprint does not do yet; nothing was moved"))
.with("mutated", json!(false))
.with("_hint", json!("KiCad replaces the whole footprint on an IPC update, so a flip means mirroring every pad, shape and text and swapping their layers client-side, which the bridge does not do yet. Flip the footprint in the PCB editor (select it, press F), read kicad_placement_state again, then move it with the same side or without side."))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn fp(r: &str, x: f64, y: f64, rot: f64, side: &str, courtyard: &str, extra: &str) -> String {
format!(
"(footprint \"T:{r}\" (layer \"{side}\") (uuid \"{r}-uuid\") (at {x} {y} {rot}) (property \"Reference\" \"{r}\") (property \"Value\" \"V\") {courtyard} {extra} (pad \"1\" smd rect (at -1 0) (size 1 0.6) (layers \"{side}\") (net \"N_{r}\")) (pad \"2\" smd rect (at 1 0) (size 1 0.6) (layers \"{side}\") (net \"GND\")))"
)
}
const RECT: &str = "(fp_rect (start -2 -1) (end 2 1) (layer \"F.CrtYd\"))";
fn board(footprints: &[String]) -> Board {
let text = format!(
"(kicad_pcb (version 20260206) (generator \"pcbnew\") (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal) (25 \"Edge.Cuts\" user) (31 \"F.CrtYd\" user)) (gr_rect (start 100 100) (end 150 130) (layer \"Edge.Cuts\")) {})",
footprints.join(" ")
);
pcb::parse_pcb_text(&text, "placement.kicad_pcb").unwrap()
}
#[test]
fn move_arguments_reject_unknown_rotation_without_touching_placement() {
let b = board(&[fp("Q2", 110.0, 110.0, 0.0, "F.Cu", RECT, "")]);
for bad in [
json!({"ref":"Q2","x":120,"y":110,"rot":270,"dryRun":true,"frame":"kicad"}),
json!({"ref":"Q2","x":120,"y":110,"rot":270,"dryRun":false,"frame":"kicad"}),
json!({"refs":[{"ref":"Q2","x":120,"y":110,"rot":270}],"dryRun":true}),
json!({"refs":[{"ref":"Q2","x":120,"y":110,"rotation":180,"rott":90}]}),
json!({"ref":"Q2","x":120,"y":110,"frame":"kicad","unexpected":true}),
] {
let e = plan_moves(&b, &bad).unwrap_err();
assert_eq!(e.code, "unknown_argument");
}
let original = placements(&b);
assert_eq!(original[0].rotation, 0.0);
assert_eq!(original[0].x, 110.0);
}
#[test]
fn move_arguments_keep_documented_options_and_rotation() {
let b = board(&[fp("Q2", 110.0, 110.0, 0.0, "F.Cu", RECT, "")]);
let plan = plan_moves(&b, &json!({
"filePath":"fixture.kicad_pcb","expectedRevision":"fixture-hash",
"socketPath":"/tmp/ipc", "frame":"kicad",
"ref":"Q2","x":120,"y":110,"rotation":180,
"save":true,"dryRun":true,"allowOverlap":false,
"allowOutside":false,"allowLocked":false
})).unwrap();
assert_eq!(plan.moves.len(), 1);
assert_eq!(plan.moves[0].after.rotation, 180.0);
let batch = plan_moves(&b, &json!({
"refs":[{"ref":"Q2","x":120,"y":110,"rotation":270}],
"frame":"standard","dryRun":true
})).unwrap();
assert_eq!(batch.moves[0].after.rotation, -90.0);
}
#[test]
fn a_real_call_with_its_reason_caller_and_frame_note_still_moves() {
// What reaches plan_moves from Adom Bridge: the reason and caller ride in the arguments,
// frame:"standard" has become _fromFrame, and helpers pass ipcTimeoutMs.
let b = board(&[fp("Q2", 110.0, 110.0, 0.0, "F.Cu", RECT, "")]);
let call = json!({"filePath": "C:/p/b.kicad_pcb", "ref": "Q2", "x": 120, "y": -110, "rotation": 90, "frame": "standard",
"dryRun": true, "reason": "move Q2 beside the connector", "caller": {"thread": "t"}, "aiThread": "t", "ipcTimeoutMs": 20000});
let native = crate::board_model::args_to_kicad_frame(&call).unwrap();
assert_eq!(native["_fromFrame"], json!("standard"));
let plan = plan_moves(&b, &native).unwrap();
assert_eq!((plan.moves[0].after.x, plan.moves[0].after.y, plan.moves[0].after.rotation), (120.0, 110.0, 90.0));
// a misspelling is still caught beside them, and a _ key inside a refs entry is not a bridge note
let e = plan_moves(&b, &json!({"ref": "Q2", "x": 120, "y": 110, "reason": "r", "_fromFrame": "standard", "rot": 90})).unwrap_err();
assert_eq!((e.code.as_str(), e.detail["suggestedKey"].clone()), ("unknown_argument", json!("rotation")));
let e = plan_moves(&b, &json!({"refs": [{"ref": "Q2", "x": 120, "y": 110, "_x": 1}]})).unwrap_err();
assert_eq!(e.code, "unknown_argument");
// kicad_align_parts hands its own arguments on: only the move's survive
let align = json!({"filePath": "C:/p/b.kicad_pcb", "refs": [{"ref": "Q2", "x": 120, "y": 110}], "mode": "top", "by": "anchor", "to": 4,
"spacing": 2, "source": "live", "dryRun": true, "reason": "line them up", "_fromFrame": "standard"});
let kept = move_args_only(&align);
for k in ["mode", "by", "to", "spacing", "source"] {
assert!(kept.get(k).is_none(), "{k}");
}
for k in ["filePath", "refs", "dryRun", "reason", "_fromFrame"] {
assert!(kept.get(k).is_some(), "{k}");
}
assert!(plan_moves(&b, &kept).is_ok());
assert_eq!(plan_moves(&b, &align).unwrap_err().code, "unknown_argument");
}
#[test]
fn courtyard_box_follows_rotation_and_falls_back_to_pads() {
let b = board(&[fp("U1", 110.0, 110.0, 0.0, "F.Cu", RECT, ""), fp("U2", 120.0, 110.0, 90.0, "F.Cu", RECT, ""), fp("R1", 130.0, 110.0, 0.0, "F.Cu", "", "")]);
let p = placements(&b);
assert_eq!(p[0].courtyard, Rect { min_x: 108.0, min_y: 109.0, max_x: 112.0, max_y: 111.0 });
assert_eq!(p[0].courtyard_source, "courtyard");
// 90 degrees: the 4 x 2 box becomes 2 x 4.
assert_eq!(p[1].courtyard, Rect { min_x: 119.0, min_y: 108.0, max_x: 121.0, max_y: 112.0 });
// No courtyard: pads span x 128.5..131.5, y 109.7..110.3, plus 0.25.
assert_eq!(p[2].courtyard, Rect { min_x: 128.25, min_y: 109.45, max_x: 131.75, max_y: 110.55 });
assert_eq!(p[2].courtyard_source, "pads+0.25mm");
assert_eq!(p[2].pads.as_ref().unwrap()[1], PadNet { pad: "2".into(), net: "GND".into() });
assert!(p.iter().all(|x| x.inside_outline == Some(true)));
let circle = board(&[fp("TP1", 105.0, 105.0, 0.0, "F.Cu", "(fp_circle (center 0 0) (end 1 0) (layer \"F.CrtYd\"))", "")]);
assert_eq!(placements(&circle)[0].courtyard, Rect { min_x: 104.0, min_y: 104.0, max_x: 106.0, max_y: 106.0 });
}
#[test]
fn overlap_math_is_strict_and_side_aware() {
let a = Rect { min_x: 0.0, min_y: 0.0, max_x: 2.0, max_y: 2.0 };
assert!(a.overlaps(&Rect { min_x: 1.0, min_y: 1.0, max_x: 3.0, max_y: 3.0 }));
assert!(!a.overlaps(&Rect { min_x: 2.0, min_y: 0.0, max_x: 4.0, max_y: 2.0 }), "a shared edge is not an overlap");
assert!(!a.overlaps(&Rect { min_x: 0.0, min_y: 2.0, max_x: 2.0, max_y: 4.0 }));
assert!(!a.overlaps(&Rect { min_x: 5.0, min_y: 5.0, max_x: 6.0, max_y: 6.0 }));
assert!(a.contains(&Rect { min_x: 0.5, min_y: 0.5, max_x: 1.5, max_y: 1.5 }));
assert!(a.contains(&a));
assert!(!a.contains(&Rect { min_x: 0.5, min_y: 0.5, max_x: 2.5, max_y: 1.5 }));
// U1 and U2 collide (both front); U3 sits under U1 on the back and does not.
let b = board(&[fp("U1", 110.0, 110.0, 0.0, "F.Cu", RECT, ""), fp("U2", 113.0, 110.0, 0.0, "F.Cu", RECT, ""), fp("U3", 110.0, 110.0, 0.0, "B.Cu", "(fp_rect (start -2 -1) (end 2 1) (layer \"B.CrtYd\"))", ""), fp("U10", 114.0, 110.0, 0.0, "F.Cu", RECT, "")]);
let o = overlaps(&placements(&b));
assert_eq!(o, vec![Overlap { a: "U1".into(), b: "U2".into(), side: "F.Cu".into() }, Overlap { a: "U2".into(), b: "U10".into(), side: "F.Cu".into() }]);
assert!(ref_key("C2") < ref_key("C10"));
assert!(ref_key("C10") < ref_key("R1"));
}
#[test]
fn mst_ratsnest() {
assert_eq!(mst_length(&[]), 0.0);
assert_eq!(mst_length(&[(0.0, 0.0)]), 0.0);
assert_eq!(mst_length(&[(0.0, 0.0), (3.0, 4.0)]), 5.0);
// A square: three sides, never the diagonal.
assert!((mst_length(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]) - 3.0).abs() < 1e-9);
// A far point joins through its nearest neighbour.
assert!((mst_length(&[(0.0, 0.0), (1.0, 0.0), (11.0, 0.0)]) - 11.0).abs() < 1e-9);
let b = board(&[fp("U1", 110.0, 110.0, 0.0, "F.Cu", RECT, ""), fp("U2", 120.0, 110.0, 0.0, "F.Cu", RECT, "")]);
let (nets, total) = nets(&b);
let gnd = nets.iter().find(|n| n.name == "GND").unwrap();
assert_eq!(gnd.refs, vec!["U1", "U2"]);
assert_eq!(gnd.pad_count, 2);
assert_eq!(gnd.mst_length_mm, 10.0);
assert_eq!(nets.iter().find(|n| n.name == "N_U1").unwrap().mst_length_mm, 0.0);
assert_eq!(total, 10.0);
assert_eq!(nets[0].name, "GND", "largest net first");
}
#[test]
fn outline_test_and_parking() {
let b = board(&[fp("U1", 110.0, 110.0, 0.0, "F.Cu", RECT, ""), fp("U2", 149.0, 110.0, 0.0, "F.Cu", RECT, ""), fp("U3", 160.0, 110.0, 0.0, "F.Cu", RECT, "")]);
let p = placements(&b);
assert_eq!(p[0].inside_outline, Some(true));
assert_eq!(p[1].inside_outline, Some(false), "straddles the right edge");
assert_eq!(p[2].inside_outline, Some(false));
assert_eq!(parked(&p, outline(&b).as_ref()), vec!["U3"], "only the one wholly to the right of the outline is parked");
let s = state(&b, false);
assert_eq!(s["outsideOutline"], json!(["U2", "U3"]));
assert_eq!(s["parked"], json!(["U3"]));
assert_eq!(s["outline"]["maxX"], 150.0);
assert_eq!(s["padNetsOmitted"], false);
let v = validate(&b);
assert_eq!(v["placed"], false);
assert_eq!(v["courtyardOverlapCount"], 0);
// No outline: the test is unknown, nothing is parked.
let no_edge = pcb::parse_pcb_text(&format!("(kicad_pcb (version 20260206) (layers (0 \"F.Cu\" signal)) {})", fp("U1", 1.0, 1.0, 0.0, "F.Cu", RECT, "")), "x").unwrap();
assert_eq!(placements(&no_edge)[0].inside_outline, None);
assert!(parked(&placements(&no_edge), None).is_empty());
}
#[test]
fn move_plan_checks_before_anything_moves() {
let b = board(&[fp("U1", 110.0, 110.0, 0.0, "F.Cu", RECT, ""), fp("U2", 120.0, 110.0, 0.0, "F.Cu", RECT, ""), fp("H1", 140.0, 120.0, 0.0, "F.Cu", RECT, "(locked yes)")]);
// A clean move, rotated; the same side spelled differently is not a flip.
let plan = plan_moves(&b, &json!({"ref": "u1", "x": 130, "y": 112, "rotation": 90, "side": "front"})).unwrap();
assert_eq!(plan.moves.len(), 1);
let m = &plan.moves[0];
assert_eq!(m.reference, "U1");
assert_eq!(m.uuid, "U1-uuid");
assert_eq!(m.after, Pose { x: 130.0, y: 112.0, rotation: 90.0, side: "F.Cu".into() });
assert!(!m.flipped);
assert_eq!(m.courtyard_after, Rect { min_x: 129.0, min_y: 110.0, max_x: 131.0, max_y: 114.0 });
assert_eq!(plan.checks["ok"], true);
assert_eq!(plan.undo_label, "Adom: place U1");
// A side change is a flip, refused before any check with nothing written.
let e = plan_moves(&b, &json!({"ref": "U1", "x": 130, "y": 112, "side": "B.Cu"})).unwrap_err();
assert_eq!(e.code, "side_change_not_supported");
assert_eq!(e.detail["mutated"], false);
assert!(e.detail["_hint"].as_str().unwrap().contains("press F"));
// Onto U2: refused, the offender named, nothing written.
let e = plan_moves(&b, &json!({"ref": "U1", "x": 121, "y": 110})).unwrap_err();
assert_eq!(e.code, "courtyard_overlap");
assert_eq!(e.detail["offendingRefs"], json!(["U2"]));
assert_eq!(e.detail["mutated"], false);
assert!(plan_moves(&b, &json!({"ref": "U1", "x": 121, "y": 110, "allowOverlap": true})).is_ok());
// Off the board.
let e = plan_moves(&b, &json!({"ref": "U1", "x": 149, "y": 110})).unwrap_err();
assert_eq!(e.code, "outside_outline");
assert_eq!(e.detail["outsideRefs"], json!(["U1"]));
assert!(plan_moves(&b, &json!({"ref": "U1", "x": 200, "y": 110, "allowOutside": true})).is_ok());
// A batch is one plan; two moved parts colliding with each other is refused too.
let batch = plan_moves(&b, &json!({"refs": [{"ref": "U1", "x": 130, "y": 112}, {"ref": "U2", "x": 130, "y": 120, "rotation": 450}]})).unwrap();
assert_eq!(batch.moves[1].after.rotation, 90.0);
assert_eq!(batch.undo_label, "Adom: place U1, U2");
let e = plan_moves(&b, &json!({"refs": [{"ref": "U1", "x": 130, "y": 112}, {"ref": "U2", "x": 131, "y": 112}]})).unwrap_err();
assert_eq!(e.code, "courtyard_overlap");
assert_eq!(e.detail["offendingRefs"], json!([]), "both offenders are being moved");
// Argument errors.
assert_eq!(plan_moves(&b, &json!({"x": 1, "y": 2})).unwrap_err().code, "missing_ref");
assert_eq!(plan_moves(&b, &json!({"ref": "U9", "x": 1, "y": 2})).unwrap_err().code, "unknown_footprint");
assert_eq!(plan_moves(&b, &json!({"ref": "U1", "x": "a", "y": 2})).unwrap_err().code, "invalid_geometry");
assert_eq!(plan_moves(&b, &json!({"ref": "U1", "x": 130, "y": 112, "side": "In1.Cu"})).unwrap_err().code, "invalid_side");
assert_eq!(plan_moves(&b, &json!({"ref": "H1", "x": 130, "y": 112})).unwrap_err().code, "footprint_locked");
assert!(plan_moves(&b, &json!({"ref": "H1", "x": 130, "y": 112, "allowLocked": true})).is_ok());
assert_eq!(plan_moves(&b, &json!({"refs": []})).unwrap_err().code, "invalid_refs");
assert_eq!(plan_moves(&b, &json!({"refs": [{"ref": "U1", "x": 130, "y": 112}, {"ref": "U1", "x": 131, "y": 112}]})).unwrap_err().code, "invalid_refs");
}
#[test]
fn drc_filter_keeps_placement_violations_and_limits() {
let report = json!({
"errors": 3, "warnings": 1, "unconnected": 7, "source": "live-editor-snapshot",
"violations": [
{"type": "courtyards_overlap", "severity": "error", "description": "Courtyards overlap"},
{"type": "clearance", "severity": "error", "description": "Clearance violation"},
{"type": "track_dangling", "severity": "error", "description": "Track has unconnected end"},
{"type": "silk_over_copper", "severity": "warning", "description": "Silkscreen clipped by solder mask"},
],
"violationReportMayBeTruncated": true, "possiblyTruncatedViolationTypes": ["clearance"],
});
let f = drc_filter(&report);
assert_eq!(f["available"], true);
assert_eq!(f["placementViolationCount"], 3);
assert_eq!(f["placementErrors"], 2);
assert_eq!(f["otherViolationCount"], 1);
assert_eq!(f["placementClean"], false);
assert_eq!(f["unconnected"], 7);
assert_eq!(f["violationReportMayBeTruncated"], true);
assert_eq!(f["possiblyTruncatedViolationTypes"], json!(["clearance"]));
assert_eq!(drc_filter(&json!({"violations": []}))["placementClean"], true);
}
#[test]
fn state_bounds_per_pad_nets_above_the_limit() {
let many: Vec<String> = (0..(DETAIL_LIMIT + 1)).map(|i| fp(&format!("R{i}"), 100.0 + (i % 40) as f64, 100.0 + (i / 40) as f64 * 3.0, 0.0, "F.Cu", "", "")).collect();
let b = board(&many);
let s = state(&b, false);
assert_eq!(s["padNetsOmitted"], true);
assert!(s["footprints"][0].get("pads").is_none());
let d = state(&b, true);
assert_eq!(d["padNetsOmitted"], false);
assert_eq!(d["footprints"][0]["pads"].as_array().unwrap().len(), 2);
assert_eq!(file_revision("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
}
// -----------------------------------------------------------------------
// The child transform
// -----------------------------------------------------------------------
const MM: i64 = 1_000_000;
const PAD_URL: &str = "type.googleapis.com/kiapi.board.types.Pad";
const SHAPE_URL: &str = "type.googleapis.com/kiapi.board.types.BoardGraphicShape";
/// A Pad proto: number (3), net (4, a nested message kept verbatim), padstack (6)
/// with its angle (6.6) and a per-layer offset (6.5.11, pad-local, kept), position (7).
fn pad_bytes(x: i64, y: i64, angle: f64) -> Vec<u8> {
let mut out = Vec::new();
wire_message(&mut out, 3, b"1");
let mut net = Vec::new();
wire_message(&mut net, 2, b"GND");
wire_message(&mut out, 4, &net);
let mut stack = Vec::new();
let mut layer = Vec::new();
wire_varint(&mut layer, 1, 1);
wire_message(&mut layer, 11, &vector2_bytes(100_000, 0));
wire_message(&mut stack, 5, &layer);
wire_message(&mut stack, 6, &angle_bytes(angle));
wire_message(&mut out, 6, &stack);
wire_message(&mut out, 7, &vector2_bytes(x, y));
out
}
fn field_of(bytes: &[u8], number: u32) -> Vec<u8> {
let fields = parse_fields(bytes).unwrap();
let f = fields.iter().find(|f| f.number == number).unwrap_or_else(|| panic!("no field {number}"));
f.data.to_vec()
}
fn pad_of(bytes: &[u8]) -> ((i64, i64), f64) {
let stack = field_of(bytes, 6);
(decode_vector2(&field_of(bytes, 7)).unwrap(), decode_angle(&field_of(&stack, 6)).unwrap())
}
fn shape_bytes(geometry_field: u32, body: &[u8]) -> Vec<u8> {
let mut shape = Vec::new();
wire_message(&mut shape, geometry_field, body);
let mut out = Vec::new();
wire_message(&mut out, 1, &shape);
wire_varint(&mut out, 2, 39); // layer: kept
out
}
fn polyset_points(bytes: &[u8]) -> Vec<(i64, i64)> {
let polygon = field_of(bytes, 1);
let line = field_of(&polygon, 1);
// Point nodes only (an arc node carries field 2 instead).
parse_fields(&line).unwrap().iter().filter(|f| f.number == 1).filter_map(|n| parse_fields(n.data).unwrap().iter().find(|f| f.number == 1).map(|f| decode_vector2(f.data).unwrap())).collect()
}
#[test]
fn child_transform_math_matches_kicad_rotate_point() {
// Translation only: exact integer shift, angles untouched.
let t = ChildTransform::new((10 * MM, 20 * MM, 0.0), (30 * MM, 40 * MM, 0.0));
assert!(t.is_translation() && t.is_cardinal());
assert_eq!(t.point(9 * MM, 20 * MM), (29 * MM, 40 * MM));
assert_eq!(t.angle(-90.0), -90.0);
// Rotation by 90 about a non-origin anchor: a pad left of the anchor ends up
// below it (y down, positive angle counter-clockwise on screen), exactly.
let r = ChildTransform::new((10 * MM, 20 * MM, 0.0), (30 * MM, 40 * MM, 90.0));
assert!(!r.is_translation() && r.is_cardinal());
assert_eq!(r.point(9 * MM, 20 * MM), (30 * MM, 41 * MM));
assert_eq!(r.point(12 * MM, 23 * MM), (33 * MM, 38 * MM), "a pad at an offset: (2, 3) becomes (3, -2)");
assert_eq!(r.angle(0.0), 90.0);
assert_eq!(r.angle(315.0), 45.0);
// The delta is the change of orientation, whatever the absolute values.
let same = ChildTransform::new((10 * MM, 20 * MM, 30.0), (30 * MM, 40 * MM, 120.0));
assert_eq!(same.point(12 * MM, 23 * MM), r.point(12 * MM, 23 * MM));
assert_eq!(ChildTransform::new((0, 0, 350.0), (0, 0, 10.0)).delta_deg, 20.0);
assert_eq!(ChildTransform::new((0, 0, 0.0), (0, 0, -180.0)).delta_deg, 180.0);
// 45 degrees agrees with to_board on the same local point.
let d = ChildTransform::new((0, 0, 0.0), (5 * MM, 0, 45.0));
let (bx, by) = to_board((5.0, 0.0), 45.0, (1.0, 0.0));
let (px, py) = d.point(MM, 0);
assert!(((px as f64 / MM as f64) - bx).abs() < 1e-6 && ((py as f64 / MM as f64) - by).abs() < 1e-6);
assert!(!d.is_cardinal());
}
#[test]
fn wire_codec_round_trips_every_wire_type() {
let mut b = Vec::new();
wire_varint(&mut b, 1, u64::MAX); // 10-byte varint (a negative int64)
wire_double(&mut b, 2, -1.5);
wire_message(&mut b, 3, b"abc");
put_varint(&mut b, (4 << 3) | 5);
b.extend_from_slice(&7u32.to_le_bytes());
let fields = parse_fields(&b).unwrap();
assert_eq!(fields.iter().map(|f| (f.number, f.wire)).collect::<Vec<_>>(), vec![(1, 0), (2, 1), (3, 2), (4, 5)]);
assert_eq!(varint_value(fields[0].data).unwrap() as i64, -1);
assert_eq!(fields[2].data, b"abc");
let mut out = Vec::new();
for f in &fields {
write_field(&mut out, f.number, f.wire, f.data);
}
assert_eq!(out, b, "re-encoding reproduces the bytes");
assert_eq!(decode_vector2(&vector2_bytes(-3 * MM, 0)).unwrap(), (-3 * MM, 0));
assert_eq!(decode_angle(&angle_bytes(-90.0)).unwrap(), -90.0);
assert_eq!(decode_distance(&distance_bytes(-5)).unwrap(), -5);
assert!(parse_fields(&[0x08]).is_err(), "truncated");
assert!(parse_fields(&[0x0b]).is_err(), "group wire type");
}
#[test]
fn pad_moves_with_the_footprint_and_keeps_its_other_fields() {
let pad = pad_bytes(9 * MM, 20 * MM, 45.0);
// Translation: exact.
let t = ChildTransform::new((10 * MM, 20 * MM, 0.0), (30 * MM, 40 * MM, 0.0));
let moved = transform_child(&t, PAD_URL, &pad).unwrap();
assert_eq!(pad_of(&moved), ((29 * MM, 40 * MM), 45.0));
// Rotation by 90: position and orientation both turn.
let r = ChildTransform::new((10 * MM, 20 * MM, 0.0), (30 * MM, 40 * MM, 90.0));
let moved = transform_child(&r, PAD_URL, &pad).unwrap();
assert_eq!(pad_of(&moved), ((30 * MM, 41 * MM), 135.0));
// Everything else rides along byte for byte: number, net, the pad-local offset.
assert_eq!(field_of(&moved, 3), b"1");
assert_eq!(field_of(&moved, 4), field_of(&pad, 4));
let stack = field_of(&moved, 6);
assert_eq!(field_of(&stack, 5), field_of(&field_of(&pad, 6), 5));
assert_eq!(moved.len(), pad.len());
// Identity leaves the bytes as they were.
let id = ChildTransform::new((10 * MM, 20 * MM, 0.0), (10 * MM, 20 * MM, 0.0));
assert_eq!(transform_child(&id, PAD_URL, &pad).unwrap(), pad);
}
#[test]
fn texts_shapes_polygons_and_zones_move() {
let r = ChildTransform::new((10 * MM, 20 * MM, 0.0), (30 * MM, 40 * MM, 90.0));
// A field: BoardText inside Field, position and angle.
let mut attrs = Vec::new();
wire_message(&mut attrs, 4, &angle_bytes(45.0));
wire_message(&mut attrs, 14, &vector2_bytes(MM, MM)); // size: kept
let mut text = Vec::new();
wire_message(&mut text, 2, &vector2_bytes(10 * MM, 18 * MM));
wire_message(&mut text, 3, &attrs);
wire_message(&mut text, 5, b"U1");
let mut board_text = Vec::new();
wire_message(&mut board_text, 2, &text);
let mut field = Vec::new();
wire_message(&mut field, 2, b"Reference");
wire_message(&mut field, 3, &board_text);
let moved = transform_child(&r, "type.googleapis.com/kiapi.board.types.Field", &field).unwrap();
let t = field_of(&field_of(&moved, 3), 2);
assert_eq!(decode_vector2(&field_of(&t, 2)).unwrap(), (28 * MM, 40 * MM));
let a = field_of(&t, 3);
assert_eq!(decode_angle(&field_of(&a, 4)).unwrap(), 135.0);
assert_eq!(decode_vector2(&field_of(&a, 14)).unwrap(), (MM, MM), "a size is not a point");
assert_eq!(field_of(&t, 5), b"U1");
// A courtyard rectangle at a cardinal angle stays a rectangle, corners rotated.
let mut rect = Vec::new();
wire_message(&mut rect, 1, &vector2_bytes(8 * MM, 19 * MM));
wire_message(&mut rect, 2, &vector2_bytes(12 * MM, 21 * MM));
let moved = transform_child(&r, SHAPE_URL, &shape_bytes(5, &rect)).unwrap();
let shape = field_of(&moved, 1);
let rect_out = field_of(&shape, 5);
assert_eq!(decode_vector2(&field_of(&rect_out, 1)).unwrap(), (29 * MM, 42 * MM), "(-2, -1) turns into (-1, 2)");
assert_eq!(decode_vector2(&field_of(&rect_out, 2)).unwrap(), (31 * MM, 38 * MM));
assert_eq!(varint_value(&field_of(&moved, 2)).unwrap(), 39, "layer kept");
// Off the cardinal angles it becomes a closed four-point polygon (field 8).
let d = ChildTransform::new((10 * MM, 20 * MM, 0.0), (10 * MM, 20 * MM, 45.0));
let moved = transform_child(&d, SHAPE_URL, &shape_bytes(5, &rect)).unwrap();
let shape = field_of(&moved, 1);
assert!(parse_fields(&shape).unwrap().iter().all(|f| f.number != 5));
let pts = polyset_points(&field_of(&shape, 8));
assert_eq!(pts.len(), 4);
let (s, c) = 45f64.to_radians().sin_cos();
let expect = |x: f64, y: f64| ((10.0 + x * c + y * s) * MM as f64, (20.0 - x * s + y * c) * MM as f64);
for (p, (ex, ey)) in pts.iter().zip([expect(-2.0, -1.0), expect(2.0, -1.0), expect(2.0, 1.0), expect(-2.0, 1.0)]) {
assert!((p.0 as f64 - ex).abs() <= 1.0 && (p.1 as f64 - ey).abs() <= 1.0, "{p:?} vs ({ex}, {ey})");
}
// A rounded rectangle off-cardinal is refused, not approximated.
let mut rounded = rect.clone();
wire_message(&mut rounded, 3, &distance_bytes(200_000));
let e = transform_child(&d, SHAPE_URL, &shape_bytes(5, &rounded)).unwrap_err();
assert!(e.reason.contains("rounded rectangle"));
assert_eq!(e.type_url, SHAPE_URL);
// A polygon with a point node and an arc node.
let mut line = Vec::new();
let mut node = Vec::new();
wire_message(&mut node, 1, &vector2_bytes(9 * MM, 20 * MM));
wire_message(&mut line, 1, &node);
let mut arc = Vec::new();
wire_message(&mut arc, 1, &vector2_bytes(11 * MM, 20 * MM));
wire_message(&mut arc, 2, &vector2_bytes(12 * MM, 21 * MM));
wire_message(&mut arc, 3, &vector2_bytes(11 * MM, 22 * MM));
let mut arc_node = Vec::new();
wire_message(&mut arc_node, 2, &arc);
wire_message(&mut line, 1, &arc_node);
wire_varint(&mut line, 2, 1);
let mut polygon = Vec::new();
wire_message(&mut polygon, 1, &line);
let mut set = Vec::new();
wire_message(&mut set, 1, &polygon);
let moved = transform_child(&r, SHAPE_URL, &shape_bytes(8, &set)).unwrap();
let line_out = field_of(&field_of(&field_of(&field_of(&moved, 1), 8), 1), 1);
let nodes = parse_fields(&line_out).unwrap();
assert_eq!(decode_vector2(&field_of(nodes[0].data, 1)).unwrap(), (30 * MM, 41 * MM));
let arc_out = field_of(nodes[1].data, 2);
assert_eq!(decode_vector2(&field_of(&arc_out, 1)).unwrap(), (30 * MM, 39 * MM));
assert_eq!(decode_vector2(&field_of(&arc_out, 2)).unwrap(), (31 * MM, 38 * MM));
assert_eq!(decode_vector2(&field_of(&arc_out, 3)).unwrap(), (32 * MM, 39 * MM));
assert_eq!(varint_value(nodes[2].data).unwrap(), 1, "closed flag kept");
// A zone: the same polygon as outline (4) and as a filled layer (10.2).
let mut fill = Vec::new();
wire_varint(&mut fill, 1, 1);
wire_message(&mut fill, 2, &set);
let mut zone = Vec::new();
wire_message(&mut zone, 4, &set);
wire_message(&mut zone, 5, b"keepout");
wire_message(&mut zone, 10, &fill);
let moved = transform_child(&r, "type.googleapis.com/kiapi.board.types.Zone", &zone).unwrap();
assert_eq!(polyset_points(&field_of(&moved, 4))[0], (30 * MM, 41 * MM));
assert_eq!(polyset_points(&field_of(&field_of(&moved, 10), 2))[0], (30 * MM, 41 * MM));
assert_eq!(field_of(&moved, 5), b"keepout");
// Segment, circle, arc, bezier: every point.
let mut bez = Vec::new();
for (i, x) in [8, 9, 11, 12].iter().enumerate() {
wire_message(&mut bez, i as u32 + 1, &vector2_bytes(x * MM, 20 * MM));
}
let moved = transform_child(&r, SHAPE_URL, &shape_bytes(9, &bez)).unwrap();
let b = field_of(&field_of(&moved, 1), 9);
assert_eq!(decode_vector2(&field_of(&b, 1)).unwrap(), (30 * MM, 42 * MM));
assert_eq!(decode_vector2(&field_of(&b, 4)).unwrap(), (30 * MM, 38 * MM));
}
#[test]
fn text_box_dimension_barcode_and_passthrough_rules() {
let r = ChildTransform::new((10 * MM, 20 * MM, 0.0), (30 * MM, 40 * MM, 90.0));
let d = ChildTransform::new((10 * MM, 20 * MM, 0.0), (10 * MM, 20 * MM, 30.0));
// Text box: corners and angle at cardinal angles, refused otherwise.
let mut attrs = Vec::new();
wire_message(&mut attrs, 4, &angle_bytes(0.0));
let mut tb = Vec::new();
wire_message(&mut tb, 2, &vector2_bytes(8 * MM, 19 * MM));
wire_message(&mut tb, 3, &vector2_bytes(12 * MM, 21 * MM));
wire_message(&mut tb, 4, &attrs);
let mut board_tb = Vec::new();
wire_message(&mut board_tb, 2, &tb);
let url = "type.googleapis.com/kiapi.board.types.BoardTextBox";
let moved = transform_child(&r, url, &board_tb).unwrap();
let t = field_of(&moved, 2);
assert_eq!(decode_vector2(&field_of(&t, 2)).unwrap(), (29 * MM, 42 * MM));
assert_eq!(decode_angle(&field_of(&field_of(&t, 4), 4)).unwrap(), 90.0);
assert!(transform_child(&d, url, &board_tb).unwrap_err().reason.contains("multiple of 90"));
// Orthogonal dimension: about 90 degrees a horizontal one becomes vertical, height kept.
let mut ortho = Vec::new();
wire_message(&mut ortho, 1, &vector2_bytes(9 * MM, 20 * MM));
wire_message(&mut ortho, 2, &vector2_bytes(11 * MM, 20 * MM));
wire_message(&mut ortho, 3, &distance_bytes(2 * MM));
wire_varint(&mut ortho, 5, AA_X_AXIS);
let mut dim = Vec::new();
wire_message(&mut dim, 6, &ortho);
let url = "type.googleapis.com/kiapi.board.types.Dimension";
let o = field_of(&transform_child(&r, url, &dim).unwrap(), 6);
assert_eq!(decode_vector2(&field_of(&o, 1)).unwrap(), (30 * MM, 41 * MM));
assert_eq!(varint_value(&field_of(&o, 5)).unwrap(), AA_Y_AXIS);
assert_eq!(decode_distance(&field_of(&o, 3)).unwrap(), 2 * MM);
// A vertical one about 90 degrees becomes horizontal with the height negated.
let mut vert = Vec::new();
wire_message(&mut vert, 3, &distance_bytes(2 * MM));
wire_varint(&mut vert, 5, AA_Y_AXIS);
let mut dim_v = Vec::new();
wire_message(&mut dim_v, 6, &vert);
let o = field_of(&transform_child(&r, url, &dim_v).unwrap(), 6);
assert_eq!(varint_value(&field_of(&o, 5)).unwrap(), AA_X_AXIS);
assert_eq!(decode_distance(&field_of(&o, 3)).unwrap(), -2 * MM);
// About 180: the axis stays, the height flips; an absent alignment is horizontal.
let half = ChildTransform::new((0, 0, 0.0), (0, 0, 180.0));
let mut plain = Vec::new();
wire_message(&mut plain, 3, &distance_bytes(MM));
let mut dim_p = Vec::new();
wire_message(&mut dim_p, 6, &plain);
let o = field_of(&transform_child(&half, url, &dim_p).unwrap(), 6);
assert_eq!(decode_distance(&field_of(&o, 3)).unwrap(), -MM);
assert!(parse_fields(&o).unwrap().iter().all(|f| f.number != 5));
let o = field_of(&transform_child(&r, url, &dim_p).unwrap(), 6);
assert_eq!(varint_value(&field_of(&o, 5)).unwrap(), AA_Y_AXIS, "appended when it has to change");
// Aligned dimension with its text.
let mut text = Vec::new();
wire_message(&mut text, 2, &vector2_bytes(10 * MM, 18 * MM));
let mut al = Vec::new();
wire_message(&mut al, 2, &vector2_bytes(11 * MM, 20 * MM));
let mut dim_a = Vec::new();
wire_message(&mut dim_a, 4, &text);
wire_message(&mut dim_a, 5, &al);
let moved = transform_child(&r, url, &dim_a).unwrap();
assert_eq!(decode_vector2(&field_of(&field_of(&moved, 4), 2)).unwrap(), (28 * MM, 40 * MM));
assert_eq!(decode_vector2(&field_of(&field_of(&moved, 5), 2)).unwrap(), (30 * MM, 39 * MM));
// Barcode and reference image.
let mut bc = Vec::new();
wire_message(&mut bc, 5, &vector2_bytes(9 * MM, 20 * MM));
wire_message(&mut bc, 6, &angle_bytes(10.0));
let moved = transform_child(&r, "type.googleapis.com/kiapi.board.types.Barcode", &bc).unwrap();
assert_eq!(decode_vector2(&field_of(&moved, 5)).unwrap(), (30 * MM, 41 * MM));
assert_eq!(decode_angle(&field_of(&moved, 6)).unwrap(), 100.0);
let mut img = Vec::new();
wire_message(&mut img, 3, &vector2_bytes(9 * MM, 20 * MM));
wire_message(&mut img, 6, b"png");
let moved = transform_child(&r, "type.googleapis.com/kiapi.board.types.ReferenceImage", &img).unwrap();
assert_eq!(decode_vector2(&field_of(&moved, 3)).unwrap(), (30 * MM, 41 * MM));
assert_eq!(field_of(&moved, 6), b"png");
// Groups and 3D models pass through; anything else is refused by name.
assert_eq!(transform_child(&r, "type.googleapis.com/kiapi.board.types.Group", b"\x12\x03abc").unwrap(), b"\x12\x03abc");
assert_eq!(transform_child(&r, "type.googleapis.com/kiapi.board.types.Footprint3DModel", b"\x0a\x01m").unwrap(), b"\x0a\x01m");
let e = transform_child(&r, "type.googleapis.com/kiapi.board.types.Track", b"").unwrap_err();
assert_eq!(e.type_url, "type.googleapis.com/kiapi.board.types.Track");
assert!(CHILD_TYPES.contains(&"kiapi.board.types.Pad"));
// Malformed bytes are an error, never silently kept.
assert!(transform_child(&r, PAD_URL, &[0x3a, 0x05, 0x08]).is_err());
}
#[test]
fn child_check_proves_the_body_moved() {
let before = board(&[fp("U1", 110.0, 110.0, 0.0, "F.Cu", RECT, ""), fp("R1", 120.0, 110.0, 0.0, "F.Cu", "", "")]);
let plan = plan_moves(&before, &json!({"refs": [{"ref": "U1", "x": 130, "y": 112, "rotation": 90}, {"ref": "R1", "x": 140, "y": 120, "rotation": 90}]})).unwrap();
let (mu, mr) = (&plan.moves[0], &plan.moves[1]);
// KiCad did it right: the children keep their local geometry at the new pose.
let good = board(&[fp("U1", 130.0, 112.0, 90.0, "F.Cu", RECT, ""), fp("R1", 140.0, 120.0, 90.0, "F.Cu", "", "")]);
let c = check_children(mu, &before.footprints[0], &good.footprints[0]);
assert_eq!(c.method, "courtyardCentre");
assert_eq!(c.expected, [130.0, 112.0]);
assert_eq!(c.actual, Some([130.0, 112.0]));
assert!(c.ok);
let c = check_children(mr, &before.footprints[1], &good.footprints[1]);
assert_eq!(c.method, "firstPad");
assert_eq!(c.expected, [140.0, 121.0], "pad 1 at local (-1, 0) turned by 90 lies below the anchor");
assert!(c.ok && c.delta_mm.unwrap() < 1e-6);
// The bug: the anchor moved, the body stayed at the old absolute place, which the
// re-read board shows as shifted local geometry.
let stale_rect = "(fp_rect (start 0 -22) (end 4 -18) (layer \"F.CrtYd\"))";
let bad = board(&[fp("U1", 130.0, 112.0, 90.0, "F.Cu", stale_rect, ""), format!("(footprint \"T:R1\" (layer \"F.Cu\") (uuid \"R1-uuid\") (at 140 120 90) (property \"Reference\" \"R1\") (property \"Value\" \"V\") (pad \"1\" smd rect (at -10 21) (size 1 0.6) (layers \"F.Cu\")) (pad \"2\" smd rect (at -10 19) (size 1 0.6) (layers \"F.Cu\")))")]);
let c = check_children(mu, &before.footprints[0], &bad.footprints[0]);
assert!(!c.ok);
assert_eq!(c.actual, Some([110.0, 110.0]));
assert!((c.delta_mm.unwrap() - (20f64.powi(2) + 2f64.powi(2)).sqrt()).abs() < 1e-6);
assert!(c.describe("U1").starts_with("U1 courtyardCentre expected (130, 112) got (110, 110), off by "));
let c = check_children(mr, &before.footprints[1], &bad.footprints[1]);
assert_eq!(c.method, "firstPad");
assert_eq!(c.actual, Some([161.0, 130.0]));
assert!(!c.ok);
// Within a hundredth of a millimetre is a match; beyond it is not.
let near = board(&[fp("U1", 130.004, 112.0, 90.0, "F.Cu", RECT, "")]);
assert!(check_children(mu, &before.footprints[0], &near.footprints[0]).ok);
let far = board(&[fp("U1", 130.02, 112.0, 90.0, "F.Cu", RECT, "")]);
assert!(!check_children(mu, &before.footprints[0], &far.footprints[0]).ok);
// A courtyard that vanished on read-back is a failure with nothing to compare.
let gone = board(&[fp("U1", 130.0, 112.0, 90.0, "F.Cu", "", "")]);
let c = check_children(mu, &before.footprints[0], &gone.footprints[0]);
assert!(!c.ok && c.actual.is_none());
assert!(c.describe("U1").contains("nothing to compare"));
}
}
#[cfg(test)]
mod outline_tests {
use super::*;
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<Edge> {
let c = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)];
(0..4).map(|i| (c[i], c[(i + 1) % 4])).collect()
}
/// The stepped TSSOP-16 courtyard of wiki #125 (body narrow, pad rows wide), as a polygon.
fn tssop(cx: f64, cy: f64) -> Vec<Edge> {
let p = [(-2.45, -3.0), (2.45, -3.0), (2.45, -2.6), (3.9, -2.6), (3.9, 2.6), (2.45, 2.6), (2.45, 3.0), (-2.45, 3.0), (-2.45, 2.6), (-3.9, 2.6), (-3.9, -2.6), (-2.45, -2.6)];
let v: Vec<(f64, f64)> = p.iter().map(|(x, y)| (cx + x, cy + y)).collect();
(0..v.len()).map(|i| (v[i], v[(i + 1) % v.len()])).collect()
}
#[test]
fn a_part_in_the_notch_of_a_stepped_courtyard_does_not_overlap() {
// Inside the TSSOP's bounding box (its corner), but outside its real outline.
let r = rect(3.0, 2.7, 4.8, 3.4);
assert!(!outlines_overlap(&tssop(0.0, 0.0), &r));
}
#[test]
fn crossing_and_containment_both_overlap() {
assert!(outlines_overlap(&tssop(0.0, 0.0), &rect(3.5, 1.0, 5.0, 2.0)));
assert!(outlines_overlap(&tssop(0.0, 0.0), &rect(-0.5, -0.5, 0.5, 0.5)));
assert!(outlines_overlap(&rect(-0.5, -0.5, 0.5, 0.5), &tssop(0.0, 0.0)));
}
#[test]
fn courtyards_that_only_share_an_edge_do_not_overlap() {
assert!(!outlines_overlap(&rect(0.0, 0.0, 1.0, 1.0), &rect(1.0, 0.0, 2.0, 1.0)));
}
}