← 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 ·0752ebcbc7 ·24d ago ·parent 9b083dc
9 files changed +635−10
rust/Cargo.lock+3−3
@@ -244,7 +244,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.2"+version = "1.0.3" dependencies = [  "kicad-core",  "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.2"+version = "1.0.3" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -280,7 +280,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.2"+version = "1.0.3" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.2"+version = "1.0.3" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/groups.rs+1
@@ -23,6 +23,7 @@ pub static GROUPS: &[Group] = &[     Group { name: "misc", verbs: || crate::verbs_misc::VERBS, dispatch: crate::verbs_misc::dispatch },     Group { name: "install", verbs: || crate::verbs_install::VERBS, dispatch: crate::verbs_install::dispatch },     Group { name: "routing", verbs: || crate::verbs_routing::VERBS, dispatch: crate::verbs_routing::dispatch },+    Group { name: "placement", verbs: || crate::verbs_placement::VERBS, dispatch: crate::verbs_placement::dispatch },     Group { name: "windows", verbs: || crate::verbs_windows::VERBS, dispatch: crate::verbs_windows::dispatch },     Group { name: "show", verbs: || crate::verbs_show::VERBS, dispatch: crate::verbs_show::dispatch },     Group { name: "demo", verbs: || crate::verbs_demo::VERBS, dispatch: crate::verbs_demo::dispatch },
rust/crates/kicad-bridge/src/main.rs+1
@@ -18,6 +18,7 @@ mod verbs_lint; mod verbs_maint; mod verbs_misc; mod verbs_netlist;+mod verbs_placement; mod verbs_routing; mod verbs_schematic; mod verbs_show;
rust/crates/kicad-bridge/src/verbs_placement.rsadded+210
@@ -0,0 +1,210 @@+//! Verb group "placement": kicad_placement_state, kicad_move_footprint and+//! kicad_placement_validate on `kicad_core::ipc` (live, one native Undo step per move)+//! with `kicad_core::placement` doing the geometry. When no PCB editor has the board+//! (or the IPC API is off) the read-only calls, and a dryRun move, fall back to the file+//! on disk through `kicad_core::pcb` and say `source: "file"`. A real move never writes+//! the file: it is live or nothing.++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::{pcb, placement};++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_placement_state",+        summary: "Every footprint with its courtyard box, side, pose, pads and nets, the board outline, the nets each footprint touches and a ratsnest estimate: the input an AI needs to place a board.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"expectedRevision\"?: \"<rev>\", \"detail\"?: false, \"socketPath\"?: \"ipc://...\"}",+        example: "kicad_placement_state {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Live from the PCB editor when it has the board open (source live-editor, revision for kicad_move_footprint); from the file otherwise (source file). Coordinates are mm, +y down. courtyard is the F.CrtYd or B.CrtYd box in board coordinates (pads plus 0.25 mm when a footprint has none: courtyardSource says so). nets[].refs is the cluster list; ratsnest.totalMm is the MST length over pad centres, the number that should fall as you place. Above 200 footprints per-pad nets are omitted unless detail:true.",+        related: &["kicad_move_footprint", "kicad_placement_validate", "kicad_board_pads", "kicad_routing_state"],+        pitfalls: &["The outline is the Edge.Cuts bounding box, so a non-rectangular board's corners count as inside.", "Courtyard boxes are per side: a front part over a back part is not an overlap, which is KiCad's own courtyard rule.", "insideOutline is null when the board has no Edge.Cuts."],+    },+    Verb {+        name: "kicad_move_footprint",+        summary: "Move one footprint, or a batch, live in the PCB editor as ONE native Undo step, after checking the revision, courtyard collisions and the board outline.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"expectedRevision\", \"ref\": \"U1\", \"x\": 120.5, \"y\": 91, \"rotation\"?: 90, \"side\"?: \"F.Cu\" | \"B.Cu\" (must equal the current side for now) | \"refs\": [{\"ref\",\"x\",\"y\",\"rotation\"?,\"side\"?}, ...], \"dryRun\"?: false, \"allowOverlap\"?: false, \"allowOutside\"?: false, \"allowLocked\"?: false, \"save\"?: false}",+        example: "kicad_move_footprint {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<from placement_state>\",\"ref\":\"C12\",\"x\":132.4,\"y\":88.1,\"rotation\":90}",+        hint: "Pass filePath, expectedRevision from placement_state, ref with x and y in mm (rotation in degrees), or refs for a batch that lands as one Undo step. Refuses stale_board, courtyard_overlap (names the offending refs), outside_outline and side_change_not_supported with nothing written; allowOverlap / allowOutside override on purpose. dryRun evaluates the checks and reports without editing. KiCad replaces the whole footprint on an IPC update and takes every child at the absolute coordinates in the proto, so the bridge transforms every pad, field, shape, text, zone and dimension of the proto client-side before sending it. The response carries before and after poses, the courtyard check, undoSteps:1, the new revision and verified, which is true only when the read-back courtyard (or first pad) moved with the anchor; after[].children carries the numbers.",+        related: &["kicad_placement_state", "kicad_placement_validate", "kicad_route_net"],+        pitfalls: &["Needs KiCad 10.0.1+ with the IPC API server on and the board open in the PCB editor; dryRun alone works from the file.", "A locked footprint is refused (footprint_locked) unless allowLocked:true.", "Do not blindly retry a timed-out move (mutation_outcome_unknown, ipc_timeout); read placement_state first.", "Moving a footprint leaves its copper where it was; place before routing.", "side changes are refused for now (side_change_not_supported): a flip mirrors every child and swaps its layers, which is separate work. Flip in the PCB editor (F), read placement_state again, then move with the same side or no side.", "The proto children are transformed client-side because KiCad replaces the footprint on update (FOOTPRINT::Deserialize keeps the children's absolute coordinates); a child type without a transform rule refuses the move (unsupported_child_item names the type URL), and a text box or rounded courtyard rectangle only turns by multiples of 90 degrees.", "verified:false with postCommitError 'children did not move' means the anchor moved and the body did not; undo in KiCad and report it, do not move again."],+    },+    Verb {+        name: "kicad_placement_validate",+        summary: "Check the placement: courtyard overlaps between every footprint pair, footprints outside the outline or parked, the ratsnest total, and KiCad DRC filtered to placement-class violations.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"socketPath\"?: \"ipc://...\"}",+        example: "kicad_placement_validate {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Live snapshot when the editor has the board, the file otherwise. placed is true with zero overlaps and nothing outside the outline; drc.placementViolations is KiCad's own verdict (courtyards_overlap, copper_edge_clearance, silk, hole and pad clearance) with the same truncation flags as kicad_routing_validate.",+        related: &["kicad_placement_state", "kicad_move_footprint", "kicad_routing_validate", "kicad_run_drc"],+        pitfalls: &["parked lists footprints wholly to the right of the outline (where tools/make_placement_fixture.py puts them); they count as outside too.", "DRC needs kicad-cli beside KiCad; without it drc.available is false and the geometric checks still run."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_placement_state" => with_fallback(state, args, Which::State),+        "kicad_move_footprint" => with_fallback(state, args, Which::Move),+        "kicad_placement_validate" => with_fallback(state, args, Which::Validate),+        _ => return None,+    })+}++#[derive(Clone, Copy, PartialEq)]+enum Which {+    State,+    Move,+    Validate,+}++/// Errors that mean "no PCB editor serves this board here", after which the file on+/// disk is the next best source for a read or a dry run.+const NO_EDITOR: &[&str] = &["ipc_unavailable", "api_server_off", "no_board_open", "board_mismatch", "no_pcb_frame", "unsupported_kicad"];++fn with_fallback(state: &mut State, args: &Value, which: Which) -> Value {+    let live_result = live(state, args, which);+    if live_result["success"] == json!(true) {+        return live_result;+    }+    let code = live_result["errorCode"].as_str().unwrap_or("").to_string();+    if !NO_EDITOR.contains(&code.as_str()) {+        return live_result;+    }+    let dry = args.get("dryRun") == Some(&json!(true));+    if which == Which::Move && !dry {+        let mut v = live_result;+        v["_hint"] = json!(format!("{} A real move is live only: open the board in the PCB editor with the IPC API on (kicad_ipc_api {{\"enable\":true}}, relaunch). dryRun:true evaluates the checks against the file without an editor.", v["_hint"].as_str().unwrap_or("")));+        return v;+    }+    let mut out = from_file(state, args, which);+    if let Some(o) = out.as_object_mut() {+        o.insert("live".into(), json!({"errorCode": live_result["errorCode"], "error": live_result["error"]}));+    }+    out+}++#[cfg(feature = "ipc")]+fn live(state: &mut State, args: &Value, which: Which) -> Value {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| std::path::PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    let result = match which {+        Which::State => ipc::placement_state(&ctx, args),+        Which::Move => ipc::move_footprint(&ctx, args),+        Which::Validate => ipc::placement_validate(&ctx, args),+    };+    match result {+        Ok(v) => v,+        Err(e) => ipc::error_response(&e),+    }+}++#[cfg(not(feature = "ipc"))]+fn live(_state: &mut State, _args: &Value, _which: Which) -> Value {+    let mut v = fail("ipc_unavailable", "This build of the bridge has the ipc feature off; live placement needs the KiCad IPC client compiled in", "Use a build with the ipc feature (the default). The read-only placement verbs still answer from the file.");+    v["ipcFeature"] = json!(false);+    v+}++/// The file path: `pcb::load` (read-only, an open board is fine) and the same geometry.+fn from_file(state: &mut State, args: &Value, which: Which) -> Value {+    let loaded = match pcb::load(args, true) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let board = &loaded.board;+    let revision = placement::file_revision(&loaded.text);+    let open_elsewhere = pcb::lock_file(&loaded.path).is_some();+    let path = norm(&loaded.path);+    let mut out = match which {+        Which::State => {+            let detail = args.get("detail") == Some(&json!(true));+            let mut v = json!({+                "success": true, "boardPath": path, "revision": revision, "source": "file",+                "_hint": "Read from the file (no PCB editor has this board on the IPC API). The revision is the file's own hash: it works for kicad_move_footprint dryRun only; a real move needs the board open in the PCB editor with the IPC API on, then read placement_state again for the live revision.",+            });+            merge(&mut v, &placement::state(board, detail));+            if let Some(exp) = args.get("expectedRevision").and_then(Value::as_str) {+                v["stale"] = json!(exp != revision);+            }+            v+        }+        Which::Move => {+            if let Some(exp) = args.get("expectedRevision").and_then(Value::as_str) {+                if exp != revision {+                    let mut v = fail("stale_board", "expectedRevision must match the board file", "Read kicad_placement_state on the file for its current revision, or omit expectedRevision for a file dryRun.");+                    v["currentRevision"] = json!(revision);+                    v["mutated"] = json!(false);+                    return v;+                }+            }+            match placement::plan_moves(board, args) {+                Ok(plan) => {+                    let mut v = json!({+                        "success": true, "dryRun": true, "mutated": false, "revision": revision, "source": "file", "undoSteps": 0, "boardPath": path,+                        "_hint": "Checks passed against the file; nothing was edited. A real move needs the board open in the PCB editor with the IPC API on and expectedRevision from a live kicad_placement_state.",+                    });+                    merge(&mut v, &placement::plan_json(&plan));+                    v+                }+                Err(e) => {+                    let mut v = fail(&e.code, e.message, "Inspect kicad_placement_state and correct the move before retrying.");+                    for (k, val) in e.detail {+                        v[k] = val;+                    }+                    v["source"] = json!("file");+                    v+                }+            }+        }+        Which::Validate => {+            let mut v = json!({+                "success": true, "revision": revision, "source": "file", "boardPath": path,+                "_hint": "Checked from the file (no PCB editor has this board on the IPC API). placed requires zero courtyard overlaps and nothing outside the outline; drc.placementViolations is KiCad's verdict on the file.",+            });+            merge(&mut v, &placement::validate(board));+            let kicad_cli = state.kicad_info().primary().map(|p| std::path::PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file());+            v["drc"] = file_drc(kicad_cli.as_deref(), &loaded.path, &loaded.text);+            v+        }+    };+    if open_elsewhere {+        out["boardOpenElsewhere"] = json!(true);+        out["_warning"] = json!("KiCad's lock file says this board is open in an editor the IPC API does not serve; unsaved edits there are not in this file.");+    }+    out+}++#[cfg(feature = "ipc")]+fn file_drc(kicad_cli: Option<&std::path::Path>, path: &std::path::Path, text: &str) -> Value {+    match kicad_core::ipc::drc_snapshot(kicad_cli, path, text) {+        Ok(d) => {+            let mut f = placement::drc_filter(&d);+            f["source"] = json!("file-snapshot");+            f+        }+        Err(e) => json!({"available": false, "errorCode": e.code, "reason": e.message}),+    }+}++#[cfg(not(feature = "ipc"))]+fn file_drc(kicad_cli: Option<&std::path::Path>, path: &std::path::Path, _text: &str) -> Value {+    let mut d = kicad_core::freerouting::drc_file(kicad_cli, path);+    d["placementViolations"] = json!([]);+    d["placementFiltered"] = json!(false);+    d+}++fn merge(into: &mut Value, from: &Value) {+    if let (Some(a), Some(b)) = (into.as_object_mut(), from.as_object()) {+        for (k, v) in b {+            a.insert(k.clone(), v.clone());+        }+    }+}
rust/crates/kicad-bridge/src/verbs_windows.rs+50−6
@@ -359,10 +359,34 @@ pub(crate) fn post_verb(state: &mut State, command: &str, args: &Value, out: &mu     if !(wants_fg && out["_broughtToUser"] == json!(true)) {         // With the etiquette loop running (Windows) the one-shot push would be a second action         // on a window the loop already handled once; keep it only where no loop exists.+        // Exception (gate on 2026-09-12, ConfRoomROG and arav-rog): a freshly spawned editor+        // re-raises itself when its board finishes loading WITHOUT a foreground transition+        // (it already was the foreground while loading), so the loop sees nothing to bounce+        // and the user's window stays covered. For spawn verbs the one-shot check runs+        // regardless, twice: now and after a short settle for the late re-raise.         let loop_running = native().etiquette_debug() != Value::Null;-        if let Some(ev) = if loop_running { None } else { one_shot_focus_check(fg_before) } {+        if let Some(ev) = if loop_running && !spawns { None } else { one_shot_focus_check(fg_before) } {             events.push(ev);         }+        if spawns {+            // Wait for the editor to finish loading (its "Load PCB" / "Loading" progress+            // dialog gone), because that is the moment it raises itself; then check twice.+            for _ in 0..16 {+                let loading = scan_dialogs().iter().any(|d| wm::is_progress_dialog(&d.title, &d.body));+                if !loading {+                    break;+                }+                sleep_ms(500);+            }+            sleep_ms(700);+            if let Some(ev) = one_shot_focus_check(fg_before) {+                events.push(ev);+            }+            sleep_ms(700);+            if let Some(ev) = one_shot_focus_check(fg_before) {+                events.push(ev);+            }+        }     }     // kicad_state already carries the loop's ring buffer; never clobber it with the     // per-verb list (which is empty while the loop runs).@@ -627,15 +651,35 @@ fn is_kicad_hwnd(hwnd: u64) -> bool { /// and the user had a non-KiCad window before, push KiCad back once (never activate the /// user's window: Windows re-activates it naturally) and report the event. fn one_shot_focus_check(fg_before: Option<u64>) -> Option<Value> {-    let after = native().foreground().ok().filter(|h| *h != 0)?;     let before = fg_before?;-    if after == before || !is_kicad_hwnd(after) || is_kicad_hwnd(before) {+    if is_kicad_hwnd(before) {+        return None;+    }+    // What covers the user's window is the top of the Z order, not only the activated+    // window: a KiCad editor raises itself when its board finishes loading without a+    // foreground transition (gate on 2026-09-12).+    let fg = native().foreground().ok().filter(|h| *h != 0);+    let top = native().z_top().ok().filter(|h| *h != 0);+    let after = match (fg, top) {+        (Some(f), _) if is_kicad_hwnd(f) => f,+        (_, Some(t)) if is_kicad_hwnd(t) => t,+        _ => return None,+    };+    if after == before {         return None;     }     let title = title_of(after);-    let action = match native().push_to_background(after) {-        Ok(()) => "pushed-to-background",-        Err(_) => "push-failed",+    let pushed = native().push_to_background(after).is_ok();+    // A Z-order push alone does not hold: the editor still owns the activation and raises+    // itself again on its next paint. Give the user's window its activation back (this+    // restores what the user had before the verb; it is not a foreground of ours).+    let restored = ab::desktop_restore_user_window(before, "restore the user's window after a KiCad window raised itself").is_ok()+        || matches!(native().bring_to_front(before), Ok(true));+    let action = match (pushed, restored) {+        (true, true) => "pushed-to-background, user window restored",+        (true, false) => "pushed-to-background",+        (false, true) => "user window restored",+        (false, false) => "push-failed",     };     let ev = json!({"event": "self-raise", "hwnd": after, "title": title, "userWindow": before, "action": action, "at": kicad_common::now_utc_iso()});     record_focus_event(ev.clone());
rust/crates/kicad-core/src/ab.rs+9
@@ -595,6 +595,15 @@ pub fn desktop_close_window(hwnd: u64, reason: &str) -> Result<CloseOutcome, AbE }  /// The one sanctioned foreground, through ab (used when the platform has no bring_to_front).+/// Bring a window forward through ab WITHOUT changing its maximized/normal state: used to+/// hand the activation back to the user's own window after a KiCad self-raise. ab holds+/// the foreground rights the bridge process lacks (a SetForegroundWindow from the bridge+/// reports success and does not stick; measured on ConfRoomROG 2026-09-12).+pub fn desktop_restore_user_window(hwnd: u64, reason: &str) -> Result<Value, AbError> {+    let r = call("desktop_bring_to_front", json!({"hwnd": hwnd, "reason": reason}), Duration::from_secs(10))?;+    Ok(r.body)+}+ pub fn desktop_bring_to_front(hwnd: u64, reason: &str) -> Result<Value, AbError> {     let r = call("desktop_bring_to_front", json!({"hwnd": hwnd, "state": "restore", "reason": reason}), Duration::from_secs(10))?;     Ok(r.body)
rust/crates/kicad-core/src/ipc.rs+359
@@ -27,6 +27,7 @@ use kicad_ipc_rs::{BoardLayerInfo, BoardNet, DocumentSpecifier, DocumentType, Ed  use crate::cli; use crate::pcb::{self, Board, Plan, RoutingError};+use crate::placement;  /// The ipc cargo feature is compiled in. The verb group asks this before promising live /// routing; a build with the feature off has no `ipc` module at all.@@ -766,6 +767,216 @@ pub fn routing_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {     Ok(out) } +// ---------------------------------------------------------------------------+// Placement: kicad_placement_state, kicad_move_footprint, kicad_placement_validate+// ---------------------------------------------------------------------------++pub const PLACEMENT_STATE_HINT: &str = "Use this revision as expectedRevision in kicad_move_footprint. Coordinates are mm, +y down; courtyard boxes are board-frame and per side. Cluster by nets[].refs, place decoupling caps beside their IC, keep the power stage together, and watch ratsnest.totalMm fall. kicad_placement_validate is the check.";++/// `kicad_placement_state`, live: the editor's own board text parsed for footprints,+/// courtyards, nets and the ratsnest estimate. Read-only.+pub fn placement_state(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (_text, board, revision) = s.snapshot()?;+    let detail = args.get("detail") == Some(&json!(true));+    let mut out = json!({+        "success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",+        "kicadVersion": s.version.full_version, "socket": s.socket,+        "_hint": PLACEMENT_STATE_HINT,+    });+    merge(&mut out, &placement::state(&board, detail));+    if let Some(exp) = args.get("expectedRevision").and_then(Value::as_str) {+        out["stale"] = json!(exp != revision);+    }+    Ok(out)+}++/// The moved footprints as KiCad will store them. KiCad's `UpdateItems` does not edit a+/// footprint in place: it builds a new one from this proto (`FOOTPRINT::Deserialize`),+/// removes the old one and adds the new one, and Deserialize takes every child (pads,+/// fields, shapes, texts, zones, ...) at the ABSOLUTE board coordinates the proto+/// carries. So besides `position` and `orientation` this moves every child with the+/// same rigid-body transform (`placement::ChildTransform`): the four mandatory fields+/// through the typed proto, every `definition.items` payload on the wire+/// (`placement::transform_child`). A child type without a transform rule refuses the+/// whole move (`unsupported_child_item`); a side change is refused+/// (`side_change_not_supported`), so `layer` is only ever re-set to what it was. Every+/// move must have its item, or the editor no longer holds that footprint+/// (`footprint_not_found`).+pub fn build_footprint_updates(items: Vec<EditablePcbItem>, moves: &[placement::Move]) -> RResult<Vec<EditablePcbItem>> {+    let layer_id = |name: &str| BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")));+    let mut out = Vec::with_capacity(moves.len());+    for m in moves {+        if m.flipped || m.after.side != m.before.side {+            return Err(placement::side_change_error(&m.reference, &m.before.side, &m.after.side));+        }+        let found = items.iter().find(|i| i.id() == Some(m.uuid.as_str()));+        let Some(EditablePcbItem::Footprint(fp)) = found else {+            return Err(RoutingError::new("footprint_not_found", format!("The live board has no footprint with id {} ({}); read kicad_placement_state again", m.uuid, m.reference)).with("mutated", json!(false)));+        };+        let mut fp = fp.clone();+        let p = fp.proto_mut();+        // The old pose is the proto's own: that is the frame the children are in.+        let (ox, oy) = p.position.as_ref().map(|v| (v.x_nm, v.y_nm)).unwrap_or((0, 0));+        let orot = p.orientation.as_ref().map(|a| a.value_degrees).unwrap_or(0.0);+        let xf = placement::ChildTransform::new((ox, oy, orot), (nm(m.after.x), nm(m.after.y), m.after.rotation));+        for field in [&mut p.reference_field, &mut p.value_field, &mut p.datasheet_field, &mut p.description_field].into_iter().flatten() {+            if let Some(text) = field.text.as_mut().and_then(|t| t.text.as_mut()) {+                if let Some(v) = text.position.as_mut() {+                    let (x, y) = xf.point(v.x_nm, v.y_nm);+                    v.x_nm = x;+                    v.y_nm = y;+                }+                if let Some(a) = text.attributes.as_mut().and_then(|a| a.angle.as_mut()) {+                    a.value_degrees = xf.angle(a.value_degrees);+                }+            }+        }+        if let Some(def) = p.definition.as_mut() {+            for item in def.items.iter_mut() {+                item.value = placement::transform_child(&xf, &item.type_url, &item.value).map_err(|e| {+                    RoutingError::new("unsupported_child_item", format!("Footprint {} carries a {} item the bridge cannot move ({}); nothing was moved", m.reference, e.type_url, e.reason))+                        .with("typeUrl", json!(e.type_url))+                        .with("ref", json!(m.reference))+                        .with("supportedTypes", json!(placement::CHILD_TYPES))+                        .with("mutated", json!(false))+                        .with("_hint", json!("KiCad replaces the whole footprint on an IPC update, so the bridge moves every child of the proto itself and refuses rather than move one wrongly. Move this footprint in the PCB editor, or rotate by a multiple of 90 degrees when the reason names one."))+                })?;+            }+        }+        p.position = Some(Default::default());+        if let Some(pos) = p.position.as_mut() {+            pos.x_nm = nm(m.after.x);+            pos.y_nm = nm(m.after.y);+        }+        p.orientation = Some(Default::default());+        if let Some(o) = p.orientation.as_mut() {+            o.value_degrees = m.after.rotation;+        }+        p.layer = layer_id(&m.after.side)?;+        out.push(EditablePcbItem::Footprint(fp));+    }+    Ok(out)+}++/// `kicad_move_footprint`: one footprint or a batch, checked (revision, courtyards,+/// outline) then committed through `update_items` as ONE native Undo step. `dryRun`+/// runs the checks and reports without touching the editor.+pub fn move_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (_text, board, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let plan = placement::plan_moves(&board, args)?;+    let public = placement::plan_json(&plan);+    if args.get("dryRun") == Some(&json!(true)) {+        let mut out = json!({+            "success": true, "dryRun": true, "mutated": false, "revision": revision, "source": "live-editor", "undoSteps": 0,+            "_hint": "Checks passed without editing. Submit with dryRun:false and the same revision.",+        });+        merge(&mut out, &public);+        return Ok(out);+    }+    // The checks ran on a snapshot; re-read right before the commit so an edit in between+    // (the user's, or another caller's) invalidates this plan instead of racing it.+    check_revision(args, &s.revision()?)?;+    let ids: Vec<String> = plan.moves.iter().map(|m| m.uuid.clone()).collect();+    let items = s.client.get_editable_items_by_id(ids).map_err(map_err)?;+    let updates = build_footprint_updates(items, &plan.moves)?;+    let count = updates.len();+    transaction(&s, &plan.undo_label, || {+        let updated = s.client.update_editable_items(updates)?;+        if updated.len() != count {+            return Err(IpcFailure::from("KiCad did not update every requested footprint".to_string()));+        }+        Ok(())+    })?;+    let mut result = json!({+        "success": true, "mutated": true, "source": "live-editor", "undoSteps": 1, "saved": false,+        "_hint": "Footprint(s) moved live as one KiCad Undo step; verified means the read-back courtyard (or first pad) moved with the anchor, not just the anchor. Read kicad_placement_state for the new revision before the next move; kicad_placement_validate checks courtyards, the outline and KiCad DRC. The file is saved only with save:true.",+    });+    merge(&mut result, &public);+    // Commit succeeded. A read or save failure after it is reported as postCommitError,+    // never as a failed move that invites a replay.+    match s.snapshot() {+        Ok((_, after, rev)) => {+            result["revision"] = json!(rev);+            let places = placement::placements(&after);+            let close = |a: f64, b: f64| (a - b).abs() < 0.0005;+            let mut all_match = true;+            let mut stayed: Vec<String> = Vec::new();+            let verified: Vec<Value> = plan+                .moves+                .iter()+                .map(|m| match places.iter().zip(after.footprints.iter()).find(|(p, _)| p.reference == m.reference) {+                    Some((p, after_fp)) => {+                        let pose_ok = close(p.x, m.after.x) && close(p.y, m.after.y) && close(placement_angle(p.rotation), placement_angle(m.after.rotation)) && p.side == m.after.side;+                        // The anchor alone proves nothing (KiCad rebuilds the footprint from+                        // the proto): the courtyard or first pad must have moved with it.+                        let children = board.footprints.iter().find(|f| f.reference == m.reference).map(|before_fp| placement::check_children(m, before_fp, after_fp));+                        let children_ok = children.as_ref().map(|c| c.ok).unwrap_or(false);+                        if !children_ok {+                            stayed.push(children.as_ref().map(|c| c.describe(&m.reference)).unwrap_or_else(|| format!("{} was not on the board before the move", m.reference)));+                        }+                        let ok = pose_ok && children_ok;+                        all_match &= ok;+                        json!({"ref": m.reference, "x": p.x, "y": p.y, "rotation": p.rotation, "side": p.side, "courtyard": p.courtyard, "insideOutline": p.inside_outline, "matches": ok, "poseMatches": pose_ok, "children": children})+                    }+                    None => {+                        all_match = false;+                        json!({"ref": m.reference, "matches": false, "missing": true})+                    }+                })+                .collect();+            result["after"] = json!(verified);+            result["verified"] = json!(all_match);+            if !stayed.is_empty() {+                result["postCommitError"] = json!(format!("children did not move: {}", stayed.join("; ")));+            } else if !all_match {+                result["postCommitError"] = json!("The live board does not show every footprint at its requested pose; inspect kicad_placement_state");+            }+            let (_, total) = placement::nets(&after);+            result["ratsnest"] = json!({"totalMm": total});+            if args.get("save") == Some(&json!(true)) {+                match s.client.save_document() {+                    Ok(()) => result["saved"] = json!(true),+                    Err(e) => result["postCommitError"] = json!(e.to_string()),+                }+            }+        }+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    Ok(result)+}++fn placement_angle(d: f64) -> f64 {+    let mut r = d % 360.0;+    if r < 0.0 {+        r += 360.0;+    }+    r+}++/// `kicad_placement_validate`, live: courtyard overlaps, footprints outside the outline+/// or parked, the ratsnest total, and KiCad DRC on the live snapshot filtered to the+/// placement-class violations.+pub fn placement_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, board, revision) = s.snapshot()?;+    let mut out = json!({+        "success": true, "revision": revision, "source": "live-editor",+        "_hint": "placed requires zero courtyard overlaps and nothing outside the outline; drc.placementViolations is KiCad's own verdict on courtyards, edge clearance, silk and pad clearance. A stale result is superseded by newer edits.",+    });+    merge(&mut out, &placement::validate(&board));+    out["drc"] = match drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text) {+        Ok(d) => placement::drc_filter(&d),+        Err(e) => json!({"available": false, "errorCode": e.code, "reason": e.message}),+    };+    let current = s.revision()?;+    out["currentRevision"] = json!(current);+    out["stale"] = json!(current != revision);+    Ok(out)+}+ fn merge(into: &mut Value, from: &Value) {     if let (Some(a), Some(b)) = (into.as_object_mut(), from.as_object()) {         for (k, v) in b {@@ -980,6 +1191,154 @@ mod tests {         assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 999999})), Duration::from_millis(60000));     } +    #[test]+    fn footprint_updates_move_the_children_with_the_anchor() {+        use kicad_ipc_rs::FootprintItem;+        use placement::{angle_bytes, vector2_bytes, wire_message, wire_varint};+        const MM: i64 = 1_000_000;+        // An `Any` child without naming prost's type: the crate's own into_any builds one.+        let any = |name: &str, value: Vec<u8>| {+            let mut a = EditablePcbItem::Group(kicad_ipc_rs::GroupItem::new("x", vec![])).into_any();+            a.type_url = format!("type.googleapis.com/kiapi.board.types.{name}");+            a.value = value;+            a+        };+        // U1 at (10, 20) mm, unrotated, with its reference text 2 mm above the anchor,+        // pad 1 one millimetre to the left (padstack angle 0) and a 4 x 2 mm courtyard.+        let mut fp = FootprintItem::from_proto(Default::default());+        {+            let p = fp.proto_mut();+            p.id = Some(Default::default());+            if let Some(id) = p.id.as_mut() {+                id.value = "u1-uuid".into();+            }+            p.position = Some(Default::default());+            if let Some(v) = p.position.as_mut() {+                v.x_nm = 10 * MM;+                v.y_nm = 20 * MM;+            }+            p.layer = BoardLayerInfo::id_from_name("F.Cu").unwrap();+            p.locked = 1;+            p.symbol_sheet_name = "Root".into();+            p.reference_field = Some(Default::default());+            if let Some(f) = p.reference_field.as_mut() {+                f.name = "Reference".into();+                f.text = Some(Default::default());+                if let Some(bt) = f.text.as_mut() {+                    bt.text = Some(Default::default());+                    if let Some(t) = bt.text.as_mut() {+                        t.text = "U1".into();+                        t.position = Some(Default::default());+                        if let Some(v) = t.position.as_mut() {+                            v.x_nm = 10 * MM;+                            v.y_nm = 18 * MM;+                        }+                        t.attributes = Some(Default::default());+                        if let Some(a) = t.attributes.as_mut() {+                            a.angle = Some(Default::default());+                            a.keep_upright = true;+                        }+                    }+                }+            }+            let mut pad = Vec::new();+            wire_message(&mut pad, 3, b"1");+            let mut stack = Vec::new();+            wire_message(&mut stack, 6, &angle_bytes(0.0));+            wire_message(&mut pad, 6, &stack);+            wire_message(&mut pad, 7, &vector2_bytes(9 * MM, 20 * MM));+            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 mut shape = Vec::new();+            wire_message(&mut shape, 5, &rect);+            let mut courtyard = Vec::new();+            wire_message(&mut courtyard, 1, &shape);+            wire_varint(&mut courtyard, 2, BoardLayerInfo::id_from_name("F.CrtYd").unwrap() as u64);+            p.definition = Some(Default::default());+            if let Some(def) = p.definition.as_mut() {+                def.items.push(any("Pad", pad));+                def.items.push(any("BoardGraphicShape", courtyard));+                def.items.push(any("Group", vec![0x12, 0x01, b'g']));+            }+        }+        let mv = placement::Move {+            reference: "U1".into(),+            uuid: "u1-uuid".into(),+            before: placement::Pose { x: 10.0, y: 20.0, rotation: 0.0, side: "F.Cu".into() },+            after: placement::Pose { x: 30.0, y: 40.0, rotation: 90.0, side: "F.Cu".into() },+            courtyard_before: placement::Rect { min_x: 8.0, min_y: 19.0, max_x: 12.0, max_y: 21.0 },+            courtyard_after: placement::Rect { min_x: 29.0, min_y: 38.0, max_x: 31.0, max_y: 42.0 },+            courtyard_source: "courtyard",+            flipped: false,+        };+        let out = build_footprint_updates(vec![EditablePcbItem::Footprint(fp.clone())], &[mv.clone()]).unwrap();+        assert_eq!(out.len(), 1);+        let EditablePcbItem::Footprint(f) = &out[0] else { panic!("expected a footprint, got {:?}", out[0]) };+        let p = f.proto();+        assert_eq!(p.id.as_ref().unwrap().value, "u1-uuid");+        let pos = p.position.as_ref().unwrap();+        assert_eq!((pos.x_nm, pos.y_nm), (30 * MM, 40 * MM));+        assert_eq!(p.orientation.as_ref().unwrap().value_degrees, 90.0);+        assert_eq!(p.layer, BoardLayerInfo::id_from_name("F.Cu").unwrap());+        // Everything else the editor sent rides along untouched.+        assert_eq!(p.locked, 1);+        assert_eq!(p.symbol_sheet_name, "Root");+        // The reference text turned about the anchor: 2 mm above becomes 2 mm to the left.+        let t = p.reference_field.as_ref().unwrap().text.as_ref().unwrap().text.as_ref().unwrap();+        let tp = t.position.as_ref().unwrap();+        assert_eq!((tp.x_nm, tp.y_nm), (28 * MM, 40 * MM));+        assert_eq!(t.attributes.as_ref().unwrap().angle.as_ref().unwrap().value_degrees, 90.0);+        assert!(t.attributes.as_ref().unwrap().keep_upright);+        assert_eq!(t.text, "U1");+        // The children, decoded by the IPC crate's own protobuf code: the pad 1 mm left of+        // the anchor is now 1 mm below it and turned by 90; the courtyard corners turned.+        let items = &p.definition.as_ref().unwrap().items;+        assert_eq!(items.len(), 3);+        match EditablePcbItem::from_any(items[0].clone()).unwrap() {+            EditablePcbItem::Pad(pad) => {+                let pp = pad.proto();+                assert_eq!(pp.number, "1");+                let v = pp.position.as_ref().unwrap();+                assert_eq!((v.x_nm, v.y_nm), (30 * MM, 41 * MM));+                assert_eq!(pp.pad_stack.as_ref().unwrap().angle.as_ref().unwrap().value_degrees, 90.0);+            }+            other => panic!("expected a pad, got {other:?}"),+        }+        match EditablePcbItem::from_any(items[1].clone()).unwrap() {+            EditablePcbItem::BoardGraphicShape(shape) => {+                assert_eq!(shape.proto().layer, BoardLayerInfo::id_from_name("F.CrtYd").unwrap());+                let corners: Vec<(i64, i64)> = format!("{:?}", shape.proto().shape.as_ref().unwrap().geometry)+                    .split("Vector2 { x_nm: ")+                    .skip(1)+                    .map(|s| {+                        let (x, rest) = s.split_once(", y_nm: ").unwrap();+                        (x.parse().unwrap(), rest.split(' ').next().unwrap().trim_end_matches(',').parse().unwrap())+                    })+                    .collect();+                assert_eq!(corners, vec![(29 * MM, 42 * MM), (31 * MM, 38 * MM)]);+            }+            other => panic!("expected a shape, got {other:?}"),+        }+        assert_eq!(items[2].value, vec![0x12, 0x01, b'g'], "a group passes through");+        // A child the bridge cannot move refuses the whole batch before any commit.+        let mut odd = fp.clone();+        odd.proto_mut().definition.as_mut().unwrap().items.push(any("Track", vec![]));+        let e = build_footprint_updates(vec![EditablePcbItem::Footprint(odd)], &[mv.clone()]).unwrap_err();+        assert_eq!(e.code, "unsupported_child_item");+        assert_eq!(e.detail["typeUrl"], "type.googleapis.com/kiapi.board.types.Track");+        assert_eq!(e.detail["mutated"], false);+        // A flip is refused too.+        let mut flip = mv.clone();+        flip.after.side = "B.Cu".into();+        flip.flipped = true;+        assert_eq!(build_footprint_updates(vec![EditablePcbItem::Footprint(fp.clone())], &[flip]).unwrap_err().code, "side_change_not_supported");+        // A move whose footprint the editor no longer has is refused before any commit.+        let mut gone = mv.clone();+        gone.uuid = "nope".into();+        assert_eq!(build_footprint_updates(vec![EditablePcbItem::Footprint(fp)], &[gone]).unwrap_err().code, "footprint_not_found");+    }+     #[test]     fn items_carry_kipy_units_net_and_layers() {         let text = crate::pcb::tests::fixture_text();
rust/crates/kicad-core/src/lib.rs+1
@@ -15,6 +15,7 @@ pub mod render; pub mod bridge_log; pub mod install; pub mod pcb;+pub mod placement; #[cfg(feature = "ipc")] pub mod ipc; pub mod uninstall;