← Commit history

Merge PR #302: 1.0.59: EDA Bridge Standard board model, layer stack, pours, 3D bodies, netlist and core reads (kicad-lane/board-model-1.0.59 -> master) merge

Colby Knox ·656e360486 ·15d ago ·parents 103c1b6, 983cf4f

Showing changes introduced by the merge (against its first parent).

33 files changed +3815−60
README.md+32
@@ -68,6 +68,34 @@ carries a revision guard, and is DRC-checked against a snapshot before it commit autorouter and not an electrical sign-off. Details and the regression demo: [SKILL.md](SKILL.md#live-routing-from-09340) and [demo/routing/README.md](demo/routing/README.md). The guide to both engines, with a live run and screenshots: [docs/autorouting.md](docs/autorouting.md). +## The board model: the same board data as Altium and Fusion (from 1.0.59)++`kicad_routing_state {"model":"standard"}` reads the whole board in the+[EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bridge-standard)'s shape, the one `altium_routing_state` and+`fusion_routing_state` answer: tracks, arcs, vias, pads with their sizes (a custom pad as its bounding rectangle),+parts, copper pours, the board outline with its arcs, cutouts and keepouts, the unconnected count worked out from the+copper itself (with the pads of each piece to join) and a revision that only moves when copper, parts or the outline+move. Millimetres, y up, KiCad layer names. It reads the live PCB editor (unsaved edits included) or the+`.kicad_pcb` file with KiCad closed. The edit verbs take that revision as `expectedRevision`, and with+`frame:"standard"` they take its coordinates as they are.++Its companions, by the standard's names:++| Verb | What it tells you |+|---|---|+| `kicad_layer_stack` | the stack-up: copper thickness per layer, the dielectric under each (thickness, Dk, material), planes and their nets, masks, finish |+| `kicad_pour_state` | the copper actually poured: filled area per zone and per layer, islands, zones that poured nothing |+| `kicad_bodies` | each part's 3D body as a box and its height, read from the 3D model files: the tallest parts, what a height check cannot see |+| `kicad_netlist` | the netlist as `{net: ["U1.8", ...]}`, compared pin by pin with the nets you expect, with every pin's electrical type |++And the standard's quick reads, each a view of the same model: `kicad_board_summary` (parts by side, nets, layers in+use, board size and area), `kicad_list_nets` (with pad counts), `kicad_list_designators`, `kicad_copper_layers`,+`kicad_layers_in_use`, `kicad_component_data` (a part's placement, size and nets) and `kicad_component_pins` (its pads+with net, position, size and hole).++Checked against KiCad's own DRC on six of KiCad 10's demo boards: the same unconnected count on every one. The+details, the rules and the pitfalls: the [kicad-board-model](skills/kicad-board-model/SKILL.md) skill.+ ## The demo verb: `kicad_demo`  One verb that shows the whole bridge off, built for the AI that demos Adom Bridge@@ -238,6 +266,10 @@ related verbs, and pitfalls. **Libraries & parts**: `install_library`, `install_symbol`, `install_footprint`, `install_plugin`, `place_footprint`, `adom_library_status` +**Board model (EDA Bridge Standard)**: `routing_state {"model":"standard"}`, `layer_stack`, `pour_state`,+`bodies`, `netlist`, `board_summary`, `list_nets`, `list_designators`, `copper_layers`, `layers_in_use`,+`component_data`, `component_pins`+ **Checks & export**: `run_drc`, `run_erc`, `lint_board`, `lint_schematic`, `lint_library`, `format_upgrade`, `export_gerber`, `export_pdf`, `export_svg`, `export_step`, `export_bom_csv`, `export_molecule`
SKILL.md+9
@@ -158,6 +158,14 @@ Unlabeled nets are auto-named (`Net-(R1-Pad2)`, `unconnected-(C1-Pad1)`); single nets are usually a missed wire but can be intentional, `kicad_run_erc` is the authoritative check and knows about no-connect flags. +## The board model (EDA Bridge Standard)++`kicad_routing_state {"model":"standard"}` reads the board in the shape every Adom EDA bridge answers (Altium,+Fusion): mm, y up, custom pads as rectangles, the outline with arcs, unconnected from the copper, a revision that only+moves with copper, parts or outline; live or from the file. Companions: `kicad_layer_stack`, `kicad_pour_state`,+`kicad_bodies`, `kicad_netlist`. The edit verbs take its revision, and its coordinates with `frame:"standard"`. All of+it is the `kicad-board-model` skill in this package.+ ## Live routing (from 0.9.340)  Needs KiCad 10.0.1+ with Preferences > Plugins > Enable IPC API server turned on.@@ -322,6 +330,7 @@ KiCad 10 has no schematic API, so these verbs edit the `.kicad_sch` file on disk | `kicad_sch_place_symbol` | Place a library symbol with its pins and instances block; returns the new `uuid` | `filePath`, `libId` ("Device:R"), `reference`, `at` [x, y] mm, `rotation`?, `unit`?, `value`?, `footprint`?, `libraryPath`? | | `kicad_sch_wire` | A polyline wire | `filePath`, `points` [[x, y], ...] | | `kicad_sch_label` | A label | `filePath`, `text`, `at`, `kind` local, global, hierarchical, power, `rotation`? |+| `kicad_sch_no_connect` | A no-connect flag on an open pin, so ERC stops reporting it | `filePath`, `pin` (`U1.12` or `U1.PA10`) or `at` | | `kicad_sch_move` | Move a placed symbol by reference | `filePath`, `reference`, `to` [x, y] | | `kicad_sch_set_property` | Change or add a property (Value, Footprint, ...) | `filePath`, `reference`, `name`, `value` | | `kicad_sch_delete` | Delete any item by uuid | `filePath`, `uuid` |
bridge.json+15−1
@@ -137,7 +137,21 @@     "kicad_text_bounds",     "kicad_trace_net",     "kicad_verb_times",-    "kicad_zone_state"+    "kicad_zone_state",+    "kicad_export_pos",+    "kicad_render_board",+    "kicad_layer_stack",+    "kicad_pour_state",+    "kicad_netlist",+    "kicad_bodies",+    "kicad_board_summary",+    "kicad_list_nets",+    "kicad_list_designators",+    "kicad_copper_layers",+    "kicad_layers_in_use",+    "kicad_component_data",+    "kicad_component_pins",+    "kicad_sch_no_connect"   ],   "statusVerb": "kicad_status",   "timeouts": {
docs/rust-port-plan.md+1
@@ -25,6 +25,7 @@ Written 2026-09-11 from a full audit of the 0.9.350 source, the Adom Bridge (ab) | 2026-09-12 | Placement verbs, the same way the routing verbs work. `kicad_placement_state` (every footprint with its courtyard box in board coordinates, side, pose, pads and nets, the outline, per-net footprint clusters and an MST ratsnest estimate), `kicad_move_footprint` (one footprint or a batch through `update_items`, ONE native undo step named "Adom: place U1", after the revision check, a same-side courtyard collision check that names the offending refs, and the outline check; `dryRun` reports without writing; a side change flips through the layer field, which is how KiCad's `FOOTPRINT::Deserialize` flips) and `kicad_placement_validate` (all-pairs courtyard overlaps, outside and parked footprints, the ratsnest total, KiCad DRC on the live snapshot filtered to the placement violation types with the routing verbs' truncation flags). Live when the PCB editor has the board on the IPC API; the reads and a dryRun answer from the file otherwise. Core geometry in `kicad-core/src/placement.rs` (courtyard extraction added to the PCB parser), IPC glue beside the routing operations in `ipc.rs`, the verb group in `verbs_placement.rs`. The demo fixture is the public Adom ESC G431 board: `tools/make_placement_fixture.py` (standard-library Python, a byte-preserving s-expression edit) writes an unplaced copy (149 footprints parked in a grid to the right of the outline, 434 segments, 384 vias, 57 zone fills and 66 teardrop zones removed, the 49 user zones kept) and an unrouted copy, under `demo/placement/`; both pass service-kicad DRC (KiCad 10.0.2: zero courtyard overlaps in the parked copy, 271 unconnected items as expected, the source board's own six malformed courtyards and seven intra-footprint pad clearances inherited) and the bridge's parser (integration test). 241 tests. Runner: placement_state and placement_validate in phase 2, move_footprint dryRun in phase 2.6. Live verification on a desktop is pending (John runs it). | | 2026-09-12 | Live test of `kicad_move_footprint` on KiCad 10.0.3 found the anchor moving without the body: pads and courtyard stayed in the parking lot while `verified` said true. Root cause in KiCad's `api_handler_pcb.cpp` and `footprint.cpp` (10.0 branch): `UpdateItems` does not edit a footprint in place, it builds a new one from our FootprintInstance proto, removes the old one and adds the new one, and `FOOTPRINT::Deserialize` takes every child (pads, fields, shapes, texts, zones, dimensions) at the absolute board coordinates and angles the proto carries. Fix in 1.0.2's tree: `build_footprint_updates` now applies the move's rigid-body transform to every child client-side (p' = N + R(delta)(p - O), angles + delta, KiCad's y-down RotatePoint), the four mandatory fields through the typed proto and every `definition.items` payload on the protobuf wire format by field number (`placement::transform_child`; the crate keeps the proto types private), covering Pad, BoardText, Field, BoardTextBox, BoardGraphicShape (segment, rectangle, arc, circle, polygon with arc nodes, bezier; an off-cardinal rectangle becomes a polygon as KiCad's own rotate does), Zone (outline and fills), Dimension (all five styles, with KiCad's orthogonal axis rule), ReferenceImage and Barcode; Group and Footprint3DModel pass through; anything else refuses the move with `unsupported_child_item`. Side changes are refused with `side_change_not_supported` until a mirror lands. The post-commit check now compares the read-back courtyard box centre (or the first pad) with the expected transformed value within 0.01 mm and reports `children did not move` with the numbers instead of trusting the anchor. 210 core tests. Live re-test on a desktop pending (John runs it). | | 2026-09-12 | `kicad_autoroute` as a background job. A real board takes minutes with Freerouting (a 59-net four-layer ESC: 40 passes over several minutes), the ab relay gives up around 130 s, and the reply of a timed-out synchronous call was lost even though the bridge finished the work. Now `kicad_autoroute {"engine":"freerouting","job":true}` validates exactly as the synchronous call does (not installed, bad apply, board open, no ipc feature), starts the same work (DSN, Freerouting, live or file apply, DRC) on its own thread and answers at once with `{status:"ok", job:"<id>", engine, started}`; `kicad_autoroute {"job":"<id>"}` (no engine) answers `running` with the live `stepLabel` and `percent` (mirrored from the progress registry, so `kicad_status.operations` shows the same frame), `done` with the complete routing reply merged in (routed, applied, drc, freerouting, dsn, revision, undoSteps, itemIds) or `failed` with the error reply; finished jobs stay pollable for an hour, then `job_not_found`. One job per board at a time (`job_busy` names the running id; the synchronous path refuses too). The job start and the poll run without GUI_LOCK (dispatch exempts them the way it exempts `kicad_demo`); the job thread takes GUI_LOCK itself around the editor sections (live snapshot, commit, live DRC) and holds nothing while Freerouting runs, so window verbs and polls are never queued behind a routing pass. The synchronous call is unchanged for a direct caller (timeout 900 s); the verb's hint and pitfalls now say to use `job:true` over the relay. Six unit tests on the job table (insert and poll, done and failed replies, expiry, busy per board, job mode parsing, unknown id). Live run on a desktop pending (John runs it). |+| 2026-10-26 | 1.0.59 (kicad-lane thread, not released yet): the EDA Bridge Standard board model. `kicad_routing_state {model:"standard"}` (live or from the file), `kicad_layer_stack`, `kicad_pour_state`, `kicad_bodies` (3D model bounds from STEP and VRML), `kicad_netlist`; the edit verbs take the standard revision and `frame:"standard"`. Unconnected matches KiCad's DRC on six KiCad 10 demo boards; live revision equals file revision; on DESKTOP-BAR6S0F (KiCad 10.0.4) a silk edit kept the revision, a move changed it, the stale one was refused, and 86 of 86 bodies cover their pads. Also: the no-ipc build compiles again (verbs_windows.rs gated), bridge.json lists every served verb. |  ## The goal in one sentence 
package.json+1
@@ -31,6 +31,7 @@     "skills/kicad-autorouting/SKILL.md",     "skills/kicad-place-route-loop/SKILL.md",     "skills/kicad-copper-pours/SKILL.md",+    "skills/kicad-board-model/SKILL.md",     "skills/kicad-tour/SKILL.md",     "skills/kicad-tour/tour_runner.py",     "skills/kicad-web-control/SKILL.md",
rust/Cargo.lock+3−3
@@ -244,7 +244,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.58"+version = "1.0.59" dependencies = [  "kicad-core",  "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.58"+version = "1.0.59" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.58"+version = "1.0.59" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.58"+version = "1.0.59" 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: "standard", verbs: || crate::verbs_standard::VERBS, dispatch: crate::verbs_standard::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 },
rust/crates/kicad-bridge/src/main.rs+1
@@ -23,6 +23,7 @@ mod verbs_placement; mod verbs_routing; mod verbs_schematic; mod verbs_show;+mod verbs_standard; mod verbs_windows;  use std::sync::atomic::{AtomicU64, Ordering};
rust/crates/kicad-bridge/src/verbs.rs+16
@@ -46,8 +46,24 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal     let mut prog = kicad_core::progress::begin(command, crate::verbs_show::kicad_running);     // Taskbar: the Adom badge and the progress bar while this verb drives KiCad (dropped at return).     let _driving = crate::taskbar::note_driving(command, verb.mechanism.as_str());+    // The edit verbs take the standard's y-up frame on request (frame:"standard"): what+    // kicad_routing_state {model:"standard"} reports is then what they take.+    let framed;+    let args = if kicad_core::board_model::FRAME_VERBS.contains(&command) {+        framed = match kicad_core::board_model::args_to_kicad_frame(args) {+            Ok(a) => a,+            Err(e) => return fail("bad_args", e, "Leave frame out for KiCad's own y-down coordinates."),+        };+        &framed+    } else {+        args+    };     // Verb groups ported later live in their own files; each returns Some when it owns the verb.     if let Some(mut out) = crate::groups::dispatch(state, command, args) {+        if args.get("_fromFrame").is_some() {+            out["inputFrame"] = json!("standard");+            out["_frameNote"] = json!("Your coordinates were taken in the standard's y-up frame; positions in this reply are KiCad's own (y down). kicad_routing_state {model:\"standard\"} reads the result back in the standard's frame.");+        }         reply_hints(command, &mut out);         prog.attach(&mut out);         if let Some(o) = out.as_object_mut() {
rust/crates/kicad-bridge/src/verbs_netlist.rs+93
@@ -22,6 +22,16 @@ pub static VERBS: &[Verb] = &[         related: &["kicad_trace_net", "kicad_find_connections", "kicad_analyze_connections"],         pitfalls: &["needs kicad-cli (KiCad >= 7)", "hierarchical designs: pass the ROOT sheet"],     },+    Verb {+        name: "kicad_netlist",+        summary: "The compiled netlist in the EDA Bridge Standard's shape: every net with its pins as Ref.Pin across every sheet (hierarchy resolved); or one net; or compare with the nets you expect. parts:true adds each part's pins with their electrical types.",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 150,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\" | \"projectPath\": \"C:/.../x.kicad_pro\", \"net\"?: \"GND\", \"expect\"?: {\"GND\": [\"U1.4\", \"C3.2\"]}, \"parts\"?: true}",+        example: "kicad_netlist {\"projectPath\":\"C:/Users/john/proj/board.kicad_pro\",\"expect\":{\"VBUS\":[\"J1.1\",\"U1.1\"]}}",+        hint: "nets is {net: [\"U1.8\", ...]}, the shape altium_netlist answers. Check a schematic you drew or edited before updating the board: pass expect and read mismatches ({net, missing, extra}; absent:true when the net is not there at all). Net names match case-insensitively. parts:true gives every part with its pins (number, name, electrical: input, output, bidirectional, tri_state, passive, power_in, power_out, open_collector, open_emitter, free, unspecified, no_connect), the input to a schematic review.",+        related: &["kicad_extract_netlist", "kicad_trace_net", "kicad_run_erc", "kicad_sch_wire"],+        pitfalls: &["Reads the saved schematic through kicad-cli: unsaved edits in the open editor are not in it.", "An unconnected pin is its own net, named unconnected-(U1-Pad3) by KiCad.", "electrical can carry +no_connect: the pin has a no-connect flag on the sheet."],+    },     Verb {         name: "kicad_trace_net",         summary: "Every pin on one named net (read-only).",@@ -57,6 +67,7 @@ pub static VERBS: &[Verb] = &[ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {     Some(match command {         "kicad_extract_netlist" => extract_netlist(state, args),+        "kicad_netlist" => standard_netlist(state, args),         "kicad_trace_net" => trace_net(state, args),         "kicad_find_connections" => find_connections(state, args),         "kicad_analyze_connections" => analyze_connections(state, args),@@ -234,3 +245,85 @@ fn analyze_connections(state: &mut State, args: &Value) -> Value {     v["_hint"] = json!("Heuristic overview from the netlist alone. suspectedUnconnected lists nets with a single node: usually a missed wire, sometimes an intentional test point or no-connect; kicad_run_erc is the authoritative check and knows about no-connect flags.");     v }++/// `kicad_netlist`: the standard's netlist, `{net: ["Ref.Pin", ...]}`, with the expect comparison and+/// the parts with their pin types.+fn standard_netlist(state: &mut State, args: &Value) -> Value {+    let mut a = args.clone();+    if arg_str(args, "filePath").is_none() {+        // a project names its root sheet: the .kicad_sch beside it with the same stem+        if let Some(pro) = arg_str(args, "projectPath") {+            let p = PathBuf::from(pro.trim());+            a["filePath"] = json!(p.with_extension("kicad_sch").to_string_lossy());+        }+    }+    let (model, path) = match model_for(state, &a) {+        Ok(m) => m,+        Err(v) => return v,+    };+    let want = arg_str(args, "net").map(str::to_lowercase);+    let mut nets = serde_json::Map::new();+    for n in &model.nets {+        if want.as_deref().map_or(false, |w| n.name.to_lowercase() != w) {+            continue;+        }+        nets.insert(n.name.clone(), json!(n.nodes.iter().map(|x| format!("{}.{}", x.reference, x.pin)).collect::<Vec<_>>()));+    }+    if let (Some(w), true) = (arg_str(args, "net"), nets.is_empty()) {+        let mut v = fail("no_such_net", format!("no net named {w} in {path}"), "kicad_netlist without net lists every net name.");+        v["netCount"] = json!(model.nets.len());+        return v;+    }+    let mut out = json!({"success": true, "path": path, "netCount": nets.len(), "nets": nets, "compileErrors": []});+    if let Some(expect) = args.get("expect").and_then(Value::as_object) {+        let all: std::collections::HashMap<String, (&str, Vec<String>)> = model.nets.iter()+            .map(|n| (n.name.to_lowercase(), (n.name.as_str(), n.nodes.iter().map(|x| format!("{}.{}", x.reference, x.pin)).collect())))+            .collect();+        let mut mismatches = Vec::new();+        for (net, pins) in expect {+            let pins: Vec<String> = pins.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect();+            match all.get(&net.to_lowercase()) {+                None => mismatches.push(json!({"net": net, "absent": true, "missing": pins, "extra": []})),+                Some((name, have)) => {+                    let missing: Vec<&String> = pins.iter().filter(|p| !have.contains(p)).collect();+                    let extra: Vec<&String> = have.iter().filter(|p| !pins.contains(p)).collect();+                    if !missing.is_empty() || !extra.is_empty() {+                        mismatches.push(json!({"net": name, "missing": missing, "extra": extra}));+                    }+                }+            }+        }+        out["ok"] = json!(mismatches.is_empty());+        out["mismatches"] = json!(mismatches);+    }+    if args.get("parts") == Some(&json!(true)) {+        let mut by: std::collections::BTreeMap<&str, Vec<Value>> = std::collections::BTreeMap::new();+        for n in &model.nets {+            for x in &n.nodes {+                // KiCad 10 writes the pin's name as "<name>_<number>": the name alone is what the symbol shows+                let name = x.pinfunction.strip_suffix(&format!("_{}", x.pin)).unwrap_or(&x.pinfunction);+                by.entry(&x.reference).or_default().push(json!({"number": x.pin, "name": name, "electrical": x.pintype, "net": n.name}));+            }+        }+        let parts: Vec<Value> = model.components.iter().map(|c| {+            let mut pins = by.remove(c.reference.as_str()).unwrap_or_default();+            pins.sort_by(|a, b| natural(a["number"].as_str().unwrap_or("")).cmp(&natural(b["number"].as_str().unwrap_or(""))));+            json!({"designator": c.reference, "libReference": c.libsource, "comment": c.value, "footprint": c.footprint, "description": c.description, "pins": pins})+        }).collect();+        out["parts"] = json!(parts);+    }+    out["_hint"] = json!(if out.get("mismatches").map_or(false, |m| m.as_array().map_or(false, |a| !a.is_empty())) {+        "mismatches lists each expected net that differs: missing pins are not on it, extra pins are on it but were not expected. Fix the schematic (kicad_sch_wire, kicad_sch_label) and read again."+    } else {+        "nets is {net: [Ref.Pin]} across every sheet. Pass expect to check a schematic pin by pin, parts:true for each part's pins with their electrical types."+    });+    out+}++/// Pin numbers in natural order: 2 before 10, A2 before A10.+fn natural(s: &str) -> (String, u64, String) {+    let head: String = s.chars().take_while(|c| !c.is_ascii_digit()).collect();+    let digits: String = s[head.len()..].chars().take_while(|c| c.is_ascii_digit()).collect();+    let rest = s[head.len() + digits.len()..].to_string();+    (head, digits.parse().unwrap_or(0), rest)+}
rust/crates/kicad-bridge/src/verbs_placement.rs+2−2
@@ -26,7 +26,7 @@ pub static VERBS: &[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}",+        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} frame?: \"standard\" takes y-up coordinates as kicad_routing_state {model:standard} reports them",         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"],@@ -96,7 +96,7 @@ fn live(state: &mut State, args: &Value, which: Which) -> Value {     };     let result = match which {         Which::State => ipc::placement_state(&ctx, args),-        Which::Move => ipc::move_footprint(&ctx, args),+        Which::Move => ipc::with_standard_revision(&ctx, args, |a| ipc::move_footprint(&ctx, a)),         Which::Validate => ipc::placement_validate(&ctx, args),     };     match result {
rust/crates/kicad-bridge/src/verbs_routing.rs+22−19
@@ -60,17 +60,17 @@ pub static VERBS: &[Verb] = &[         name: "kicad_routing_state",         summary: "Inspect the live editor: pads, copper, revision and KiCad-measured net connectivity.",         mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,-        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"socketPath\"?: \"ipc://...\", \"ipcTimeoutMs\"?: 5000}",-        example: "kicad_routing_state {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",-        hint: "Pass exact filePath and optional local ipc:// socketPath. Use revision as expectedRevision for edits. Choosing how to route the rest? kicad_autoroute names both engines: ai (recommended, you drive kicad_route_net) and freerouting (deterministic, installed on demand).",+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"socketPath\"?: \"ipc://...\", \"ipcTimeoutMs\"?: 5000, \"model\"?: \"standard\", \"frame\"?: \"standard|kicad\", \"source\"?: \"auto|live|file\"}",+        example: "kicad_routing_state {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"model\":\"standard\"}",+        hint: "Pass exact filePath and optional local ipc:// socketPath. Use revision as expectedRevision for edits. model:\"standard\" answers the EDA Bridge Standard board model instead (the shape altium_routing_state and fusion_routing_state answer): tracks, arcs, vias, pads with sizes, components, pours, the outline with its arcs, unconnected counted from the copper, a revision that moves only with copper, parts or outline, mm with y up; filePath may be left out when one board is open, and it reads the file when no editor serves the board (source:\"file\" forces that). Choosing how to route the rest? kicad_autoroute names both engines: ai (recommended, you drive kicad_route_net) and freerouting (deterministic, installed on demand).",         related: &["kicad_route_net", "kicad_routing_validate", "kicad_ipc_api", "kicad_autoroute"],-        pitfalls: &["Requires KiCad 10.0.1+ with the IPC API server enabled and the board open in the PCB editor; no file fallback (kicad_board_pads reads the file).", "api_server_off means the switch in kicad_common.json is off: kicad_ipc_api {\"enable\":true}, then relaunch KiCad."],+        pitfalls: &["Without model:\"standard\" it requires KiCad 10.0.1+ with the IPC API server enabled and the board open in the PCB editor; no file fallback (kicad_board_pads reads the file).", "model:\"standard\" is y UP (KiCad's y negated) while the edit verbs take KiCad's y down: frame:\"kicad\" answers the model in KiCad's own frame.", "A model read from the file (source file) does not see unsaved edits in an open editor.", "api_server_off means the switch in kicad_common.json is off: kicad_ipc_api {\"enable\":true}, then relaunch KiCad."],     },     Verb {         name: "kicad_route_net",         summary: "Commit a waypoint path or branching paths for ONE net live with native Undo and DRC preflight.",         mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,-        input: "{\"filePath\", \"expectedRevision\", \"net\", \"points\": [\"R1.2\", [120.5, 90], {\"x\":130,\"y\":88,\"layer\":\"B.Cu\"}] | \"paths\": [[...],[...]], \"width\"?: 0.25, \"layer\"?: \"F.Cu\", \"viaSize\"?: 0.6, \"viaDrill\"?: 0.3, \"validate\"?: true, \"dryRun\"?: false, \"save\"?: false}",+        input: "{\"filePath\", \"expectedRevision\", \"net\", \"points\": [\"R1.2\", [120.5, 90], {\"x\":130,\"y\":88,\"layer\":\"B.Cu\"}] | \"paths\": [[...],[...]], \"width\"?: 0.25, \"layer\"?: \"F.Cu\", \"viaSize\"?: 0.6, \"viaDrill\"?: 0.3, \"validate\"?: true, \"dryRun\"?: false, \"save\"?: false} frame?: \"standard\" takes y-up coordinates as kicad_routing_state {model:standard} reports them",         example: "kicad_route_net {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<from routing_state>\",\"net\":\"GND\",\"points\":[\"J1.1\",[112,85],\"J2.1\"],\"width\":0.25}",         hint: "Pass filePath, expectedRevision from routing_state, exact net name, points or paths, optional width/layer/viaSize/viaDrill. dryRun validates only; save:true explicitly saves.",         related: &["kicad_routing_state", "kicad_remove_route", "kicad_routing_validate"],@@ -80,7 +80,7 @@ pub static VERBS: &[Verb] = &[         name: "kicad_remove_route",         summary: "Remove specific unlocked copper item IDs live as one Undo step.",         mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,-        input: "{\"filePath\", \"expectedRevision\", \"itemIds\": [\"<uuid>\", ...]}",+        input: "{\"filePath\", \"expectedRevision\", \"itemIds\": [\"<uuid>\", ...], \"dryRun\"?: true (lists what would go and KiCad's DRC without it, removes nothing), \"validate\"?: false}",         example: "kicad_remove_route {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<rev>\",\"itemIds\":[\"6f1c...\"]}",         hint: "filePath, expectedRevision, itemIds from route_net. Refuses missing IDs, pads, footprints and locked copper.",         related: &["kicad_route_net", "kicad_routing_state"],@@ -90,7 +90,7 @@ pub static VERBS: &[Verb] = &[         name: "kicad_add_zone",         summary: "Add copper zones (pours) live as one Undo step: DRC preflight with the zone filled, then created and filled through the IPC API.",         mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,-        input: "{\"filePath\", \"expectedRevision\", \"net\", \"layers\": [\"F.Cu\"], \"polygon\": [[x, y], ...] mm, \"priority\"?: 0, \"clearance\"?: 0.2, \"minThickness\"?: 0.25, \"connection\"?: \"thermal\"|\"solid\", \"thermalGap\"?, \"thermalBridge\"?, \"name\"?, \"dryRun\"?, \"validate\"?, \"refill\"?, \"save\"?} or {\"zones\": [ {...}, ... ]}",+        input: "{\"filePath\", \"expectedRevision\", \"net\", \"layers\": [\"F.Cu\"], \"polygon\": [[x, y], ...] mm, \"priority\"?: 0, \"clearance\"?: 0.2, \"minThickness\"?: 0.25, \"connection\"?: \"thermal\"|\"solid\", \"thermalGap\"?, \"thermalBridge\"?, \"name\"?, \"dryRun\"?, \"validate\"?, \"refill\"?, \"save\"?} or {\"zones\": [ {...}, ... ]} frame?: \"standard\" takes y-up coordinates as kicad_routing_state {model:standard} reports them",         example: "kicad_add_zone {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<rev>\",\"net\":\"+VBAT\",\"layers\":[\"F.Cu\",\"B.Cu\"],\"polygon\":[[130,60],[166,60],[166,90],[130,90]],\"priority\":2}",         hint: "Pours for current, heat and copper retention (ablation): power and return nets first, higher priority for the smaller nets inside a bigger pour, thermal reliefs by default (solid for the power stage). The preflight fills the zone on a snapshot and refuses new DRC errors; kicad_zone_state reads the filled copper back.",         related: &["kicad_zone_state", "kicad_route_net", "kicad_routing_validate", "kicad_routing_state"],@@ -100,7 +100,7 @@ pub static VERBS: &[Verb] = &[         name: "kicad_apply_zone_plan",         summary: "Reconcile a complete explicit-owned zone plan as one native Undo step after full-candidate DRC.",         mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,-        input: "{filePath, expectedRevision, ownedItemIds:[], zones:[{name,net,layers,polygon,...}], dryRun?, refill:false?, save?}",+        input: "{filePath, expectedRevision, ownedItemIds:[], zones:[{name,net,layers,polygon,...}], dryRun?, refill:false?, save?} frame?: \"standard\" takes y-up coordinates as kicad_routing_state {model:standard} reports them",         example: "kicad_apply_zone_plan {filePath,expectedRevision,ownedItemIds:[],zones:[]}",         hint: "Use exact owned item IDs from prior successful replies. Unowned zones are preserved; ambiguous names refuse. Dry run has no edit or save. Returns unchangedIds, removedIds and ownedItemIds. Native definitions with known serialization defaults normalized, not area, determine equality. This verb never refills; refill:true refuses before mutation. Call kicad_zone_state with refill:true separately and use its returned revision.",         related: &["kicad_zone_state", "kicad_add_zone"],@@ -169,7 +169,7 @@ pub static VERBS: &[Verb] = &[         name: "kicad_add_track",         summary: "Write one copper segment to a CLOSED board file.",         mechanism: Mechanism::File, risk: "write", timeout_sec: 130,-        input: "{\"filePath\", \"start\": \"R1.2\" | [x,y], \"end\": ..., \"net\"?: \"GND\" | 0, \"layer\"?: \"F.Cu\", \"width\"?: 0.2, \"backup\"?: true}",+        input: "{\"filePath\", \"start\": \"R1.2\" | [x,y], \"end\": ..., \"net\"?: \"GND\" | 0, \"layer\"?: \"F.Cu\", \"width\"?: 0.2, \"backup\"?: true} frame?: \"standard\" takes y-up coordinates as kicad_routing_state {model:standard} reports them",         example: "kicad_add_track {\"filePath\":\"C:/d/x.kicad_pcb\",\"start\":\"R1.2\",\"end\":[120.5,90],\"net\":\"GND\",\"layer\":\"F.Cu\"}",         hint: "kicad_add_track {\"filePath\":\"...\",\"start\":\"R1.2\",\"end\":[120.5,90],\"net\":\"GND\",\"layer\":\"F.Cu\"}. Endpoints are pad names or [x,y] mm. Width defaults to the board's own.",         related: &["kicad_route", "kicad_add_via", "kicad_board_pads", "kicad_run_drc"],@@ -179,7 +179,7 @@ pub static VERBS: &[Verb] = &[         name: "kicad_add_via",         summary: "Drop one via so a route can change layer.",         mechanism: Mechanism::File, risk: "write", timeout_sec: 130,-        input: "{\"filePath\", \"at\": [x,y] | \"R1.2\", \"net\"?: \"GND\", \"layers\"?: [\"F.Cu\",\"B.Cu\"], \"size\"?: 0.6, \"drill\"?: 0.3, \"backup\"?: true}",+        input: "{\"filePath\", \"at\": [x,y] | \"R1.2\", \"net\"?: \"GND\", \"layers\"?: [\"F.Cu\",\"B.Cu\"], \"size\"?: 0.6, \"drill\"?: 0.3, \"backup\"?: true} frame?: \"standard\" takes y-up coordinates as kicad_routing_state {model:standard} reports them",         example: "kicad_add_via {\"filePath\":\"C:/d/x.kicad_pcb\",\"at\":[120,90],\"net\":\"GND\",\"layers\":[\"F.Cu\",\"B.Cu\"]}",         hint: "kicad_add_via {\"filePath\":\"...\",\"at\":[120,90],\"net\":\"GND\",\"layers\":[\"F.Cu\",\"B.Cu\"]}. Defaults 0.6 mm pad / 0.3 mm drill.",         related: &["kicad_add_track", "kicad_route", "kicad_list_design_rules"],@@ -189,7 +189,7 @@ pub static VERBS: &[Verb] = &[         name: "kicad_route",         summary: "Lay a whole path for one net in a single write, vias included.",         mechanism: Mechanism::File, risk: "write", timeout_sec: 130,-        input: "{\"filePath\", \"net\"?: \"GND\", \"points\": [\"R1.2\", [120,88], {\"x\":130,\"y\":88,\"layer\":\"B.Cu\"}, \"U1.7\"], \"width\"?: 0.25, \"layer\"?: \"F.Cu\", \"viaSize\"?: 0.6, \"viaDrill\"?: 0.3, \"backup\"?: true}",+        input: "{\"filePath\", \"net\"?: \"GND\", \"points\": [\"R1.2\", [120,88], {\"x\":130,\"y\":88,\"layer\":\"B.Cu\"}, \"U1.7\"], \"width\"?: 0.25, \"layer\"?: \"F.Cu\", \"viaSize\"?: 0.6, \"viaDrill\"?: 0.3, \"backup\"?: true} frame?: \"standard\" takes y-up coordinates as kicad_routing_state {model:standard} reports them",         example: "kicad_route {\"filePath\":\"C:/d/x.kicad_pcb\",\"net\":\"GND\",\"points\":[\"R1.2\",[120,88],{\"x\":130,\"y\":88,\"layer\":\"B.Cu\"},\"U1.7\"]}",         hint: "kicad_route {\"filePath\":\"...\",\"net\":\"GND\",\"points\":[\"R1.2\",[120,88],{\"x\":130,\"y\":88,\"layer\":\"B.Cu\"},\"U1.7\"]}. A waypoint with a different layer drops a via there.",         related: &["kicad_add_track", "kicad_board_pads", "kicad_run_drc"],@@ -223,6 +223,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_silk_fields_batch" => live(state,args,Live::SilkFieldsBatch),         "kicad_silk_graphics_batch" => live(state,args,Live::SilkGraphicsBatch),         "kicad_silk_text_batch" => live(state,args,Live::SilkTextBatch),+        "kicad_routing_state" if args.get("model").and_then(Value::as_str) == Some("standard") => crate::verbs_standard::standard_model(state, args),         "kicad_routing_state" => live(state, args, Live::State),         "kicad_text_bounds" => live(state,args,Live::TextBounds),         "kicad_route_net" => live(state, args, Live::RouteNet),@@ -274,19 +275,21 @@ fn live(state: &mut State, args: &Value, which: Live) -> Value {         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)),     };+    // the edit verbs take the standard model's revision as well as the editor's text hash+    let edit = |run: fn(&ipc::Ctx, &Value) -> Result<Value, kicad_core::pcb::RoutingError>| ipc::with_standard_revision(&ctx, args, |a| run(&ctx, a));     let result = match which {-        Live::SilkFieldsBatch => ipc::silk_fields_batch(&ctx,args),-        Live::SilkGraphicsBatch => ipc::silk_graphics_batch(&ctx,args),+        Live::SilkFieldsBatch => edit(ipc::silk_fields_batch),+        Live::SilkGraphicsBatch => edit(ipc::silk_graphics_batch),         Live::State => ipc::routing_state(&ctx, args),         Live::TextBounds => ipc::text_bounds(&ctx,args),-        Live::SilkTextBatch => ipc::silk_text_batch(&ctx,args),-        Live::RouteNet => ipc::route_net(&ctx, args),-        Live::RemoveRoute => ipc::remove_route(&ctx, args),+        Live::SilkTextBatch => edit(ipc::silk_text_batch),+        Live::RouteNet => edit(ipc::route_net),+        Live::RemoveRoute => edit(ipc::remove_route),         Live::Validate => ipc::routing_validate(&ctx, args),-        Live::AddZone => ipc::add_zone(&ctx, args),-        Live::ApplyZonePlan => ipc::apply_zone_plan(&ctx,args),+        Live::AddZone => edit(ipc::add_zone),+        Live::ApplyZonePlan => edit(ipc::apply_zone_plan),         Live::ZoneState => ipc::zone_state(&ctx, args),-        Live::RemoveZone => ipc::remove_zone(&ctx, args),+        Live::RemoveZone => edit(ipc::remove_zone),         Live::SelectNet => ipc::select_net(&ctx, args),         Live::SelectClear => ipc::select_clear(&ctx, args),     };
rust/crates/kicad-bridge/src/verbs_schematic.rs+38
@@ -116,6 +116,20 @@ pub static VERBS: &[Verb] = &[             "Reference must stay unique on the sheet; the verb refuses a name already in use",         ],     },+    Verb {+        name: "kicad_sch_no_connect",+        summary: "Put a no-connect flag on a pin of a .kicad_sch (an intentionally open pin: a spare GPIO, an unused connector pin), so ERC stops reporting it (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"pin\": \"U1.PA10\" | \"U1.12\" | \"at\": [x_mm, y_mm]}",+        example: "kicad_sch_no_connect {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"pin\":\"U1.12\"}",+        hint: "pin names the symbol and its pin number (or pin name), and the flag lands on that pin's connection point, worked out from the symbol's position, angle and mirror; at places it at a point you already know. Writes (no_connect (at x y) (uuid ..)) as eeschema does. On disk only: reload the editor to see it. .bak written; the ERC delta shows the pin_not_connected error gone.",+        related: &["kicad_sch_label", "kicad_sch_wire", "kicad_run_erc", "kicad_netlist"],+        pitfalls: &[+            "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",+            "a flag on a pin that is wired is an ERC error of its own (no_connect_connected): flag only open pins",+            "a flag must sit exactly on the pin's connection point; with at, a point off the pin is a dangling flag (ERC warns)",+        ],+    }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {@@ -123,6 +137,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_sch_place_symbol" => edit(state, args, place_symbol),         "kicad_sch_wire" => edit(state, args, wire),         "kicad_sch_label" => edit(state, args, label),+        "kicad_sch_no_connect" => edit(state, args, no_connect),         "kicad_sch_move" => edit(state, args, move_symbol),         "kicad_sch_delete" => edit(state, args, delete),         "kicad_sch_set_property" => edit(state, args, set_property),@@ -323,6 +338,29 @@ fn label(ctx: &mut Ctx) -> Result<Done, Value> {     }) } +/// `kicad_sch_no_connect` (wiki #131): a flag on a pin's connection point, by pin or by point.+fn no_connect(ctx: &mut Ctx) -> Result<Done, Value> {+    let (at, pin) = if let Some(spec) = arg_any(ctx.args, &["pin", "pins"]) {+        let (reference, want) = spec.trim().split_once('.').ok_or_else(|| fail("bad_arg", format!("pin {spec} is not Ref.Pin"), "Name the symbol and its pin: U1.12 or U1.PA10."))?;+        let points = ctx.sch.pin_points(reference).map_err(|e| op_fail("not_found", e, "kicad_netlist {parts:true} lists every part with its pins."))?;+        // the pin by number, else by name+        let hit = points.iter().find(|(n, _)| n == want).map(|(_, p)| *p).or_else(|| {+            let names = ctx.sch.pin_names(reference);+            names.iter().find(|(_, name)| name.eq_ignore_ascii_case(want)).and_then(|(num, _)| points.iter().find(|(n, _)| n == num).map(|(_, p)| *p))+        });+        let at = hit.ok_or_else(|| fail("pin_not_found", format!("{reference} has no pin {want}"), "Pins are matched by number first, then by name; kicad_netlist {parts:true} lists them."))?;+        (at, Some(spec.trim().to_string()))+    } else {+        (point(ctx.args, "at")?, None)+    };+    let uuid = ctx.sch.add_no_connect(at).map_err(|e| op_fail("edit_failed", e, "Nothing was written."))?;+    Ok(Done {+        output: format!("Added a no-connect flag{} at ({}, {}) in {}", pin.as_ref().map(|p| format!(" on {p}")).unwrap_or_default(), schematic::fmt_num(at.0), schematic::fmt_num(at.1), file_name(ctx.path)),+        uuid,+        extra: json!({"at": [at.0, at.1], "pin": pin}),+    })+}+ fn move_symbol(ctx: &mut Ctx) -> Result<Done, Value> {     let reference = arg_any(ctx.args, &["reference", "ref"]).ok_or_else(|| fail("missing_arg", "reference is required", "Pass the designator of the symbol to move, e.g. R5."))?.trim().to_string();     let to = point(ctx.args, "to")?;
rust/crates/kicad-bridge/src/verbs_standard.rsadded+348
@@ -0,0 +1,348 @@+//! Verb group "standard": the EDA Bridge Standard's board model and its companions+//! (adom/eda-bridge-standard), the same shapes altium_* and fusion_* answer, so one AI and the+//! shared checks work on every tool. `kicad_routing_state {"model":"standard"}` is the board model+//! (routed here from the routing group); `kicad_layer_stack` and `kicad_pour_state` are the stack-up+//! and the poured copper. Each reads the live PCB editor when it serves the board (unsaved edits+//! included) and the file otherwise; `source` forces one. Built by `kicad_core::board_model`.++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::board_model;++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_layer_stack",+        summary: "The board's stack-up: copper layers top to bottom with copper thickness and the dielectric under each (thickness, Dk, material), the masks and the board thickness. For impedance, current capacity and fab quotes.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"socketPath\"?: \"ipc://...\"}",+        example: "kicad_layer_stack {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "The standard's layer_stack: layers[] are the copper layers top to bottom, each with dielectricUm (the dielectric BELOW it), dk1000 (Dk x 1000: 4500 = 4.5) and material; a KiCad power layer is kind plane with the net that pours most of it. A board saved without a stack-up answers KiCad's defaults (35 um copper, FR4 Dk 4.5 shared over the board thickness) with fromDefaults:true. Edit it in KiCad's Board Setup > Physical Stackup.",+        related: &["kicad_routing_state", "kicad_pour_state", "kicad_list_design_rules"],+        pitfalls: &["Sublayers of one dielectric are summed and their Dk weighted by thickness.", "masks give a Dk only when the file states one."],+    },+    Verb {+        name: "kicad_pour_state",+        summary: "Every copper pour (zone) on every layer with the copper KiCad actually poured: filled area per zone and per layer, islands, and the zones that poured nothing.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"geometry\"?: true, \"frame\"?: \"standard|kicad\"}",+        example: "kicad_pour_state {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"geometry\":false}",+        hint: "The standard's pour_state: filledMm2 is the poured copper (each island's area, holes already cut), filledAreaByLayerMm2 sums it per layer, unfilled lists zones with no fill (refill in KiCad with B, or kicad_zone_state {refill:true} on the live board). geometry:false drops the outlines for a small reply. Each fill is KiCad's own fractured polygon: the holes are joined to the outline by zero-width cuts, so area and point-in-polygon are exact as given (holes is empty).",+        related: &["kicad_zone_state", "kicad_apply_zone_plan", "kicad_routing_state", "kicad_layer_stack"],+        pitfalls: &["A file read sees the fills last saved: after editing copper, refill before trusting filledMm2.", "Rule areas (keepouts) are not pours; kicad_routing_state {model:standard} lists them under keepouts."],+    },+    Verb {+        name: "kicad_bodies",+        summary: "Every part's 3D body as a box: its footprint rectangle on the board and its height range above the surface it sits on, read from the 3D model files the footprints name. For height checks, enclosure fit and placement review.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 120,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\", \"refs\"?: [\"U1\", \"J2\"]}",+        example: "kicad_bodies {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "The standard's bodies companion: bodies[] {ref, side, x0, y0, x1, y1, z0, z1, heightMm} in mm, y up like kicad_routing_state {model:standard}; z is measured from the surface the part sits on (a bottom part's height is below the board). tallest names the highest parts. noBody lists parts with no model or a model that could not be read, with why: a check must not pass a part it could not see.",+        related: &["kicad_routing_state", "kicad_show_3d_board", "kicad_layer_stack"],+        pitfalls: &["Bounds come from the model file: a STEP's vertices and circular edges, a VRML's points. A model named .wrl that only ships as .step (KiCad 10's library) is read as the .step, as KiCad does.", "Embedded models (kicad-embed://) are listed in noBody for now.", "THT leads below the board show as a negative z0."],+    },+    Verb {+        name: "kicad_board_summary",+        summary: "One-screen summary of the board: parts by side, nets, pads, copper layers and those actually in use, the outline's size and area, unconnected and the revision.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}",+        example: "kicad_board_summary {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Start here on an unknown board, then kicad_routing_state {model:\"standard\"} for the whole of it, kicad_component_data or kicad_list_nets for detail. Reads the live PCB editor when it serves the board, else the file (source says which); filePath may be left out when one board is open.",+        related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],+        pitfalls: &["A file read does not see unsaved edits in an open editor."],+    },+    Verb {+        name: "kicad_list_nets",+        summary: "Every net on the board with its pad count.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}",+        example: "kicad_list_nets {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Names exactly as the design has them; match them case-insensitively. kicad_netlist gives the schematic's nets. Reads the live PCB editor when it serves the board, else the file (source says which); filePath may be left out when one board is open.",+        related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],+        pitfalls: &["A file read does not see unsaved edits in an open editor."],+    },+    Verb {+        name: "kicad_list_designators",+        summary: "Every part designator on the board, in natural order (R2 before R10).",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}",+        example: "kicad_list_designators {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "kicad_component_data {designator} for one part's placement. Reads the live PCB editor when it serves the board, else the file (source says which); filePath may be left out when one board is open.",+        related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],+        pitfalls: &["A file read does not see unsaved edits in an open editor."],+    },+    Verb {+        name: "kicad_copper_layers",+        summary: "The board's copper layers top to bottom by KiCad's names (F.Cu, In1.Cu.., B.Cu).",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}",+        example: "kicad_copper_layers {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "kicad_layer_stack adds thicknesses and Dk; kicad_layers_in_use says which carry copper. Reads the live PCB editor when it serves the board, else the file (source says which); filePath may be left out when one board is open.",+        related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],+        pitfalls: &["A file read does not see unsaved edits in an open editor."],+    },+    Verb {+        name: "kicad_layers_in_use",+        summary: "Which copper layers actually carry copper (tracks, arcs, vias, pads, pours), densest first, and which are empty.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}",+        example: "kicad_layers_in_use {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "An empty inner layer on a 4-layer board is often a missing plane: kicad_pour_state shows the pours. Reads the live PCB editor when it serves the board, else the file (source says which); filePath may be left out when one board is open.",+        related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],+        pitfalls: &["A file read does not see unsaved edits in an open editor."],+    },+    Verb {+        name: "kicad_component_data",+        summary: "Placement data for one part or all of them: footprint, value, side, position, rotation, the size of its pads' extent, pad count and the nets on its pads.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"designator\"?: \"U1\", \"limit\"?: 500, \"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}",+        example: "kicad_component_data {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "mm in the standard's frame (y up; frame:\"kicad\" for KiCad's y down). Leave designator out for every part. Reads the live PCB editor when it serves the board, else the file (source says which); filePath may be left out when one board is open.",+        related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],+        pitfalls: &["A file read does not see unsaved edits in an open editor."],+    },+    Verb {+        name: "kicad_component_pins",+        summary: "Every pad of one part with its net, position, layer, size, rotation and hole.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"designator\": \"U1\", \"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}",+        example: "kicad_component_pins {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "mm in the standard's frame (y up). The pad names are what kicad_route_net takes as waypoints (U1.3). Reads the live PCB editor when it serves the board, else the file (source says which); filePath may be left out when one board is open.",+        related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],+        pitfalls: &["A file read does not see unsaved edits in an open editor."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_layer_stack" => companion(state, args, Companion::Stack),+        "kicad_pour_state" => companion(state, args, Companion::Pours),+        "kicad_bodies" => bodies(state, args),+        "kicad_board_summary" => view(state, args, "board_summary"),+        "kicad_list_nets" => view(state, args, "list_nets"),+        "kicad_list_designators" => view(state, args, "list_designators"),+        "kicad_copper_layers" => view(state, args, "copper_layers"),+        "kicad_layers_in_use" => view(state, args, "layers_in_use"),+        "kicad_component_data" => view(state, args, "component_data"),+        "kicad_component_pins" => view(state, args, "component_pins"),+        _ => return None,+    })+}++/// Errors that mean no PCB editor serves this board here, after which the file is the next best read.+const NO_EDITOR: &[&str] = &["ipc_unavailable", "api_server_off", "no_board_open", "board_mismatch", "no_pcb_frame", "unsupported_kicad", "ipc_feature_off"];++/// The board's text and where it came from.+struct Read {+    text: String,+    file: String,+    live: bool,+    kicad_version: Option<String>,+    /// why the live read was not used, when the file was read instead+    live_error: Option<Value>,+}++/// The live editor's serialisation when it serves the board (source auto or live), else the file+/// (auto or file).+fn read(state: &mut State, args: &Value) -> Result<Read, Value> {+    let source = args.get("source").and_then(Value::as_str).unwrap_or("auto");+    if !matches!(source, "auto" | "live" | "file") {+        return Err(fail("bad_args", "source must be auto, live or file", "auto reads the live editor when it serves the board and the file otherwise."));+    }+    let mut live_error = None;+    if source != "file" {+        match live_text(state, args) {+            Ok((text, file, version)) => return Ok(Read { text, file, live: true, kicad_version: Some(version), live_error: None }),+            Err(e) => {+                let code = e["errorCode"].as_str().unwrap_or("");+                if source == "live" || !NO_EDITOR.contains(&code) {+                    return Err(e);+                }+                live_error = Some(json!({"errorCode": e["errorCode"], "error": e["error"]}));+            }+        }+    }+    let Some(path) = args.get("filePath").and_then(Value::as_str) else {+        let mut v = fail("bad_args", "filePath is required to read the board from its file", "Pass the .kicad_pcb path, or open the board in the PCB editor with the IPC API on (kicad_ipc_api) and leave filePath out.");+        if let Some(l) = live_error {+            v["live"] = l;+        }+        return Err(v);+    };+    match std::fs::read_to_string(path) {+        Ok(text) => Ok(Read { text, file: path.to_string(), live: false, kicad_version: None, live_error }),+        Err(e) => Err(fail("board_not_found", format!("could not read {path}: {e}"), "Pass the absolute path of an existing .kicad_pcb.")),+    }+}++#[cfg(feature = "ipc")]+fn live_text(state: &mut State, args: &Value) -> Result<(String, String, String), 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)),+    };+    ipc::board_text(&ctx, args).map_err(|e| ipc::error_response(&e))+}++#[cfg(not(feature = "ipc"))]+fn live_text(_state: &mut State, _args: &Value) -> Result<(String, String, String), Value> {+    Err(fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "The board is read from the file instead."))+}++fn opts(args: &Value) -> Result<board_model::Opts, Value> {+    match args.get("frame").and_then(Value::as_str) {+        None | Some("standard") | Some("yup") => Ok(board_model::Opts { y_up: true }),+        Some("kicad") => Ok(board_model::Opts { y_up: false }),+        Some(other) => Err(fail("bad_args", format!("frame must be \"standard\" (y up) or \"kicad\" (y down), not \"{other}\""), "Leave frame out for the standard's y-up frame.")),+    }+}++/// Where the reply came from, said the same way on every standard verb.+fn stamp(v: &mut Value, r: &Read) {+    v["success"] = json!(true);+    v["source"] = json!(if r.live { "live-editor" } else { "file" });+    v["file"] = json!(r.file);+    if let Some(k) = &r.kicad_version {+        v["kicadVersion"] = json!(k);+    }+    if let Some(l) = &r.live_error {+        v["live"] = l.clone();+    }+}++fn file_note(r: &Read) -> &'static str {+    match (r.live, r.live_error.is_some()) {+        (true, _) => "",+        (false, true) => "Read from the file (no PCB editor serves this board with the IPC API on; see live): unsaved edits are not in it. ",+        (false, false) => "Read from the file: unsaved edits in an open editor are not in it. ",+    }+}++/// `kicad_routing_state {"model":"standard"}`: the standard board model.+pub fn standard_model(state: &mut State, args: &Value) -> Value {+    let o = match opts(args) { Ok(o) => o, Err(e) => return e };+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    match board_model::build(&r.text, Some(&r.file), &o) {+        Ok(mut v) => {+            stamp(&mut v, &r);+            if r.live {+                v["kicadRevision"] = json!(kicad_core::pcm::sha256_hex(r.text.as_bytes()));+            }+            v["_hint"] = json!(format!("{}{}", file_note(&r), MODEL_HINT));+            v+        }+        Err(e) => fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    }+}++const MODEL_HINT: &str = "The EDA Bridge Standard board model: mm, y UP (KiCad's y negated; frame:\"kicad\" keeps KiCad's y down), KiCad layer names, custom pads as their bounding rectangle (anchor gives KiCad's anchor), unconnected counted from the copper (splitNets names each piece's pads; isolatedCopper lists dead pour islands, not counted). Pass revision as expectedRevision to kicad_route_net, kicad_move_footprint and the other edit verbs: it moves only with copper, parts or the outline. The edit verbs take KiCad's own coordinates (y down). Companions: kicad_layer_stack, kicad_pour_state.";++#[derive(Clone, Copy)]+enum Companion {+    Stack,+    Pours,+}++fn companion(state: &mut State, args: &Value, which: Companion) -> Value {+    let o = match opts(args) { Ok(o) => o, Err(e) => return e };+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    let built = match which {+        Companion::Stack => board_model::stackup(&r.text),+        Companion::Pours => board_model::pours(&r.text, &o, args.get("geometry") != Some(&json!(false))),+    };+    match built {+        Ok(mut v) => {+            stamp(&mut v, &r);+            let hint = match which {+                Companion::Stack => if v["fromDefaults"] == json!(true) {+                    "This board has no stack-up of its own: these are KiCad's defaults (35 um copper, FR4 Dk 4.5). Set the real one in Board Setup > Physical Stackup before trusting impedance."+                } else {+                    "layers[] top to bottom; dielectricUm and dk1000 are the dielectric BELOW each copper layer (dk1000 4500 = Dk 4.5)."+                },+                Companion::Pours => "filledMm2 is the copper KiCad poured (holes cut). unfilled zones poured nothing: refill (KiCad: B) before trusting the numbers.",+            };+            v["_hint"] = json!(format!("{}{}", file_note(&r), hint));+            v+        }+        Err(e) => fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    }+}++/// The install's 3D model library: where `${KICAD<n>_3DMODEL_DIR}` points.+fn models_dir(state: &mut State) -> Option<std::path::PathBuf> {+    let info = state.kicad_info();+    let base = std::path::PathBuf::from(&info.primary()?.base_dir);+    [base.join("share/kicad/3dmodels"), base.join("share/3dmodels"), base.join("Contents/SharedSupport/3dmodels"), base.join("3dmodels")]+        .into_iter().find(|d| d.is_dir())+}++/// KiCad's own path variables (Preferences > Configure Paths), from kicad_common.json.+fn kicad_vars(state: &mut State) -> std::collections::HashMap<String, String> {+    let info = state.kicad_info();+    let Some(dir) = info.primary().and_then(|p| kicad_core::detect::config_dir(&p.version)) else { return Default::default() };+    let doc: Value = std::fs::read_to_string(dir.join("kicad_common.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+    doc["environment"]["vars"].as_object().map(|m| m.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect()).unwrap_or_default()+}++/// `kicad_bodies`: each part's 3D bodies as boxes, from the model files.+fn bodies(state: &mut State, args: &Value) -> Value {+    let o = match opts(args) { Ok(o) => o, Err(e) => return e };+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    let models = models_dir(state);+    let vars = kicad_vars(state);+    let project = std::path::Path::new(&r.file).parent().map(|p| p.to_path_buf()).unwrap_or_default();+    let mut cache: std::collections::HashMap<String, Result<(kicad_core::bodies::Bounds, String), String>> = Default::default();+    let mut resolve = |path: &str| -> Result<(kicad_core::bodies::Bounds, String), String> {+        cache.entry(path.to_string()).or_insert_with(|| {+            let expanded = kicad_core::bodies::expand(path, models.as_deref(), &project, &vars).ok_or_else(|| format!("a path variable in {path} is not set (KiCad: Preferences > Configure Paths)"))?;+            let file = kicad_core::bodies::find_model(&expanded, &project).ok_or_else(|| format!("model file not found: {expanded}"))?;+            let b = kicad_core::bodies::file_bounds(&file)?;+            Ok((b, file.to_string_lossy().replace('\\', "/")))+        }).clone()+    };+    let mut v = match kicad_core::bodies::bodies(&r.text, o.y_up, &mut resolve) {+        Ok(v) => v,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    if let Some(want) = args.get("refs").and_then(Value::as_array) {+        let want: Vec<&str> = want.iter().filter_map(Value::as_str).collect();+        for k in ["bodies", "noBody"] {+            if let Some(a) = v[k].as_array_mut() {+                a.retain(|b| want.iter().any(|w| b["ref"].as_str().map_or(false, |r| r.eq_ignore_ascii_case(w))));+            }+        }+    }+    let mut tall: Vec<(String, f64)> = v["bodies"].as_array().into_iter().flatten().map(|b| (b["ref"].as_str().unwrap_or("").to_string(), b["heightMm"].as_f64().unwrap_or(0.0))).collect();+    tall.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));+    tall.dedup_by(|a, b| a.0 == b.0);+    v["tallest"] = json!(tall.iter().take(5).map(|(r, h)| json!({"ref": r, "heightMm": h})).collect::<Vec<_>>());+    v["models3dDir"] = json!(models.map(|d| d.to_string_lossy().replace('\\', "/")));+    stamp(&mut v, &r);+    let missing = v["noBody"].as_array().map_or(0, |a| a.len());+    v["_hint"] = json!(format!("{}bodies are boxes in mm, y up (frame:\"kicad\" for y down), z from the surface each part sits on. {}",+        file_note(&r), if missing > 0 { format!("{missing} part(s) in noBody have no readable model: a height or clearance check cannot see them.") } else { "Every part has a body.".into() }));+    v+}++/// The core read verbs that are views of the board model (`board_model::derive`).+fn view(state: &mut State, args: &Value, which: &str) -> Value {+    let o = match opts(args) { Ok(o) => o, Err(e) => return e };+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    let model = match board_model::build(&r.text, Some(&r.file), &o) {+        Ok(m) => m,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    match board_model::derive(&model, which, args) {+        Ok(mut v) => {+            stamp(&mut v, &r);+            v["revision"] = model["revision"].clone();+            v["frame"] = model["frame"].clone();+            v["_hint"] = json!(format!("{}mm, {} frame. kicad_routing_state {{model:\"standard\"}} is the whole board in one read.", file_note(&r), if o.y_up { "the standard's y-up" } else { "KiCad's y-down" }));+            v+        }+        Err((code, msg)) => fail(&code, msg, "kicad_list_designators names every part on the board."),+    }+}
rust/crates/kicad-bridge/src/verbs_windows.rs+26−4
@@ -1704,9 +1704,9 @@ pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Va         // file it really has open before reusing the window.         let mut path_verified = false;         if kind == WindowKind::PcbEditor {-            if let Ok(open) = kicad_core::ipc::open_board_paths(Duration::from_secs(3)) {+            if let Ok(open) = open_board_paths(Duration::from_secs(3)) {                 let wanted = norm(&path);-                if open.iter().any(|p| kicad_core::ipc::same_board_path(p, &wanted)) {+                if open.iter().any(|p| same_board_path(p, &wanted)) {                     path_verified = true;                 } else if !open.is_empty() {                     return json!({@@ -1812,8 +1812,8 @@ fn open_file_without_windows(kind: WindowKind, path: &Path, pid: u32, log: Optio             return out;         }         if kind == WindowKind::PcbEditor {-            if let Ok(open) = kicad_core::ipc::open_board_paths(Duration::from_secs(2)) {-                if open.iter().any(|p| kicad_core::ipc::same_board_path(p, &wanted)) {+            if let Ok(open) = open_board_paths(Duration::from_secs(2)) {+                if open.iter().any(|p| same_board_path(p, &wanted)) {                     confirmed = true;                     break;                 }@@ -3518,3 +3518,25 @@ mod linked_refresh_tests {         }     } }++/// The boards KiCad's API says are open; without the ipc feature (the macOS type-check build) there is+/// no API to ask, and the callers fall back to the window title.+#[cfg(feature = "ipc")]+fn open_board_paths(timeout: Duration) -> Result<Vec<String>, String> {+    kicad_core::ipc::open_board_paths(timeout)+}++#[cfg(not(feature = "ipc"))]+fn open_board_paths(_timeout: Duration) -> Result<Vec<String>, String> {+    Err("this build has the ipc feature off".into())+}++#[cfg(feature = "ipc")]+fn same_board_path(a: &str, b: &str) -> bool {+    kicad_core::ipc::same_board_path(a, b)+}++#[cfg(not(feature = "ipc"))]+fn same_board_path(a: &str, b: &str) -> bool {+    a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/"))+}
rust/crates/kicad-core/examples/board_model.rsadded+34
@@ -0,0 +1,34 @@+//! Print the standard board model of a .kicad_pcb file (a development aid):+//!   cargo run -p kicad-core --no-default-features --example board_model -- board.kicad_pcb [--kicad] [--full]+fn main() {+    let args: Vec<String> = std::env::args().skip(1).collect();+    if let Some(i) = args.iter().position(|a| a == "--model") {+        let b = kicad_core::bodies::file_bounds(std::path::Path::new(&args[i + 1])).unwrap();+        println!("{}: min {:?} max {:?}", args[i + 1], b.min, b.max);+        return;+    }+    let path = args.iter().find(|a| !a.starts_with("--")).expect("a .kicad_pcb path");+    let text = std::fs::read_to_string(path).expect("readable board");+    let opts = kicad_core::board_model::Opts { y_up: !args.iter().any(|a| a == "--kicad") };+    let t = std::time::Instant::now();+    let v = kicad_core::board_model::build(&text, Some(path), &opts).expect("a board");+    let ms = t.elapsed().as_millis();+    if args.iter().any(|a| a == "--stack") {+        println!("{}", serde_json::to_string_pretty(&kicad_core::board_model::stackup(&text).unwrap()).unwrap());+        return;+    }+    if args.iter().any(|a| a == "--pours") {+        let p = kicad_core::board_model::pours(&text, &opts, false).unwrap();+        println!("{}", serde_json::to_string_pretty(&p).unwrap());+        return;+    }+    if args.iter().any(|a| a == "--full") {+        println!("{}", serde_json::to_string_pretty(&v).unwrap());+        return;+    }+    println!("{path}: {ms} ms, revision {}, counts {}, unconnected {}, split {}, outline edges {} ({} arcs), cutouts {}, open edges {}, unfilled {}, keepouts {}",+        v["revision"], v["counts"], v["unconnected"], v["splitNets"], v["outlineEdges"].as_array().map(|a| a.len()).unwrap_or(0),+        v["outlineEdges"].as_array().map(|a| a.iter().filter(|e| e["kind"] == "arc").count()).unwrap_or(0),+        v["cutouts"].as_array().map(|a| a.len()).unwrap_or(0), v["outlineOpenEdges"], v["unfilledZones"].as_array().map(|a| a.len()).unwrap_or(0),+        v["keepouts"].as_array().map(|a| a.len()).unwrap_or(0));+}
rust/crates/kicad-core/src/board_model.rsadded+1852
@@ -0,0 +1,1852 @@+//! The EDA Bridge Standard's board model (adom/eda-bridge-standard, `schema/board.schema.json`) built+//! from `.kicad_pcb` text: the live editor's own serialisation (IPC `GetBoardAsString`) or the file.+//!+//! The rules it keeps, from the standard's `docs/board-model.md`:+//! - millimetres, absolute, y UP: KiCad's frame is y down, so every y is negated (`frame: "kicad"`+//!   keeps KiCad's y). Angles need no change: KiCad measures them counterclockwise on screen, which is+//!   counterclockwise in a y-up frame;+//! - copper layers by KiCad's names, `*.Cu` for a pad on every copper layer;+//! - a custom-shape pad is its bounding rectangle (`shape: "rect"`, rotation 0), never 0 x 0;+//! - net-less copper is kept with an empty net;+//! - the outline as edges with its arcs (center, radius, angle1 to angle2 counterclockwise);+//! - `unconnected` computed from the copper itself;+//! - `revision` changes only with copper, parts or the outline (silkscreen, text and fills do not move it).+//!+//! Nothing here talks to KiCad; `crate::ipc::routing_state` and the file read build on it.++use std::collections::{BTreeMap, BTreeSet, HashMap};++use serde_json::{json, Value};++use crate::model_check::{parse_sexpr, Sx};+use crate::pcb::NAME_BASED_NET_VERSION;++/// Options for one build.+#[derive(Clone, Copy, Debug)]+pub struct Opts {+    /// The standard's frame (y up). `false` keeps KiCad's y-down frame.+    pub y_up: bool,+}++impl Default for Opts {+    fn default() -> Self {+        Opts { y_up: true }+    }+}++type P = (f64, f64);++/// 1 nm resolution: KiCad's own unit, so nothing is rounded away. `+ 0.0` turns -0 into 0.+fn r6(v: f64) -> f64 {+    (v * 1e6).round() / 1e6 + 0.0+}++fn num(sx: &Sx, i: usize) -> Option<f64> {+    sx.atom_at(i).and_then(|a| a.parse::<f64>().ok())+}++/// `(tag x y ...)` as a y-up point.+fn xy_of(sx: Option<&Sx>) -> Option<P> {+    let s = sx?;+    Some((num(s, 1)?, -num(s, 2)?))+}++fn rotate(p: P, deg: f64) -> P {+    let (s, c) = deg.to_radians().sin_cos();+    (p.0 * c - p.1 * s, p.0 * s + p.1 * c)+}++fn add(a: P, b: P) -> P {+    (a.0 + b.0, a.1 + b.1)+}++fn dist(a: P, b: P) -> f64 {+    ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()+}++/// A footprint's placement: local y-up coordinates to board coordinates.+#[derive(Clone, Copy)]+struct Place {+    at: P,+    angle: f64,+}++impl Place {+    const BOARD: Place = Place { at: (0.0, 0.0), angle: 0.0 };+    fn apply(&self, p: P) -> P {+        add(self.at, rotate(p, self.angle))+    }+}++/// The circle through three points: (center, radius), or None when they are collinear.+fn circle3(a: P, b: P, c: P) -> Option<(P, f64)> {+    let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));+    if d.abs() < 1e-12 {+        return None;+    }+    let (a2, b2, c2) = (a.0 * a.0 + a.1 * a.1, b.0 * b.0 + b.1 * b.1, c.0 * c.0 + c.1 * c.1);+    let ux = (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d;+    let uy = (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d;+    Some(((ux, uy), dist((ux, uy), a)))+}++fn angle_deg(c: P, p: P) -> f64 {+    let a = (p.1 - c.1).atan2(p.0 - c.0).to_degrees();+    if a < 0.0 {+        a + 360.0+    } else {+        a+    }+}++/// Counterclockwise sweep from `a1` to `a2`, in (0, 360].+fn ccw_sweep(a1: f64, a2: f64) -> f64 {+    let s = (a2 - a1).rem_euclid(360.0);+    if s < 1e-9 {+        360.0+    } else {+        s+    }+}++/// A three-point arc as (center, radius, angle1, angle2) with angle1 -> angle2 counterclockwise.+#[derive(Clone, Copy, Debug)]+struct Arc3 {+    center: P,+    radius: f64,+    a1: f64,+    a2: f64,+}++fn arc_of(start: P, mid: P, end: P) -> Option<Arc3> {+    let (center, radius) = circle3(start, mid, end)?;+    let (s, m, e) = (angle_deg(center, start), angle_deg(center, mid), angle_deg(center, end));+    // counterclockwise from start through mid to end, or the other way round+    let ccw = ccw_sweep(s, m) < ccw_sweep(s, e);+    let (a1, a2) = if ccw { (s, e) } else { (e, s) };+    Some(Arc3 { center, radius, a1, a2 })+}++impl Arc3 {+    fn point(&self, deg: f64) -> P {+        let r = deg.to_radians();+        (self.center.0 + self.radius * r.cos(), self.center.1 + self.radius * r.sin())+    }+    /// Points every `step` degrees, ends included, counterclockwise.+    fn sample(&self, step: f64) -> Vec<P> {+        let sweep = ccw_sweep(self.a1, self.a2);+        let n = ((sweep / step).ceil() as usize).max(1);+        (0..=n).map(|k| self.point(self.a1 + sweep * k as f64 / n as f64)).collect()+    }+}++/// The points of a `(pts (xy ..) (arc (start)(mid)(end)) ..)` list, arcs sampled every 2 degrees.+fn pts_of(pts: Option<&Sx>) -> Vec<P> {+    let mut out = Vec::new();+    for c in pts.and_then(Sx::as_list).into_iter().flatten() {+        match c.tag() {+            Some("xy") => {+                if let Some(p) = xy_of(Some(c)) {+                    out.push(p);+                }+            }+            Some("arc") => {+                let (Some(s), Some(m), Some(e)) = (xy_of(c.find("start")), xy_of(c.find("mid")), xy_of(c.find("end"))) else { continue };+                match arc_of(s, m, e) {+                    Some(a) => {+                        let mut ps = a.sample(2.0);+                        // keep the arc's own direction (start to end) inside the outline+                        if dist(ps[0], s) > dist(*ps.last().unwrap(), s) {+                            ps.reverse();+                        }+                        out.extend(ps);+                    }+                    None => out.extend([s, e]),+                }+            }+            _ => {}+        }+    }+    out+}++fn shoelace(poly: &[P]) -> f64 {+    let n = poly.len();+    (0..n).map(|k| {+        let (a, b) = (poly[k], poly[(k + 1) % n]);+        a.0 * b.1 - b.0 * a.1+    }).sum::<f64>() / 2.0+}++fn stroke_width(sx: &Sx) -> f64 {+    sx.find("stroke").and_then(|s| s.find("width")).or_else(|| sx.find("width")).and_then(|w| num(w, 1)).unwrap_or(0.0)+}++fn filled(sx: &Sx) -> bool {+    matches!(sx.find("fill").and_then(Sx::value), Some("yes") | Some("solid"))+}++// -- nets and layers ---------------------------------------------------------------------------++struct Ctx {+    name_based: bool,+    nets: HashMap<i64, String>,+    /// copper layers top to bottom+    copper: Vec<String>,+}++impl Ctx {+    fn net(&self, node: &Sx) -> String {+        if let Some(n) = node.find("net_name").and_then(Sx::value) {+            return n.to_string();+        }+        let Some(net) = node.find("net") else { return String::new() };+        if self.name_based {+            return net.value().unwrap_or("").to_string();+        }+        if let Some(name) = net.atom_at(2) {+            return name.to_string();+        }+        match net.value().and_then(|v| v.parse::<i64>().ok()) {+            Some(n) => self.nets.get(&n).cloned().unwrap_or_default(),+            None => net.value().unwrap_or("").to_string(),+        }+    }++    fn is_copper(&self, layer: &str) -> bool {+        layer.ends_with(".Cu") && (layer == "*.Cu" || layer == "F&B.Cu" || self.copper.iter().any(|l| l == layer) || layer.starts_with("In"))+    }++    fn index(&self, layer: &str) -> Option<usize> {+        self.copper.iter().position(|l| l == layer)+    }++    /// A list of layer names as the copper layers it covers, top to bottom.+    fn expand(&self, layers: &[String]) -> Vec<String> {+        let mut out: Vec<String> = Vec::new();+        for l in layers {+            if l == "*.Cu" {+                return self.copper.clone();+            }+            if l == "F&B.Cu" {+                for x in [self.copper.first(), self.copper.last()].into_iter().flatten() {+                    if !out.contains(x) {+                        out.push(x.clone());+                    }+                }+            } else if self.is_copper(l) && !out.contains(l) {+                out.push(l.clone());+            }+        }+        out.sort_by_key(|l| self.index(l).unwrap_or(usize::MAX));+        out+    }+}++fn layers_of(sx: &Sx) -> Vec<String> {+    if let Some(l) = sx.find("layers") {+        return l.as_list().unwrap_or(&[]).iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect();+    }+    sx.find("layer").and_then(Sx::value).map(|l| vec![l.to_string()]).unwrap_or_default()+}++fn board_ctx(root: &Sx) -> Ctx {+    let version = root.find("version").and_then(|v| num(v, 1)).unwrap_or(0.0) as i64;+    let mut nets = HashMap::new();+    for n in root.find_all("net") {+        if let (Some(k), Some(name)) = (n.value().and_then(|v| v.parse::<i64>().ok()), n.atom_at(2)) {+            nets.insert(k, name.to_string());+        }+    }+    let mut copper: Vec<(i64, String)> = Vec::new();+    if let Some(ls) = root.find("layers").and_then(Sx::as_list) {+        for l in ls.iter().skip(1) {+            let (Some(id), Some(name)) = (l.tag().and_then(|t| t.parse::<i64>().ok()), l.atom_at(1)) else { continue };+            if name.ends_with(".Cu") {+                copper.push((id, name.to_string()));+            }+        }+    }+    // top to bottom: F.Cu, In1.Cu .. In30.Cu, B.Cu, whatever ids the file version uses+    let rank = |n: &str| -> i64 {+        match n {+            "F.Cu" => 0,+            "B.Cu" => 1000,+            _ => n.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|r| r.parse::<i64>().ok()).unwrap_or(999),+        }+    };+    copper.sort_by_key(|(_, n)| rank(n));+    Ctx { name_based: version >= NAME_BASED_NET_VERSION, nets, copper: copper.into_iter().map(|(_, n)| n).collect() }+}++// -- the model, in the y-up frame ----------------------------------------------------------------++#[derive(Clone, Debug)]+struct Track {+    a: P,+    b: P,+    width: f64,+    layer: String,+    net: String,+    owner: Option<String>,+}++#[derive(Clone, Debug)]+struct ArcT {+    arc: Arc3,+    width: f64,+    layer: String,+    net: String,+    owner: Option<String>,+}++#[derive(Clone, Debug)]+struct Via {+    at: P,+    size: f64,+    drill: f64,+    layers: Vec<String>,+    net: String,+    kind: String,+}++#[derive(Clone, Debug)]+struct Pad {+    owner: String,+    name: String,+    at: P,+    size: P,+    rotation: f64,+    layer: String,+    copper: Vec<String>,+    drill: f64,+    net: String,+    shape: &'static str,+    kicad_shape: String,+    kind: String,+    anchor: Option<P>,+    /// a custom pad's real copper outline (board coordinates), when it is one drawn polygon over the anchor+    copper_points: Option<Vec<P>>,+}++#[derive(Clone, Debug)]+struct Comp {+    reference: String,+    value: String,+    at: P,+    rotation: f64,+    bottom: bool,+    footprint: String,+    has3d: bool,+    attrs: Vec<String>,+}++#[derive(Clone, Debug)]+struct Poly {+    net: String,+    layer: String,+    points: Vec<P>,+    name: Option<String>,+    priority: i64,+    hatched: bool,+    /// the copper KiCad poured on this layer, each island's outline (holes slit in)+    fills: Vec<Vec<P>>,+    owner: Option<String>,+    graphic: bool,+}++#[derive(Clone, Debug)]+struct Keepout {+    layers: Vec<String>,+    points: Vec<P>,+    rules: BTreeMap<String, String>,+    owner: Option<String>,+}++#[derive(Clone, Debug)]+enum EdgeKind {+    Line,+    Arc(Arc3),+}++#[derive(Clone, Debug)]+struct Edge {+    a: P,+    b: P,+    kind: EdgeKind,+}++struct Model {+    tracks: Vec<Track>,+    arcs: Vec<ArcT>,+    vias: Vec<Via>,+    pads: Vec<Pad>,+    comps: Vec<Comp>,+    polys: Vec<Poly>,+    keepouts: Vec<Keepout>,+    /// loops of edges; [0] is the outer outline when there is one+    loops: Vec<Vec<Edge>>,+    loose_edges: usize,+    copper: Vec<String>,+}++/// The board model as JSON (the standard's shape) from `.kicad_pcb` text.+pub fn build(text: &str, file: Option<&str>, opts: &Opts) -> Result<Value, String> {+    let (root, repaired) = parse_board(text)?;+    let m = model(&root);+    let revision = revision(&m);+    let mut v = to_json(&m, file, &revision, opts);+    if repaired > 0 {+        v["repairedOnRead"] = json!({"teardropFilterRatio": repaired, "why": "KiCad 9 wrote (curved_edges no)filter_ratio 0.9) without the opening parenthesis; KiCad's own reader accepts it, so this read does too. Saving the board in KiCad 10 rewrites it correctly."});+    }+    Ok(v)+}++/// The standard board model of a `.kicad_pcb` on disk (KiCad need not run; unsaved edits in an open+/// editor are not in the file).+pub fn read_file(path: &str, opts: &Opts) -> Result<Value, String> {+    let text = std::fs::read_to_string(path).map_err(|e| format!("could not read {path}: {e}"))?;+    build(&text, Some(path), opts)+}++/// The standard revision of a board text: a hash of its copper, parts and outline.+pub fn revision_of_text(text: &str) -> Result<String, String> {+    Ok(revision(&model(&parse_board(text)?.0)))+}++/// Where the first top-level list closes: (byte offset just past it, line), quotes respected.+fn root_end(text: &str) -> Option<(usize, usize)> {+    let (mut depth, mut quoted, mut escaped, mut line) = (0i64, false, false, 1usize);+    for (i, c) in text.char_indices() {+        if c == '\n' {+            line += 1;+        }+        if quoted {+            if escaped { escaped = false } else if c == '\\' { escaped = true } else if c == '"' { quoted = false }+            continue;+        }+        match c {+            '"' => quoted = true,+            '(' => depth += 1,+            ')' => {+                depth -= 1;+                if depth == 0 {+                    return Some((i + 1, line));+                }+            }+            _ => {}+        }+    }+    None+}++/// Parse a board, refusing text whose root list closes before the end of the file (the rest would be+/// silently dropped). One known KiCad 9 writer bug is repaired first; returns how many were.+fn parse_board(text: &str) -> Result<(Sx, usize), String> {+    let early = |t: &str| root_end(t).filter(|(end, _)| !t[*end..].trim().is_empty());+    let mut owned = None;+    let mut repaired = 0;+    if early(text).is_some() && text.contains(")filter_ratio ") {+        repaired = text.matches(")filter_ratio ").count();+        owned = Some(text.replace(")filter_ratio ", ")(filter_ratio "));+    }+    let t = owned.as_deref().unwrap_or(text);+    if let Some((_, line)) = early(t) {+        return Err(format!("the board's parentheses close the kicad_pcb list at line {line}, before the end of the file: the file is damaged (open and save it in KiCad, or restore a backup)"));+    }+    let root = parse_sexpr(t).ok_or("empty board text")?;+    if root.tag() != Some("kicad_pcb") {+        return Err("not a .kicad_pcb (no kicad_pcb root)".into());+    }+    Ok((root, repaired))+}++fn model(root: &Sx) -> Model {+    let cx = board_ctx(root);+    let mut m = Model { tracks: vec![], arcs: vec![], vias: vec![], pads: vec![], comps: vec![], polys: vec![], keepouts: vec![], loops: vec![], loose_edges: 0, copper: cx.copper.clone() };+    let mut edges: Vec<Edge> = Vec::new();+    for item in root.as_list().unwrap_or(&[]).iter().skip(1) {+        match item.tag() {+            Some("segment") => {+                let (Some(a), Some(b)) = (xy_of(item.find("start")), xy_of(item.find("end"))) else { continue };+                let layer = item.find("layer").and_then(Sx::value).unwrap_or("").to_string();+                m.tracks.push(Track { a, b, width: item.find("width").and_then(|w| num(w, 1)).unwrap_or(0.0), layer, net: cx.net(item), owner: None });+            }+            Some("arc") => {+                let (Some(s), Some(mid), Some(e)) = (xy_of(item.find("start")), xy_of(item.find("mid")), xy_of(item.find("end"))) else { continue };+                let width = item.find("width").and_then(|w| num(w, 1)).unwrap_or(0.0);+                let layer = item.find("layer").and_then(Sx::value).unwrap_or("").to_string();+                match arc_of(s, mid, e) {+                    Some(arc) => m.arcs.push(ArcT { arc, width, layer, net: cx.net(item), owner: None }),+                    None => m.tracks.push(Track { a: s, b: e, width, layer, net: cx.net(item), owner: None }),+                }+            }+            Some("via") => {+                let Some(at) = xy_of(item.find("at")) else { continue };+                let kind = item.atom_at(1).filter(|k| matches!(*k, "blind" | "micro" | "through" | "buried")).unwrap_or("through").to_string();+                let layers = cx.expand(&layers_of(item));+                let span = match (layers.first(), layers.last()) {+                    (Some(a), Some(b)) => vec![a.clone(), b.clone()],+                    _ => vec![cx.copper.first().cloned().unwrap_or("F.Cu".into()), cx.copper.last().cloned().unwrap_or("B.Cu".into())],+                };+                m.vias.push(Via { at, size: item.find("size").and_then(|w| num(w, 1)).unwrap_or(0.0), drill: item.find("drill").and_then(|w| num(w, 1)).unwrap_or(0.0), layers: span, net: cx.net(item), kind });+            }+            Some("footprint") | Some("module") => footprint(&cx, item, &mut m, &mut edges),+            Some("zone") => zone(&cx, item, None, &mut m),+            Some(t) if t.starts_with("gr_") => graphic(&cx, item, Place::BOARD, None, &mut m, &mut edges),+            _ => {}+        }+    }+    let (loops, loose) = chain(edges);+    m.loops = loops;+    m.loose_edges = loose;+    m+}++fn footprint(cx: &Ctx, fp: &Sx, m: &mut Model, edges: &mut Vec<Edge>) {+    let at = fp.find("at");+    let place = Place { at: xy_of(at).unwrap_or((0.0, 0.0)), angle: at.and_then(|a| num(a, 3)).unwrap_or(0.0) };+    let layer = fp.find("layer").and_then(Sx::value).unwrap_or("F.Cu");+    let reference = fp.find_all("property").find(|p| p.value() == Some("Reference")).and_then(|p| p.atom_at(2))+        .or_else(|| fp.find_all("fp_text").find(|t| t.value() == Some("reference")).and_then(|t| t.atom_at(2)))+        .unwrap_or("").to_string();+    let value = fp.find_all("property").find(|p| p.value() == Some("Value")).and_then(|p| p.atom_at(2))+        .or_else(|| fp.find_all("fp_text").find(|t| t.value() == Some("value")).and_then(|t| t.atom_at(2)))+        .unwrap_or("").to_string();+    // a model counts unless it is hidden: (hide yes) since KiCad 8, a bare `hide` atom before+    let has3d = fp.find_all("model").any(|md| {+        let hidden = md.find("hide").map(|h| h.value() != Some("no")).unwrap_or(false) || md.as_list().unwrap_or(&[]).iter().any(|a| a.as_atom() == Some("hide"));+        !hidden+    });+    let attrs = fp.find("attr").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default();+    m.comps.push(Comp { reference: reference.clone(), value, at: place.at, rotation: place.angle, bottom: layer == "B.Cu", footprint: fp.value().unwrap_or("").to_string(), has3d, attrs });+    for pad in fp.find_all("pad") {+        if let Some(p) = pad_of(cx, pad, &place, &reference) {+            m.pads.push(p);+        }+    }+    for c in fp.as_list().unwrap_or(&[]).iter() {+        match c.tag() {+            Some(t) if t.starts_with("fp_") && t != "fp_text" => graphic(cx, c, place, Some(&reference), m, edges),+            Some("zone") => zone(cx, c, Some(&reference), m),+            _ => {}+        }+    }+}++fn pad_of(cx: &Ctx, pad: &Sx, place: &Place, owner: &str) -> Option<Pad> {+    let name = pad.value().unwrap_or("").to_string();+    let kind = pad.atom_at(2).unwrap_or("").to_string();+    let kshape = pad.atom_at(3).unwrap_or("").to_string();+    let at = pad.find("at")?;+    let local = xy_of(Some(at))?;+    // a pad's angle in the board file is absolute (footprint angle included)+    let angle = num(at, 3).unwrap_or(0.0);+    let center = place.apply(local);+    let size = pad.find("size").map(|s| (num(s, 1).unwrap_or(0.0), num(s, 2).unwrap_or(0.0))).unwrap_or((0.0, 0.0));+    let drill = pad.find("drill").map(|d| {+        // (drill d) or (drill oval w h): the smaller is the hole's width+        let vals: Vec<f64> = d.as_list().unwrap_or(&[]).iter().skip(1).filter_map(Sx::as_atom).filter_map(|a| a.parse().ok()).collect();+        vals.iter().cloned().fold(f64::MAX, f64::min)+    }).filter(|d| *d < f64::MAX).unwrap_or(0.0);+    let copper = cx.expand(&layers_of(pad));+    let npth = kind == "np_thru_hole";+    if copper.is_empty() && !npth {+        return None;+    }+    let layer = if copper.len() > 1 || npth || layers_of(pad).iter().any(|l| l == "*.Cu") { "*.Cu".to_string() } else { copper[0].clone() };+    let copper = if npth && copper.is_empty() { cx.copper.clone() } else { copper };+    let net = if npth { String::new() } else { cx.net(pad) };+    let base = Pad { owner: owner.to_string(), name, at: center, size, rotation: angle, layer, copper, drill, net, shape: "rect", kicad_shape: kshape.clone(), kind, anchor: None, copper_points: None };+    Some(match kshape.as_str() {+        "circle" => Pad { shape: "circle", ..base },+        "oval" => Pad { shape: "round", ..base },+        "roundrect" => Pad { shape: "roundrect", ..base },+        "trapezoid" => {+            // its widest extent: the size plus the delta on each side+            let d = pad.find("rect_delta").map(|r| (num(r, 1).unwrap_or(0.0).abs(), num(r, 2).unwrap_or(0.0).abs())).unwrap_or((0.0, 0.0));+            Pad { size: (size.0 + d.1, size.1 + d.0), ..base }+        }+        "custom" => custom_pad(pad, base, center, size, angle),+        _ => base,+    })+}++/// A custom pad as its bounding rectangle: the anchor and every primitive, rotated with the pad.+fn custom_pad(pad: &Sx, base: Pad, center: P, size: P, angle: f64) -> Pad {+    let anchor_round = pad.find("options").and_then(|o| o.find("anchor")).and_then(Sx::value) == Some("circle");+    let (hx, hy) = (size.0 / 2.0, size.1 / 2.0);+    let mut local: Vec<P> = if anchor_round {+        (0..16).map(|k| { let a = (k as f64 * 22.5f64).to_radians(); (hx * a.cos(), hx * a.sin()) }).collect()+    } else {+        vec![(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)]+    };+    let mut grow: f64 = 0.0;+    for prim in pad.find("primitives").and_then(Sx::as_list).into_iter().flatten().skip(1) {+        let w = stroke_width(prim);+        grow = grow.max(w / 2.0);+        match prim.tag() {+            Some("gr_poly") => local.extend(pts_of(prim.find("pts"))),+            Some("gr_line") | Some("gr_rect") => local.extend([xy_of(prim.find("start")), xy_of(prim.find("end"))].into_iter().flatten()),+            Some("gr_circle") => {+                if let (Some(c), Some(e)) = (xy_of(prim.find("center")), xy_of(prim.find("end"))) {+                    let r = dist(c, e);+                    local.extend([(c.0 - r, c.1 - r), (c.0 + r, c.1 + r), (c.0 - r, c.1 + r), (c.0 + r, c.1 - r)]);+                }+            }+            Some("gr_arc") => {+                if let (Some(s), Some(mid), Some(e)) = (xy_of(prim.find("start")), xy_of(prim.find("mid")), xy_of(prim.find("end"))) {+                    match arc_of(s, mid, e) { Some(a) => local.extend(a.sample(2.0)), None => local.extend([s, e]) }+                }+            }+            Some("gr_curve") | Some("gr_bbox") => local.extend(pts_of(prim.find("pts"))),+            _ => {}+        }+    }+    // the real outline, when it is exact: one filled polygon primitive that covers the anchor (the usual+    // custom pad); several shapes would need a union, so they keep only the rectangle+    let polys: Vec<&Sx> = pad.find("primitives").and_then(Sx::as_list).into_iter().flatten().skip(1).collect();+    let copper_points = match polys.as_slice() {+        [one] if one.tag() == Some("gr_poly") && (filled(one) || one.find("fill").is_none()) => {+            let pts = pts_of(one.find("pts"));+            let anchor = if anchor_round { vec![(0.0, 0.0)] } else { vec![(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)] };+            let covers = pts.len() >= 3 && anchor.iter().all(|a| in_poly(*a, &pts) || pts.iter().enumerate().any(|(k, q)| dist_seg(*a, *q, pts[(k + 1) % pts.len()]) < 1e-6));+            covers.then(|| pts.iter().map(|q| add(center, rotate(*q, angle))).collect())+        }+        _ => None,+    };+    let world: Vec<P> = local.iter().map(|p| add(center, rotate(*p, angle))).collect();+    let (x0, x1) = world.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (y0, y1) = world.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    Pad {+        at: ((x0 + x1) / 2.0, (y0 + y1) / 2.0),+        size: (x1 - x0 + 2.0 * grow, y1 - y0 + 2.0 * grow),+        rotation: 0.0,+        shape: "rect",+        anchor: Some(center),+        copper_points,+        ..base+    }+}++fn zone(cx: &Ctx, z: &Sx, owner: Option<&str>, m: &mut Model) {+    let layers = layers_of(z);+    let points = pts_of(z.find("polygon").and_then(|p| p.find("pts")));+    if let Some(k) = z.find("keepout") {+        let rules = k.as_list().unwrap_or(&[]).iter().skip(1).filter_map(|r| Some((r.tag()?.to_string(), r.value()?.to_string()))).collect();+        m.keepouts.push(Keepout { layers, points, rules, owner: owner.map(str::to_string) });+        return;+    }+    let copper = cx.expand(&layers);+    if copper.is_empty() {+        return;+    }+    let net = cx.net(z);+    let name = z.find("name").and_then(Sx::value).map(str::to_string);+    let priority = z.find("priority").and_then(|p| num(p, 1)).unwrap_or(0.0) as i64;+    let hatched = z.find("fill").and_then(|f| f.find("mode")).and_then(Sx::value) == Some("hatch");+    let mut fills: HashMap<String, Vec<Vec<P>>> = HashMap::new();+    for f in z.find_all("filled_polygon") {+        let layer = f.find("layer").and_then(Sx::value).map(str::to_string).unwrap_or_else(|| copper[0].clone());+        let pts = pts_of(f.find("pts"));+        if pts.len() >= 3 {+            fills.entry(layer).or_default().push(pts);+        }+    }+    for layer in copper {+        let f = fills.remove(&layer).unwrap_or_default();+        m.polys.push(Poly { net: net.clone(), layer, points: points.clone(), name: name.clone(), priority, hatched, fills: f, owner: owner.map(str::to_string), graphic: false });+    }+}++/// A board (`gr_*`) or footprint (`fp_*`) graphic: on a copper layer it is copper (a track, an arc+/// or a filled polygon); on Edge.Cuts it is part of the outline.+fn graphic(cx: &Ctx, g: &Sx, place: Place, owner: Option<&str>, m: &mut Model, edges: &mut Vec<Edge>) {+    let layer = g.find("layer").and_then(Sx::value).unwrap_or("");+    let edge = layer == "Edge.Cuts";+    if !edge && !cx.is_copper(layer) {+        return;+    }+    let kind = g.tag().unwrap_or("")[3..].to_string();+    let p = |tag: &str| xy_of(g.find(tag)).map(|v| place.apply(v));+    let width = stroke_width(g);+    let net = cx.net(g);+    let owner_s = owner.map(str::to_string);+    let mut lines: Vec<(P, P)> = Vec::new();+    let mut arcs: Vec<Arc3> = Vec::new();+    let mut poly: Option<Vec<P>> = None;+    match kind.as_str() {+        "line" => {+            if let (Some(a), Some(b)) = (p("start"), p("end")) {+                lines.push((a, b));+            }+        }+        "arc" => {+            if let (Some(s), Some(mid), Some(e)) = (p("start"), p("mid"), p("end")) {+                match arc_of(s, mid, e) { Some(a) => arcs.push(a), None => lines.push((s, e)) }+            } else if let (Some(c), Some(s), Some(sweep)) = (p("start"), p("end"), g.find("angle").and_then(|a| num(a, 1))) {+                // KiCad 5: start is the centre, end the arc's start, angle the sweep (clockwise on screen)+                let r = dist(c, s);+                let a0 = angle_deg(c, s);+                let a1 = a0 - sweep;+                let (lo, hi) = if sweep >= 0.0 { (a1, a0) } else { (a0, a1) };+                arcs.push(Arc3 { center: c, radius: r, a1: lo.rem_euclid(360.0), a2: hi.rem_euclid(360.0) });+            }+        }+        "circle" => {+            if let (Some(c), Some(e)) = (p("center"), p("end")) {+                let r = dist(c, e);+                if filled(g) && !edge {+                    poly = Some((0..180).map(|k| { let a = (k as f64 * 2.0).to_radians(); (c.0 + r * a.cos(), c.1 + r * a.sin()) }).collect());+                } else {+                    arcs.push(Arc3 { center: c, radius: r, a1: 0.0, a2: 360.0 });+                }+            }+        }+        "rect" => {+            if let (Some(s), Some(e)) = (xy_of(g.find("start")), xy_of(g.find("end"))) {+                let corners: Vec<P> = [s, (e.0, s.1), e, (s.0, e.1)].iter().map(|q| place.apply(*q)).collect();+                if filled(g) && !edge {+                    poly = Some(corners);+                } else {+                    for k in 0..4 {+                        lines.push((corners[k], corners[(k + 1) % 4]));+                    }+                }+            }+        }+        "poly" => {+            let pts: Vec<P> = pts_of(g.find("pts")).into_iter().map(|q| place.apply(q)).collect();+            if pts.len() >= 3 && filled(g) && !edge {+                poly = Some(pts);+            } else {+                for k in 0..pts.len() {+                    // an outline polygon closes itself; a copper one does too+                    if pts.len() >= 3 || k + 1 < pts.len() {+                        lines.push((pts[k], pts[(k + 1) % pts.len()]));+                    }+                }+            }+        }+        "curve" => {+            let c: Vec<P> = pts_of(g.find("pts")).into_iter().map(|q| place.apply(q)).collect();+            if c.len() == 4 {+                let bez = |t: f64| {+                    let u = 1.0 - t;+                    let k = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];+                    (c.iter().zip(k).map(|(p, w)| p.0 * w).sum(), c.iter().zip(k).map(|(p, w)| p.1 * w).sum())+                };+                let pts: Vec<P> = (0..=32).map(|k| bez(k as f64 / 32.0)).collect();+                for w in pts.windows(2) {+                    lines.push((w[0], w[1]));+                }+            }+        }+        _ => {}+    }+    if edge {+        for (a, b) in lines {+            edges.push(Edge { a, b, kind: EdgeKind::Line });+        }+        for a in arcs {+            let full = ccw_sweep(a.a1, a.a2) >= 360.0 - 1e-9;+            let (s, e) = (a.point(a.a1), a.point(if full { a.a1 } else { a.a2 }));+            edges.push(Edge { a: s, b: e, kind: EdgeKind::Arc(a) });+        }+        return;+    }+    for (a, b) in lines {+        m.tracks.push(Track { a, b, width, layer: layer.to_string(), net: net.clone(), owner: owner_s.clone() });+    }+    for a in arcs {+        m.arcs.push(ArcT { arc: a, width, layer: layer.to_string(), net: net.clone(), owner: owner_s.clone() });+    }+    if let Some(points) = poly {+        m.polys.push(Poly { net, layer: layer.to_string(), fills: vec![points.clone()], points, name: None, priority: 0, hatched: false, owner: owner_s, graphic: true });+    }+}++/// Chain the Edge.Cuts edges into closed loops. The loop of largest area comes first (the outline);+/// the others are cutouts. Edges that close no loop are counted, not guessed.+fn chain(mut edges: Vec<Edge>) -> (Vec<Vec<Edge>>, usize) {+    const TOL: f64 = 0.01;+    let mut loops: Vec<Vec<Edge>> = Vec::new();+    let mut loose = 0;+    while let Some(first) = edges.pop() {+        if dist(first.a, first.b) < 1e-9 {+            // a full circle closes on itself+            loops.push(vec![first]);+            continue;+        }+        let mut lp = vec![first];+        loop {+            let tail = lp.last().unwrap().b;+            if dist(tail, lp[0].a) < TOL && lp.len() > 1 {+                break;+            }+            let next = edges.iter().enumerate()+                .map(|(i, e)| (i, dist(e.a, tail).min(dist(e.b, tail))))+                .filter(|(_, d)| *d < TOL)+                .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())+                .map(|(i, _)| i);+            let Some(i) = next else { break };+            let mut e = edges.swap_remove(i);+            if dist(e.a, tail) > dist(e.b, tail) {+                std::mem::swap(&mut e.a, &mut e.b);+            }+            lp.push(e);+        }+        let closed = lp.len() > 1 && dist(lp.last().unwrap().b, lp[0].a) < TOL;+        if closed {+            loops.push(lp);+        } else {+            loose += lp.len();+        }+    }+    let area = |lp: &Vec<Edge>| shoelace(&ring(lp)).abs();+    loops.sort_by(|a, b| area(b).partial_cmp(&area(a)).unwrap_or(std::cmp::Ordering::Equal));+    // the outline counterclockwise, as the y-up frame reads it, starting at its lowest-left vertex: the+    // same loop whatever order the file lists its pieces in (KiCad's live serialisation and the saved+    // file differ)+    for lp in loops.iter_mut() {+        if shoelace(&ring(lp)) < 0.0 {+            lp.reverse();+            for e in lp.iter_mut() {+                std::mem::swap(&mut e.a, &mut e.b);+            }+        }+        let key = |e: &Edge| ((e.a.0 * 1e4).round() as i64, (e.a.1 * 1e4).round() as i64);+        if let Some(first) = (0..lp.len()).min_by_key(|i| key(&lp[*i])) {+            lp.rotate_left(first);+        }+    }+    (loops, loose)+}++/// A loop as a polygon, arcs sampled every 2 degrees in the loop's direction.+fn ring(lp: &[Edge]) -> Vec<P> {+    let mut out = Vec::new();+    for e in lp {+        match &e.kind {+            EdgeKind::Line => out.push(e.a),+            EdgeKind::Arc(a) => {+                let mut ps = a.sample(2.0);+                if dist(ps[0], e.a) > dist(*ps.last().unwrap(), e.a) {+                    ps.reverse();+                }+                ps.pop();+                out.extend(ps);+            }+        }+    }+    out+}++// -- companions: stack-up and poured copper ----------------------------------------------------++/// KiCad's own defaults when a board has no stack-up: what its Board Setup shows for a new board.+const DEFAULT_COPPER_MM: f64 = 0.035;+const DEFAULT_THICKNESS_MM: f64 = 1.6;+const DEFAULT_DK: f64 = 4.5;++/// The stack-up (the standard's `layer_stack`): the copper layers top to bottom, each with the+/// dielectric BELOW it (thickness, Dk, material), the masks and the board thickness. A board saved+/// without a stack-up gets KiCad's defaults, said so (`fromDefaults`).+pub fn stackup(text: &str) -> Result<Value, String> {+    let (root, _) = parse_board(text)?;+    let cx = board_ctx(&root);+    let m = model(&root);+    // KiCad's layer type per copper layer (signal, power, mixed, jumper) and its user name+    let mut kinds: HashMap<String, (String, Option<String>)> = HashMap::new();+    for l in root.find("layers").and_then(Sx::as_list).into_iter().flatten().skip(1) {+        if let (Some(name), Some(kind)) = (l.atom_at(1), l.atom_at(2)) {+            kinds.insert(name.to_string(), (kind.to_string(), l.atom_at(3).map(str::to_string)));+        }+    }+    let general_mm = root.find("general").and_then(|g| g.find("thickness")).and_then(|t| num(t, 1));+    let stack = root.find("setup").and_then(|s| s.find("stackup"));+    // (name, type, thickness mm, material, Dk) in board order+    let mut rows: Vec<(String, String, f64, Option<String>, Option<f64>)> = Vec::new();+    for l in stack.and_then(Sx::as_list).into_iter().flatten().filter(|c| c.tag() == Some("layer")) {+        let name = l.value().unwrap_or("").to_string();+        let kind = l.find("type").and_then(Sx::value).unwrap_or("").to_string();+        // a dielectric with sublayers: (thickness a) (material ..) (epsilon_r ..) addsublayer (thickness b) ..+        let items = l.as_list().unwrap_or(&[]);+        let (mut total, mut dk_sum, mut mats): (f64, f64, Vec<String>) = (0.0, 0.0, Vec::new());+        let any_dk = items.iter().any(|it| it.tag() == Some("epsilon_r"));+        let mut cur_t: Option<f64> = None;+        let mut cur_dk: Option<f64> = None;+        let flush = |t: Option<f64>, dk: Option<f64>, total: &mut f64, dk_sum: &mut f64| {+            if let Some(t) = t {+                *total += t;+                *dk_sum += t * dk.unwrap_or(DEFAULT_DK);+            }+        };+        for it in items.iter().skip(2) {+            match (it.tag(), it.as_atom()) {+                (Some("thickness"), _) => cur_t = num(it, 1),+                (Some("epsilon_r"), _) => cur_dk = num(it, 1),+                (Some("material"), _) => { if let Some(mt) = it.value() { if !mats.iter().any(|x| x == mt) { mats.push(mt.to_string()) } } }+                (_, Some("addsublayer")) => { flush(cur_t.take(), cur_dk.take(), &mut total, &mut dk_sum); }+                _ => {}+            }+        }+        flush(cur_t, cur_dk, &mut total, &mut dk_sum);+        // a dielectric without a Dk gets FR4's; a mask only says one when the file does+        let dk = if total > 0.0 && (kind == "core" || kind == "prepreg" || kind.contains("Mask") && any_dk) { Some(dk_sum / total) } else { None };+        rows.push((name, kind, total, if mats.is_empty() { None } else { Some(mats.join(" + ")) }, dk));+    }+    let from_defaults = !rows.iter().any(|r| r.1 == "copper");+    if from_defaults {+        // KiCad's default build: copper of 35 um, the core shared out evenly between the copper layers+        let n = cx.copper.len().max(2);+        let thick = general_mm.unwrap_or(DEFAULT_THICKNESS_MM);+        let diel = (thick - n as f64 * DEFAULT_COPPER_MM) / (n - 1) as f64;+        rows.clear();+        for (i, l) in cx.copper.iter().enumerate() {+            rows.push((l.clone(), "copper".into(), DEFAULT_COPPER_MM, None, None));+            if i + 1 < cx.copper.len() {+                rows.push((format!("dielectric {}", i + 1), if i == 0 { "core".into() } else { "prepreg".into() }, diel, Some("FR4".into()), Some(DEFAULT_DK)));+            }+        }+    }+    // the plane's net: the net of the most pour area on that layer+    let plane_net = |layer: &str| -> Option<String> {+        let mut by: BTreeMap<&str, f64> = BTreeMap::new();+        for g in m.polys.iter().filter(|g| g.layer == layer && !g.net.is_empty()) {+            *by.entry(&g.net).or_default() += shoelace(&g.points).abs();+        }+        by.into_iter().max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|(n, _)| n.to_string())+    };+    let mut layers = Vec::new();+    let (mut planes, mut mids) = (0, 0);+    let copper_rows: Vec<usize> = rows.iter().enumerate().filter(|(_, r)| r.1 == "copper").map(|(i, _)| i).collect();+    for (k, &i) in copper_rows.iter().enumerate() {+        let (name, _, t, _, _) = &rows[i];+        let (ktype, user) = kinds.get(name).cloned().unwrap_or(("signal".into(), None));+        let plane = ktype == "power";+        let mut j = json!({"name": name, "kind": if plane { "plane" } else { "signal" }, "kicadType": ktype, "copperUm": r6(t * 1000.0)});+        if let Some(u) = user {+            j["userName"] = json!(u);+        }+        if name.starts_with("In") {+            if plane {+                planes += 1;+                j["plane"] = json!(planes);+            } else {+                mids += 1;+                j["mid"] = json!(name.trim_start_matches("In").trim_end_matches(".Cu").parse::<i64>().unwrap_or(mids));+            }+        }+        if plane {+            if let Some(n) = plane_net(name) {+                j["net"] = json!(n);+            }+        }+        // the dielectric between this copper and the next: every non-copper row in between+        if let Some(&next) = copper_rows.get(k + 1) {+            let diel: Vec<&(String, String, f64, Option<String>, Option<f64>)> = rows[i + 1..next].iter().filter(|r| r.2 > 0.0).collect();+            let um: f64 = diel.iter().map(|r| r.2).sum::<f64>() * 1000.0;+            let dk = if um > 0.0 { diel.iter().map(|r| r.2 * r.4.unwrap_or(DEFAULT_DK)).sum::<f64>() * 1000.0 / um } else { DEFAULT_DK };+            j["dielectricUm"] = json!(r6(um));+            j["dk1000"] = json!((dk * 1000.0).round());+            let mats: Vec<String> = diel.iter().filter_map(|r| r.3.clone()).collect();+            if !mats.is_empty() {+                j["material"] = json!(mats.join(" / "));+            }+            j["dielectricKinds"] = json!(diel.iter().map(|r| r.1.clone()).collect::<Vec<_>>());+        }+        layers.push(j);+    }+    let sum_mm: f64 = rows.iter().map(|r| r.2).sum();+    let mask = |side: &str| rows.iter().find(|r| r.1 == format!("{side} Solder Mask")).map(|r| json!({"um": r6(r.2 * 1000.0), "dk1000": r.4.map(|d| (d * 1000.0).round())}));+    let finish = stack.and_then(|s| s.find("copper_finish")).and_then(Sx::value);+    Ok(json!({+        "copperLayers": layers.len(),+        "totalUm": r6(general_mm.unwrap_or(sum_mm) * 1000.0),+        "stackSumUm": r6(sum_mm * 1000.0),+        "layers": layers,+        "masks": {"top": mask("Top"), "bottom": mask("Bottom")},+        "copperFinish": finish,+        "fromDefaults": from_defaults,+    }))+}++/// Poured copper (the standard's `pour_state`): every zone on every copper layer with the copper+/// KiCad actually poured. KiCad stores each island with its holes joined to the outline by+/// zero-width cuts (`fractured`): its area and a point-in-polygon test are exact as they stand.+pub fn pours(text: &str, opts: &Opts, geometry: bool) -> Result<Value, String> {+    let (root, _) = parse_board(text)?;+    let m = model(&root);+    let mut by_layer: BTreeMap<String, f64> = BTreeMap::new();+    let mut zones = Vec::new();+    let mut unfilled = Vec::new();+    for (k, g) in m.polys.iter().enumerate().filter(|(_, g)| !g.graphic) {+        let area: f64 = g.fills.iter().map(|f| shoelace(f).abs()).sum();+        *by_layer.entry(g.layer.clone()).or_default() += area;+        let mut z = json!({"net": g.net, "name": g.name, "layer": g.layer, "pourIndex": k, "priority": g.priority,+            "filledMm2": r6(area), "filled": !g.fills.is_empty(), "islands": g.fills.len(), "hatched": g.hatched});+        if let Some(o) = &g.owner {+            z["ref"] = json!(o);+        }+        if geometry {+            z["outline"] = json!(g.points.iter().map(|p| pa(*p, opts)).collect::<Vec<_>>());+            z["fills"] = json!(g.fills.iter().map(|f| json!({"outline": f.iter().map(|p| pa(*p, opts)).collect::<Vec<_>>(), "holes": [], "fractured": true})).collect::<Vec<_>>());+        }+        if g.fills.is_empty() {+            unfilled.push(json!({"net": g.net, "name": g.name, "layer": g.layer, "pourIndex": k}));+        }+        zones.push(z);+    }+    Ok(json!({+        "zones": zones, "unfilled": unfilled,+        "filledAreaByLayerMm2": by_layer.into_iter().map(|(l, a)| (l, json!(r6(a)))).collect::<serde_json::Map<String, Value>>(),+        "revision": revision(&m),+    }))+}++// -- core reads derived from the model ----------------------------------------------------------++/// The standard's core read verbs that are views of the board model (`board_summary`, `list_nets`,+/// `list_designators`, `copper_layers`, `layers_in_use`, `component_data`, `component_pins`), from a+/// model `build` returned. Positions in mm in the model's frame (Altium answers mils; the standard's+/// conventions are mm).+pub fn derive(model: &Value, view: &str, args: &Value) -> Result<Value, (String, String)> {+    let arr = |k: &str| model[k].as_array().cloned().unwrap_or_default();+    let (pads, comps) = (arr("pads"), arr("components"));+    let f = |v: &Value| v.as_f64().unwrap_or(0.0);+    let copper: Vec<String> = model["copperLayers"].as_array().into_iter().flatten().filter_map(|l| l.as_str().map(str::to_string)).collect();+    // copper pieces per layer: tracks, arcs, vias (every layer they span), pads, pours+    let in_use = || {+        let mut n: BTreeMap<String, usize> = copper.iter().map(|l| (l.clone(), 0)).collect();+        let mut bump = |l: &str| { if let Some(c) = n.get_mut(l) { *c += 1 } };+        for k in ["tracks", "arcs", "polygons"] { for t in arr(k) { bump(t["layer"].as_str().unwrap_or("")) } }+        for p in &pads {+            match p["layer"].as_str().unwrap_or("") {+                "*.Cu" => { let ls: Vec<String> = p["layers"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or(copper.clone()); for l in ls { bump(&l) } }+                l => bump(l),+            }+        }+        for v in arr("vias") {+            let (a, b) = (copper.iter().position(|l| v["layers"][0] == l.as_str()), copper.iter().position(|l| v["layers"][1] == l.as_str()));+            if let (Some(a), Some(b)) = (a, b) { for l in &copper[a.min(b)..=a.max(b)] { bump(l) } }+        }+        let mut v: Vec<(String, usize)> = n.into_iter().collect();+        v.sort_by(|a, b| b.1.cmp(&a.1));+        v+    };+    let want = |k: &str| args.get(k).or_else(|| args.get("ref")).and_then(Value::as_str).map(str::to_string);+    let find_comp = |d: &str| comps.iter().find(|c| c["ref"].as_str().map_or(false, |r| r.eq_ignore_ascii_case(d))).cloned();+    let pins_of = |d: &str| -> Vec<Value> { pads.iter().filter(|p| p["ref"].as_str().map_or(false, |r| r.eq_ignore_ascii_case(d))).map(|p| json!({+        "name": p["name"], "net": p["net"], "x": p["x"], "y": p["y"], "layer": p["layer"], "w": p["sizeX"], "h": p["sizeY"], "rotation": p["rotation"], "hole": p["drill"], "shape": p["shape"]})).collect() };+    let comp_row = |c: &Value| {+        let d = c["ref"].as_str().unwrap_or("");+        let ps: Vec<&Value> = pads.iter().filter(|p| p["ref"] == c["ref"]).collect();+        let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+        for p in &ps {+            let (hx, hy) = (f(&p["sizeX"]) / 2.0, f(&p["sizeY"]) / 2.0);+            let r = f(&p["rotation"]).to_radians();+            let (ex, ey) = (hx * r.cos().abs() + hy * r.sin().abs(), hx * r.sin().abs() + hy * r.cos().abs());+            x0 = x0.min(f(&p["x"]) - ex); x1 = x1.max(f(&p["x"]) + ex); y0 = y0.min(f(&p["y"]) - ey); y1 = y1.max(f(&p["y"]) + ey);+        }+        let mut nets: Vec<&str> = ps.iter().filter_map(|p| p["net"].as_str()).filter(|n| !n.is_empty()).collect();+        nets.sort(); nets.dedup();+        json!({"designator": d, "pattern": c["pattern"], "footprint": c["footprint"], "comment": c["value"], "side": c["side"], "bottom": c["side"] == "bottom",+               "x": c["x"], "y": c["y"], "rotation": c["rotation"], "padsBox": if ps.is_empty() { Value::Null } else { json!([r6(x0), r6(y0), r6(x1), r6(y1)]) },+               "w": if ps.is_empty() { json!(0.0) } else { json!(r6(x1 - x0)) }, "h": if ps.is_empty() { json!(0.0) } else { json!(r6(y1 - y0)) },+               "pads": ps.len(), "nets": nets, "attributes": c["attributes"], "has3d": c["has3d"]})+    };+    Ok(match view {+        "list_nets" => {+            let mut by: BTreeMap<&str, usize> = model["nets"].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n, 0)).collect();+            for p in &pads { if let Some(c) = by.get_mut(p["net"].as_str().unwrap_or("")) { *c += 1 } }+            json!({"count": by.len(), "nets": by.iter().map(|(n, c)| json!({"name": n, "pads": c})).collect::<Vec<_>>()})+        }+        "list_designators" => {+            let mut d: Vec<&str> = comps.iter().filter_map(|c| c["ref"].as_str()).collect();+            d.sort_by_key(|r| natural_key(r));+            json!({"count": d.len(), "designators": d})+        }+        "copper_layers" => json!({"count": copper.len(), "layers": copper.iter().enumerate().map(|(i, l)| json!({"name": l, "index": i})).collect::<Vec<_>>()}),+        "layers_in_use" => {+            let u = in_use();+            json!({"copperLayersInUse": u.iter().filter(|(_, n)| *n > 0).map(|(l, n)| json!({"name": l, "primitives": n})).collect::<Vec<_>>(),+                   "copperLayersEmpty": u.iter().filter(|(_, n)| *n == 0).map(|(l, _)| l).collect::<Vec<_>>()})+        }+        "board_summary" => {+            let u = in_use();+            // in stack order, top to bottom+            let used: Vec<&String> = copper.iter().filter(|l| u.iter().any(|(m, n)| m == *l && *n > 0)).collect();+            let o: Vec<(f64, f64)> = model["outline"].as_array().into_iter().flatten().map(|p| (f(&p[0]), f(&p[1]))).collect();+            let size = if o.len() >= 3 {+                let mut pts = o.clone();+                for e in model["outlineEdges"].as_array().into_iter().flatten().filter(|e| e["kind"] == "arc") {+                    let a = Arc3 { center: (f(&e["center"][0]), f(&e["center"][1])), radius: f(&e["radius"]), a1: f(&e["angle1"]), a2: f(&e["angle2"]) };+                    pts.extend(a.sample(2.0));+                }+                let b = bbox_of(&pts, 0.0);+                json!({"x0": r6(b.0), "y0": r6(b.1), "x1": r6(b.2), "y1": r6(b.3), "widthMm": r6(b.2 - b.0), "heightMm": r6(b.3 - b.1), "areaMm2": r6(shoelace(&ring_of(model)).abs())})+            } else { Value::Null };+            let top = comps.iter().filter(|c| c["side"] == "top").count();+            json!({"summary": {"board": model["file"], "components": comps.len(), "componentsTop": top, "componentsBottom": comps.len() - top,+                "nets": model["counts"]["nets"], "pads": pads.len(), "copperLayers": copper.len(), "copperLayersInUse": used.len(), "copperLayersInUseNames": used,+                "unconnected": model["unconnected"], "counts": model["counts"], "outline": size, "revision": model["revision"]}})+        }+        "component_data" => match want("designator") {+            Some(d) => match find_comp(&d) {+                Some(c) => json!({"component": comp_row(&c)}),+                None => return Err(("designator_not_found".into(), format!("no part {d} on the board"))),+            },+            None => {+                let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize;+                json!({"count": comps.len(), "truncated": comps.len() > limit, "components": comps.iter().take(limit).map(comp_row).collect::<Vec<_>>()})+            }+        },+        "component_pins" => {+            let Some(d) = want("designator") else { return Err(("bad_args".into(), "designator is required (e.g. U1)".into())) };+            if find_comp(&d).is_none() { return Err(("designator_not_found".into(), format!("no part {d} on the board"))) }+            let mut pins = pins_of(&d);+            pins.sort_by_key(|p| natural_key(p["name"].as_str().unwrap_or("")));+            json!({"designator": d, "count": pins.len(), "pins": pins})+        }+        other => return Err(("bad_args".into(), format!("no view named {other}"))),+    })+}++/// The outline as a polygon (arcs sampled) from a model's `outlineEdges`.+fn ring_of(model: &Value) -> Vec<P> {+    let f = |v: &Value| v.as_f64().unwrap_or(0.0);+    let edges: Vec<Edge> = model["outlineEdges"].as_array().into_iter().flatten().map(|e| {+        let (a, b) = ((f(&e["start"][0]), f(&e["start"][1])), (f(&e["end"][0]), f(&e["end"][1])));+        let kind = if e["kind"] == "arc" { EdgeKind::Arc(Arc3 { center: (f(&e["center"][0]), f(&e["center"][1])), radius: f(&e["radius"]), a1: f(&e["angle1"]), a2: f(&e["angle2"]) }) } else { EdgeKind::Line };+        Edge { a, b, kind }+    }).collect();+    ring(&edges)+}++/// Designators and pin names in natural order: R2 before R10, A2 before A10.+fn natural_key(s: &str) -> (String, u64, String) {+    let head: String = s.chars().take_while(|c| !c.is_ascii_digit()).collect();+    let digits: String = s[head.len()..].chars().take_while(|c| c.is_ascii_digit()).collect();+    let rest = s[head.len() + digits.len()..].to_string();+    (head, digits.parse().unwrap_or(0), rest)+}++// -- the edit verbs in the standard's frame ----------------------------------------------------++/// The edit verbs whose coordinates `frame:"standard"` turns from y-up into KiCad's y-down.+pub const FRAME_VERBS: &[&str] = &["kicad_route_net", "kicad_move_footprint", "kicad_add_zone", "kicad_apply_zone_plan", "kicad_add_via", "kicad_add_track", "kicad_route"];++fn flip_point(v: &mut Value) {+    match v {+        Value::Array(a) if a.len() >= 2 && a[0].is_number() && a[1].is_number() => {+            a[1] = json!(-a[1].as_f64().unwrap_or(0.0) + 0.0);+        }+        Value::Object(o) => {+            if let Some(y) = o.get("y").and_then(Value::as_f64) {+                o.insert("y".into(), json!(-y + 0.0));+            }+        }+        _ => {}+    }+}++fn flip_list(v: Option<&mut Value>) {+    if let Some(Value::Array(a)) = v {+        a.iter_mut().for_each(flip_point);+    }+}++/// An edit verb's arguments given in the standard's frame (`frame:"standard"`, y up, what+/// `kicad_routing_state {model:"standard"}` reports) rewritten in KiCad's frame (y down), which is+/// what the verbs take. Waypoints, polygons, `at`, `start`, `end` and `x`/`y` (also in `refs`) have+/// their y negated; pad names and angles stay as they are (KiCad's angles are counterclockwise as+/// seen, the standard's too). `frame` absent or "kicad" leaves the arguments alone.+pub fn args_to_kicad_frame(args: &Value) -> Result<Value, String> {+    match args.get("frame").and_then(Value::as_str) {+        None | Some("kicad") => return Ok(args.clone()),+        Some("standard") | Some("yup") => {}+        Some(other) => return Err(format!("frame must be \"standard\" (y up, as kicad_routing_state {{model:\"standard\"}} reports) or \"kicad\" (y down), not \"{other}\"")),+    }+    let mut a = args.clone();+    if let Some(o) = a.as_object_mut() {+        o.remove("frame");+        o.insert("_fromFrame".into(), json!("standard"));+    }+    flip_list(a.get_mut("points"));+    if let Some(Value::Array(paths)) = a.get_mut("paths") {+        for p in paths.iter_mut() {+            flip_list(Some(p));+        }+    }+    flip_list(a.get_mut("polygon"));+    for k in ["at", "start", "end"] {+        if let Some(v) = a.get_mut(k) {+            flip_point(v);+        }+    }+    flip_point(&mut a);+    for k in ["refs", "zones"] {+        if let Some(Value::Array(items)) = a.get_mut(k) {+            for it in items.iter_mut() {+                flip_point(it);+                flip_list(it.get_mut("polygon"));+            }+        }+    }+    Ok(a)+}++// -- connectivity from the copper ------------------------------------------------------------------++fn dist_seg(p: P, a: P, b: P) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 < 1e-18 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+    dist(p, (a.0 + t * dx, a.1 + t * dy))+}++fn seg_seg(a: P, b: P, c: P, d: P) -> f64 {+    let cross = |o: P, p: P, q: P| (p.0 - o.0) * (q.1 - o.1) - (p.1 - o.1) * (q.0 - o.0);+    let (d1, d2, d3, d4) = (cross(c, d, a), cross(c, d, b), cross(a, b, c), cross(a, b, d));+    if ((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0)) {+        return 0.0;+    }+    dist_seg(a, c, d).min(dist_seg(b, c, d)).min(dist_seg(c, a, b)).min(dist_seg(d, a, b))+}++fn in_poly(p: P, poly: &[P]) -> bool {+    let mut inside = false;+    let n = poly.len();+    for k in 0..n {+        let (a, b) = (poly[k], poly[(k + n - 1) % n]);+        if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 {+            inside = !inside;+        }+    }+    inside+}++type BBox = (f64, f64, f64, f64);++fn bbox_of(pts: &[P], grow: f64) -> BBox {+    let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    for p in pts {+        b = (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1));+    }+    (b.0 - grow, b.1 - grow, b.2 + grow, b.3 + grow)+}++fn overlap(a: &BBox, b: &BBox) -> bool {+    a.0 <= b.2 && b.0 <= a.2 && a.1 <= b.3 && b.1 <= a.3+}++/// One piece of copper for the connectivity walk: a capsule (a track, a via, a round or oval pad: a+/// segment with a radius) or a polygon (a rectangular pad, a poured island), on a set of layers.+#[derive(Clone)]+enum Shape {+    Capsule { a: P, b: P, r: f64 },+    Poly { pts: Vec<P> },+}++#[derive(Clone)]+struct Cu {+    shape: Shape,+    /// copper layer indexes+    layers: Vec<usize>,+    bbox: BBox,+}++const EPS: f64 = 1e-4;++fn touch(x: &Cu, y: &Cu) -> bool {+    if !overlap(&x.bbox, &y.bbox) || !x.layers.iter().any(|l| y.layers.contains(l)) {+        return false;+    }+    match (&x.shape, &y.shape) {+        (Shape::Capsule { a, b, r }, Shape::Capsule { a: c, b: d, r: s }) => seg_seg(*a, *b, *c, *d) <= r + s + EPS,+        (Shape::Capsule { a, b, r }, Shape::Poly { pts }) | (Shape::Poly { pts }, Shape::Capsule { a, b, r }) => {+            in_poly(*a, pts) || (0..pts.len()).any(|k| seg_seg(*a, *b, pts[k], pts[(k + 1) % pts.len()]) <= r + EPS)+        }+        (Shape::Poly { pts: p }, Shape::Poly { pts: q }) => {+            in_poly(p[0], q) || in_poly(q[0], p)+                || (0..p.len()).any(|i| (0..q.len()).any(|j| seg_seg(p[i], p[(i + 1) % p.len()], q[j], q[(j + 1) % q.len()]) <= EPS))+        }+    }+}++fn pad_cu(p: &Pad, idx: &dyn Fn(&str) -> Option<usize>) -> Cu {+    let layers: Vec<usize> = p.copper.iter().filter_map(|l| idx(l)).collect();+    let (hx, hy) = (p.size.0 / 2.0, p.size.1 / 2.0);+    let shape = if p.shape == "round" || p.shape == "circle" {+        let (r, half) = if hx >= hy { (hy, (hx - hy, 0.0)) } else { (hx, (0.0, hy - hx)) };+        let d = rotate(half, p.rotation);+        Shape::Capsule { a: (p.at.0 - d.0, p.at.1 - d.1), b: add(p.at, d), r }+    } else {+        Shape::Poly { pts: [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)].iter().map(|q| add(p.at, rotate(*q, p.rotation))).collect() }+    };+    let bbox = shape_bbox(&shape);+    Cu { shape, layers, bbox }+}++fn shape_bbox(s: &Shape) -> BBox {+    match s {+        Shape::Capsule { a, b, r } => bbox_of(&[*a, *b], *r),+        Shape::Poly { pts } => bbox_of(pts, 0.0),+    }+}++fn capsule(a: P, b: P, r: f64, layers: Vec<usize>) -> Cu {+    let shape = Shape::Capsule { a, b, r };+    Cu { bbox: shape_bbox(&shape), shape, layers }+}++/// What the connectivity walk found.+struct Connectivity {+    unconnected: usize,+    split: Vec<Value>,+    /// (net, layer, a point on it, area mm2), y up+    isolated: Vec<(String, String, P, f64)>,+}++/// Connections still to make, from the copper: per net, (groups of touching copper) - 1. Pours count+/// with the copper KiCad poured (or the outline drawn when the zone is not filled). A group that is+/// only poured copper is an isolated island, dead copper KiCad warns about, not a connection to make:+/// it is listed under `isolatedCopper` and not counted. A stray track or via still counts.+fn unrouted(m: &Model) -> Connectivity {+    let idx = |l: &str| m.copper.iter().position(|c| c == l);+    let all: Vec<usize> = (0..m.copper.len()).collect();+    // each piece of copper with what it is: Some(pad name), None for a track or via, Pour(layer)+    #[derive(Clone)]+    enum What { Pad(String), Wire, Pour(String, f64, P) }+    let mut by_net: BTreeMap<&str, Vec<(Cu, What)>> = BTreeMap::new();+    for p in &m.pads {+        if !p.net.is_empty() {+            by_net.entry(&p.net).or_default().push((pad_cu(p, &idx), What::Pad(format!("{}.{}", p.owner, p.name))));+        }+    }+    for t in &m.tracks {+        if t.net.is_empty() { continue }+        if let Some(l) = idx(&t.layer) {+            by_net.entry(&t.net).or_default().push((capsule(t.a, t.b, t.width / 2.0, vec![l]), What::Wire));+        }+    }+    for a in &m.arcs {+        if a.net.is_empty() { continue }+        if let Some(l) = idx(&a.layer) {+            let pts = a.arc.sample(2.0);+            for w in pts.windows(2) {+                by_net.entry(&a.net).or_default().push((capsule(w[0], w[1], a.width / 2.0, vec![l]), What::Wire));+            }+        }+    }+    for v in &m.vias {+        if v.net.is_empty() { continue }+        let (lo, hi) = (v.layers.first().and_then(|l| idx(l)).unwrap_or(0), v.layers.last().and_then(|l| idx(l)).unwrap_or(all.len().saturating_sub(1)));+        by_net.entry(&v.net).or_default().push((capsule(v.at, v.at, v.size / 2.0, (lo.min(hi)..=lo.max(hi)).collect()), What::Wire));+    }+    for g in &m.polys {+        if g.net.is_empty() { continue }+        let Some(l) = idx(&g.layer) else { continue };+        let islands: Vec<&Vec<P>> = if g.fills.is_empty() { vec![&g.points] } else { g.fills.iter().collect() };+        for pts in islands {+            if pts.len() >= 3 {+                by_net.entry(&g.net).or_default().push((Cu { bbox: bbox_of(pts, 0.0), shape: Shape::Poly { pts: pts.clone() }, layers: vec![l] }, What::Pour(g.layer.clone(), shoelace(pts).abs(), pts[0])));+            }+        }+    }+    let mut out = Connectivity { unconnected: 0, split: Vec::new(), isolated: Vec::new() };+    for (net, items) in by_net {+        let n = items.len();+        let mut parent: Vec<usize> = (0..n).collect();+        fn find(p: &mut [usize], x: usize) -> usize {+            let mut r = x;+            while p[r] != r { r = p[r]; }+            let mut y = x;+            while p[y] != r { let nx = p[y]; p[y] = r; y = nx; }+            r+        }+        for i in 0..n {+            for j in (i + 1)..n {+                let (a, b) = (find(&mut parent, i), find(&mut parent, j));+                if a != b && touch(&items[i].0, &items[j].0) {+                    parent[a] = b;+                }+            }+        }+        let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();+        for i in 0..n {+            let r = find(&mut parent, i);+            groups.entry(r).or_default().push(i);+        }+        let mut live: Vec<Vec<String>> = Vec::new();+        for members in groups.values() {+            let only_pour = members.iter().all(|i| matches!(items[*i].1, What::Pour(..)));+            if only_pour {+                // pours join only on a shared layer, so an island is on one layer+                let (mut layer, mut area, mut at) = (String::new(), 0.0, (0.0, 0.0));+                for i in members {+                    if let What::Pour(l, a, p) = &items[*i].1 {+                        if layer.is_empty() { layer = l.clone(); at = *p; }+                        area += a;+                    }+                }+                out.isolated.push((net.to_string(), layer, at, area));+                continue;+            }+            live.push(members.iter().filter_map(|i| if let What::Pad(p) = &items[*i].1 { Some(p.clone()) } else { None }).collect());+        }+        if live.len() > 1 {+            out.unconnected += live.len() - 1;+            // each piece by the pads it holds (a few each): what to join+            let pieces: Vec<Value> = live.iter().map(|pads| json!({"pads": pads.iter().take(6).collect::<Vec<_>>(), "padCount": pads.len()})).collect();+            out.split.push(json!({"net": net, "groups": live.len(), "pieces": pieces}));+        }+    }+    out+}++// -- revision and JSON ---------------------------------------------------------------------------++/// FNV-1a 64, the reference bridge's revision hash: stable across builds and machines.+fn fnv(s: &str) -> String {+    let mut h: u64 = 0xcbf29ce484222325;+    for b in s.as_bytes() {+        h ^= *b as u64;+        h = h.wrapping_mul(0x100000001b3);+    }+    format!("{h:016x}")+}++/// Copper, parts and outline, canonical (y-up, 1 nm): fills, text and silkscreen are left out, so a+/// refill or a moved designator does not make a reader's revision stale.+fn revision(m: &Model) -> String {+    let id = Opts { y_up: true };+    let mut canon = String::new();+    // each kind's items sorted, so the order a serialisation lists them in does not matter+    for v in [tracks_json(m, &id), arcs_json(m, &id), vias_json(m, &id), pads_json(m, &id), comps_json(m, &id), polys_json(m, &id, false), edges_json(m.loops.first().map(Vec::as_slice).unwrap_or(&[]), &id)] {+        let mut items: Vec<String> = v.as_array().map(|a| a.iter().map(Value::to_string).collect()).unwrap_or_default();+        items.sort_unstable();+        canon.push_str(&items.join(","));+        canon.push('|');+    }+    fnv(&canon)+}++fn pj(p: P, o: &Opts) -> Value {+    json!({"x": r6(p.0), "y": r6(if o.y_up { p.1 } else { -p.1 })})+}++fn pa(p: P, o: &Opts) -> Value {+    json!([r6(p.0), r6(if o.y_up { p.1 } else { -p.1 })])+}++/// Angles are KiCad's own in both frames: counterclockwise as seen on screen, which is counterclockwise+/// in the standard's y-up frame.+fn ang(a: f64) -> f64 {+    r6(a)+}++fn with_owner(mut v: Value, owner: &Option<String>) -> Value {+    if let Some(o) = owner {+        v["ref"] = json!(o);+    }+    v+}++fn tracks_json(m: &Model, o: &Opts) -> Value {+    Value::Array(m.tracks.iter().map(|t| with_owner(json!({"start": pj(t.a, o), "end": pj(t.b, o), "width": r6(t.width), "layer": t.layer, "net": t.net}), &t.owner)).collect())+}++fn arc_angles(a: &Arc3) -> (f64, f64) {+    (ang(a.a1), ang(a.a2))+}++fn arcs_json(m: &Model, o: &Opts) -> Value {+    Value::Array(m.arcs.iter().map(|a| {+        let (s, e) = arc_angles(&a.arc);+        with_owner(json!({"center": pj(a.arc.center, o), "radius": r6(a.arc.radius), "startAngle": s, "endAngle": e,+            "start": pj(a.arc.point(a.arc.a1), o), "end": pj(a.arc.point(a.arc.a2), o), "width": r6(a.width), "layer": a.layer, "net": a.net}), &a.owner)+    }).collect())+}++fn vias_json(m: &Model, o: &Opts) -> Value {+    Value::Array(m.vias.iter().map(|v| {+        let mut j = json!({"position": pj(v.at, o), "size": r6(v.size), "drill": r6(v.drill), "layers": v.layers, "net": v.net});+        if v.kind != "through" {+            j["kind"] = json!(v.kind);+        }+        j+    }).collect())+}++fn pads_json(m: &Model, o: &Opts) -> Value {+    Value::Array(m.pads.iter().map(|p| {+        let mut j = json!({"ref": p.owner, "name": p.name, "x": r6(p.at.0), "y": r6(if o.y_up { p.at.1 } else { -p.at.1 }),+            "sizeX": r6(p.size.0), "sizeY": r6(p.size.1), "rotation": ang(p.rotation), "layer": p.layer, "drill": r6(p.drill), "net": p.net,+            "shape": p.shape, "padType": p.kind});+        if p.kicad_shape != p.shape {+            j["kicadShape"] = json!(p.kicad_shape);+        }+        if let Some(a) = p.anchor {+            j["anchor"] = pj(a, o);+        }+        if let Some(c) = &p.copper_points {+            j["copperPoints"] = json!(c.iter().map(|q| pa(*q, o)).collect::<Vec<_>>());+        }+        if p.layer == "*.Cu" && p.copper.len() != m.copper.len() {+            j["layers"] = json!(p.copper);+        }+        j+    }).collect())+}++fn comps_json(m: &Model, o: &Opts) -> Value {+    Value::Array(m.comps.iter().map(|c| {+        let pattern = c.footprint.rsplit(':').next().unwrap_or("").to_string();+        json!({"ref": c.reference, "x": r6(c.at.0), "y": r6(if o.y_up { c.at.1 } else { -c.at.1 }), "rotation": ang(c.rotation),+               "side": if c.bottom { "bottom" } else { "top" }, "pattern": pattern, "footprint": c.footprint, "value": c.value,+               "has3d": c.has3d, "attributes": c.attrs})+    }).collect())+}++fn polys_json(m: &Model, o: &Opts, with_fills: bool) -> Value {+    Value::Array(m.polys.iter().map(|g| {+        let mut j = json!({"net": g.net, "layer": g.layer, "points": g.points.iter().map(|p| pa(*p, o)).collect::<Vec<_>>()});+        if g.graphic {+            j["kind"] = json!("graphic");+        } else {+            j["kind"] = json!("zone");+            j["priority"] = json!(g.priority);+            if let Some(n) = &g.name {+                j["name"] = json!(n);+            }+            if with_fills {+                j["filled"] = json!(!g.fills.is_empty());+                j["filledMm2"] = json!(r6(g.fills.iter().map(|f| shoelace(f).abs()).sum::<f64>()));+                j["hatched"] = json!(g.hatched);+            }+        }+        with_owner(j, &g.owner)+    }).collect())+}++fn edges_json(lp: &[Edge], o: &Opts) -> Value {+    Value::Array(lp.iter().map(|e| match &e.kind {+        EdgeKind::Line => json!({"kind": "line", "start": pa(e.a, o), "end": pa(e.b, o)}),+        EdgeKind::Arc(a) => {+            let (a1, a2) = arc_angles(a);+            json!({"kind": "arc", "start": pa(e.a, o), "end": pa(e.b, o), "center": pa(a.center, o), "radius": r6(a.radius), "angle1": a1, "angle2": a2})+        }+    }).collect())+}++fn to_json(m: &Model, file: Option<&str>, revision: &str, o: &Opts) -> Value {+    let outer: &[Edge] = m.loops.first().map(Vec::as_slice).unwrap_or(&[]);+    let mut nets: BTreeSet<&str> = BTreeSet::new();+    nets.extend(m.pads.iter().map(|p| p.net.as_str()));+    nets.extend(m.tracks.iter().map(|t| t.net.as_str()));+    nets.extend(m.arcs.iter().map(|t| t.net.as_str()));+    nets.extend(m.vias.iter().map(|t| t.net.as_str()));+    nets.extend(m.polys.iter().map(|t| t.net.as_str()));+    nets.remove("");+    let conn = unrouted(m);+    let unfilled: Vec<Value> = m.polys.iter().filter(|g| !g.graphic && g.fills.is_empty()).map(|g| json!({"net": g.net, "layer": g.layer, "name": g.name})).collect();+    let mut out = json!({+        "units": "mm",+        "frame": if o.y_up { "standard" } else { "kicad" },+        "file": file, "boardId": file, "revision": revision,+        "counts": {"tracks": m.tracks.len(), "arcs": m.arcs.len(), "vias": m.vias.len(), "pads": m.pads.len(),+                   "components": m.comps.len(), "polygons": m.polys.len(), "nets": nets.len()},+        "unconnected": conn.unconnected, "splitNets": conn.split,+        "nets": nets.iter().collect::<Vec<_>>(),+        "copperLayers": m.copper,+        "tracks": tracks_json(m, o), "arcs": arcs_json(m, o), "vias": vias_json(m, o), "pads": pads_json(m, o),+        "components": comps_json(m, o), "polygons": polys_json(m, o, true),+        "outline": outer.iter().map(|e| pa(e.a, o)).collect::<Vec<_>>(),+        "outlineEdges": edges_json(outer, o),+    });+    if m.loops.len() > 1 {+        out["cutouts"] = Value::Array(m.loops[1..].iter().map(|lp| edges_json(lp, o)).collect());+    }+    if !m.keepouts.is_empty() {+        out["keepouts"] = Value::Array(m.keepouts.iter().map(|k| with_owner(json!({"layers": k.layers, "points": k.points.iter().map(|p| pa(*p, o)).collect::<Vec<_>>(), "rules": k.rules}), &k.owner)).collect());+    }+    if !conn.isolated.is_empty() {+        out["isolatedCopper"] = json!(conn.isolated.iter().map(|(n, l, at, a)| json!({"net": n, "layer": l, "at": pj(*at, o), "areaMm2": r6(*a)})).collect::<Vec<_>>());+    }+    if !unfilled.is_empty() {+        out["unfilledZones"] = json!(unfilled);+    }+    if m.loose_edges > 0 {+        out["outlineOpenEdges"] = json!(m.loose_edges);+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board(body: &str) -> String {+        format!("(kicad_pcb (version 20260206) (generator \"pcbnew\")\n (layers (0 \"F.Cu\" signal) (4 \"In1.Cu\" signal) (6 \"In2.Cu\" signal) (2 \"B.Cu\" signal) (25 \"Edge.Cuts\" user))\n{body})")+    }++    const RECT_OUTLINE: &str = "(gr_rect (start 0 0) (end 40 20) (stroke (width 0.1) (type solid)) (fill no) (layer \"Edge.Cuts\"))";++    fn std_model(body: &str) -> Value {+        build(&board(body), Some("C:/t/t.kicad_pcb"), &Opts::default()).unwrap()+    }++    #[test]+    fn y_is_negated_and_layers_keep_kicad_names() {+        let v = std_model(&format!("{RECT_OUTLINE}(segment (start 1 2) (end 5 2) (width 0.25) (layer \"In1.Cu\") (net \"A\"))"));+        assert_eq!(v["units"], "mm");+        assert_eq!(v["tracks"][0]["start"], json!({"x": 1.0, "y": -2.0}));+        assert_eq!(v["tracks"][0]["layer"], "In1.Cu");+        assert_eq!(v["copperLayers"], json!(["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]));+        let k = build(&board("(segment (start 1 2) (end 5 2) (width 0.25) (layer \"F.Cu\") (net \"A\"))"), None, &Opts { y_up: false }).unwrap();+        assert_eq!(k["tracks"][0]["start"], json!({"x": 1.0, "y": 2.0}));+        assert_eq!(k["frame"], "kicad");+    }++    #[test]+    fn rounded_outline_keeps_its_arcs_counterclockwise() {+        // a 10 x 10 board with one rounded corner (radius 1) at (10, 10) in KiCad's frame+        let v = std_model("(gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\"))(gr_line (start 10 0) (end 10 9) (layer \"Edge.Cuts\"))\+            (gr_arc (start 10 9) (mid 9.707107 9.707107) (end 9 10) (layer \"Edge.Cuts\"))(gr_line (start 9 10) (end 0 10) (layer \"Edge.Cuts\"))(gr_line (start 0 10) (end 0 0) (layer \"Edge.Cuts\"))");+        let edges = v["outlineEdges"].as_array().unwrap();+        assert_eq!(edges.len(), 5);+        let arc = edges.iter().find(|e| e["kind"] == "arc").unwrap();+        assert!((arc["center"][0].as_f64().unwrap() - 9.0).abs() < 1e-5 && (arc["center"][1].as_f64().unwrap() + 9.0).abs() < 1e-5, "{arc}");+        assert!((arc["radius"].as_f64().unwrap() - 1.0).abs() < 1e-5);+        // y-up: the corner is the bottom-right, the quarter from 270 to 360+        assert!((arc["angle1"].as_f64().unwrap() - 270.0).abs() < 1e-3, "{arc}");+        assert!((arc["angle2"].as_f64().unwrap() - 0.0).abs() < 1e-3 || (arc["angle2"].as_f64().unwrap() - 360.0).abs() < 1e-3);+        assert_eq!(v["outline"].as_array().unwrap().len(), 5);+        assert!(shoelace(&v["outline"].as_array().unwrap().iter().map(|p| (p[0].as_f64().unwrap(), p[1].as_f64().unwrap())).collect::<Vec<_>>()) > 0.0, "counterclockwise");+    }++    #[test]+    fn a_round_board_is_one_full_arc() {+        let v = std_model("(gr_circle (center 20 20) (end 30 20) (layer \"Edge.Cuts\") (fill no))");+        assert_eq!(v["outlineEdges"].as_array().unwrap().len(), 1);+        assert_eq!(v["outlineEdges"][0]["kind"], "arc");+        assert_eq!(v["outlineEdges"][0]["radius"], json!(10.0));+        assert_eq!(v["outlineEdges"][0]["angle2"], json!(360.0));+    }++    #[test]+    fn pads_are_placed_and_rotated_with_their_footprint() {+        // footprint at (10, 10) rotated 90 (counterclockwise on screen); pad 1 at local (1, 0)+        let v = std_model("(footprint \"lib:R_0603\" (layer \"F.Cu\") (at 10 10 90) (property \"Reference\" \"R1\") \+            (pad \"1\" smd roundrect (at 1 0 90) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\") (net \"A\")) \+            (pad \"2\" thru_hole circle (at -1 0 90) (size 1.6 1.6) (drill 0.8) (layers \"*.Cu\" \"*.Mask\") (net \"B\")))");+        let p1 = &v["pads"][0];+        // local +x turned 90 counterclockwise points up the screen: KiCad (10, 9), y-up (10, -9)+        assert_eq!((p1["x"].as_f64().unwrap(), p1["y"].as_f64().unwrap()), (10.0, -9.0));+        assert_eq!(p1["rotation"], json!(90.0));+        assert_eq!(p1["layer"], "F.Cu");+        assert_eq!(p1["shape"], "roundrect");+        assert_eq!(p1["ref"], "R1");+        let p2 = &v["pads"][1];+        assert_eq!(p2["layer"], "*.Cu");+        assert_eq!(p2["drill"], json!(0.8));+        assert_eq!(v["components"][0]["pattern"], "R_0603");+        assert_eq!(v["components"][0]["side"], "top");+    }++    #[test]+    fn a_custom_pad_is_its_bounding_rectangle() {+        // pic_programmer's custom pad: anchor 0.3 x 0.3, a pentagon from x -0.5 to 1, y -0.75 to 0.75+        let v = std_model("(footprint \"x:y\" (layer \"B.Cu\") (at 100 50) (property \"Reference\" \"JP1\") \+            (pad \"1\" smd custom (at -0.725 0) (size 0.3 0.3) (layers \"B.Cu\" \"B.Mask\") (net \"VCC\") (options (clearance outline) (anchor rect)) \+            (primitives (gr_poly (pts (xy 1 0) (xy 0.5 -0.75) (xy -0.5 -0.75) (xy -0.5 0.75) (xy 0.5 0.75)) (width 0) (fill yes)))))");+        let p = &v["pads"][0];+        assert_eq!(p["shape"], "rect");+        assert_eq!(p["kicadShape"], "custom");+        assert_eq!(p["rotation"], json!(0.0));+        assert!((p["sizeX"].as_f64().unwrap() - 1.5).abs() < 1e-9, "{p}");+        assert!((p["sizeY"].as_f64().unwrap() - 1.5).abs() < 1e-9);+        assert!((p["x"].as_f64().unwrap() - (100.0 - 0.725 + 0.25)).abs() < 1e-9);+        assert_eq!(p["anchor"], json!({"x": 99.275, "y": -50.0}));+        assert_eq!(p["layer"], "B.Cu");+        assert_eq!(v["components"][0]["side"], "bottom");+        // its real outline: the pentagon, which covers the anchor+        let c = p["copperPoints"].as_array().unwrap();+        assert_eq!(c.len(), 5);+        assert_eq!(c[0], json!([100.0 - 0.725 + 1.0, -50.0]));+    }++    #[test]+    fn net_less_copper_is_kept_and_not_counted_as_unconnected() {+        let v = std_model("(gr_line (start 0 0) (end 5 0) (stroke (width 0.2)) (layer \"F.Cu\"))\+            (zone (net 0) (net_name \"\") (layer \"B.Cu\") (polygon (pts (xy 0 0) (xy 5 0) (xy 5 5))))");+        assert_eq!(v["tracks"][0]["net"], "");+        assert_eq!(v["polygons"][0]["net"], "");+        assert_eq!(v["unconnected"], json!(0));+    }++    #[test]+    fn unconnected_is_counted_from_the_copper() {+        let pads = "(footprint \"x:y\" (layer \"F.Cu\") (at 0 0) (property \"Reference\" \"U1\") \+            (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"N\")) \+            (pad \"2\" smd rect (at 10 0) (size 1 1) (layers \"F.Cu\") (net \"N\")) \+            (pad \"3\" smd rect (at 20 0) (size 1 1) (layers \"F.Cu\") (net \"N\")))";+        let open = std_model(pads);+        assert_eq!(open["unconnected"], json!(2));+        assert_eq!(open["splitNets"][0]["groups"], json!(3));+        assert_eq!(open["splitNets"][0]["pieces"][2]["pads"], json!(["U1.3"]));+        // a track from pad 1 to pad 2, a via, and a back-side track from the via to pad 3: pad 3 is+        // SMD on F.Cu, so the back track does not reach it+        let half = std_model(&format!("{pads}(segment (start 0 0) (end 10 0) (width 0.2) (layer \"F.Cu\") (net \"N\"))\+            (via (at 15 0) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net \"N\"))(segment (start 15 0) (end 20 0) (width 0.2) (layer \"B.Cu\") (net \"N\"))"));+        assert_eq!(half["unconnected"], json!(2), "the via and the back track are their own piece");+        let done = std_model(&format!("{pads}(segment (start 0 0) (end 10 0) (width 0.2) (layer \"F.Cu\") (net \"N\"))(arc (start 10 0) (mid 15 -5) (end 20 0) (width 0.2) (layer \"F.Cu\") (net \"N\"))"));+        assert_eq!(done["unconnected"], json!(0));+        assert_eq!(done["arcs"][0]["center"], json!({"x": 15.0, "y": 0.0}));+        // through the arc's top: y-up, the arc runs counterclockwise from 0 to 180+        assert_eq!((done["arcs"][0]["startAngle"].as_f64().unwrap(), done["arcs"][0]["endAngle"].as_f64().unwrap()), (0.0, 180.0));+    }++    #[test]+    fn a_pour_joins_pads_through_its_spokes() {+        // the fill leaves a hole round pad 2's centre (a thermal gap) but a spoke reaches its edge+        let v = std_model("(footprint \"x:y\" (layer \"F.Cu\") (at 0 0) (property \"Reference\" \"U1\") \+            (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"G\")) (pad \"2\" smd rect (at 10 0) (size 1 1) (layers \"F.Cu\") (net \"G\")))\+            (zone (net \"G\") (layer \"F.Cu\") (polygon (pts (xy -2 -2) (xy 12 -2) (xy 12 2) (xy -2 2)))\+             (filled_polygon (layer \"F.Cu\") (pts (xy -2 -2) (xy 9.6 -2) (xy 9.6 -0.1) (xy 9.8 -0.1) (xy 9.8 0.1) (xy 9.4 0.1) (xy 9.4 -2) (xy -2 -2) (xy -2 2) (xy 9.4 2) (xy 9.4 0.2) (xy -2 0.2))))");+        assert_eq!(v["unconnected"], json!(0), "{}", v["splitNets"]);+        assert_eq!(v["polygons"][0]["filled"], json!(true));+    }++    #[test]+    fn revision_moves_with_copper_not_with_silkscreen() {+        let base = format!("{RECT_OUTLINE}(segment (start 1 2) (end 5 2) (width 0.25) (layer \"F.Cu\") (net \"A\"))");+        let r0 = revision_of_text(&board(&base)).unwrap();+        let silk = revision_of_text(&board(&format!("{base}(gr_text \"hello\" (at 3 3) (layer \"F.SilkS\"))"))).unwrap();+        assert_eq!(r0, silk);+        let moved = revision_of_text(&board(&base.replace("(end 5 2)", "(end 6 2)"))).unwrap();+        assert_ne!(r0, moved);+        assert_eq!(r0.len(), 16);+    }++    #[test]+    fn kicad9_teardrop_writer_bug_is_repaired_and_other_damage_refused() {+        let pad = "(footprint \"x:y\" (layer \"F.Cu\") (at 0 0) (property \"Reference\" \"R1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"A\") (teardrops (curved_edges no)filter_ratio 0.9) (enabled yes))))";+        let v = build(&board(&format!("{pad}{pad}")), None, &Opts::default()).unwrap();+        assert_eq!(v["counts"]["components"], json!(2));+        assert_eq!(v["repairedOnRead"]["teardropFilterRatio"], json!(2));+        let e = build(&board("(segment (start 0 0) (end 1 0)))) (segment (start 0 0) (end 1 0))"), None, &Opts::default()).unwrap_err();+        assert!(e.contains("damaged"), "{e}");+    }++    #[test]+    fn an_isolated_pour_island_is_not_a_connection_to_make() {+        let v = std_model("(footprint \"x:y\" (layer \"F.Cu\") (at 0 0) (property \"Reference\" \"U1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"G\")))\+            (zone (net \"G\") (layer \"F.Cu\") (polygon (pts (xy -2 -2) (xy 30 -2) (xy 30 2) (xy -2 2)))\+             (filled_polygon (layer \"F.Cu\") (pts (xy -2 -2) (xy 5 -2) (xy 5 2) (xy -2 2))) (filled_polygon (layer \"F.Cu\") (pts (xy 20 -2) (xy 30 -2) (xy 30 2) (xy 20 2))))");+        assert_eq!(v["unconnected"], json!(0));+        assert_eq!(v["isolatedCopper"], json!([{"net": "G", "layer": "F.Cu", "at": {"x": 20.0, "y": 2.0}, "areaMm2": 40.0}]));+    }++    #[test]+    fn revision_and_outline_do_not_depend_on_the_order_items_are_listed() {+        let lines = ["(gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\"))", "(gr_line (start 10 0) (end 10 10) (layer \"Edge.Cuts\"))", "(gr_line (start 10 10) (end 0 10) (layer \"Edge.Cuts\"))", "(gr_line (start 0 10) (end 0 0) (layer \"Edge.Cuts\"))",+            "(segment (start 1 1) (end 2 1) (width 0.2) (layer \"F.Cu\") (net \"A\"))", "(segment (start 3 1) (end 4 1) (width 0.2) (layer \"F.Cu\") (net \"A\"))"];+        let a = std_model(&lines.concat());+        let mut rev = lines.to_vec();+        rev.reverse();+        let b = std_model(&rev.concat());+        assert_eq!(a["revision"], b["revision"]);+        assert_eq!(a["outlineEdges"], b["outlineEdges"]);+        assert_eq!(a["outline"][0], json!([0.0, -10.0]), "the lowest-left vertex first");+    }++    #[test]+    fn stackup_reads_copper_and_the_dielectric_below_each_layer() {+        let text = "(kicad_pcb (version 20241229) (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (4 \"In1.Cu\" power \"GND\") (2 \"B.Cu\" signal))\+            (setup (stackup (layer \"F.Mask\" (type \"Top Solder Mask\") (thickness 0.01)) (layer \"F.Cu\" (type \"copper\") (thickness 0.035))\+             (layer \"dielectric 1\" (type \"prepreg\") (thickness 0.1) (material \"FR4\") (epsilon_r 4.4) addsublayer (thickness 0.1) (material \"FR4\") (epsilon_r 4.6))\+             (layer \"In1.Cu\" (type \"copper\") (thickness 0.0175)) (layer \"dielectric 2\" (type \"core\") (thickness 1.2) (material \"FR4\") (epsilon_r 4.5))\+             (layer \"B.Cu\" (type \"copper\") (thickness 0.035)) (copper_finish \"ENIG\")))\+            (zone (net \"GND\") (layer \"In1.Cu\") (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10)))))";+        let v = stackup(text).unwrap();+        assert_eq!(v["copperLayers"], json!(3));+        assert_eq!(v["fromDefaults"], json!(false));+        let l = v["layers"].as_array().unwrap();+        assert_eq!(l[0]["copperUm"], json!(35.0));+        assert_eq!(l[0]["dielectricUm"], json!(200.0));+        assert_eq!(l[0]["dk1000"], json!(4500.0), "the sublayers' Dk weighted by thickness");+        assert_eq!(l[1]["kind"], "plane");+        assert_eq!(l[1]["net"], "GND");+        assert_eq!(l[1]["plane"], json!(1));+        assert!(l[2].get("dielectricUm").is_none(), "nothing below the bottom layer");+        assert_eq!(v["totalUm"], json!(1600.0));+        assert_eq!(v["copperFinish"], "ENIG");+        let d = stackup("(kicad_pcb (version 20241229) (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)))").unwrap();+        assert_eq!(d["fromDefaults"], json!(true));+        assert!((d["layers"][0]["dielectricUm"].as_f64().unwrap() - 1530.0).abs() < 1e-6);+    }++    #[test]+    fn pours_report_the_copper_poured() {+        let v = pours(&board("(zone (net \"G\") (name \"gnd\") (layer \"F.Cu\") (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10)))\+            (filled_polygon (layer \"F.Cu\") (pts (xy 0 0) (xy 4 0) (xy 4 5) (xy 0 5))))(zone (net \"G\") (layer \"B.Cu\") (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))))"), &Opts::default(), true).unwrap();+        assert_eq!(v["zones"][0]["filledMm2"], json!(20.0));+        assert_eq!(v["zones"][0]["fills"][0]["outline"][1], json!([4.0, 0.0]));+        assert_eq!(v["filledAreaByLayerMm2"]["F.Cu"], json!(20.0));+        assert_eq!(v["unfilled"][0]["layer"], "B.Cu");+    }++    #[test]+    fn edit_arguments_in_the_standard_frame_turn_into_kicads() {+        let a = args_to_kicad_frame(&json!({"frame": "standard", "net": "GND", "points": ["J1.1", [10.0, -20.0], {"x": 12.0, "y": -21.0, "layer": "B.Cu"}],+            "paths": [[[1.0, -2.0]]], "polygon": [[0.0, -1.0]], "at": [3.0, -4.0], "x": 5.0, "y": -6.0, "rotation": 90.0,+            "refs": [{"ref": "U1", "x": 1.0, "y": -2.0}], "zones": [{"polygon": [[7.0, -8.0]]}]})).unwrap();+        assert_eq!(a["points"], json!(["J1.1", [10.0, 20.0], {"x": 12.0, "y": 21.0, "layer": "B.Cu"}]));+        assert_eq!(a["paths"], json!([[[1.0, 2.0]]]));+        assert_eq!((a["polygon"][0][1].clone(), a["at"][1].clone(), a["y"].clone(), a["rotation"].clone()), (json!(1.0), json!(4.0), json!(6.0), json!(90.0)));+        assert_eq!(a["refs"][0]["y"], json!(2.0));+        assert_eq!(a["zones"][0]["polygon"][0], json!([7.0, 8.0]));+        assert!(a.get("frame").is_none());+        let same = json!({"y": 3.0});+        assert_eq!(args_to_kicad_frame(&same).unwrap(), same);+        assert!(args_to_kicad_frame(&json!({"frame": "metric"})).is_err());+    }++    #[test]+    fn core_reads_are_views_of_the_model() {+        let m = std_model(&format!("{RECT_OUTLINE}(footprint \"lib:R_0603\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"R10\") (property \"Value\" \"10k\")\+            (pad \"1\" smd rect (at -1 0) (size 1 1) (layers \"F.Cu\") (net \"A\")) (pad \"2\" smd rect (at 1 0) (size 1 1) (layers \"F.Cu\") (net \"B\")))\+            (footprint \"lib:R_0603\" (layer \"B.Cu\") (at 20 10) (property \"Reference\" \"R2\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"B.Cu\") (net \"A\")))\+            (segment (start 9 10) (end 20 10) (width 0.2) (layer \"B.Cu\") (net \"A\"))"));+        let d = derive(&m, "list_designators", &json!({})).unwrap();+        assert_eq!(d["designators"], json!(["R2", "R10"]));+        let n = derive(&m, "list_nets", &json!({})).unwrap();+        assert_eq!(n["nets"], json!([{"name": "A", "pads": 2}, {"name": "B", "pads": 1}]));+        let s = derive(&m, "board_summary", &json!({})).unwrap();+        assert_eq!(s["summary"]["componentsBottom"], json!(1));+        assert_eq!(s["summary"]["outline"]["widthMm"], json!(40.0));+        assert_eq!(s["summary"]["outline"]["areaMm2"], json!(800.0));+        assert_eq!(s["summary"]["copperLayersInUseNames"], json!(["F.Cu", "B.Cu"]));+        let c = derive(&m, "component_data", &json!({"designator": "r10"})).unwrap();+        assert_eq!(c["component"]["w"], json!(3.0));+        assert_eq!(c["component"]["nets"], json!(["A", "B"]));+        assert_eq!(c["component"]["comment"], "10k");+        let p = derive(&m, "component_pins", &json!({"designator": "R10"})).unwrap();+        assert_eq!(p["pins"][1]["x"], json!(11.0));+        assert_eq!(derive(&m, "component_pins", &json!({"designator": "U9"})).unwrap_err().0, "designator_not_found");+    }++    #[test]+    fn numbered_nets_of_older_files_resolve_to_names() {+        let text = "(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)) (net 0 \"\") (net 1 \"GND\")\+            (segment (start 0 0) (end 1 0) (width 0.2) (layer \"B.Cu\") (net 1))\+            (zone (net 1) (net_name \"GND\") (layers \"F&B.Cu\") (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1)))))";+        let v = build(text, None, &Opts::default()).unwrap();+        assert_eq!(v["tracks"][0]["net"], "GND");+        assert_eq!(v["copperLayers"], json!(["F.Cu", "B.Cu"]));+        assert_eq!(v["polygons"].as_array().unwrap().len(), 2, "a two-layer zone is one polygon per layer");+    }+}
rust/crates/kicad-core/src/bodies.rsadded+448
@@ -0,0 +1,448 @@+//! 3D bodies (the EDA Bridge Standard's `bodies` companion): each part's 3D model as a box, its+//! footprint rectangle on the board and its height range above the surface it sits on, in mm.+//!+//! KiCad keeps no heights in the board: they are in the model files a footprint names. This reads a+//! STEP file's topological vertices and its circles (each circle's real extent in its own plane, so a+//! radial capacitor is not made a radius taller) or a VRML file's points (KiCad's 0.1 inch unit), then+//! places the box the way KiCad's 3D viewer does (`render_3d_opengl.cpp`):+//! model = T(offset) * Rz(-rz) * Ry(-ry) * Rx(-rx) * S(scale), in a y-up space;+//! board = T(position) * Rz(orientation) * [bottom: (x, y, z) -> (x, -y, -z)] * model.+//! Nothing here talks to KiCad; the caller resolves the model paths (`${KICAD10_3DMODEL_DIR}` etc.).++use std::collections::HashMap;+use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::model_check::{parse_sexpr, Sx};++/// An axis-aligned box: min and max corners, mm.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct Bounds {+    pub min: [f64; 3],+    pub max: [f64; 3],+}++impl Bounds {+    fn empty() -> Self {+        Bounds { min: [f64::MAX; 3], max: [f64::MIN; 3] }+    }+    fn add(&mut self, p: [f64; 3]) {+        for i in 0..3 {+            self.min[i] = self.min[i].min(p[i]);+            self.max[i] = self.max[i].max(p[i]);+        }+    }+    fn is_empty(&self) -> bool {+        self.min[0] > self.max[0]+    }+    fn corners(&self) -> Vec<[f64; 3]> {+        let mut out = Vec::with_capacity(8);+        for &x in &[self.min[0], self.max[0]] {+            for &y in &[self.min[1], self.max[1]] {+                for &z in &[self.min[2], self.max[2]] {+                    out.push([x, y, z]);+                }+            }+        }+        out+    }+}++// -- STEP ---------------------------------------------------------------------------------------++/// The numbers in a STEP argument list such as `'',(0.8,0.4,0.)`.+fn numbers(s: &str) -> Vec<f64> {+    s.split(|c: char| c == ',' || c == '(' || c == ')' || c.is_whitespace()).filter_map(|t| t.parse::<f64>().ok()).collect()+}++/// The `#n` references in a STEP argument list, in order.+fn refs(s: &str) -> Vec<u64> {+    s.split(|c: char| !(c.is_ascii_digit() || c == '#')).filter_map(|t| t.strip_prefix('#')).filter_map(|t| t.parse().ok()).collect()+}++/// The bounds of a STEP model in mm: its vertices, and every circular edge swept between its two+/// vertices in its own plane (a cylinder's side bulges past its vertices; a trimmed arc of a larger+/// circle does not reach the whole circle). Falls back to every point when the file has no vertices.+/// Ignores assembly placements (KiCad's library models are one body in one frame).+pub fn step_bounds(text: &str) -> Option<Bounds> {+    let scale = if text.contains("CONVERSION_BASED_UNIT('INCH'") || text.contains("CONVERSION_BASED_UNIT ( 'INCH'") {+        25.4+    } else if text.contains("SI_UNIT($,.METRE.)") || text.contains("SI_UNIT ( $ , .METRE. )") {+        1000.0+    } else {+        1.0+    };+    let mut points: HashMap<u64, [f64; 3]> = HashMap::new();+    let mut dirs: HashMap<u64, [f64; 3]> = HashMap::new();+    let mut axes: HashMap<u64, (u64, Option<u64>, Option<u64>)> = HashMap::new();+    let mut vertices: HashMap<u64, u64> = HashMap::new();+    let mut circles: HashMap<u64, (u64, f64)> = HashMap::new();+    // (start vertex, end vertex, curve, same sense as the curve)+    let mut edges: Vec<(u64, u64, u64, bool)> = Vec::new();+    // statements end at ';' outside a quoted string+    let mut stmt = String::new();+    let mut quoted = false;+    let data = text.find("DATA;").map(|i| &text[i + 5..]).unwrap_or(text);+    for c in data.chars() {+        if c == '\'' {+            quoted = !quoted;+        }+        if c == ';' && !quoted {+            let st = stmt.trim();+            if let Some(rest) = st.strip_prefix('#') {+                if let Some((id, body)) = rest.split_once('=') {+                    if let Ok(id) = id.trim().parse::<u64>() {+                        let body = body.trim();+                        let (kind, args) = body.split_once('(').map(|(k, a)| (k.trim(), a)).unwrap_or((body, ""));+                        // skip the entity's name string before reading numbers+                        let after_name = args.split_once("',").map(|(_, r)| r).unwrap_or(args);+                        match kind {+                            "CARTESIAN_POINT" => {+                                let n = numbers(after_name);+                                if n.len() >= 3 {+                                    points.insert(id, [n[0], n[1], n[2]]);+                                }+                            }+                            "DIRECTION" => {+                                let n = numbers(after_name);+                                if n.len() >= 3 {+                                    dirs.insert(id, [n[0], n[1], n[2]]);+                                }+                            }+                            "AXIS2_PLACEMENT_3D" => {+                                let r = refs(after_name);+                                if let Some(&loc) = r.first() {+                                    axes.insert(id, (loc, r.get(1).copied(), r.get(2).copied()));+                                }+                            }+                            "VERTEX_POINT" => {+                                if let Some(&p) = refs(after_name).first() {+                                    vertices.insert(id, p);+                                }+                            }+                            "CIRCLE" => {+                                let r = refs(after_name);+                                let rad = after_name.rsplit(',').next().and_then(|t| t.trim().trim_end_matches(')').trim().parse::<f64>().ok());+                                if let (Some(&ax), Some(rad)) = (r.first(), rad) {+                                    circles.insert(id, (ax, rad));+                                }+                            }+                            "EDGE_CURVE" => {+                                let r = refs(after_name);+                                if r.len() >= 3 {+                                    edges.push((r[0], r[1], r[2], !after_name.contains(".F.")));+                                }+                            }+                            _ => {}+                        }+                    }+                }+            }+            stmt.clear();+            continue;+        }+        stmt.push(c);+    }+    let mut b = Bounds::empty();+    for p in vertices.values() {+        if let Some(p) = points.get(p) {+            b.add(*p);+        }+    }+    let sub = |a: [f64; 3], c: [f64; 3]| [a[0] - c[0], a[1] - c[1], a[2] - c[2]];+    let dot = |a: [f64; 3], c: [f64; 3]| a[0] * c[0] + a[1] * c[1] + a[2] * c[2];+    let unit = |a: [f64; 3]| { let l = dot(a, a).sqrt().max(1e-12); [a[0] / l, a[1] / l, a[2] / l] };+    for (v1, v2, curve, same) in &edges {+        let Some((ax, r)) = circles.get(curve) else { continue };+        let Some((loc, dir, refd)) = axes.get(ax) else { continue };+        let Some(c) = points.get(loc).copied() else { continue };+        // the circle's own frame: x along its reference direction, y = normal x x+        let n = unit(dir.and_then(|d| dirs.get(&d)).copied().unwrap_or([0.0, 0.0, 1.0]));+        let mut x = refd.and_then(|d| dirs.get(&d)).copied().unwrap_or(if n[2].abs() < 0.9 { [0.0, 0.0, 1.0] } else { [1.0, 0.0, 0.0] });+        let k = dot(x, n);+        x = unit([x[0] - k * n[0], x[1] - k * n[1], x[2] - k * n[2]]);+        let y = [n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0]];+        let angle = |p: [f64; 3]| { let d = sub(p, c); dot(d, y).atan2(dot(d, x)) };+        let (p1, p2) = (vertices.get(v1).and_then(|p| points.get(p)).copied(), vertices.get(v2).and_then(|p| points.get(p)).copied());+        // the edge runs counterclockwise about the normal from its start to its end when it has the+        // curve's sense, else from its end to its start; one vertex twice is the whole circle+        let (from, sweep) = match (p1, p2) {+            (Some(a), Some(e)) if v1 != v2 => {+                let (s0, s1) = if *same { (angle(a), angle(e)) } else { (angle(e), angle(a)) };+                (s0, (s1 - s0).rem_euclid(std::f64::consts::TAU))+            }+            _ => (0.0, std::f64::consts::TAU),+        };+        let steps = ((sweep.to_degrees() / 2.0).ceil() as usize).max(1);+        for i in 0..=steps {+            let t = from + sweep * i as f64 / steps as f64;+            let (sn, cs) = t.sin_cos();+            b.add([c[0] + r * (cs * x[0] + sn * y[0]), c[1] + r * (cs * x[1] + sn * y[1]), c[2] + r * (cs * x[2] + sn * y[2])]);+        }+    }+    if b.is_empty() {+        for p in points.values() {+            b.add(*p);+        }+    }+    if b.is_empty() {+        return None;+    }+    for i in 0..3 {+        b.min[i] *= scale;+        b.max[i] *= scale;+    }+    Some(b)+}++/// The bounds of a VRML model in mm: every `point [ ... ]` list, in KiCad's VRML unit (0.1 inch).+pub fn wrl_bounds(text: &str) -> Option<Bounds> {+    let mut b = Bounds::empty();+    let mut rest = text;+    while let Some(i) = rest.find("point") {+        rest = &rest[i + 5..];+        let Some(open) = rest.find('[') else { break };+        if !rest[..open].trim().is_empty() {+            continue;+        }+        let Some(close) = rest[open..].find(']') else { break };+        let n = numbers(&rest[open + 1..open + close]);+        for t in n.chunks_exact(3) {+            b.add([t[0] * 2.54, t[1] * 2.54, t[2] * 2.54]);+        }+        rest = &rest[open + close..];+    }+    (!b.is_empty()).then_some(b)+}++// -- placement ----------------------------------------------------------------------------------++/// One `(model ...)` of a footprint.+#[derive(Clone, Debug, PartialEq)]+pub struct ModelRef {+    pub path: String,+    pub offset: [f64; 3],+    pub scale: [f64; 3],+    pub rotate: [f64; 3],+    pub hidden: bool,+}++fn xyz(m: &Sx, tag: &str, default: [f64; 3]) -> [f64; 3] {+    let Some(x) = m.find(tag).and_then(|t| t.find("xyz")) else { return default };+    let n = |i| x.atom_at(i).and_then(|a| a.parse::<f64>().ok());+    match (n(1), n(2), n(3)) {+        (Some(a), Some(b), Some(c)) => [a, b, c],+        _ => default,+    }+}++pub fn model_refs(fp: &Sx) -> Vec<ModelRef> {+    fp.find_all("model").map(|m| ModelRef {+        path: m.value().unwrap_or("").to_string(),+        offset: xyz(m, "offset", [0.0; 3]),+        scale: xyz(m, "scale", [1.0; 3]),+        rotate: xyz(m, "rotate", [0.0; 3]),+        hidden: m.find("hide").map(|h| h.value() != Some("no")).unwrap_or(false) || m.as_list().unwrap_or(&[]).iter().any(|a| a.as_atom() == Some("hide")),+    }).collect()+}++fn rot(p: [f64; 3], axis: usize, deg: f64) -> [f64; 3] {+    let (s, c) = deg.to_radians().sin_cos();+    let (i, j) = match axis { 0 => (1, 2), 1 => (2, 0), _ => (0, 1) };+    let mut q = p;+    q[i] = p[i] * c - p[j] * s;+    q[j] = p[i] * s + p[j] * c;+    q+}++/// The model's box in the footprint's own frame (y up, z up from the surface): KiCad's model transform.+pub fn in_footprint(b: &Bounds, m: &ModelRef) -> Bounds {+    let mut out = Bounds::empty();+    for p in b.corners() {+        let mut q = [p[0] * m.scale[0], p[1] * m.scale[1], p[2] * m.scale[2]];+        q = rot(q, 0, -m.rotate[0]);+        q = rot(q, 1, -m.rotate[1]);+        q = rot(q, 2, -m.rotate[2]);+        out.add([q[0] + m.offset[0], q[1] + m.offset[1], q[2] + m.offset[2]]);+    }+    out+}++/// A footprint-frame box placed on the board (y up): its footprint rectangle and its height range+/// above the surface it sits on.+pub fn on_board(b: &Bounds, at: (f64, f64), angle: f64, bottom: bool) -> ([f64; 4], [f64; 2]) {+    let mut x = (f64::MAX, f64::MIN);+    let mut y = (f64::MAX, f64::MIN);+    for p in b.corners() {+        let q = if bottom { [p[0], -p[1], -p[2]] } else { p };+        let r = rot(q, 2, angle);+        x = (x.0.min(r[0] + at.0), x.1.max(r[0] + at.0));+        y = (y.0.min(r[1] + at.1), y.1.max(r[1] + at.1));+    }+    ([x.0, y.0, x.1, y.1], [b.min[2], b.max[2]])+}++// -- paths --------------------------------------------------------------------------------------++/// Expand `${VAR}` in a model path: the 3D model dirs of every KiCad version name the install's+/// 3dmodels folder, KIPRJMOD the board's folder, anything else the variables given (KiCad's own+/// settings, then the process environment).+pub fn expand(path: &str, models_dir: Option<&Path>, project_dir: &Path, vars: &HashMap<String, String>) -> Option<String> {+    let mut out = String::new();+    let mut rest = path;+    while let Some(i) = rest.find("${") {+        out.push_str(&rest[..i]);+        let after = &rest[i + 2..];+        let end = after.find('}')?;+        let name = &after[..end];+        let val = if name == "KIPRJMOD" {+            Some(project_dir.to_string_lossy().to_string())+        } else if let Some(v) = vars.get(name) {+            Some(v.clone())+        } else if (name.ends_with("_3DMODEL_DIR") && name.starts_with("KICAD")) || name == "KISYS3DMOD" {+            models_dir.map(|d| d.to_string_lossy().to_string())+        } else {+            std::env::var(name).ok()+        };+        out.push_str(&val?);+        rest = &after[end + 1..];+    }+    out.push_str(rest);+    Some(out)+}++/// The file KiCad would load for a model path: the path itself, or the same name as STEP when a+/// VRML is named but only the STEP ships (KiCad 10's library), or the other way round.+pub fn find_model(expanded: &str, project_dir: &Path) -> Option<PathBuf> {+    let p = PathBuf::from(expanded);+    let p = if p.is_relative() { project_dir.join(p) } else { p };+    if p.is_file() {+        return Some(p);+    }+    let alts: &[&str] = match p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {+        Some("wrl") | Some("wrz") => &["step", "stp", "STEP", "STP"],+        Some("step") | Some("stp") => &["wrl", "WRL"],+        _ => &["step", "stp", "wrl"],+    };+    alts.iter().map(|e| p.with_extension(e)).find(|q| q.is_file())+}++/// Bounds of one model file, by its extension.+pub fn file_bounds(path: &Path) -> Result<Bounds, String> {+    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();+    let bytes = std::fs::read(path).map_err(|e| format!("could not read {}: {e}", path.display()))?;+    let text = String::from_utf8_lossy(&bytes);+    let b = match ext.as_str() {+        "step" | "stp" => step_bounds(&text),+        "wrl" => wrl_bounds(&text),+        other => return Err(format!("a .{other} model is not read (STEP and VRML are)")),+    };+    b.ok_or_else(|| format!("no geometry found in {}", path.display()))+}++/// Every part's bodies as the standard's boxes, from the board text. `load` returns a model file's+/// bounds (cached by the caller) or why it could not.+pub fn bodies(board_text: &str, y_up: bool, resolve: &mut dyn FnMut(&str) -> Result<(Bounds, String), String>) -> Result<Value, String> {+    let root = parse_sexpr(board_text).ok_or("empty board text")?;+    let mut out = Vec::new();+    let mut missing = Vec::new();+    let mut parts = 0;+    for fp in root.find_all("footprint") {+        parts += 1;+        let reference = fp.find_all("property").find(|p| p.value() == Some("Reference")).and_then(|p| p.atom_at(2))+            .or_else(|| fp.find_all("fp_text").find(|t| t.value() == Some("reference")).and_then(|t| t.atom_at(2)))+            .unwrap_or("").to_string();+        let at = fp.find("at");+        let num = |i| at.and_then(|a| a.atom_at(i)).and_then(|s| s.parse::<f64>().ok());+        let (x, y, angle) = (num(1).unwrap_or(0.0), -num(2).unwrap_or(0.0), num(3).unwrap_or(0.0));+        let bottom = fp.find("layer").and_then(Sx::value) == Some("B.Cu");+        let side = if bottom { "bottom" } else { "top" };+        let models: Vec<ModelRef> = model_refs(fp).into_iter().filter(|m| !m.hidden).collect();+        if models.is_empty() {+            missing.push(json!({"ref": reference, "why": "no 3D model"}));+            continue;+        }+        for m in models {+            if m.path.starts_with("kicad-embed://") {+                missing.push(json!({"ref": reference, "model": m.path, "why": "an embedded model (kicad-embed://) is not read yet"}));+                continue;+            }+            match resolve(&m.path) {+                Ok((b, file)) => {+                    let local = in_footprint(&b, &m);+                    let (r, z) = on_board(&local, (x, y), angle, bottom);+                    let fy = |v: f64| if y_up { v } else { -v };+                    let (y0, y1) = if y_up { (r[1], r[3]) } else { (fy(r[3]), fy(r[1])) };+                    let r6 = |v: f64| (v * 1e4).round() / 1e4 + 0.0;+                    out.push(json!({"ref": reference, "side": side, "x0": r6(r[0]), "y0": r6(y0), "x1": r6(r[2]), "y1": r6(y1),+                        "z0": r6(z[0]), "z1": r6(z[1]), "heightMm": r6(z[1].max(0.0)), "model": m.path, "file": file}));+                }+                Err(why) => missing.push(json!({"ref": reference, "model": m.path, "why": why})),+            }+        }+    }+    Ok(json!({"bodies": out, "noBody": missing, "parts": parts}))+}++#[cfg(test)]+mod tests {+    use super::*;++    const STEP: &str = "ISO-10303-21;\nDATA;\n#1=CARTESIAN_POINT('',(-0.8,-0.4,0.));\n#2=VERTEX_POINT('',#1);\n#3=CARTESIAN_POINT('',(0.8,0.4,0.45));\n#4=VERTEX_POINT('',#3);\n\+        #5=CARTESIAN_POINT('',(9.,9.,9.));\n#6=CARTESIAN_POINT('Origin',(0.,0.,5.));\n#7=DIRECTION('',(0.,0.,1.));\n#8=DIRECTION('',(1.,0.,0.));\n#9=AXIS2_PLACEMENT_3D('',#6,#7,#8);\n#10=CIRCLE('',#9,1.5);\n\+        #11=CARTESIAN_POINT('',(1.5,0.,5.));\n#12=VERTEX_POINT('',#11);\n#13=EDGE_CURVE('',#12,#12,#10,.T.);\n\+        #14=CIRCLE('',#9,9.);\n#15=CARTESIAN_POINT('',(9.,0.,5.));\n#16=VERTEX_POINT('',#15);\n#17=CARTESIAN_POINT('',(0.,9.,5.));\n#18=VERTEX_POINT('',#17);\n#19=EDGE_CURVE('',#18,#16,#14,.F.);\nENDSEC;\n";++    #[test]+    fn step_bounds_are_vertices_and_circles_in_their_plane() {+        let b = step_bounds(STEP).unwrap();+        // the stray control point (9, 9, 9) is not a vertex; the full circle at z 5 of radius 1.5 lies+        // flat; the quarter arc of radius 9 from (9, 0) to (0, 9) (an edge against the curve's sense,+        // so the short way) reaches no further than its ends+        assert!((b.min[0] + 1.5).abs() < 1e-9 && (b.min[1] + 1.5).abs() < 1e-9 && b.min[2] == 0.0, "{b:?}");+        assert!((b.max[0] - 9.0).abs() < 1e-9 && (b.max[1] - 9.0).abs() < 1e-9 && b.max[2] == 5.0, "{b:?}");+        let inch = STEP.replace("DATA;", "CONVERSION_BASED_UNIT('INCH',#99);\nDATA;");+        assert!((step_bounds(&inch).unwrap().max[2] - 127.0).abs() < 1e-6);+    }++    #[test]+    fn wrl_points_are_in_tenths_of_an_inch() {+        let b = wrl_bounds("#VRML V2.0 utf8\nShape { geometry IndexedFaceSet { coord Coordinate { point [ 0 0 0, 1 2 0.5 ] } } }").unwrap();+        assert_eq!(b.max, [2.54, 5.08, 1.27]);+    }++    #[test]+    fn a_body_is_placed_turned_and_flipped_like_kicads_3d_viewer() {+        // a 2 x 1 x 0.5 body offset 1 mm to +x in its footprint+        let b = Bounds { min: [-1.0, -0.5, 0.0], max: [1.0, 0.5, 0.5] };+        let m = ModelRef { path: String::new(), offset: [1.0, 0.0, 0.0], scale: [1.0; 3], rotate: [0.0; 3], hidden: false };+        let local = in_footprint(&b, &m);+        assert_eq!(local.min, [0.0, -0.5, 0.0]);+        let (r, z) = on_board(&local, (10.0, -10.0), 90.0, false);+        // turned 90 counterclockwise: the body now reaches from the origin up +y+        let r: Vec<f64> = r.iter().map(|v| (v * 1e9).round() / 1e9).collect();+        assert_eq!(r, vec![9.5, -10.0, 10.5, -8.0]);+        assert_eq!(z, [0.0, 0.5]);+        // on the bottom the footprint is mirrored top to bottom in its own frame: an offset in +y flips+        let m2 = ModelRef { offset: [0.0, 1.0, 0.0], ..m };+        let (r, _) = on_board(&in_footprint(&b, &m2), (0.0, 0.0), 0.0, true);+        assert_eq!(r, [-1.0, -1.5, 1.0, -0.5]);+    }++    #[test]+    fn model_paths_expand_like_kicad_and_fall_back_to_the_step() {+        let vars = HashMap::from([("MYLIB".to_string(), "D:/libs".to_string())]);+        let models = Path::new("C:/KiCad/share/kicad/3dmodels");+        let proj = Path::new("C:/p");+        assert_eq!(expand("${KICAD8_3DMODEL_DIR}/R.3dshapes/R.wrl", Some(models), proj, &vars).unwrap(), "C:/KiCad/share/kicad/3dmodels/R.3dshapes/R.wrl");+        assert_eq!(expand("${KIPRJMOD}/m.step", None, proj, &vars).unwrap(), "C:/p/m.step");+        assert_eq!(expand("${MYLIB}/x.step", None, proj, &vars).unwrap(), "D:/libs/x.step");+        assert!(expand("${NOPE_NOT_SET_ANYWHERE}/x.step", None, proj, &vars).is_none());+        let dir = std::env::temp_dir().join(format!("kb-bodies-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        std::fs::write(dir.join("R.step"), STEP).unwrap();+        assert_eq!(find_model(&dir.join("R.wrl").to_string_lossy(), &dir), Some(dir.join("R.step")));+        std::fs::remove_dir_all(&dir).ok();+    }+}
rust/crates/kicad-core/src/ipc.rs+163−1
@@ -722,6 +722,83 @@ pub fn routing_state(ctx: &Ctx, args: &Value) -> RResult<Value> {     Ok(out) } +/// The one board open in the PCB editor, when the caller named none (the standard's routing_state+/// reads the current board, as altium_routing_state does).+fn with_open_board(args: &Value) -> RResult<Value> {+    if args.get("filePath").and_then(Value::as_str).is_some() {+        return Ok(args.clone());+    }+    let mut builder = KiCadClientBlocking::builder().client_name(format!("adom-model-{}", std::process::id())).timeout(timeout_of(args));+    if let Some(sock) = args.get("socketPath").and_then(Value::as_str) {+        builder = builder.socket_path(sock.to_string());+    }+    let client = builder.connect().map_err(map_err)?;+    let docs = client.get_open_documents(DocumentType::Pcb).map_err(map_err)?;+    let open: Vec<String> = docs.iter().map(document_path).collect();+    match open.as_slice() {+        [one] => {+            let mut a = args.clone();+            a["filePath"] = json!(one);+            Ok(a)+        }+        [] => Err(RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([]))),+        _ => Err(RoutingError::new("board_mismatch", "More than one board is open: name one with filePath").with("openBoards", json!(open))),+    }+}++/// The live board's text as the editor serialises it, with its path and KiCad's version: what the+/// standard verbs (model, stack-up, pours) build from. `filePath` may be left out when one board is open.+pub fn board_text(_ctx: &Ctx, args: &Value) -> RResult<(String, String, String)> {+    let args = with_open_board(args)?;+    let s = connect(_ctx, &args)?;+    let text = s.client.get_board_as_string().map_err(map_err)?;+    Ok((text, s.file_path.clone(), s.version.full_version.clone()))+}++/// A standard revision (16 hex digits, `board_model::revision_of_text`) as opposed to the editor's+/// text hash (64) the edit verbs compare.+pub fn is_standard_revision(r: &str) -> bool {+    r.len() == 16 && r.bytes().all(|b| b.is_ascii_hexdigit())+}++/// Let an edit verb take the standard revision: when `expectedRevision` is one, read the live board;+/// if its copper, parts and outline are what the caller read, hand the verb the editor's current text+/// hash (its own guard then runs unchanged), else refuse with `stale_board`. Returns the arguments to+/// use and whether they were translated.+pub fn accept_standard_revision(ctx: &Ctx, args: &Value) -> RResult<(Value, bool)> {+    let Some(want) = args.get("expectedRevision").and_then(Value::as_str).filter(|r| is_standard_revision(r)) else {+        return Ok((args.clone(), false));+    };+    let s = connect(ctx, args)?;+    let text = s.client.get_board_as_string().map_err(map_err)?;+    let now = crate::board_model::revision_of_text(&text).map_err(|e| RoutingError::new("board_unparsable", e))?;+    if now != want {+        return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board: its copper, parts or outline changed since it was read")+            .with("currentRevision", json!(now)).with("mutated", json!(false)));+    }+    let mut a = args.clone();+    a["expectedRevision"] = json!(revision_of(&text));+    Ok((a, true))+}++/// Run an edit verb so it accepts either revision; after a translated call the reply's `revision` is+/// the standard one (the editor's text hash stays as `kicadRevision`).+pub fn with_standard_revision(ctx: &Ctx, args: &Value, run: impl FnOnce(&Value) -> RResult<Value>) -> RResult<Value> {+    let (args, translated) = accept_standard_revision(ctx, args)?;+    let mut v = run(&args)?;+    if translated && v["success"] == json!(true) {+        if let Ok(s) = connect(ctx, &args) {+            if let Ok(text) = s.client.get_board_as_string() {+                if let Ok(r) = crate::board_model::revision_of_text(&text) {+                    v["kicadRevision"] = json!(revision_of(&text));+                    v["revision"] = json!(r);+                }+            }+        }+    }+    Ok(v)+}+ /// `kicad_route_net`: DRC preflight on a snapshot, then one commit of the planned copper. pub fn route_net(ctx: &Ctx, args: &Value) -> RResult<Value> {     let s = connect(ctx, args)?;@@ -822,7 +899,7 @@ pub fn commit_copper(ctx: &Ctx, args: &Value, plan: &Plan, message: &str) -> RRe /// `kicad_remove_route`: delete specific unlocked copper items as one Undo step. pub fn remove_route(ctx: &Ctx, args: &Value) -> RResult<Value> {     let s = connect(ctx, args)?;-    let (_, _, revision) = s.snapshot()?;+    let (text, data, revision) = s.snapshot()?;     check_revision(args, &revision)?;     let ids: Vec<String> = match args.get("itemIds").and_then(Value::as_array) {         Some(a) if !a.is_empty() && a.len() <= 1024 && a.iter().all(Value::is_string) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),@@ -834,6 +911,9 @@ pub fn remove_route(ctx: &Ctx, args: &Value) -> RResult<Value> {     if items.len() != wanted.len() || items.iter().any(|i| item_locked(i)) {         return Err(RoutingError::new("invalid_item_ids", "Some IDs are missing, locked, or are not copper; nothing removed"));     }+    if args.get("dryRun") == Some(&json!(true)) {+        return Ok(remove_preview(ctx, &s, &text, &data, &revision, &ids, args));+    }     check_revision(args, &s.revision()?)?;     let to_delete: Vec<String> = wanted.iter().cloned().collect();     transaction(&s, "Adom remove route", || {@@ -865,6 +945,79 @@ pub fn remove_route(ctx: &Ctx, args: &Value) -> RResult<Value> {     Ok(result) } +/// `kicad_remove_route {dryRun:true}` (wiki #134): what would go and KiCad's DRC of the snapshot+/// without it. Nothing is written.+fn remove_preview(ctx: &Ctx, s: &Session, text: &str, data: &Board, revision: &str, ids: &[String], args: &Value) -> Value {+    let items: Vec<Value> = ids.iter().map(|id| {+        if let Some(t) = data.segments.iter().find(|x| x.uuid.as_deref() == Some(id.as_str())) {+            json!({"id": id, "kind": "track", "net": t.net_name, "layer": t.layer, "start": t.start, "end": t.end, "width": t.width})+        } else if let Some(v) = data.vias.iter().find(|x| x.uuid.as_deref() == Some(id.as_str())) {+            json!({"id": id, "kind": "via", "net": v.net_name, "position": v.position, "size": v.size, "drill": v.drill})+        } else {+            json!({"id": id, "kind": "arc"})+        }+    }).collect();+    let (without, cut) = strip_items(text, ids);+    let mut out = json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "wouldRemove": items,+        "_hint": "Nothing was removed. drc is KiCad's DRC of the board without these items: unconnectedAfter is what removing them leaves to route. Submit with dryRun:false and the same revision to remove them as one Undo step."});+    if args.get("validate") == Some(&json!(false)) {+        return out;+    }+    if cut != ids.len() {+        out["drc"] = json!({"available": false, "why": format!("{} of {} items were found in the board text", cut, ids.len())});+        return out;+    }+    let source = s.source_path();+    match (drc_snapshot(ctx.kicad_cli.as_deref(), &source, text), drc_snapshot(ctx.kicad_cli.as_deref(), &source, &without)) {+        (Ok(before), Ok(after)) => {+            out["unconnectedBefore"] = before["unconnected"].clone();+            out["unconnectedAfter"] = after["unconnected"].clone();+            out["newErrors"] = json!(new_errors(&before, &after).unwrap_or_default());+            out["drc"] = after;+        }+        (Err(e), _) | (_, Err(e)) => out["drc"] = json!({"available": false, "errorCode": e.code, "why": e.message}),+    }+    out+}++/// The board text with the top-level items carrying these UUIDs cut out, and how many were cut.+pub fn strip_items(text: &str, ids: &[String]) -> (String, usize) {+    // the spans of the root list's direct children, quotes respected+    let mut spans: Vec<(usize, usize)> = Vec::new();+    let (mut depth, mut quoted, mut escaped, mut start) = (0i32, false, false, 0usize);+    for (i, c) in text.char_indices() {+        if quoted {+            if escaped { escaped = false } else if c == '\\' { escaped = true } else if c == '"' { quoted = false }+            continue;+        }+        match c {+            '"' => quoted = true,+            '(' => {+                depth += 1;+                if depth == 2 { start = i; }+            }+            ')' => {+                if depth == 2 { spans.push((start, i + 1)); }+                depth -= 1;+            }+            _ => {}+        }+    }+    let mut out = String::with_capacity(text.len());+    let (mut last, mut cut) = (0, 0);+    for (a, b) in spans {+        let item = &text[a..b];+        let kind_ok = item.starts_with("(segment") || item.starts_with("(arc") || item.starts_with("(via");+        if kind_ok && ids.iter().any(|id| item.contains(&format!("(uuid \"{id}\")")) || item.contains(&format!("(tstamp \"{id}\")"))) {+            out.push_str(&text[last..a]);+            last = b;+            cut += 1;+        }+    }+    out.push_str(&text[last..]);+    (out, cut)+}+ /// `kicad_routing_validate`: KiCad DRC on the live, unsaved board snapshot. pub fn routing_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {     let s = connect(ctx, args)?;@@ -1492,6 +1645,15 @@ mod tests {     use super::*;     use std::cell::RefCell; +    #[test]+    fn strip_items_cuts_only_the_named_copper() {+        let text = "(kicad_pcb (version 1)\n\t(segment (start 0 0) (end 1 0) (uuid \"a\"))\n\t(via (at 1 0) (uuid \"b\"))\n\t(gr_text \"a (uuid \\\"a\\\")\" (uuid \"c\"))\n\t(segment (start 2 0) (end 3 0) (uuid \"d\"))\n)";+        let (out, n) = strip_items(text, &["a".to_string(), "b".to_string()]);+        assert_eq!(n, 2);+        assert!(!out.contains("(uuid \"a\"))") && !out.contains("(uuid \"b\")"));+        assert!(out.contains("(uuid \"c\")") && out.contains("(uuid \"d\")"));+    }+     #[test]     fn revision_is_sha256_of_the_board_text() {         assert_eq!(revision_of("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
rust/crates/kicad-core/src/lib.rs+2
@@ -15,6 +15,8 @@ pub mod render; pub mod bridge_log; pub mod install; pub mod pcb;+pub mod board_model;+pub mod bodies; pub mod placement; pub mod zones; #[cfg(feature = "ipc")]
rust/crates/kicad-core/src/libraries.rs+26−5
@@ -455,12 +455,21 @@ pub fn expand_kicad_uri(uri: &str, ctx: &LibCtx) -> Option<String> {     if let Some(docs) = &ctx.user_dir {         subs.push(("KICAD_USER_TEMPLATE_DIR".into(), Some(norm_path(&docs.join("template")))));     }-    if let Some(cfg) = &ctx.config_dir {-        if !ctx.version.is_empty() {-            subs.push(("KICAD_3RD_PARTY".into(), Some(norm_path(&cfg.join("3rdparty")))));-        }+    // Where the Plugin and Content Manager installs (wiki #130): KICAD<n>_3RD_PARTY, or the older+    // KICAD_3RD_PARTY spelling, from Configure Paths; else Documents/KiCad/<ver>/3rdparty, KiCad's own+    // default (the same order model_check uses).+    let third = ctx.env_overrides.get(&format!("KICAD{major}_3RD_PARTY")).cloned()+        .or_else(|| ctx.env_overrides.get("KICAD_3RD_PARTY").cloned())+        .or_else(|| ctx.user_dir.as_ref().map(|u| norm_path(&u.join("3rdparty"))))+        .or_else(|| ctx.config_dir.as_ref().filter(|_| !ctx.version.is_empty()).map(|c| norm_path(&c.join("3rdparty"))));+    if let Some(t) = third {+        subs.push((format!("KICAD{major}_3RD_PARTY"), Some(t.clone())));+        subs.push(("KICAD_3RD_PARTY".into(), Some(t)));     }     for (k, v) in &ctx.env_overrides {+        if k.ends_with("3RD_PARTY") {+            continue; // resolved above, so either spelling reaches both names+        }         subs.retain(|(name, _)| name != k);         subs.push((k.clone(), Some(v.clone())));     }@@ -1047,6 +1056,17 @@ mod tests {         }     } +    #[test]+    fn pcm_libraries_expand_to_the_3rdparty_folder() {+        // wiki #130: KiCad 10's PCM writes ${KICAD10_3RD_PARTY} into the global sym-lib-table+        let uri = "${KICAD10_3RD_PARTY}/symbols/inc_adom_wiki_x/lib.kicad_sym";+        assert_eq!(expand_kicad_uri(uri, &ctx()).unwrap(), "C:/Users/john/Documents/KiCad/10.0/3rdparty/symbols/inc_adom_wiki_x/lib.kicad_sym");+        assert_eq!(expand_kicad_uri("${KICAD_3RD_PARTY}/a.kicad_sym", &ctx()).unwrap(), "C:/Users/john/Documents/KiCad/10.0/3rdparty/a.kicad_sym");+        let mut c = ctx();+        c.env_overrides.insert("KICAD_3RD_PARTY".into(), "D:/pcm".into());+        assert_eq!(expand_kicad_uri(uri, &c).unwrap(), "D:/pcm/symbols/inc_adom_wiki_x/lib.kicad_sym", "the older spelling from Configure Paths applies to both");+    }+     #[test]     fn tokenizer_handles_quotes_and_nesting() {         let s = parse_sexp(r#"(a "b c" (d "e\"f") g)"#).unwrap();@@ -1143,8 +1163,9 @@ mod tests {             Some("C:/Users/john/Documents/KiCad/10.0/template/t")         );         assert_eq!(+            // Documents/KiCad/<ver>/3rdparty, where KiCad's PCM installs (checked on a KiCad 10.0.4 box, wiki #130)             expand_kicad_uri("${KICAD_3RD_PARTY}/footprints/x.pretty", &c).as_deref(),-            Some("C:/Users/john/AppData/Roaming/kicad/10.0/3rdparty/footprints/x.pretty")+            Some("C:/Users/john/Documents/KiCad/10.0/3rdparty/footprints/x.pretty")         );         // Plain paths pass through normalised.         assert_eq!(expand_kicad_uri("C:\\a\\.\\b\\..\\c.kicad_sym", &c).as_deref(), Some("C:/a/c.kicad_sym"));
rust/crates/kicad-core/src/pcb.rs+47−4
@@ -538,8 +538,11 @@ fn extract_courtyard_edges(fp: &Sx) -> Vec<(Point, Point)> {     for node in fp.find_all("fp_arc") {         if on_courtyard(node) {             if let (Some(a), Some(m), Some(b)) = (point_of(node.find("start")), point_of(node.find("mid")), point_of(node.find("end"))) {-                out.push((a, m.clone()));-                out.push((m, b));+                // every 2 degrees, like the board model: two chords cut a large arc short (wiki #133)+                let pts = arc_points(&a, &m, &b);+                for w in pts.windows(2) {+                    out.push((w[0].clone(), w[1].clone()));+                }             }         }     }@@ -557,7 +560,8 @@ fn extract_courtyard_edges(fp: &Sx) -> Vec<(Point, Point)> {         if on_courtyard(node) {             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));-                let n = 24;+                // 2 degrees: a 24-gon's chords cut a 24 mm ring's hole 0.2 mm short (wiki #133)+                let n = 180;                 let at = |k: usize| {                     let t = std::f64::consts::TAU * k as f64 / n as f64;                     pt(c.x + r * t.cos(), c.y + r * t.sin())@@ -583,6 +587,33 @@ fn extract_courtyard_edges(fp: &Sx) -> Vec<(Point, Point)> {     out } +/// A three-point arc as points every 2 degrees from start through mid to end (start and end exact);+/// collinear points are a straight line.+pub fn arc_points(a: &Point, m: &Point, b: &Point) -> Vec<Point> {+    let d = 2.0 * (a.x * (m.y - b.y) + m.x * (b.y - a.y) + b.x * (a.y - m.y));+    if d.abs() < 1e-12 {+        return vec![a.clone(), b.clone()];+    }+    let (a2, m2, b2) = (a.x * a.x + a.y * a.y, m.x * m.x + m.y * m.y, b.x * b.x + b.y * b.y);+    let cx = (a2 * (m.y - b.y) + m2 * (b.y - a.y) + b2 * (a.y - m.y)) / d;+    let cy = (a2 * (b.x - m.x) + m2 * (a.x - b.x) + b2 * (m.x - a.x)) / d;+    let r = dist((cx, cy), (a.x, a.y));+    let ang = |p: &Point| (p.y - cy).atan2(p.x - cx);+    let tau = std::f64::consts::TAU;+    let (s, mid, e) = (ang(a), ang(m), ang(b));+    let ccw = |x: f64, y: f64| (y - x).rem_euclid(tau);+    // the sweep from start to end that passes through mid, signed+    let sweep = if ccw(s, mid) < ccw(s, e) { ccw(s, e) } else { -(tau - ccw(s, e)) };+    let n = ((sweep.abs().to_degrees() / 2.0).ceil() as usize).max(1);+    let mut out: Vec<Point> = (0..=n).map(|k| {+        let t = s + sweep * k as f64 / n as f64;+        Point { x: cx + r * t.cos(), y: cy + r * t.sin() }+    }).collect();+    out[0] = a.clone();+    out[n] = b.clone();+    out+}+ fn point_of(node: Option<&Sx>) -> Option<Point> {     let n = node?;     Some(Point { x: atom_f64(n, 1)?, y: atom_f64(n, 2)? })@@ -662,11 +693,23 @@ fn extract_dimensions(tree: &Sx) -> Option<Dimensions> {             if !on_edge(node) {                 continue;             }-            for coord in ["start", "end", "center", "mid"] {+            for coord in ["start", "end", "mid"] {                 if let Some(p) = point_of(node.find(coord)) {                     update(p.x, p.y);                 }             }+            // an arc reaches as far as its sweep goes, a circle its radius all round (wiki #133:+            // one 340 degree edge arc read only by its three points left the box 100 mm short)+            if let (Some(a), Some(m), Some(b)) = (point_of(node.find("start")), point_of(node.find("mid")), point_of(node.find("end"))) {+                for p in arc_points(&a, &m, &b) {+                    update(p.x, p.y);+                }+            }+            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));+                update(c.x - r, c.y - r);+                update(c.x + r, c.y + r);+            }             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)) {
rust/crates/kicad-core/src/placement.rs+17−8
@@ -208,17 +208,26 @@ fn on_boundary(pt: (f64, f64), edges: &[Edge]) -> bool {     }) } -/// Points that sample an outline's region: its corners, the middle of each edge, and the-/// centre of its corners.+/// Points that sample an outline's region: its corners, the middle of each edge, and a point a hair+/// inside the outline beside each edge's middle (wiki #133). Not the centre of the corners: a ring+/// courtyard's centre is in its hole, and a ray from there skims the polygon's vertices. fn probes(e: &[Edge]) -> Vec<(f64, f64)> {-    let mut v: Vec<(f64, f64)> = Vec::with_capacity(e.len() * 2 + 1);+    const D: f64 = 1e-4;+    let mut v: Vec<(f64, f64)> = Vec::with_capacity(e.len() * 3);     for &(a, b) in e {+        let m = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);         v.push(a);-        v.push(((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0));-    }-    if !e.is_empty() {-        let n = e.len() as f64;-        v.push((e.iter().map(|(a, _)| a.0).sum::<f64>() / n, e.iter().map(|(a, _)| a.1).sum::<f64>() / n));+        v.push(m);+        let (dx, dy) = (b.0 - a.0, b.1 - a.1);+        let l = (dx * dx + dy * dy).sqrt();+        if l > 1e-9 {+            let n = (-dy / l * D, dx / l * D);+            for q in [(m.0 + n.0, m.1 + n.1), (m.0 - n.0, m.1 - n.1)] {+                if inside(q, e) {+                    v.push(q);+                }+            }+        }     }     v }
rust/crates/kicad-core/src/schematic.rs+68
@@ -722,6 +722,74 @@ impl Schematic {         Ok(uuid)     } +    /// Where each pin of a placed symbol connects, in sheet millimetres (y down), every unit placed+    /// under that reference: [(pin number, (x, y))]. Library pins are y up; the symbol's angle turns+    /// them counterclockwise as seen, then its mirror flips them (KiCad's order).+    pub fn pin_points(&self, reference: &str) -> Result<Vec<(String, (f64, f64))>, String> {+        let mut out = Vec::new();+        let mut found = false;+        for inst in self.top().iter().filter(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)) {+            found = true;+            let lib_key = inst.child_value("lib_name").or_else(|| inst.child_value("lib_id")).unwrap_or_default();+            let Some(lib) = self.lib_symbol(&lib_key) else { return Err(format!("{reference}: its library symbol {lib_key} is not in the sheet's lib_symbols")) };+            let base = lib_key.rsplit(':').next().unwrap_or(&lib_key).to_string();+            let unit = inst.child("unit").and_then(|u| u.num(0)).unwrap_or(1.0) as u32;+            let at = inst.child("at").ok_or("symbol has no (at ...)")?;+            let (sx, sy, rot) = (at.num(0).unwrap_or(0.0), at.num(1).unwrap_or(0.0), at.num(2).unwrap_or(0.0));+            let mirror = inst.child_value("mirror");+            let (sin, cos) = rot.to_radians().sin_cos();+            for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {+                let Some(name) = sub.arg(0) else { continue };+                let Some(u) = sub_unit(&name, &base) else { continue };+                if u != 0 && u != unit {+                    continue;+                }+                for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {+                    let (Some(num), Some(pat)) = (pin.child_value("number"), pin.child("at")) else { continue };+                    let (px, py) = (pat.num(0).unwrap_or(0.0), pat.num(1).unwrap_or(0.0));+                    let (mut qx, mut qy) = (px * cos - py * sin, px * sin + py * cos);+                    match mirror.as_deref() {+                        Some("x") => qy = -qy,+                        Some("y") => qx = -qx,+                        _ => {}+                    }+                    let r = |v: f64| (v * 1e4).round() / 1e4 + 0.0;+                    out.push((num, (r(sx + qx), r(sy - qy))));+                }+            }+        }+        if !found {+            return Err(format!("no symbol with reference {reference} on this sheet"));+        }+        Ok(out)+    }++    /// A placed symbol's pins as (number, name), from its library symbol.+    pub fn pin_names(&self, reference: &str) -> Vec<(String, String)> {+        let mut out = Vec::new();+        for inst in self.top().iter().filter(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)) {+            let lib_key = inst.child_value("lib_name").or_else(|| inst.child_value("lib_id")).unwrap_or_default();+            let Some(lib) = self.lib_symbol(&lib_key) else { continue };+            for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {+                for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {+                    if let (Some(num), Some(name)) = (pin.child_value("number"), pin.child_value("name")) {+                        if !out.iter().any(|(n, _): &(String, String)| *n == num) {+                            out.push((num, name));+                        }+                    }+                }+            }+        }+        out+    }++    /// A no-connect flag at `at` (on a pin's connection point): `(no_connect (at x y) (uuid ..))`.+    pub fn add_no_connect(&mut self, at: (f64, f64)) -> Result<String, String> {+        let uuid = new_uuid();+        self.insert_item(Node::list("no_connect", vec![Node::list("at", vec![Node::number(at.0), Node::number(at.1)]), Node::list("uuid", vec![Node::quoted(&uuid)])]));+        Ok(uuid)+    }+     /// Move a symbol (and its fields with it) so its anchor lands on `to`.     pub fn move_symbol(&mut self, reference: &str, to: (f64, f64)) -> Result<(String, (f64, f64)), String> {         let idx = self.find_symbol_index(reference).ok_or_else(|| format!("no symbol with reference {reference} on this sheet"))?;
rust/crates/kicad-core/tests/board_model.rsadded+66
@@ -0,0 +1,66 @@+//! The standard board model on the boards this page ships, checked against the parser the routing+//! verbs already use (`pcb::pads`, `segments`, `vias`): same pads at the same place, same copper.+use std::path::PathBuf;++use kicad_core::board_model::{build, revision_of_text, Opts};+use kicad_core::pcb;++fn repo(rel: &str) -> PathBuf {+    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)+}++const BOARDS: &[&str] = &[+    "demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb",+    "demo/placement/esc-g431-unrouted.kicad_pcb",+    "tour-pack-rp2040/template/rp2040-breakout.kicad_pcb",+    "docs/autorouting/live-routing.kicad_pcb",+];++#[test]+fn pads_and_copper_agree_with_the_routing_parser() {+    for rel in BOARDS {+        let path = repo(rel);+        let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{rel}: {e}"));+        let legacy = pcb::parse_pcb_text(&text, &path.to_string_lossy()).unwrap();+        let k = build(&text, None, &Opts { y_up: false }).unwrap();+        let s = build(&text, None, &Opts::default()).unwrap();+        assert_eq!(k["revision"], s["revision"], "{rel}: the revision does not depend on the frame");+        let old = pcb::pads(&legacy);+        let new = k["pads"].as_array().unwrap();+        let mut checked = 0;+        for p in &old {+            if p.layers.iter().all(|l| !l.ends_with(".Cu")) {+                continue;+            }+            let hit = new.iter().find(|n| n["ref"] == p.reference.as_str() && n["name"] == p.pad.as_str()+                && (n["x"].as_f64().unwrap() - p.x).abs() < 1e-5 && (n["y"].as_f64().unwrap() - p.y).abs() < 1e-5);+            let hit = hit.or_else(|| new.iter().find(|n| n["ref"] == p.reference.as_str() && n["name"] == p.pad.as_str() && n.get("anchor").is_some()+                && (n["anchor"]["x"].as_f64().unwrap() - p.x).abs() < 1e-5 && (n["anchor"]["y"].as_f64().unwrap() - p.y).abs() < 1e-5));+            let hit = hit.unwrap_or_else(|| panic!("{rel}: pad {} at ({}, {}) not in the model", p.name, p.x, p.y));+            assert_eq!(hit["net"], p.net_name.as_str(), "{rel}: {}", p.name);+            // y-up is the same pad mirrored+            let i = new.iter().position(|n| std::ptr::eq(n, hit)).unwrap();+            let up = &s["pads"][i];+            assert!((up["y"].as_f64().unwrap() + hit["y"].as_f64().unwrap()).abs() < 1e-9);+            checked += 1;+        }+        assert!(checked > 0, "{rel}");+        assert_eq!(k["counts"]["tracks"].as_u64().unwrap() as usize, legacy.segments.len(), "{rel}");+        assert_eq!(k["counts"]["vias"].as_u64().unwrap() as usize, legacy.vias.len(), "{rel}");+        assert_eq!(k["counts"]["components"].as_u64().unwrap() as usize, legacy.footprints.len(), "{rel}");+        assert!(k["outlineEdges"].as_array().unwrap().len() >= 4, "{rel}: a closed outline");+        assert!(k.get("outlineOpenEdges").is_none(), "{rel}");+        assert!(k["pads"].as_array().unwrap().iter().all(|p| p["sizeX"].as_f64().unwrap() > 0.0 && p["sizeY"].as_f64().unwrap() > 0.0), "{rel}: no 0 x 0 pad");+    }+}++#[test]+fn revision_ignores_silkscreen_and_follows_copper() {+    let text = std::fs::read_to_string(repo("docs/autorouting/live-routing.kicad_pcb")).unwrap();+    let r0 = revision_of_text(&text).unwrap();+    let at = text.rfind(')').unwrap();+    let silk = format!("{}(gr_text \"note\" (at 1 1) (layer \"F.SilkS\") (effects (font (size 1 1))))\n)", &text[..at]);+    assert_eq!(revision_of_text(&silk).unwrap(), r0);+    let copper = format!("{}(segment (start 1 1) (end 2 1) (width 0.2) (layer \"F.Cu\") (net 0))\n)", &text[..at]);+    assert_ne!(revision_of_text(&copper).unwrap(), r0);+}
rust/crates/kicad-core/tests/no_connect.rsadded+51
@@ -0,0 +1,51 @@+//! Wiki #131: pin connection points, checked against KiCad's own demo sheets: every no-connect flag+//! KiCad placed sits on a pin the bridge computes (rotated and mirrored symbols included).+use kicad_core::schematic::Schematic;+use std::path::PathBuf;++#[test]+fn every_demo_no_connect_flag_lands_on_a_computed_pin() {+    let dir = PathBuf::from(std::env::var("KICAD_DEMO_SCH").unwrap_or_else(|_| format!("{}/../../../../fixtures/sch", env!("CARGO_MANIFEST_DIR"))));+    let Ok(rd) = std::fs::read_dir(&dir) else { eprintln!("no demo sheets at {}; skipped", dir.display()); return };+    let (mut flags, mut hit) = (0, 0);+    let mut misses = Vec::new();+    for e in rd.flatten().filter(|e| e.path().extension().map_or(false, |x| x == "kicad_sch")) {+        let text = std::fs::read_to_string(e.path()).unwrap();+        let sch = Schematic::parse(&text).unwrap();+        let refs: std::collections::BTreeSet<String> = sch.symbols().into_iter().map(|s| s.reference).collect();+        let mut pins = Vec::new();+        for r in &refs {+            if let Ok(p) = sch.pin_points(r) {+                pins.extend(p.into_iter().map(|(n, at)| (r.clone(), n, at)));+            }+        }+        for line in text.lines().collect::<Vec<_>>().windows(2) {+            if line[0].trim() == "(no_connect" {+                let nums: Vec<f64> = line[1].trim().trim_start_matches("(at").trim_end_matches(')').split_whitespace().filter_map(|t| t.parse().ok()).collect();+                if nums.len() < 2 { continue }+                flags += 1;+                if pins.iter().any(|(_, _, (x, y))| (x - nums[0]).abs() < 1e-3 && (y - nums[1]).abs() < 1e-3) {+                    hit += 1;+                } else {+                    misses.push(format!("{}: ({}, {})", e.file_name().to_string_lossy(), nums[0], nums[1]));+                }+            }+        }+    }+    eprintln!("{hit} of {flags} no-connect flags on a computed pin");+    assert!(flags == 0 || hit == flags, "{} missed: {:?}", misses.len(), &misses[..misses.len().min(8)]);+}++#[test]+fn a_flag_lands_on_a_named_pin_of_the_fixture_sheet() {+    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../tour-pack-rp2040/template/rp2040-breakout.kicad_sch");+    let mut sch = Schematic::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();+    let pins = sch.pin_points("U1").unwrap();+    let (_, at) = pins.iter().find(|(n, _)| n == "1").expect("U1 pin 1");+    let uuid = sch.add_no_connect(*at).unwrap();+    let text = sch.to_text();+    let again = Schematic::parse(&text).unwrap();+    assert!(text.contains(&format!("(uuid \"{uuid}\")")));+    assert!(again.pin_points("U1").unwrap().iter().any(|(_, p)| p == at));+    assert!(text.contains("(no_connect"));+}
rust/crates/kicad-core/tests/ring_courtyards.rsadded+38
@@ -0,0 +1,38 @@+//! Wiki #133: concentric ring courtyards (two F.CrtYd circles each, an annulus) do not overlap, and a+//! board edge drawn as one large arc bounds the outline by its real extent.+use kicad_core::{pcb, placement};++fn ring(reference: &str, d_in: f64, d_out: f64) -> String {+    format!("(footprint \"x:{reference}\" (layer \"F.Cu\") (at 100 100) (property \"Reference\" \"{reference}\")\+        (fp_circle (center 0 0) (end {} 0) (layer \"F.CrtYd\") (stroke (width 0.05)))\+        (fp_circle (center 0 0) (end {} 0) (layer \"F.CrtYd\") (stroke (width 0.05))))", d_in / 2.0, d_out / 2.0)+}++#[test]+fn nested_ring_courtyards_do_not_overlap() {+    // the sensor from the report: electrodes and guards, all on one centre+    let fps = [ring("E2", 23.0, 31.95), ring("G2", 43.8, 47.7), ring("E4", 48.0, 56.95), ring("G3", 57.25, 59.25)].concat();+    let text = format!("(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal)) {fps})");+    let board = pcb::parse_pcb_text(&text, "t.kicad_pcb").unwrap();+    let o = placement::overlaps(&placement::placements(&board));+    assert!(o.is_empty(), "{o:?}");+}++#[test]+fn a_large_edge_arc_bounds_the_outline_by_its_bulge() {+    // a 146 mm ring edge drawn as one arc of about 340 degrees: start and end low, mid at the top+    let r = 73.0_f64;+    let (cx, cy) = (100.0, 110.0);+    let a = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+    let (s, e, m) = (a(100.0), a(80.0), a(270.0));+    let text = format!("(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal) (25 \"Edge.Cuts\" user))\+        (gr_arc (start {} {}) (mid {} {}) (end {} {}) (layer \"Edge.Cuts\") (stroke (width 0.1)))\+        (gr_line (start {} {}) (end {} {}) (layer \"Edge.Cuts\") (stroke (width 0.1)))\+        (gr_circle (center {cx} {cy}) (end {} {cy}) (layer \"Edge.Cuts\") (stroke (width 0.1))))",+        s.0, s.1, m.0, m.1, e.0, e.1, e.0, e.1, s.0, s.1, cx + 22.5);+    let board = pcb::parse_pcb_text(&text, "t.kicad_pcb").unwrap();+    let o = placement::outline(&board).unwrap();+    assert!((o.min_x - (cx - r)).abs() < 0.01 && (o.max_x - (cx + r)).abs() < 0.01, "{o:?}");+    assert!((o.min_y - (cy - r)).abs() < 0.01, "{o:?}");+}+
skills/kicad-board-model/SKILL.mdadded+69
@@ -0,0 +1,69 @@+---+name: kicad-board-model+description: Read a KiCad board the way every Adom EDA bridge reads one (the EDA Bridge Standard board model): kicad_routing_state {model:"standard"} gives tracks, arcs, vias, pads with sizes, parts, pours, the outline with its arcs, unconnected counted from the copper and a revision that only moves with copper, parts or the outline, in mm with y up, the same shape altium_routing_state and fusion_routing_state answer. Its companions are kicad_layer_stack (stack-up, copper and dielectric thickness, Dk), kicad_pour_state (copper actually poured), kicad_bodies (3D body boxes and part heights from the model files) and kicad_netlist (nets as Ref.Pin, pin electrical types, expect comparison). Works live from the PCB editor or from the .kicad_pcb file without KiCad running. Trigger words: board summary, list nets, list designators, component pins, component data, layers in use, board model, read the whole board, board as data, standard board model, eda bridge standard, routing_state standard, stack-up, layer stack, dielectric thickness, Dk, copper weight, poured copper, filled area, 3d bodies, part heights, tallest part, component height, netlist, pin types, compare the netlist, check the nets, same as altium, cross-tool, frame standard, y up.+---++# The board model: one shape for every EDA tool++`kicad_routing_state {"model":"standard"}` answers the board as the EDA Bridge Standard defines it+(adom/eda-bridge-standard, `schema/board.schema.json`). Altium and Fusion answer the same shape from+`altium_routing_state` and `fusion_routing_state`, so a check, a planner or an AI that reads one reads+them all. Without `model` the verb answers its older KiCad shape (segments, pad groups measured by+KiCad), which AI Flow reads: both stay.++## Where it reads from++- **Live** when the PCB editor has the board and KiCad's IPC API server is on (`kicad_ipc_api`): the+  editor's own serialisation, unsaved edits included. `filePath` may be left out when one board is open.+- **The file** otherwise (or with `source:"file"`): no KiCad needed, but unsaved edits are not in it.+  The reply says which (`source`), and `live` says why a live read was not possible.++## The rules it keeps++| Rule | What you see |+|---|---|+| Millimetres, absolute, **y up** | KiCad's y is negated. `frame:"kicad"` keeps KiCad's y down. Angles are KiCad's own (counterclockwise as seen) in both frames |+| KiCad layer names | `F.Cu`, `In1.Cu`..`In30.Cu`, `B.Cu`; `*.Cu` is a pad on every copper layer (a partial set adds `layers`) |+| Pad shapes | `circle`, `round` (KiCad's oval: a stadium), `rect`, `roundrect`; `kicadShape` keeps KiCad's own name when it differs |+| Custom pads are their bounding rectangle | `shape:"rect"`, rotation 0, `kicadShape:"custom"`, `anchor` = KiCad's anchor (where tracks land), and `copperPoints` (the real outline) when the copper is one drawn polygon over the anchor; pads drawn from several shapes keep only the rectangle |+| Net-less copper is kept | `net:""`: free copper shapes, pours on no net; footprint copper carries `ref` |+| Outline with arcs | `outlineEdges` (line, or arc with center, radius, angle1 to angle2 counterclockwise), `outline` = their start points, `cutouts` for inner loops, `keepouts` for rule areas |+| `unconnected` from the copper | pads, tracks, arcs, vias and the poured copper joined where they touch on a shared layer. `splitNets` names each piece's pads (what to join). An isolated pour island is `isolatedCopper` (`{net, layer, at, areaMm2}`), not a connection to make (KiCad's DRC warns about it the same way) |+| `revision` moves only with copper, parts or outline | silkscreen, text and refills leave it alone; the order KiCad lists items in does not matter |++Checked against KiCad's own DRC on six of KiCad 10's demo boards (StickHub, CM5 Minima, RoyalBlue54L+Feather, Tiny Tapeout, pic_programmer, microwave): the same unconnected count on every one.++## Editing with it++- Pass `revision` as `expectedRevision` to `kicad_route_net`, `kicad_move_footprint`, the zone and silk+  verbs: they accept it (and their older 64-character revision). A change to copper, parts or the+  outline since you read it refuses with `stale_board` and `currentRevision`, nothing written. After a+  call made with it, the reply's `revision` is the new standard one.+- Pass `frame:"standard"` to the edit verbs to give coordinates exactly as the model reports them (y up):+  `kicad_route_net`, `kicad_move_footprint`, `kicad_add_zone`, `kicad_apply_zone_plan`, `kicad_add_via`,+  `kicad_add_track`, `kicad_route`. Without it they take KiCad's y down. Pad names (`"U1.3"`) work in both.++## The companions++| Verb | Standard name | What |+|---|---|---|+| `kicad_layer_stack` | layer_stack | copper layers top to bottom: copperUm, the dielectric BELOW each (dielectricUm, dk1000 = Dk x 1000, material), planes with their net, masks, finish. `fromDefaults:true` when the board has no stack-up of its own |+| `kicad_pour_state` | pour_state | every zone per layer: filledMm2 (poured, holes cut), islands, `unfilled`, `filledAreaByLayerMm2`; `geometry:false` for a small reply |+| `kicad_bodies` | bodies | every part's 3D body as a box: x0..x1, y0..y1 on the board, z0..z1 above its surface, `tallest`; `noBody` lists parts a height check cannot see (no model, file missing) |+| `kicad_netlist` | netlist | `{net: ["U1.8", ...]}` across every sheet; `expect` compares pin by pin (`mismatches`); `parts:true` gives each part's pins with electrical types for a schematic review |++## Quick reads++Views of the same model, by the standard's names, for when the whole board is more than you need:+`kicad_board_summary`, `kicad_list_nets`, `kicad_list_designators`, `kicad_copper_layers`, `kicad_layers_in_use`,+`kicad_component_data {designator?}`, `kicad_component_pins {designator}`. All in mm in the standard's frame, live or+from the file like the model.++## Pitfalls++- A file read sees what was last saved, including the last fill: refill before trusting pour areas.+- 3D heights come from the model files (a STEP's vertices and circular edges, a VRML's points); a+  footprint naming `.wrl` reads the `.step` beside it, as KiCad 10 does. Embedded models are in `noBody`.+- KiCad 9 wrote some teardrop blocks without an opening parenthesis; the read repairs that one known+  case and says so (`repairedOnRead`). Any other damaged file is refused with the line where it breaks.
skills/kicad-bridge-publish/SKILL.md+20−3
@@ -248,6 +248,23 @@ with that page's skills package (adom/wiki#170). The zip stays here.  `tools/build_native_release.py --publish` signs `kicad-bridge.exe` as Adom Industries, Inc. as the LAST change to it, then checks that the exe inside the zip carries the signature, and refuses to publish otherwise. Windows 11-Smart App Control blocks unsigned exes; every native build up to 1.0.52 shipped unsigned. The signer is a private-tool outside this repo (`ADOM_SIGN_EXE`, default `~/bin/adom-sign-exe`); its account details never go in this repo,-a skill, a README or an issue. `--sign` signs a local build without publishing.+Smart App Control blocks unsigned exes; every native build up to 1.0.52 shipped unsigned. `--sign` signs a local+build without publishing. Two signers, one certificate:++- **A local helper** (`ADOM_SIGN_EXE`, default `~/bin/adom-sign-exe`) when one is installed.+- **Otherwise Azure Trusted Signing on the signing host** (from 1.0.59, `tools/sign_azure.py`), the path every+  Adom release uses (altium-bridge's `release.py`). Its private config (`config.json` + `sign-verify.ps1`) lives+  in `$ADOM_SIGNING_DIR`, else `~/.adom/signing/kicad-bridge`, else `~/.adom/signing/altium-bridge`. The script:+  1. stages the exe on the host (`send_files`, a folder name without dots);+  2. signs and verifies it there with one `run_script` (Status Valid, the expected subject, a Microsoft+     timestamp, `signtool verify /pa`);+  3. pulls it back, checks the sha256 and the signer `CN = "Adom Industries, Inc."` here, and writes+     `rust/dist/signing-report.json`.++  `az_token_unavailable` means the host's Azure login lapsed: renew it with a device-code `az login` on the host+  and have the account owner approve it.++The account details never go in this repo, a skill, a README, an issue or a reply.++The public manifest is `adom-bridge-kicad-manifest.json` on the page (bridge.json `updateManifestUrl`). An+insiders release never writes or pushes it: only `tools/promote.py` does, with a person's words.
skills/kicad-bridge-test/run_verb_tests.py+49−2
@@ -91,6 +91,23 @@ CHECKS = {     # placement (1.0.3): live when the PCB editor has the board on the IPC API, the file otherwise     "placement_state": lambda r: ok(r) and "revision" in r and isinstance(r.get("footprints"), list)                                  and len(r["footprints"]) > 0 and "totalMm" in (r.get("ratsnest") or {}),+    # the EDA Bridge Standard board model and its companions (1.0.59), read from the fixture's file+    "standard_model": lambda r: ok(r) and r.get("units") == "mm" and len(r.get("pads") or []) > 0+                                and len(r.get("revision") or "") == 16 and isinstance(r.get("unconnected"), int)+                                and len(r.get("outlineEdges") or []) >= 3,+    "layer_stack": lambda r: ok(r) and (r.get("copperLayers") or 0) >= 2 and "dielectricUm" in (r.get("layers") or [{}])[0],+    "pour_state": lambda r: ok(r) and isinstance(r.get("zones"), list) and "filledAreaByLayerMm2" in r,+    "bodies": lambda r: ok(r) and (r.get("parts") or 0) > 0 and isinstance(r.get("bodies"), list)+                        and len(r["bodies"]) + len(r.get("noBody") or []) >= r["parts"],+    "std_netlist": lambda r: ok(r) and (r.get("netCount") or 0) > 0 and isinstance(r.get("nets"), dict)+                             and any(p.get("pins") for p in (r.get("parts") or [])),+    "board_summary": lambda r: ok(r) and (r.get("summary") or {}).get("components", 0) > 0,+    "count_list": lambda r: ok(r) and (r.get("count") or 0) > 0,+    "no_connect_placed": lambda r: ok(r) and bool(r.get("uuid")) and isinstance(r.get("at"), list) and r.get("pin") == "U1.1",+    "net_list": lambda r: ok(r) and isinstance(r.get("nets"), list) and r.get("count") == len(r["nets"]),+    "layers_in_use": lambda r: ok(r) and len(r.get("copperLayersInUse") or []) > 0,+    "component_data": lambda r: ok(r) and (r.get("component") or {}).get("pads", 0) > 0,+    "component_pins": lambda r: ok(r) and (r.get("count") or 0) > 0,     "move_dry": lambda r: ok(r) and r.get("dryRun") is True and r.get("mutated") is False                           and "checks" in r and r.get("undoSteps") == 0,     "taskbar_report": lambda r: ok(r) and r.get("errors") == 0 and ("already" in r or "written" in r),@@ -153,6 +170,20 @@ def matrix(w: dict) -> dict:         "run_erc":           dict(phase=2, args={"filePath": S}, timeout=200),         "lint_board":        dict(phase=2, args={"filePath": B}, timeout=200),         "lint_schematic":    dict(phase=2, args={"filePath": S}, timeout=200),+        # ---- phase 2a: the EDA Bridge Standard board model and companions (1.0.59), from the file+        "layer_stack":       dict(phase=2, args={"filePath": B, "source": "file"}, check="layer_stack"),+        "pour_state":        dict(phase=2, args={"filePath": B, "source": "file", "geometry": False}, check="pour_state"),+        "bodies":            dict(phase=2, args={"filePath": B, "source": "file"}, check="bodies", timeout=120,+                                  note="3D model bounds from the files the footprints name; every part is a body or in noBody"),+        "netlist":           dict(phase=2, args={"filePath": S, "parts": True}, check="std_netlist", timeout=120),+        "board_summary":     dict(phase=2, args={"filePath": B, "source": "file"}, check="board_summary"),+        "list_nets":         dict(phase=2, args={"filePath": B, "source": "file"}, check="net_list",+                                  note="the rp2040 fixture board carries no nets: an empty list is the right answer"),+        "list_designators":  dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),+        "copper_layers":     dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),+        "layers_in_use":     dict(phase=2, args={"filePath": B, "source": "file"}, check="layers_in_use"),+        "component_data":    dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_data"),+        "component_pins":    dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_pins"),         # ---- phase 2b: native-build verbs (Rust port). All read-only or on the fixture copy.         "freerouting":       dict(phase=1, args={"action": "status"}, timeout=60,                                   note="status only: install is on demand and asks the user first"),@@ -175,6 +206,8 @@ def matrix(w: dict) -> dict:                                   note="fixture copy only; the .bak is removed with the fixture dir"),         "sch_wire":          dict(phase=2.2, args={"filePath": S, "points": [[200, 146.19], [200, 140]]}, timeout=150),         "sch_label":         dict(phase=2.2, args={"filePath": S, "text": "VERBTEST_NET", "at": [200, 140], "kind": "local"}, timeout=150),+        "sch_no_connect":    dict(phase=2.2, args={"filePath": S, "pin": "U1.1"}, check="no_connect_placed", timeout=150,+                                  note="wiki #131: the flag lands on U1 pin 1's connection point, worked out from the symbol"),         "sch_set_property":  dict(phase=2.3, args={"filePath": S, "reference": "R_VERBTEST", "name": "Value", "value": "2k2"}, timeout=150),         "sch_move":          dict(phase=2.4, args={"filePath": S, "reference": "R_VERBTEST", "to": [210, 150]}, timeout=150),         "sch_delete":        dict(phase=2.5, args={"__special": "sch_delete"}, timeout=150,@@ -184,6 +217,9 @@ def matrix(w: dict) -> dict:         "export_gerber":     dict(phase=2, args={"filePath": B, "outputDir": OUT + "/gerbers"}, timeout=220),         "export_pdf":        dict(phase=2, args={"filePath": B, "outputPath": OUT + "/board.pdf"}, timeout=220),         "export_svg":        dict(phase=2, args={"filePath": B, "outputDir": OUT + "/svg"}, timeout=220),+        "export_pos":        dict(phase=2, args={"filePath": B, "outputDir": OUT + "/pos", "fab": "jlcpcb"}, timeout=150),+        "render_board":      dict(phase=2, args={"filePath": B, "outputDir": OUT + "/render", "side": "top", "width": 800, "height": 450}, timeout=220,+                                  note="kicad-cli pcb render (KiCad 9+), headless"),         "export_step":       dict(phase=2, args={"filePath": B, "outputPath": OUT + "/board.step"}, timeout=320),         "export_bom_csv":    dict(phase=2, args={"filePath": S, "outputPath": OUT + "/bom.csv"}, timeout=200),         "export_molecule":   dict(phase=2, args={"filePath": B, "outputDir": OUT + "/molecule"}, timeout=320),@@ -783,7 +819,12 @@ def main():                 if ok(st) and st.get("revision"):                     break                 time.sleep(5)-            record("routing_state", st, ok(st) and bool(st.get("revision")) and isinstance(st.get("pads"), (list, str)))+            # the standard board model, live: the same board, the revision the edit verbs accept+            sm = ab(a.target, "kicad_routing_state", {"filePath": L, "model": "standard"}, timeout=150)+            std_ok = CHECKS["standard_model"](sm) and sm.get("source") == "live-editor"+            record("routing_state", {"legacy": st, "standard": {k: sm.get(k) for k in ("success", "source", "revision", "unconnected", "counts", "errorCode", "error")}},+                   ok(st) and bool(st.get("revision")) and isinstance(st.get("pads"), (list, str)) and std_ok,+                   None if std_ok else "the standard model did not read live")             rev = [st.get("revision")]             pads = st.get("pads") or []             if isinstance(pads, str):@@ -824,8 +865,14 @@ def main():             if routed is not None:                 record("route_net", routed, ok(routed) and bool(routed.get("itemIds")))                 ids = routed.get("itemIds") or []+                # wiki #134: a dry run must leave the copper where it is+                d = step("remove_route", {"filePath": L, "expectedRevision": rev[0], "itemIds": ids, "dryRun": True, "reason": WHY}, chain=False)+                after = ab(a.target, "kicad_routing_state", {"filePath": L}, timeout=150)+                still = {x.get("uuid") for x in (after.get("segments") or [])} | {x.get("uuid") for x in (after.get("vias") or [])}+                dry_ok = ok(d) and d.get("mutated") is False and all(i in still for i in ids)                 r = step("remove_route", {"filePath": L, "expectedRevision": rev[0], "itemIds": ids, "reason": WHY})-                record("remove_route", r, ok(r))+                record("remove_route", {"dryRun": d, "remove": r}, dry_ok and ok(r),+                       None if dry_ok else "the dry run moved copper or did not answer mutated:false")             else:                 well_formed = bool(last.get("errorCode")) and last.get("mutated") is not True                 record("route_net", last, well_formed, "no straight pad-to-pad hop passed DRC; the refusal path was exercised")
tools/build_native_release.py+20−7
@@ -137,14 +137,21 @@ def main():     # Smart App Control blocks unsigned exes, so a published build is ALWAYS signed as Adom     # Industries, Inc.; the signer is a private tool outside this public repo (ADOM_SIGN_EXE,     # default ~/bin/adom-sign-exe). Every native build up to 1.0.52 shipped unsigned.+    # Two signers, the same certificate: a local helper (ADOM_SIGN_EXE, ~/bin/adom-sign-exe) where one is+    # installed, else Azure Trusted Signing on the signing host through Adom Bridge (tools/sign_azure.py, the+    # path every Adom release uses; its private config is outside this repo).     signed = False     if publish or "--sign" in sys.argv:         signer = os.environ.get("ADOM_SIGN_EXE") or os.path.expanduser("~/bin/adom-sign-exe")-        if not os.path.isfile(signer):-            sys.exit(f"REFUSING: no exe signer at {signer}; an unsigned exe is never published")-        r = subprocess.run([signer, str(stage / "kicad-bridge.exe"), "--bridge", "kicad-bridge"], text=True)-        if r.returncode != 0:-            sys.exit("REFUSING: signing failed (see above); nothing was published")+        if os.path.isfile(signer):+            r = subprocess.run([signer, str(stage / "kicad-bridge.exe"), "--bridge", "kicad-bridge"], text=True)+            if r.returncode != 0:+                sys.exit("REFUSING: signing failed (see above); nothing was published")+        else:+            sys.path.insert(0, str(ROOT / "tools"))+            import sign_azure+            report = sign_azure.sign(stage / "kicad-bridge.exe", ver)+            (dist / "signing-report.json").write_text(json.dumps(report, indent=2) + "\n")         signed = authenticode_size(stage / "kicad-bridge.exe") > 0         if not signed:             sys.exit("REFUSING: the signer reported success but the exe carries no signature")@@ -189,7 +196,7 @@ def publish_zip(ver, zpath, sha, notes):     REF = "adom/kicad-bridge"     # 1. The repo must tell the same story as the zip: push every rust source file.     src = [str(p.relative_to(ROOT)) for p in sorted((RUST).rglob("*")) if p.is_file() and "target" not in p.parts and "dist" not in p.parts]-    src += ["tools/build_native_release.py"]+    src += ["tools/build_native_release.py", "tools/sign_azure.py"]     msg = f"{ver}: {notes}"     failed = push_chunked(REF, src, msg, cwd=str(ROOT))     if failed:@@ -207,7 +214,9 @@ def publish_zip(ver, zpath, sha, notes):     # ab reads every platforms entry as a download (url, sha256, size): one zip serves both OSes.     dl = {"url": m["url"], "sha256": sha, "size": zpath.stat().st_size}     m["platforms"] = {k: dict(v, **dl) for k, v in PLATFORMS.items() if v.get("supported")}-    mpath.write_text(json.dumps(m, indent=2) + "\n")+    # NOT written back to mpath: the page's adom-bridge-kicad-manifest.json IS the public tier+    # (bridge.json updateManifestUrl), so a local copy at the insiders version is one careless push+    # away from promoting it. Only tools/promote.py moves it.     # 4. INSIDERS tier only. The public manifest moves through tools/promote.py.     ins = dict(m); ins["tier"] = "insiders"     import tempfile@@ -223,6 +232,10 @@ def publish_zip(ver, zpath, sha, notes):     prior = led.read_text(encoding="utf-8")     head, _, rest = prior.partition("\n\n## ")     led.write_text((head + "\n\n" + entry + ("## " + rest if rest else "")) if rest else (prior + entry), encoding="utf-8")+    # 6. The page repo keeps the ledger (it was left local before). Never the public manifest here.+    failed = push_chunked(REF, ["RELEASE_NOTES.md"], f"{ver}: release record", cwd=str(ROOT))+    if failed:+        print("WARNING: the RELEASE_NOTES.md push failed; push it by hand")     print(f"\nSHIPPED TO INSIDERS ({ver}, native). Public tier untouched.")     print(f"  Promote with: python3 tools/promote.py {ver} --because \"<your words>\"")     print("  Boxes pick it up on the 4 h poll, or now with bridge_install {manifestUrl: the insiders version.json, force: true}")
tools/sign_azure.pyadded+236
@@ -0,0 +1,236 @@+#!/usr/bin/env python3+"""Authenticode-sign kicad-bridge.exe as Adom Industries, Inc. through Azure Trusted Signing, the path every+Adom release uses (adom/altium-bridge scripts/release.py, sign_pe_files).++The signing runs on a Windows host reached through Adom Bridge, under that host's own Azure login. Every+identifier (host, staging folder, endpoint, account, profile, tenant, timestamp URL, expected subject) and the+sign-verify.ps1 script live in a PRIVATE folder outside this public repo: $ADOM_SIGNING_DIR, else+~/.adom/signing/kicad-bridge, else ~/.adom/signing/altium-bridge (the same account signs every Adom bridge).+Nothing from it is ever written to this repo, a reply, a log line or an issue.++  python3 tools/sign_azure.py <exe> <version>     # sign one exe in place; exits non-zero unless verified++On the signing machine itself (this WSL runs on the config's host) it signs locally through powershell.exe,+staging under the host's %LOCALAPPDATA%; elsewhere it goes through Adom Bridge. The flow, each step a gate:+  1. stage the exe and sign-verify.ps1 on the host (send_files; the folder name has no dots);+  2. one run_script signs and verifies there: Get-AuthenticodeSignature Valid, the expected subject,+     a Microsoft timestamp, signtool verify /pa;+  3. pull the signed exe back, check its sha256 matches the host's and differs from the unsigned build;+  4. read its Authenticode signer here (openssl): CN = "Adom Industries, Inc.".+"""+import base64+import hashlib+import json+import os+import shutil+import struct+import subprocess+import sys+import tempfile+from pathlib import Path++SIGNER_ORG = "Adom Industries, Inc."+THREAD = "kicad-bridge release signing"+++def signing_dir():+    env = os.environ.get("ADOM_SIGNING_DIR")+    if env:+        return Path(env)+    for name in ("kicad-bridge", "altium-bridge"):+        d = Path.home() / ".adom" / "signing" / name+        if (d / "config.json").is_file():+            return d+    return Path.home() / ".adom" / "signing" / "kicad-bridge"+++def authenticode_subjects(path):+    """Certificate subjects in a PE's Authenticode signature, read without Windows: the security data+    directory points at a WIN_CERTIFICATE holding PKCS#7 DER. [] means unsigned."""+    b = Path(path).read_bytes()+    pe = struct.unpack_from("<I", b, 0x3C)[0]+    if b[pe:pe + 4] != b"PE\0\0":+        raise SystemExit(f"{path}: not a PE file")+    opt = pe + 24+    magic = struct.unpack_from("<H", b, opt)[0]+    dirs = opt + (112 if magic == 0x20B else 96)+    off, size = struct.unpack_from("<II", b, dirs + 4 * 8)+    if not off or not size:+        return []+    length, _rev, ctype = struct.unpack_from("<IHH", b, off)+    if ctype != 2:+        return []+    with tempfile.NamedTemporaryFile(suffix=".p7b", delete=False) as f:+        f.write(b[off + 8:off + length])+    r = subprocess.run(["openssl", "pkcs7", "-inform", "DER", "-in", f.name, "-print_certs", "-noout"], capture_output=True, text=True)+    os.unlink(f.name)+    return [l[len("subject="):].strip() for l in r.stdout.splitlines() if l.startswith("subject=")]+++def assert_signed(path):+    subs = authenticode_subjects(path)+    if not any(f'CN = "{SIGNER_ORG}"' in s for s in subs):+        raise SystemExit(f"REFUSED: {path} is not Authenticode-signed by {SIGNER_ORG} (found {subs or 'no signature'})")+    return subs+++def ab_json(*args, timeout=600):+    out = subprocess.run(["adom-bridge", *args], capture_output=True, text=True, timeout=timeout).stdout+    d = json.loads(out[out.find("{"):]) if "{" in out else {}+    o = d.get("output")+    if isinstance(o, str) and o.strip().startswith("{"):+        try:+            d = json.loads(o)+        except ValueError:+            pass+    return d+++POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"+++def redact(text, cfg):+    """A message with every private config value blanked: the signing script's hints name the tenant."""+    text = str(text)+    for k in ("endpoint", "account", "profile", "tenant", "timestampUrl", "stageDir"):+        v = str(cfg.get(k) or "")+        if len(v) > 3:+            text = text.replace(v, f"<{k}>")+    return text+++def local_windows_host():+    """This machine's Windows name when we run in WSL on it (the signing machine itself), else None."""+    if not os.path.exists(POWERSHELL):+        return None+    r = subprocess.run(["/mnt/c/Windows/System32/cmd.exe", "/c", "hostname"], capture_output=True, text=True, cwd="/mnt/c")+    return r.stdout.strip() or None+++def checks_of(v, cfg):+    return {+        "Status Valid": v.get("status") == "Valid",+        "signer subject": cfg["expectSubject"] in (v.get("subject") or ""),+        "timestamped": "Microsoft" in (v.get("timestamper") or ""),+        "signtool verify /pa": "Successfully verified" in (v.get("signtoolVerify") or ""),+    }+++def sign_locally(exe, ver, cfg, ps1):+    """On the signing machine itself (WSL on it): stage under its own %LOCALAPPDATA%, run sign-verify.ps1+    with powershell.exe, no Adom Bridge round trip."""+    win = lambda p: subprocess.run(["wslpath", "-w", str(p)], capture_output=True, text=True).stdout.strip()+    lad = subprocess.run(["/mnt/c/Windows/System32/cmd.exe", "/c", "echo %LOCALAPPDATA%"], capture_output=True, text=True, cwd="/mnt/c").stdout.strip()+    stage_win = cfg["stageDir"].replace("%LOCALAPPDATA%", lad) + "\\kicad-build-" + ver.replace(".", "_")+    stage = Path(subprocess.run(["wslpath", "-u", stage_win], capture_output=True, text=True).stdout.strip())+    shutil.rmtree(stage, ignore_errors=True)+    stage.mkdir(parents=True)+    unsigned_sha = hashlib.sha256(exe.read_bytes()).hexdigest()+    shutil.copy2(exe, stage / "kicad-bridge.exe")+    shutil.copy2(ps1, stage / "sign-verify.ps1")+    print(f"== sign kicad-bridge.exe {ver} here (the signing machine) ==")+    r = subprocess.run([POWERSHELL, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", win(stage / "sign-verify.ps1"),+                        "-Exe", win(stage / "kicad-bridge.exe"), "-Endpoint", cfg["endpoint"], "-Account", cfg["account"],+                        "-Profile", cfg["profile"], "-Tenant", cfg["tenant"], "-Timestamp", cfg["timestampUrl"]],+                       capture_output=True, text=True, cwd="/mnt/c", timeout=540)+    out = r.stdout+    try:+        v = json.loads(out[out.find("{"):out.rfind("}") + 1])+    except ValueError:+        raise SystemExit(f"REFUSED: the signing script gave no verdict (exit {r.returncode}): {redact((out + r.stderr)[-600:], cfg)}")+    if v.get("error"):+        raise SystemExit(f"REFUSED: cannot sign ({v['error']}): {redact(v.get('detail', ''), cfg)}\n  fix: {redact(v.get('fix', ''), cfg)}")+    checks = checks_of(v, cfg)+    if not all(checks.values()):+        raise SystemExit(f"REFUSED: signature checks failed: {checks}")+    signed = stage / "kicad-bridge.exe"+    signed_sha = hashlib.sha256(signed.read_bytes()).hexdigest()+    if signed_sha == unsigned_sha:+        raise SystemExit("REFUSED: the exe is byte-identical after signing")+    subs = assert_signed(signed)+    shutil.copy2(signed, exe)+    shutil.rmtree(stage, ignore_errors=True)+    print("   " + ", ".join(f"{k} {'ok' if ok else 'FAIL'}" for k, ok in checks.items()) + f"; verified again here, sha256 {signed_sha[:12]}")+    return {"file": "kicad-bridge.exe", "version": ver, "status": v["status"], "subject": SIGNER_ORG, "timestamper": v["timestamper"],+            "signtoolVerify": "Successfully verified", "sha256Unsigned": unsigned_sha, "sha256": signed_sha,+            "localSubjects": [x for x in subs if SIGNER_ORG in x], "where": "signing machine (local)"}+++def sign(exe, ver):+    """Sign `exe` in place: here when this is the signing machine (WSL on it), else on the signing host+    through Adom Bridge. Returns the signing report (no private values in it)."""+    exe = Path(exe)+    sd = signing_dir()+    cfg_p, ps1 = sd / "config.json", sd / "sign-verify.ps1"+    if not cfg_p.is_file() or not ps1.is_file():+        raise SystemExit(f"REFUSED: private signing config missing at {sd} (config.json + sign-verify.ps1). It is "+                         f"deliberately not in this public repo; ask Colby to copy it over privately.")+    cfg = json.loads(cfg_p.read_text())+    host = cfg["host"]+    here = local_windows_host()+    if here and here.lower() == host.lower():+        return sign_locally(exe, ver, cfg, ps1)+    lad = cfg.get("localAppData") or (ab_json("--target", host, "--ai-thread", THREAD, "run_script", json.dumps({+        "interpreter": "powershell", "scriptB64": base64.b64encode(b"$env:LOCALAPPDATA").decode(), "timeout": 30,+        "reason": "read the signing host's LOCALAPPDATA for the staging folder"})).get("stdout") or "").strip()+    if not lad:+        raise SystemExit("REFUSED: could not read the signing host's LOCALAPPDATA for the staging folder")+    stage = cfg["stageDir"].replace("%LOCALAPPDATA%", lad) \+        + "\\kicad-build-" + ver.replace(".", "_")  # a dotted folder name reads as a file path to send_files+    unsigned_sha = hashlib.sha256(exe.read_bytes()).hexdigest()+    work = Path(tempfile.mkdtemp(prefix="kicad-sign-"))+    name = "kicad-bridge.exe"+    shutil.copy2(exe, work / name)+    shutil.copy2(ps1, work / "sign-verify.ps1")+    print(f"== sign {name} {ver} on the signing host ==")+    sent = ab_json("--target", host, "--ai-thread", THREAD, "send_files", json.dumps({+        "filePaths": [str(work / name), str(work / "sign-verify.ps1")], "dest": stage, "destIsFolder": True,+        "reason": f"stage kicad-bridge {ver} for Authenticode signing"}))+    if int(sent.get("filesReceived") or 0) != 2:+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: the signing host did not receive both files: {sent.get('error') or sent.get('_hint') or 'no reply'}")+    call = (f"(& '{stage}\\sign-verify.ps1' -Exe '{stage}\\{name}' -Endpoint '{cfg['endpoint']}' -Account '{cfg['account']}' "+            f"-Profile '{cfg['profile']}' -Tenant '{cfg['tenant']}' -Timestamp '{cfg['timestampUrl']}' | Out-String)")+    cmd = f"$r = @({call}); '[' + ($r -join ',') + ']'"+    r = ab_json("--target", host, "--ai-thread", THREAD, "run_script", json.dumps({+        "interpreter": "powershell", "scriptB64": base64.b64encode(cmd.encode()).decode(), "timeout": 540,+        "reason": f"Authenticode-sign kicad-bridge {ver} as {SIGNER_ORG}, then Get-AuthenticodeSignature + signtool verify"}), timeout=900)+    so = r.get("stdout") or ""+    try:+        v = json.loads(so[so.find("["):])[0]+    except (ValueError, IndexError):+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: the signing script gave no verdict (stdout {len(so)} chars, stderr {len(r.get('stderr') or '')} chars, error {r.get('errorCode') or r.get('error') or '-'})")+    if v.get("error"):+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: the signing host cannot sign ({v['error']}): {redact(v.get('detail', ''), cfg)}\n  fix: {redact(v.get('fix', ''), cfg)}")+    checks = checks_of(v, cfg)+    if not all(checks.values()):+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: signature checks failed on the host: {checks}")+    pulled = ab_json("--target", host, "--ai-thread", THREAD, "pull_file", json.dumps({+        "filePaths": [f"{stage}\\{name}"], "saveTo": str(work / "back"), "reason": f"pull the signed kicad-bridge {ver} back for packaging"}))+    files = pulled.get("files") or []+    if not files:+        shutil.rmtree(work, ignore_errors=True)+        raise SystemExit(f"REFUSED: could not pull the signed exe back: {pulled.get('error') or 'no files'}")+    back = Path(files[0]["path"])+    signed_sha = hashlib.sha256(back.read_bytes()).hexdigest()+    if v.get("sha256") and signed_sha != v["sha256"].lower():+        raise SystemExit("REFUSED: the signed exe changed in transit (host and local sha256 differ)")+    if signed_sha == unsigned_sha:+        raise SystemExit("REFUSED: the pulled-back exe is byte-identical to the unsigned build")+    subs = assert_signed(back)+    shutil.copy2(back, exe)+    shutil.rmtree(work, ignore_errors=True)+    report = {"file": name, "version": ver, "status": v["status"], "subject": SIGNER_ORG, "timestamper": v["timestamper"],+              "signtoolVerify": "Successfully verified", "sha256Unsigned": unsigned_sha, "sha256": signed_sha,+              "localSubjects": [s for s in subs if SIGNER_ORG in s]}+    print("   " + ", ".join(f"{k} {'ok' if ok else 'FAIL'}" for k, ok in checks.items()) + f"; verified again here, sha256 {signed_sha[:12]}")+    return report+++if __name__ == "__main__":+    if len(sys.argv) != 3:+        sys.exit(__doc__)+    print(json.dumps(sign(sys.argv[1], sys.argv[2]), indent=2))