← Commit history

1.0.3: 1.0.3: placement. kicad_placement_state, kicad_move_footprint (a batch of moves as one native undo step, refused on courtyard overlap or outside the outline, footprint children transformed client-side because KiCad replaces the footprint on update, verified by the read-back courtyard) and kicad_placement_validate; tools/make_placement_fixture.py with --strip-zones and --strip-graphics; demo/placement with the public ESC G431 fixtures and Claude Fable 5.1's placement take. Foreground etiquette: a spawned editor that raises itself when its board finishes loading is now pushed back and the user's window gets its activation restored through ab (the bridge process has no foreground rights); the loop restores the user's window on every bounce. Gate on ConfRoomROG: 88 pass, zero steals.

John Lauer ·2569119354 ·24d ago ·parent 0752ebc
7 files changed +1899−4
rust/crates/kicad-core/src/pcb.rs+60
@@ -131,6 +131,11 @@ pub struct Footprint {     pub attr: String,     pub pad_count: usize,     pub pads: Vec<FpPad>,+    /// Courtyard vertices in footprint-local mm (every `fp_line`, `fp_rect`, `fp_poly`,+    /// `fp_circle` and `fp_arc` on F.CrtYd or B.CrtYd), empty when the footprint has no+    /// courtyard. Not serialised: `crate::placement` turns it into a board-frame box.+    #[serde(skip)]+    pub courtyard: Vec<Point>, }  #[derive(Clone, Debug, Default, Serialize)]@@ -452,11 +457,66 @@ fn extract_footprints(tree: &Sx, name_based: bool) -> Vec<Footprint> {             attr: value_string(node.find("attr")).unwrap_or_default(),             pad_count: pads.len(),             pads,+            courtyard: extract_courtyard(node),         });     }     out } +/// The courtyard outline's vertices in footprint-local coordinates: a rectangle's four+/// corners, a circle's four extreme points, a line's ends, an arc's start, mid and end,+/// a polygon's points. A footprint with no F.CrtYd or B.CrtYd shape yields nothing.+fn extract_courtyard(fp: &Sx) -> Vec<Point> {+    let mut out = Vec::new();+    let on_courtyard = |node: &Sx| matches!(node.find("layer").and_then(Sx::value), Some("F.CrtYd") | Some("B.CrtYd"));+    for node in fp.find_all("fp_line").chain(fp.find_all("fp_arc")) {+        if !on_courtyard(node) {+            continue;+        }+        for coord in ["start", "mid", "end"] {+            if let Some(p) = point_of(node.find(coord)) {+                out.push(p);+            }+        }+    }+    for node in fp.find_all("fp_rect") {+        if !on_courtyard(node) {+            continue;+        }+        if let (Some(a), Some(b)) = (point_of(node.find("start")), point_of(node.find("end"))) {+            out.push(Point { x: a.x, y: a.y });+            out.push(Point { x: b.x, y: a.y });+            out.push(Point { x: b.x, y: b.y });+            out.push(Point { x: a.x, y: b.y });+        }+    }+    for node in fp.find_all("fp_circle") {+        if !on_courtyard(node) {+            continue;+        }+        if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) {+            let r = dist((c.x, c.y), (e.x, e.y));+            out.push(Point { x: c.x - r, y: c.y });+            out.push(Point { x: c.x + r, y: c.y });+            out.push(Point { x: c.x, y: c.y - r });+            out.push(Point { x: c.x, y: c.y + r });+        }+    }+    for node in fp.find_all("fp_poly") {+        if !on_courtyard(node) {+            continue;+        }+        if let Some(pts) = node.find("pts") {+            for xy in pts.find_all("xy") {+                if let (Some(x), Some(y)) = (atom_f64(xy, 1), atom_f64(xy, 2)) {+                    out.push(Point { x, y });+                }+            }+        }+    }+    out+}+ fn point_of(node: Option<&Sx>) -> Option<Point> {     let n = node?;     Some(Point { x: atom_f64(n, 1)?, y: atom_f64(n, 2)? })
rust/crates/kicad-core/src/placement.rsadded+1716
@@ -0,0 +1,1716 @@+//! 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>,+}++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)),+    }+}++/// 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]);+            if p.side == q.side && p.courtyard.overlaps(&q.courtyard) {+                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,+}++#[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+    }+}++/// `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> {+    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,+        });+    }+    // 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.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).+struct WireField<'a> {+    number: u32,+    wire: u8,+    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);+}++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+}++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 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"));+    }+}
rust/crates/kicad-core/tests/placement_fixture.rsadded+73
@@ -0,0 +1,73 @@+//! The placement demo fixture (`demo/placement/`, built by `tools/make_placement_fixture.py`+//! from the public Adom ESC G431 board) read back through the bridge's own parser: both+//! copies keep every footprint, carry zero segments and zero vias, and the unplaced copy+//! has every footprint parked to the right of the outline with no courtyard collision,+//! which is exactly what `kicad_placement_state` and `kicad_placement_validate` report.++use std::path::{Path, PathBuf};++use kicad_core::pcb;+use kicad_core::placement;++fn repo_file(rel: &str) -> PathBuf {+    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)+}++fn load(name: &str) -> pcb::Board {+    let p = repo_file(&format!("demo/placement/{name}"));+    let text = std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display()));+    pcb::parse_pcb_text(&text, &p.to_string_lossy()).unwrap()+}++#[test]+fn unplaced_copy_is_parked_unrouted_and_collision_free() {+    let b = load("esc-g431-unplaced.kicad_pcb");+    assert_eq!(b.footprints.len(), 149);+    assert_eq!(b.segments.len(), 0);+    assert_eq!(b.vias.len(), 0);+    assert_eq!(b.net_format, pcb::NetFormat::Name);+    assert_eq!(pcb::copper_layers(&b), vec!["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);+    // Teardrop zones are gone with the copper; the user's pours stay.+    assert_eq!(b.zones.len(), 49);+    let outline = placement::outline(&b).expect("Edge.Cuts outline");+    assert_eq!((outline.min_x, outline.min_y, outline.max_x, outline.max_y), (104.0, 58.0, 168.0, 132.0));+    let places = placement::placements(&b);+    assert!(places.iter().all(|p| p.rotation == 0.0), "parked footprints are at rotation 0");+    assert!(places.iter().all(|p| p.inside_outline == Some(false)));+    let parked = placement::parked(&places, Some(&outline));+    assert_eq!(parked.len(), 149, "every footprint sits to the right of the outline");+    assert!(places.iter().all(|p| p.courtyard.min_x >= 173.0 - 1e-6), "5 mm margin from the outline");+    assert_eq!(placement::overlaps(&places), Vec::<placement::Overlap>::new(), "the parking grid has no courtyard collisions");+    assert!(places.iter().all(|p| p.side == "F.Cu"), "the ESC is assembled on one side and the tool keeps every side as it was");+    let with_courtyard = places.iter().filter(|p| p.courtyard_source == "courtyard").count();+    assert!(with_courtyard > 140, "{with_courtyard} of 149 have a courtyard shape");+    let s = placement::state(&b, false);+    assert_eq!(s["footprintCount"], 149);+    assert_eq!(s["padNetsOmitted"], false);+    assert!(s["ratsnest"]["totalMm"].as_f64().unwrap() > 1000.0, "parked parts have a long ratsnest");+    let v = placement::validate(&b);+    assert_eq!(v["placed"], false);+    assert_eq!(v["courtyardOverlapCount"], 0);+    assert_eq!(v["parked"].as_array().unwrap().len(), 149);+}++#[test]+fn unrouted_copy_keeps_the_original_placement() {+    let b = load("esc-g431-unrouted.kicad_pcb");+    assert_eq!(b.footprints.len(), 149);+    assert_eq!(b.segments.len(), 0);+    assert_eq!(b.vias.len(), 0);+    assert_eq!(b.zones.len(), 49);+    let u2 = b.footprints.iter().find(|f| f.reference == "U2").unwrap();+    let at = u2.position.as_ref().unwrap();+    assert_eq!((at.x, at.y, at.rotation), (130.895002, 87.0, Some(-90.0)));+    let places = placement::placements(&b);+    let inside = places.iter().filter(|p| p.inside_outline == Some(true)).count();+    assert!(inside >= 140, "{inside} of 149 inside the outline (the board's own placement)");+    let unplaced = load("esc-g431-unplaced.kicad_pcb");+    let (_, routed_total) = placement::nets(&b);+    let (nets, parked_total) = placement::nets(&unplaced);+    assert!(parked_total > routed_total * 2.0, "the ratsnest estimate falls when parts are placed: {parked_total} parked vs {routed_total} placed");+    let gnd = nets.iter().find(|n| n.name == "GND").expect("GND net");+    assert!(gnd.refs.len() > 20);+}
rust/crates/kicad-platform/src/lib.rs+4
@@ -175,6 +175,10 @@ pub trait Platform: Sync + Send {     /// UI Automation: names of descendants matching a substring (bounded).     fn uia_find(&self, _hwnd: u64, _name: &str, _limit: usize) -> Result<Vec<String>, String> { nope("uia_find") }     fn foreground(&self) -> Result<u64, String> { nope("foreground") }+    /// The window on top of the Z order (first visible, non-minimized, titled top-level+    /// window), which is what the user sees covering everything else; it can differ from+    /// the foreground (activated) window when a window raised itself without activation.+    fn z_top(&self) -> Result<u64, String> { nope("z_top") }     /// Push a window to the bottom of the z-order without activating anything.     fn push_to_background(&self, _hwnd: u64) -> Result<(), String> { nope("push_to_background") }     /// Show without activating (SW_SHOWNOACTIVATE).
rust/crates/kicad-platform/src/win/enumerate.rs+24
@@ -151,6 +151,30 @@ pub fn info(h: HWND, cache: &mut ExeCache, probe_ms: u32) -> WindowInfo {  /// Every visible top-level window. KiCad windows get the 150 ms WM_NULL probe so `hung` /// means something; other applications' threads are left alone.+/// The first visible, non-minimized, titled top-level window in Z order (EnumWindows walks+/// top to bottom), skipping the shell's own windows. 0 when nothing qualifies.+pub fn z_top() -> u64 {+    for h in top_level_windows() {+        if !visible(h) || minimized(h) {+            continue;+        }+        let t = title_nohang(h);+        if t.is_empty() || t == "Program Manager" || t == "Windows Input Experience" {+            continue;+        }+        let (l, tp, r, b) = rect(h);+        if r - l < 50 || b - tp < 50 {+            continue;+        }+        let c = class_name(h);+        if c == "Shell_TrayWnd" || c == "Shell_SecondaryTrayWnd" || c == "Progman" || c == "WorkerW" || c == "Windows.UI.Core.CoreWindow" {+            continue;+        }+        return h.0 as u64;+    }+    0+}+ pub fn list_visible() -> Vec<WindowInfo> {     let mut cache = ExeCache::default();     top_level_windows()
rust/crates/kicad-platform/src/win/etiquette.rs+19−4
@@ -183,9 +183,11 @@ pub fn decide(t: &Transition) -> Verdict {     if t.foreground_at_verb_start {         return Verdict::Leave("baseline foreground (user was already in it, left alone)");     }-    if t.already_bounced {-        return Verdict::Leave("already backgrounded once (hands off)");-    }+    // The bridge is the cause (a verb in flight, a guard, our fresh spawn): bounce even if+    // this hwnd was bounced before. A box with the foreground lock off (arav-rog) lets a+    // KiCad window come forward twice while it loads, and the once-ledger left the second+    // one on top of the user's window (gate on 2026-09-12). User-initiated foregrounds are+    // already excluded above (clicked_here, alt_tabbed, sanctioned, baseline).     if t.guards > 0 {         return Verdict::Bounce("steal-during-guard (bounced)");     }@@ -195,6 +197,9 @@ pub fn decide(t: &Transition) -> Verdict {     if t.spawn_watch {         return Verdict::Bounce("our freshly spawned window took the foreground (bounced)");     }+    if t.already_bounced {+        return Verdict::Leave("already backgrounded once (hands off)");+    }     if t.user_idle_past_lock {         return Verdict::Leave("foreground while bridge idle (user idle past the lock timeout, Windows granted it, left alone)");     }@@ -255,6 +260,9 @@ struct Inner {     baseline: HashSet<u64>,     /// The foreground window when the current run of verbs began, if KiCad.     baseline_fg: Option<u64>,+    /// The user's most recent non-KiCad foreground window; a bounce hands the activation+    /// back to it, because a Z-order push alone does not hold while KiCad stays active.+    last_user_fg: u64,     /// Windows the bridge created (persisted; survives verbs).     bridge_windows: HashSet<u64>,     /// (hwnd, pid) -> title at the push. One push per key, ever.@@ -657,8 +665,12 @@ fn handle_foreground(h: u64, source: Source, cache: &mut ExeCache) {             if BOUNCE_HOLD_NOACTIVATE {                 suppress_activation(h);             }+            // Hand the activation back to the user's window (restoring what they had);+            // without this KiCad keeps the activation and raises itself again.+            let user = lock().last_user_fg;+            let restored = user != 0 && user != h && matches!(focus::bring_to_front(hwnd(user)), Ok(true));             lock().bounce_holds.insert(h, now + BOUNCE_HOLD);-            log(&format!("[bg] {h}: sent behind once (z-order only, {result}). Hands off from here."));+            log(&format!("[bg] {h}: sent behind ({result}); user window {user} {}.", if restored { "restored" } else { "not restored" }));             match source {                 Source::Hook => format!("guarded-steal (hook-bounced, ms; {result})"),                 Source::Sentinel => {@@ -860,6 +872,9 @@ fn sentinel_loop() {         }         // 2. The transition.         let fg = focus::foreground();+        if fg != 0 && !is_kicad_exe(&exe_of(fg, &mut cache)) {+            lock().last_user_fg = fg;+        }         if fg != last_fg {             handle_foreground(fg, Source::Sentinel, &mut cache);             last_fg = fg;
rust/crates/kicad-platform/src/windows.rs+3
@@ -211,6 +211,9 @@ impl Platform for Native {     fn push_to_background(&self, h: u64) -> Result<(), String> {         win::focus::push_to_background(live(h)?)     }+    fn z_top(&self) -> Result<u64, String> {+        Ok(win::enumerate::z_top())+    }     fn show_no_activate(&self, h: u64) -> Result<(), String> {         win::focus::show_no_activate(live(h)?)     }