← All Pull Requests

1.0.59: EDA Bridge Standard board model, layer stack, pours, 3D bodies, netlist and core reads #21

Merged opened by Colby Knox 2026-10-26
Merges kicad-lane/board-model-1.0.59 → master

What

kicad-bridge 1.0.59: the EDA Bridge Standard board model and its companions, so one AI and the shared checks read a KiCad board exactly as they read an Altium or Fusion one.

  • kicad_routing_state {"model":"standard"} answers schema/board.schema.json:

    • mm, y up (KiCad's y negated; frame:"kicad" keeps y down), KiCad layer names;
    • custom pads as their bounding rectangle (plus anchor); net-less copper kept;
    • the outline as outlineEdges with arcs, plus cutouts and keepouts;
    • unconnected from the copper, fills included; splitNets names each piece's pads; dead pour islands go under isolatedCopper;
    • revision = FNV-1a over sorted copper, parts and outline.

    It reads live from the PCB editor or from the file; filePath is optional when one board is open. Without model the reply is byte-for-byte the old shape (AI Flow reads it).

  • Live edit verbs (route_net, remove_route, zones, silk batches, move_footprint) take the standard revision as expectedRevision. It is translated to the editor's text hash at dispatch; the old hash still works.

  • With frame:"standard", route_net, move_footprint, add_zone, apply_zone_plan, add_via, add_track and route take y-up coordinates.

  • New verbs:

    • kicad_layer_stack: stack-up, Dk, planes;
    • kicad_pour_state: poured area per zone and per layer;
    • kicad_bodies: 3D body boxes and heights from the STEP/VRML model files;
    • kicad_netlist: {net:[Ref.Pin]}, expect comparison, pin electrical types;
    • the core reads board_summary, list_nets, list_designators, copper_layers, layers_in_use, component_data, component_pins.
  • Fixes:

    • the no-ipc build (macOS type-check) compiled no more (verbs_windows.rs called kicad_core::ipc ungated);
    • root bridge.json lacked kicad_export_pos and kicad_render_board, failing its own test;
    • the model repairs KiCad 9's (curved_edges no)filter_ratio 0.9) teardrop writer bug (349 of them in KiCad 10.0.4's own RoyalBlue54L demo) and refuses any other damaged file with the line where it breaks.
  • Docs: a new kicad-board-model skill, a README section and verb list, a root SKILL.md pointer, a port-plan status row.

Base: 103c1b619652a516883fecc8e8ba35d60714ac08 (master head when pushed). branch compare: 22 files, +3084 / -29. Every deletion is a line replaced in place: catalog inputs and hints, the live dispatch now wrapped for the standard revision, the two ungated IPC calls, version 1.0.58 to 1.0.59. No dependencies on other branches.

Tests

  • cargo test --workspace (ipc on): kicad-core 274 unit + 2 new integration tests, kicad-bridge 33 (the bridge.json manifest test passes again). The integration tests match every pad on 4 repo boards against pcb::pads in position and net.
  • Still failing, not from this change: the 7 schematic_edit tests. Their fixture tour-pack/template/tour-demo.kicad_sch was never pushed (the wiki answers 404).
  • Builds: cargo check --workspace --bins (ipc), cargo check -p kicad-bridge --no-default-features, and the Windows cross-build all compile.

Native acceptance (DESKTOP-BAR6S0F, KiCad 10.0.4, this exact build)

  • Unconnected against kicad-cli DRC, six KiCad 10 demo boards: StickHub 0/0, CM5 Minima 0/0, RoyalBlue 0/0, pic_programmer 0/0, microwave 0/0, Tiny Tapeout 1/1.
  • Live StickHub (scratch copy, IPC on):
    • the live revision equals the file revision;
    • a silk edit guarded by the standard revision kept it;
    • a move changed it, and the stale one was refused with stale_board, mutated:false;
    • a frame:"standard" move restored the part and the exact original revision;
    • a frame:"standard" route_net dry run landed at KiCad's (150.4, 96.1), and the snapshot DRC was clean;
    • the legacy reply is unchanged, and its netsRemaining:[] agrees with unconnected:0.
  • Companions, live:
    • pour areas equal kicad_zone_state on every zone;
    • 86 of 86 bodies cover their own part's pads (bottom side and 45 degrees included);
    • STEP bounds are exact on R_0603, QFN-56 and CP_Radial_D8.
  • The edits were discarded and the file left unchanged.

Merge

Hold until the 1.0.59 release, so the README does not describe verbs users cannot have yet. The kicad-lane thread (now this bridge's development thread, per Colby) merges and releases to insiders after Colby's OK.

🤖 Generated with Claude Code

Diff Skip to comments (1)

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+8
@@ -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.
bridge.json+14−1
@@ -137,7 +137,20 @@     "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"   ],   "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+21−18
@@ -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"],@@ -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_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+1821
@@ -0,0 +1,1821 @@+//! 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>,+}++#[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 };+    Some(match kshape.as_str() {+        "circle" | "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"))),+            _ => {}+        }+    }+    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),+        ..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" {+        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>,+    isolated: Vec<Value>,+}++/// 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) }+    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())));+            }+        }+    }+    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 {+                let layers: BTreeSet<&str> = members.iter().filter_map(|i| if let What::Pour(l) = &items[*i].1 { Some(l.as_str()) } else { None }).collect();+                out.isolated.push(json!({"net": net, "layers": layers}));+                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 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);+    }+    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");+    }++    #[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", "layers": ["F.Cu"]}]));+    }++    #[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+77
@@ -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)?;
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/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);+}
skills/kicad-board-model/SKILL.mdadded+68
@@ -0,0 +1,68 @@+---+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`) |+| Custom pads are their bounding rectangle | `shape:"rect"`, rotation 0, `kicadShape:"custom"`, `anchor` = KiCad's anchor (where tracks land) |+| 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`, not a connection to make (KiCad 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.

Comments

Colby Knox · 15d ago

Correction to the description: without model the kicad_routing_state reply is the old shape, unchanged (same keys and contents, checked live on DESKTOP-BAR6S0F). I did not compare it byte for byte.

Log in to comment.