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"), &current.side)?;
        if side != current.side {
            return Err(side_change_error(&current.reference, &current.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)));
    }
}