skill
EDA Bridge Standard: one way for an AI to drive every PCB tool
Public Made by Adomby adom
The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.
← Commit history
standard 0.4.0: the checks engine adom-eda-checks in engine/ (Rust crate + CLI, JSON in, no bridge calls): copper_balance, return_path, fab_check (limits from adom/pcb-design-rules records, each finding names its source), impedance (apply answers add_rule args), current_capacity; moved from the Altium bridge and matching its 0.2.76 answers on lane and sentinel; README section and SKILL.md point to it
18 files changed
+1547−13
README.md+15−6SKILL.md+3−3engine/Cargo.lock+105engine/Cargo.toml+22engine/README.md+78engine/src/coppermap.rs+441engine/src/current.rs+140engine/src/fab.rs+294engine/src/geom.rs+23engine/src/impedance.rs+234engine/src/input.rs+55engine/src/lib.rs+29engine/src/main.rs+48engine/tests/fixtures.rs+56package.json+1−1page.json+1−1verbs/coverage.json+1−1verbs/standard.json+1−1README.md+15−6@@ -36,6 +36,16 @@ A verb one bridge gains should become every bridge's to-do: that is what this pa (`success`, `errorCode`, `error`, `_hint`), revision guards, dry-run plans, refusal codes, and how a bridge treats the user's running tool (background first, no dialog left for the user, never a killed tool). +## The checks engine++[`engine/`](engine/README.md) is `adom-eda-checks`, the checks tier as one Rust crate and command-line tool: the board model+and its companions go in as plain JSON, findings and plans come out, and nothing calls a bridge or a tool. A bridge+links the crate (vendored at a tagged standard version, since Cargo cannot fetch from the wiki) or runs the command, and+answers each check under its own prefix. It now answers `copper_balance`, `return_path`, `fab_check`, `impedance` and+`current_capacity`, moved over from the Altium bridge and giving the same answers on Altium's test boards. Its+conformance tests read a bridge's saved replies from `ADOM_EDA_FIXTURES`; fixtures live on each bridge's own page+(Altium's: [adom/altium-bridge](https://wiki.adom.inc/adom/altium-bridge) `fixtures/`).+ Fab limits are not kept here: they come from [adom/pcb-design-rules](https://wiki.adom.inc/adom/pcb-design-rules), the canonical record per vendor. @@ -142,7 +152,8 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status -Version 0.3.1: an internal plane is named `In<n>.Cu` by its place in the copper stack, with `kind: "plane"` ([board model](docs/board-model.md#settled-questions)); the stack-up companion gives each copper layer's board model name in `layer`. Version 0.3.0: 33 more verbs from other bridges, all agreed by the Altium bridge's thread: 26 from the KiCad bridge (the board exports ODB++, IPC-2581, IPC-356, GenCAD and DXF, `render_board_3d`, `remove_parts`, `edit_parts`, `align_parts`, `copy_routing`, projects, library footprints, `estimate_cost`, `check_clearance`, `place_decoupling`, `plan_fanout`) and 7 from the Fusion bridge (`bodies`, `render_board_layers`, `plan_route`, `generate_body`, `library_views`, `close_all_documents`, `document_info`). A bridge's own equivalents now win over a name match, so a verb that shares a standard name but does another job is not credited for it. Version 0.2.1: this page has one keeper (standard-lane) and bridge threads push only their own files (*Keeping it in+Version 0.4.0: the checks engine, `adom-eda-checks`, is on this page in `engine/` with its first five checks; fab limits+come in from adom/pcb-design-rules, never built in. Version 0.3.1: an internal plane is named `In<n>.Cu` by its place in the copper stack, with `kind: "plane"` ([board model](docs/board-model.md#settled-questions)); the stack-up companion gives each copper layer's board model name in `layer`. Version 0.3.0: 33 more verbs from other bridges, all agreed by the Altium bridge's thread: 26 from the KiCad bridge (the board exports ODB++, IPC-2581, IPC-356, GenCAD and DXF, `render_board_3d`, `remove_parts`, `edit_parts`, `align_parts`, `copy_routing`, projects, library footprints, `estimate_cost`, `check_clearance`, `place_decoupling`, `plan_fanout`) and 7 from the Fusion bridge (`bodies`, `render_board_layers`, `plan_route`, `generate_body`, `library_views`, `close_all_documents`, `document_info`). A bridge's own equivalents now win over a name match, so a verb that shares a standard name but does another job is not credited for it. Version 0.2.1: this page has one keeper (standard-lane) and bridge threads push only their own files (*Keeping it in sync*), one file per owner (`tools/equivalents/<bridge>.json`, `tools/proposed/<thread>.json`); the decisions on the KiCad and Fusion proposals are written down above; a lesson from the Fusion bridge on DRC severity is in [docs/conventions.md](docs/conventions.md). Version 0.2.0: verbs can come from any bridge. The first batch, 21 schematic verbs from the KiCad bridge (`sch_connect_pins`, `sch_bus`, `sch_items`, `sch_pins`, `sch_trace_point`, `render_schematic` ...), was agreed by the@@ -150,8 +161,6 @@ Altium bridge's thread; Altium now shows those as gaps. Earlier: version 0.1.2: settles the questions the KiCad and Fusion bridges raised as they adopted the board model (the y-down frame, pour islands, fractured fills, custom pads, reply units): the answers are in [docs/board-model.md](docs/board-model.md#settled-questions). -Next: the checks tier's implementation, so every bridge that serves the board model gets the same checks. Where it-lives is being decided (a shared crate, or Adom's analysis tools reading the board model); the verbs' names and reply-shapes stay here either way. Fixtures, a bridge's saved replies on a small board with the expected check results, live-on each bridge's own page, not here: Altium's are on [adom/altium-bridge](https://wiki.adom.inc/adom/altium-bridge)-under `fixtures/`.+Next: the rest of the checks tier moves into the engine (assembly, 3D heights, placement, schematic review, pair+routing, length tuning, designators, via fences, panels, the board report), and each bridge swaps its own copies for it,+Altium first.
SKILL.md+3−3@@ -45,9 +45,9 @@ issue whose title starts with your thread's name (the pinned issue says how). ## When you port a check (the checks tier) The checks are computed from the board model and its companions, not by the tool. A bridge that returns the board-model exactly as the schema says can run them; the Altium bridge's Rust is the reference implementation-(`bridge-rs/src/` in adom/altium-bridge: `impedance.rs`, `current.rs`, `fab.rs`, `assembly.rs`, `coppermap.rs`,-`bodies.rs`, `placement.rs`, `schreview.rs`, `pairroute.rs`, `lengthtune.rs`, `silkplace.rs`, `panel.rs`, `report.rs`).+model exactly as the schema says can run them from the shared engine, `engine/` on this page (`adom-eda-checks`:+a Rust crate and a command; `copper_balance`, `return_path`, `fab_check`, `impedance`, `current_capacity` so far). The+checks not yet moved are still in the Altium bridge's Rust (`bridge-rs/src/` in adom/altium-bridge). Planners (pair routes, serpentines, designators, via fences, thermal vias, panels) answer geometry; each bridge applies it with its own core verbs (`route_net`, `place_vias`, `apply_silkscreen`).
engine/Cargo.lockadded+105@@ -0,0 +1,105 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-eda-checks"+version = "0.1.0"+dependencies = [+ "serde_json",+]++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.154"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "syn"+version = "3.0.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.27"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
engine/Cargo.tomladded+22@@ -0,0 +1,22 @@+[package]+name = "adom-eda-checks"+version = "0.1.0"+edition = "2021"+description = "The EDA Bridge Standard's checks, computed from the board model JSON: no bridge calls, no tool."+license = "MIT"++[lib]+path = "src/lib.rs"++[[bin]]+name = "adom-eda-checks"+path = "src/main.rs"++[dependencies]+serde_json = "1"++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true
engine/README.mdadded+78@@ -0,0 +1,78 @@+# adom-eda-checks: the shared checks engine++The checks tier of the EDA Bridge Standard, computed from the board model and its companions as plain JSON. It makes+no bridge calls, runs no tool and does no I/O. A bridge links the crate (the Altium, KiCad and Fusion bridges are all+Rust) or runs the `adom-eda-checks` command, then answers the check under its own prefix (`kicad_return_path`).++The code moved over from the Altium bridge's `bridge-rs/src/`, which was the reference implementation. On Altium's own+boards the engine gives the same answers the Altium bridge 0.2.76 gives.++## Checks++| Check | Reads | What it answers |+|---|---|---|+| `copper_balance` | board, stack, pours | copper per layer and how evenly mirrored layers match (bow and twist) |+| `return_path` | board, stack, pours | signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (`apply_pours`) |+| `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read |+| `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments |+| `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs |++The other checks-tier verbs (assembly, 3D heights, placement, schematic review, pair routing, length tuning,+designators, via fences, panels, the board report) come over from the Altium bridge a module at a time.++## Input++Every check takes one JSON object:++```json+{"board": {...}, "stack": {...}, "pours": {...}, "rules": [...], "silk": {...}, "fab": {...}, "args": {...}}+```++| Key | What it is |+|---|---|+| `board` | the board model, `<prefix>_routing_state` (`schema/board.schema.json`) |+| `stack` | the stack-up companion, `<prefix>_layer_stack` |+| `pours` | poured copper, `<prefix>_pour_state` (its `zones`) |+| `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` |+| `silk` | `<prefix>_silk_state` |+| `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` |+| `args` | the check's own arguments, as the standard verb documents them |++Each part may be the bare model or the bridge's whole reply: the model's keys at the top level, or under `data`.+A missing part is never guessed. A limit with nothing to measure it against is listed in `notChecked`. Copper weight+assumed for lack of a stack is named in `assumed`.++## Use++As a crate, vendored at a tagged standard version (the wiki has no git protocol, so Cargo can't fetch it):++```toml+[dependencies]+adom-eda-checks = { path = "vendor/adom-eda-checks" }+```++```rust+let reply = adom_eda_checks::run("return_path", &serde_json::json!({"board": board, "stack": stack, "pours": pours, "args": {}}));+```++As a command:++```bash+adom-eda-checks list+adom-eda-checks return_path --board board.json --stack stack.json --pours pours.json --args '{"resolutionMm": 0.1}'+adom-eda-checks fab_check bundle.json # or - for stdin+```++Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix+(`apply_pours`, `add_rule`): a bridge adds its own.++## Tests++`cargo test` runs the unit tests. To compare with a bridge's own answers, point `ADOM_EDA_FIXTURES` at a folder of+saved replies laid out as `<bridge>/<board>.<model>.json` and `<bridge>/expected/<board>.<check>.json`:++```bash+ADOM_EDA_FIXTURES=/path/to/fixtures cargo test --test fixtures+```++Fixtures live on each bridge's own page, not here.
engine/src/coppermap.rsadded+441@@ -0,0 +1,441 @@+//! The copper of each layer as a raster (0.1 mm by default) inside the board outline, from the live board: tracks,+//! arcs, pads, vias and the copper Altium actually poured (its regions minus their holes); an internal plane counts as+//! copper everywhere. From it: how much of each layer is copper and how evenly the stack is balanced (bow and twist),+//! and where signal tracks run over a gap in the copper of the layer next to them (the return current's path).+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// A layer's copper as cells of `res` mm from (x0, y0).+#[derive(Clone)]+pub struct Raster { pub x0: f64, pub y0: f64, pub res: f64, pub w: usize, pub h: usize, pub cells: Vec<bool> }++impl Raster {+ pub fn new(x0: f64, y0: f64, x1: f64, y1: f64, res: f64) -> Raster {+ let w = (((x1 - x0) / res).ceil() as usize).max(1);+ let h = (((y1 - y0) / res).ceil() as usize).max(1);+ Raster { x0, y0, res, w, h, cells: vec![false; w * h] }+ }+ fn centre(&self, i: usize, j: usize) -> (f64, f64) { (self.x0 + (i as f64 + 0.5) * self.res, self.y0 + (j as f64 + 0.5) * self.res) }+ /// cells whose centre is in [x0, x1] x [y0, y1], clipped+ fn span(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> (usize, usize, usize, usize) {+ let c = |v: f64, o: f64, n: usize| (((v - o) / self.res - 0.5).max(0.0) as usize).min(n);+ let (i0, j0) = (c(x0, self.x0, self.w), c(y0, self.y0, self.h));+ let (i1, j1) = ((((x1 - self.x0) / self.res + 0.5).max(0.0) as usize).min(self.w), (((y1 - self.y0) / self.res + 0.5).max(0.0) as usize).min(self.h));+ (i0, j0, i1, j1)+ }+ pub fn at(&self, x: f64, y: f64) -> bool {+ let (i, j) = ((x - self.x0) / self.res, (y - self.y0) / self.res);+ if i < 0.0 || j < 0.0 || i >= self.w as f64 || j >= self.h as f64 { return false; }+ self.cells[j as usize * self.w + i as usize]+ }+ /// a segment of width w with round ends (a track, an arc's piece, an oval pad)+ pub fn capsule(&mut self, ax: f64, ay: f64, bx: f64, by: f64, w: f64) {+ let r = w / 2.0;+ let (i0, j0, i1, j1) = self.span(ax.min(bx) - r, ay.min(by) - r, ax.max(bx) + r, ay.max(by) + r);+ let (dx, dy) = (bx - ax, by - ay);+ let l2 = dx * dx + dy * dy;+ for j in j0..j1 { for i in i0..i1 {+ let (x, y) = self.centre(i, j);+ let t = if l2 > 0.0 { (((x - ax) * dx + (y - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+ let (px, py) = (ax + t * dx - x, ay + t * dy - y);+ if px * px + py * py <= r * r { self.cells[j * self.w + i] = true; }+ } }+ }+ /// a rectangle sx by sy centred at (cx, cy), turned `rot` degrees+ pub fn rect(&mut self, cx: f64, cy: f64, sx: f64, sy: f64, rot: f64) {+ let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());+ let e = (sx * sx + sy * sy).sqrt() / 2.0;+ let (i0, j0, i1, j1) = self.span(cx - e, cy - e, cx + e, cy + e);+ for j in j0..j1 { for i in i0..i1 {+ let (x, y) = self.centre(i, j);+ let (u, v) = ((x - cx) * c + (y - cy) * s, -(x - cx) * s + (y - cy) * c);+ if u.abs() <= sx / 2.0 && v.abs() <= sy / 2.0 { self.cells[j * self.w + i] = true; }+ } }+ }+ /// polygons filled by the even-odd rule (an outline with its holes), set to `on`+ pub fn polys(&mut self, rings: &[Vec<(f64, f64)>], on: bool) {+ let ys: Vec<f64> = rings.iter().flatten().map(|p| p.1).collect();+ if ys.is_empty() { return; }+ let (lo, hi) = (ys.iter().cloned().fold(f64::MAX, f64::min), ys.iter().cloned().fold(f64::MIN, f64::max));+ let (_, j0, _, j1) = self.span(self.x0, lo, self.x0, hi);+ for j in j0..j1 {+ let y = self.y0 + (j as f64 + 0.5) * self.res;+ let mut xs: Vec<f64> = Vec::new();+ for ring in rings {+ let n = ring.len();+ for k in 0..n {+ let (a, b) = (ring[k], ring[(k + 1) % n]);+ if (a.1 <= y) != (b.1 <= y) { xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)); }+ }+ }+ xs.sort_by(|a, b| a.partial_cmp(b).unwrap());+ for p in xs.chunks(2) {+ if p.len() < 2 { continue; }+ let (i0, _, i1, _) = self.span(p[0], y, p[1], y);+ for i in i0..i1 { self.cells[j * self.w + i] = on; }+ }+ }+ }+ pub fn and(&mut self, mask: &Raster) { for (c, m) in self.cells.iter_mut().zip(&mask.cells) { *c = *c && *m; } }+ pub fn count(&self) -> usize { self.cells.iter().filter(|c| **c).count() }+}++/// The stack's copper layers in order as (routing_state name, plane?, plane net).+pub fn stack_layers(stack: &Value) -> Vec<(String, bool, String)> {+ let ls: Vec<Value> = stack["layers"].as_array().cloned().unwrap_or_default();+ let n = ls.len();+ ls.iter().enumerate().map(|(i, l)| {+ if l["kind"] == "plane" { return (format!("L{}", 38 + l["plane"].as_i64().unwrap_or(1)), true, l["net"].as_str().unwrap_or("").to_string()); }+ let name = if i == 0 { "F.Cu".to_string() } else if i + 1 == n { "B.Cu".to_string() } else { format!("In{}.Cu", l["mid"].as_i64().unwrap_or(i as i64)) };+ (name, false, String::new())+ }).collect()+}++/// The outline as a ring in mm (arc edges sampled).+pub(crate) fn outline_ring(st: &Value) -> Vec<(f64, f64)> {+ let mut ring = Vec::new();+ for e in st["outlineEdges"].as_array().cloned().unwrap_or_default() {+ if e["kind"] == "arc" {+ let (cx, cy, r) = (f(&e["center"][0]), f(&e["center"][1]), f(&e["radius"]));+ let (a1, mut a2) = (f(&e["angle1"]), f(&e["angle2"]));+ if a2 <= a1 { a2 += 360.0; }+ // every 2 degrees: a chord cuts inside the arc by r(1 - cos 1deg), 3 um on a 20 mm radius (16 points per+ // arc put a hatched pour's ends 0.04 mm outside the Bluetooth Sentinel example's edge)+ let k = (((a2 - a1) / 2.0).ceil() as usize).max(4);+ for q in 0..=k { let t = (a1 + (a2 - a1) * q as f64 / k as f64).to_radians(); ring.push((cx + r * t.cos(), cy + r * t.sin())); }+ } else { ring.push((f(&e["start"][0]), f(&e["start"][1]))); }+ }+ if ring.is_empty() { ring = st["outline"].as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect(); }+ ring+}++/// Each layer's copper raster inside the outline. `refs` None: all copper. Some(nets): only the copper a return current+/// can use: pours, planes, those nets' own copper, and net-less copper (Altium lists a hatched pour's tracks with no+/// net: 8777 of the Bluetooth Sentinel example's 9378 tracks). Pure: routing_state, pour_state's zones, the stack.+pub fn rasters(st: &Value, zones: &[Value], layers: &[(String, bool, String)], res: f64, refs: Option<&[String]>) -> (Raster, BTreeMap<String, Raster>) {+ let ring = outline_ring(st);+ let (x0, x1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ let mut board = Raster::new(x0, y0, x1, y1, res);+ board.polys(&[ring], true);+ let mut out = BTreeMap::new();+ for (name, plane, _) in layers {+ let mut r = Raster::new(x0, y0, x1, y1, res);+ if *plane { r.cells = board.cells.clone(); out.insert(name.clone(), r); continue; }+ {+ let keep = |n: &Value| match refs { None => true, Some(rs) => { let n = n.as_str().unwrap_or(""); n.is_empty() || rs.iter().any(|r| r.eq_ignore_ascii_case(n)) } };+ let on_layer = |l: &Value| l.as_str().map(|s| s == name || s == "*.Cu").unwrap_or(false);+ let on = |p: &Value| on_layer(&p["layer"]) && keep(&p["net"]);+ for t in st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| on(t)) {+ r.capsule(f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]), f(&t["width"]));+ }+ for a in st["arcs"].as_array().cloned().unwrap_or_default().iter().filter(|a| on(a)) {+ let (cx, cy, rad) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+ let (s, mut e) = (f(&a["startAngle"]), f(&a["endAngle"]));+ if e <= s { e += 360.0; }+ let k = ((e - s) / 10.0).ceil().max(1.0) as usize;+ for q in 0..k {+ let (t0, t1) = ((s + (e - s) * q as f64 / k as f64).to_radians(), (s + (e - s) * (q + 1) as f64 / k as f64).to_radians());+ r.capsule(cx + rad * t0.cos(), cy + rad * t0.sin(), cx + rad * t1.cos(), cy + rad * t1.sin(), f(&a["width"]));+ }+ }+ for p in st["pads"].as_array().cloned().unwrap_or_default().iter().filter(|p| on(p)) {+ let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"]));+ // a custom pad's real copper when the bridge sends it (Fusion's copperPoints); its bounding rectangle otherwise+ let outline: Vec<(f64, f64)> = p["copperPoints"].as_array().cloned().unwrap_or_default().iter().map(|q| (f(&q[0]), f(&q[1]))).collect();+ if outline.len() >= 3 { r.polys(&[outline], true); continue; }+ match p["shape"].as_str() {+ Some("round") | Some("circle") if (sx - sy).abs() > 1e-6 => {+ let (c, s) = (f(&p["rotation"]).to_radians().cos(), f(&p["rotation"]).to_radians().sin());+ let (l, w) = ((sx - sy).abs() / 2.0, sx.min(sy));+ let (ux, uy) = if sx > sy { (c * l, s * l) } else { (-s * l, c * l) };+ r.capsule(f(&p["x"]) - ux, f(&p["y"]) - uy, f(&p["x"]) + ux, f(&p["y"]) + uy, w);+ }+ Some("round") | Some("circle") => r.capsule(f(&p["x"]), f(&p["y"]), f(&p["x"]), f(&p["y"]), sx),+ _ => r.rect(f(&p["x"]), f(&p["y"]), sx, sy, f(&p["rotation"])),+ }+ }+ for v in st["vias"].as_array().cloned().unwrap_or_default().iter().filter(|v| keep(&v["net"])) {+ r.capsule(f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]));+ }+ }+ for z in zones.iter().filter(|z| z["layer"].as_str() == Some(name)) {+ for fill in z["fills"].as_array().cloned().unwrap_or_default() {+ let pts = |v: &Value| v.as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect::<Vec<_>>();+ let mut rings = vec![pts(&fill["outline"])];+ rings.extend(fill["holes"].as_array().cloned().unwrap_or_default().iter().map(pts));+ r.polys(&rings, true);+ }+ }+ r.and(&board);+ out.insert(name.clone(), r);+ }+ (board, out)+}++/// Per layer: copper area and share of the board; per mirrored pair (1 with n, 2 with n-1...): the difference.+pub fn balance(board: &Raster, maps: &BTreeMap<String, Raster>, layers: &[(String, bool, String)]) -> (Vec<Value>, Vec<Value>) {+ let cell = board.res * board.res;+ let total = board.count() as f64;+ let pct = |name: &str| maps.get(name).map(|r| 100.0 * r.count() as f64 / total.max(1.0)).unwrap_or(0.0);+ let rows: Vec<Value> = layers.iter().map(|(n, plane, net)| {+ let mut o = json!({"layer": n, "copperMm2": r1(maps.get(n).map(|r| r.count()).unwrap_or(0) as f64 * cell), "percent": r1(pct(n))});+ if *plane { o["plane"] = json!(net); }+ o+ }).collect();+ let k = layers.len();+ let pairs = (0..k / 2).map(|i| {+ let (a, b) = (&layers[i].0, &layers[k - 1 - i].0);+ let d = (pct(a) - pct(b)).abs();+ json!({"layers": [a, b], "percent": [r1(pct(a)), r1(pct(b))], "differencePoints": r1(d), "ok": d <= 20.0})+ }).collect();+ (rows, pairs)+}++/// Per signal net: track length on each layer, and the length that runs over no reference copper on either+/// neighbouring layer, with where the first gap starts. `skip` nets are the references themselves. Pure.+pub fn return_paths(st: &Value, refs: &BTreeMap<String, Raster>, layers: &[(String, bool, String)], skip: &[String], step: f64) -> Vec<Value> {+ let idx: BTreeMap<&str, usize> = layers.iter().enumerate().map(|(i, l)| (l.0.as_str(), i)).collect();+ let mut acc: BTreeMap<(String, String), (f64, f64, Option<(f64, f64)>, Vec<(f64, f64)>)> = BTreeMap::new();+ for t in st["tracks"].as_array().cloned().unwrap_or_default() {+ let (net, layer) = (t["net"].as_str().unwrap_or(""), t["layer"].as_str().unwrap_or(""));+ if net.is_empty() || skip.iter().any(|s| s.eq_ignore_ascii_case(net)) { continue; }+ let Some(&i) = idx.get(layer) else { continue };+ let nb: Vec<&Raster> = [i.checked_sub(1), Some(i + 1)].iter().flatten().filter_map(|j| layers.get(*j)).filter_map(|l| refs.get(&l.0)).collect();+ let (ax, ay, bx, by) = (f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]));+ let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt();+ let n = (len / step).ceil().max(1.0) as usize;+ let e = acc.entry((net.to_string(), layer.to_string())).or_insert((0.0, 0.0, None, Vec::new()));+ e.0 += len;+ for k in 0..n {+ let s = (k as f64 + 0.5) / n as f64;+ let (x, y) = (ax + s * (bx - ax), ay + s * (by - ay));+ if !nb.iter().any(|r| r.at(x, y)) {+ e.1 += len / n as f64;+ if e.2.is_none() { e.2 = Some((r3(x), r3(y))); }+ e.3.push((x, y));+ }+ }+ }+ let mut out: Vec<Value> = acc.into_iter().filter(|(_, v)| v.1 > 0.0).map(|((net, layer), (len, gap, at, pts))| json!({+ "net": net, "layer": layer, "lengthMm": r3(len), "unreferencedMm": r3(gap), "percent": r1(100.0 * gap / len.max(1e-9)), "firstGapAt": at.map(|p| json!([p.0, p.1])),+ "gapRuns": gap_runs(&pts, 1.0).iter().map(|r| json!([r3(r.0), r3(r.1), r3(r.2), r3(r.3)])).collect::<Vec<_>>()})).collect();+ out.sort_by(|a, b| b["unreferencedMm"].as_f64().partial_cmp(&a["unreferencedMm"].as_f64()).unwrap());+ out+}++/// The gap sample points grouped into runs: boxes (x0, y0, x1, y1) of points within `join` mm of each other. Pure.+pub fn gap_runs(pts: &[(f64, f64)], join: f64) -> Vec<(f64, f64, f64, f64)> {+ let mut runs: Vec<(f64, f64, f64, f64)> = Vec::new();+ for &(x, y) in pts {+ match runs.iter_mut().find(|r| x >= r.0 - join && x <= r.2 + join && y >= r.1 - join && y <= r.3 + join) {+ Some(r) => { r.0 = r.0.min(x); r.1 = r.1.min(y); r.2 = r.2.max(x); r.3 = r.3.max(y); }+ None => runs.push((x, y, x, y)),+ }+ }+ // runs that grew into each other merge+ let mut merged = true;+ while merged {+ merged = false;+ 'outer: for i in 0..runs.len() {+ for j in i + 1..runs.len() {+ let (a, b) = (runs[i], runs[j]);+ if a.0 <= b.2 + join && b.0 <= a.2 + join && a.1 <= b.3 + join && b.1 <= a.3 + join {+ runs[i] = (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3));+ runs.remove(j);+ merged = true;+ break 'outer;+ }+ }+ }+ }+ runs+}++/// A plan that closes each gap: a pour patch `margin` mm round every gap run, on the neighbouring layer that already+/// carries the most reference copper, on that layer's reference net; as apply_pours arguments. Pure.+pub fn fix_plan(rows: &[Value], layers: &[(String, bool, String)], refs: &BTreeMap<String, Raster>, zones: &[Value], skip: &[String], board: (f64, f64, f64, f64), margin: f64) -> Vec<Value> {+ let idx: BTreeMap<&str, usize> = layers.iter().enumerate().map(|(i, l)| (l.0.as_str(), i)).collect();+ let mut pours = Vec::new();+ for row in rows {+ let Some(&i) = row["layer"].as_str().and_then(|l| idx.get(l)) else { continue };+ // a plane needs no patch (it is copper everywhere but its splits); pick the signal neighbour with most copper+ let best = [i.checked_sub(1), Some(i + 1)].iter().flatten().filter_map(|j| layers.get(*j)).filter(|l| !l.1)+ .max_by_key(|l| refs.get(&l.0).map(|r| r.count()).unwrap_or(0));+ let Some(nb) = best else { continue };+ let net = zones.iter().find(|z| z["layer"].as_str() == Some(nb.0.as_str())).and_then(|z| z["net"].as_str()).map(String::from)+ .or_else(|| skip.first().cloned());+ let Some(net) = net else { continue };+ for (k, r) in row["gapRuns"].as_array().cloned().unwrap_or_default().iter().enumerate() {+ let (x0, y0) = ((f(&r[0]) - margin).max(board.0 + 0.3), (f(&r[1]) - margin).max(board.1 + 0.3));+ let (x1, y1) = ((f(&r[2]) + margin).min(board.2 - 0.3), (f(&r[3]) + margin).min(board.3 - 0.3));+ if x1 <= x0 || y1 <= y0 { continue; }+ // does the patch overlap the reference copper already on that layer? (one that does not floats)+ let joins = refs.get(&nb.0).map(|ras| {+ let mut hit = false;+ let mut y = y0;+ while y <= y1 && !hit { let mut x = x0; while x <= x1 && !hit { hit = ras.at(x, y); x += ras.res; } y += ras.res; }+ hit+ }).unwrap_or(false);+ // removeDead off: a patch that only overlaps the pour it extends touches no pad or track of its net, and+ // Altium's dead-copper removal poured it as nothing (0 mm2, measured 2026-10-26)+ pours.push(json!({"net": net, "layer": nb.0, "name": format!("Return {} {}", row["net"].as_str().unwrap_or(""), k + 1), "removeDead": false,+ "points": [[r3(x0), r3(y0)], [r3(x1), r3(y0)], [r3(x1), r3(y1)], [r3(x0), r3(y1)]], "closes": {"net": row["net"], "layer": row["layer"]}, "joinsExistingCopper": joins}));+ }+ }+ pours+}++/// The board, its poured copper and its stack from the input bundle, or the refusal to answer.+fn gather(input: &Value) -> Result<(Value, Vec<Value>, Vec<(String, bool, String)>), Value> {+ let st = crate::input::board(input)?;+ if st["outline"].as_array().map(|a| a.len() < 3).unwrap_or(true) { return Err(crate::input::refuse("no_outline", "the board has no outline")); }+ let zones = crate::input::pours(input);+ let layers = crate::input::stack(input).map(|s| stack_layers(&s)).unwrap_or_default();+ if layers.is_empty() { return Err(crate::input::refuse("no_stack", "the input has no stack: pass the layer_stack companion as stack")); }+ Ok((st, zones, layers))+}++fn res_of(args: &Value) -> f64 { args.get("resolutionMm").and_then(|v| v.as_f64()).unwrap_or(0.1).clamp(0.02, 1.0) }++/// copper_balance: input {board, stack, pours, args: {resolutionMm?}}+pub fn copper_balance(input: &Value) -> Value {+ let (st, zones, layers) = match gather(input) { Ok(g) => g, Err(e) => return e };+ let args = &input["args"];+ let res = res_of(args);+ let (board, maps) = rasters(&st, &zones, &layers, res, None);+ let (rows, pairs) = balance(&board, &maps, &layers);+ let unpoured: Vec<Value> = st["polygons"].as_array().cloned().unwrap_or_default().iter()+ .filter(|g| !zones.iter().any(|z| z["layer"] == g["layer"] && z["net"] == g["net"] && z["filled"] == json!(true)))+ .map(|g| json!({"net": g["net"], "layer": g["layer"]})).collect();+ let ok = pairs.iter().all(|p| p["ok"] == json!(true));+ json!({"success": true, "boardMm2": r1(board.count() as f64 * res * res), "resolutionMm": res, "layers": rows, "pairs": pairs, "balanced": ok,+ "poursWithoutRegions": unpoured,+ "_hint": if ok { "Mirrored layers carry similar copper: the stack should stay flat through reflow." } else { "A mirrored pair differs by more than 20 points of copper: the board can bow or twist through reflow. Pour the lighter layer (apply_pours, a GND pour or copper thieving), or ask the fab to add thieving on its side." }})+}++/// return_path: input {board, stack, pours, args: {nets?, referenceNets?, resolutionMm?, fixMarginMm?}}+pub fn return_path(input: &Value) -> Value {+ let (st, zones, layers) = match gather(input) { Ok(g) => g, Err(e) => return e };+ let args = &input["args"];+ let res = res_of(args);+ // the reference nets: the ones that own pours or planes, unless named+ let mut skip: Vec<String> = args.get("referenceNets").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();+ if skip.is_empty() {+ skip = st["polygons"].as_array().cloned().unwrap_or_default().iter().filter_map(|g| g["net"].as_str().map(String::from))+ .chain(zones.iter().filter_map(|z| z["net"].as_str().map(String::from))).chain(layers.iter().filter(|l| l.1).map(|l| l.2.clone()))+ .filter(|n| !n.is_empty()).collect();+ skip.sort(); skip.dedup();+ }+ let (_, refs) = rasters(&st, &zones, &layers, res, Some(&skip));+ let mut rows = return_paths(&st, &refs, &layers, &skip, res);+ if let Some(want) = args.get("nets").and_then(|v| v.as_array()) {+ let w: Vec<String> = want.iter().filter_map(|v| v.as_str()).map(|s| s.to_ascii_lowercase()).collect();+ rows.retain(|r| r["net"].as_str().map(|n| w.contains(&n.to_ascii_lowercase())).unwrap_or(false));+ }+ let no_ref: Vec<&String> = layers.iter().map(|l| &l.0).filter(|n| refs.get(*n).map(|r| r.count() == 0).unwrap_or(true)).collect();+ // a plan to close the gaps, for the caller to read and run: nothing is changed here+ let bb = crate::geom::outline_bbox(&st).unwrap_or((0.0, 0.0, 0.0, 0.0));+ let margin = args.get("fixMarginMm").and_then(|v| v.as_f64()).unwrap_or(1.0);+ let patches = fix_plan(&rows, &layers, &refs, &zones, &skip, bb, margin);+ let fix = if patches.is_empty() { Value::Null } else {+ let pours: Vec<Value> = patches.iter().map(|p| { let mut q = p.clone(); if let Some(o) = q.as_object_mut() { o.remove("closes"); o.remove("joinsExistingCopper"); } q }).collect();+ let floating: Vec<Value> = patches.iter().filter(|p| p["joinsExistingCopper"] == json!(false)).map(|p| json!({"name": p["name"], "layer": p["layer"]})).collect();+ json!({"verb": "apply_pours", "args": {"pours": pours}, "patches": patches.len(), "closes": patches.iter().map(|p| p["closes"].clone()).collect::<Vec<_>>(),+ "floating": floating,+ "note": "A plan only: nothing changed. Each patch is a pour of the reference net round one gap run, on the neighbouring layer with the most reference copper. Read it (a patch can cut through other routing's space: the tool pours round it, keeping clearance), then run the verb with args, repour, and run return_path again. floating patches overlap no copper of their net yet: tie each to it with a via (place_vias) or it is an island."})+ };+ let note = if skip.is_empty() { json!("No net owns a pour or a plane on this board: there is no reference copper, so every signal is reported. Pour GND (apply_pours) or name referenceNets.") } else { Value::Null };+ json!({"success": true, "referenceNets": skip, "note": note, "layersWithNoReferenceCopper": no_ref, "nets": rows, "ok": rows.is_empty(), "resolutionMm": res, "fix": fix,+ "_hint": if rows.is_empty() { "Every signal track runs over pour or plane copper on a neighbouring layer." } else { "Each row is a net whose tracks on that layer run over no pour or plane on either neighbouring layer for unreferencedMm (from firstGapAt). Its return current detours around the gap: more loop area, more emission and crosstalk. Close the gap (extend the pour: apply_pours), route the net over solid copper, or add stitching capacitors or vias where it changes reference." }})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn raster_shapes_have_their_area() {+ let mut r = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+ r.rect(5.0, 5.0, 4.0, 2.0, 0.0);+ assert!((r.count() as f64 * 0.0025 - 8.0).abs() < 0.3);+ let mut c = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+ c.capsule(10.0, 10.0, 10.0, 10.0, 4.0);+ assert!((c.count() as f64 * 0.0025 - std::f64::consts::PI * 4.0).abs() < 0.3);+ let mut p = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+ p.polys(&[vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], vec![(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0)]], true);+ assert!((p.count() as f64 * 0.0025 - 96.0).abs() < 0.5);+ assert!(!p.at(3.0, 3.0) && p.at(6.0, 6.0));+ // KiCad's fractured fill: the hole joined to the outline by a zero-width cut, holes empty. Same copper.+ let mut k = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+ k.polys(&[vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 2.0), (2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0), (2.0, 2.0), (0.0, 2.0)]], true);+ assert_eq!(k.count(), p.count());+ assert!(!k.at(3.0, 3.0) && k.at(6.0, 6.0));+ }++ #[test]+ fn a_custom_pad_is_its_copper_points_when_sent() {+ let pad = |extra: Value| { let mut p = json!({"ref": "Q1", "name": "5", "x": 5.0, "y": 5.0, "sizeX": 4.0, "sizeY": 4.0, "rotation": 0, "layer": "F.Cu", "net": "D", "shape": "rect"});+ if let Value::Object(m) = extra { for (k, v) in m { p[k] = v; } } p };+ let area = |p: Value| { let st = json!({"outline": [[0, 0], [10, 0], [10, 10], [0, 10]], "pads": [p]});+ let (_, m) = rasters(&st, &[], &[("F.Cu".into(), false, "".into())], 0.05, None); m["F.Cu"].count() as f64 * 0.0025 };+ assert!((area(pad(json!({}))) - 16.0).abs() < 0.5);+ // an L of copper inside that rectangle: 4x2 + 2x2 = 12 mm2+ assert!((area(pad(json!({"copperPoints": [[3, 3], [7, 3], [7, 5], [5, 5], [5, 7], [3, 7]]}))) - 12.0).abs() < 0.5);+ }++ fn st() -> Value {+ json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+ "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [20.0, 0.0]}, {"kind": "line", "start": [20.0, 10.0]}, {"kind": "line", "start": [0.0, 10.0]}],+ "tracks": [{"net": "SIG", "layer": "F.Cu", "width": 0.2, "start": {"x": 1.0, "y": 5.0}, "end": {"x": 19.0, "y": 5.0}}]})+ }++ #[test]+ fn balance_and_return_path_on_a_split_ground() {+ let layers = vec![("F.Cu".to_string(), false, String::new()), ("B.Cu".to_string(), false, String::new())];+ // GND poured on B.Cu over x 0..8 and 12..20: a 4 mm split under the signal+ let zones = vec![json!({"layer": "B.Cu", "net": "GND", "filled": true, "fills": [+ {"outline": [[0, 0], [8, 0], [8, 10], [0, 10]], "holes": []}, {"outline": [[12, 0], [20, 0], [20, 10], [12, 10]], "holes": []}]})];+ let (board, maps) = rasters(&st(), &zones, &layers, 0.1, None);+ let (rows, pairs) = balance(&board, &maps, &layers);+ assert!((rows[1]["percent"].as_f64().unwrap() - 80.0).abs() < 1.0);+ assert!((rows[0]["percent"].as_f64().unwrap() - 1.8).abs() < 0.5);+ assert_eq!(pairs[0]["ok"], json!(false));+ let (_, refs) = rasters(&st(), &zones, &layers, 0.1, Some(&["GND".to_string()]));+ // the signal itself is not reference copper+ assert!(!refs["F.Cu"].at(10.0, 5.0));+ let rp = return_paths(&st(), &refs, &layers, &["GND".to_string()], 0.1);+ assert_eq!(rp.len(), 1);+ assert!((rp[0]["unreferencedMm"].as_f64().unwrap() - 4.0).abs() < 0.2);+ assert_eq!(rp[0]["firstGapAt"][0].as_f64().unwrap().floor(), 8.0);+ }++ #[test]+ fn stack_names_match_routing_state() {+ let s = json!({"layers": [{"name": "Top"}, {"kind": "plane", "plane": 1, "net": "GND"}, {"kind": "signal", "mid": 1}, {"name": "Bottom"}]});+ let l = stack_layers(&s);+ assert_eq!(l.iter().map(|x| x.0.as_str()).collect::<Vec<_>>(), vec!["F.Cu", "L39", "In1.Cu", "B.Cu"]);+ assert!(l[1].1 && l[1].2 == "GND");+ }+ #[test]+ fn gap_runs_group_and_the_fix_patches_the_neighbour() {+ let pts: Vec<(f64, f64)> = (0..40).map(|k| (8.05 + k as f64 * 0.1, 5.0)).chain((0..5).map(|k| (15.0 + k as f64 * 0.1, 5.0))).collect();+ let runs = gap_runs(&pts, 1.0);+ assert_eq!(runs.len(), 2);+ let layers = vec![("F.Cu".to_string(), false, String::new()), ("B.Cu".to_string(), false, String::new())];+ let zones = vec![json!({"layer": "B.Cu", "net": "GND", "filled": true, "fills": [{"outline": [[0, 0], [8, 0], [8, 10], [0, 10]], "holes": []}]})];+ let (_, refs) = rasters(&st(), &zones, &layers, 0.1, Some(&["GND".to_string()]));+ let rows = vec![json!({"net": "SIG", "layer": "F.Cu", "gapRuns": [[8.05, 5.0, 11.95, 5.0]]})];+ let p = fix_plan(&rows, &layers, &refs, &zones, &["GND".to_string()], (0.0, 0.0, 20.0, 10.0), 1.0);+ assert_eq!(p.len(), 1);+ assert_eq!(p[0]["layer"], "B.Cu");+ assert_eq!(p[0]["net"], "GND");+ assert_eq!(p[0]["points"][0], json!([7.05, 4.0]));+ assert_eq!(p[0]["joinsExistingCopper"], json!(true));+ assert_eq!(p[0]["removeDead"], json!(false));+ }+}
engine/src/current.rsadded+140@@ -0,0 +1,140 @@+//! Current capacity of a net's copper (IPC-2221): the width each layer needs for a current at an allowed temperature+//! rise, against the narrowest track the net actually has there, and how many of its vias one layer change needs.+use serde_json::{json, Map, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }++const MIL: f64 = 0.0254;++/// IPC-2221's chart as a formula: the cross-section (mil^2) for `amps` at `rise` degrees C, outer or inner layer.+fn area_mil2(amps: f64, rise: f64, outer: bool) -> f64 {+ let k = if outer { 0.048 } else { 0.024 };+ (amps / (k * rise.powf(0.44))).powf(1.0 / 0.725)+}++/// The track width (mm) `amps` needs on copper `copper_um` thick.+pub fn width_for(amps: f64, rise: f64, copper_um: f64, outer: bool) -> f64 {+ let t_mil = copper_um / 1000.0 / MIL;+ area_mil2(amps, rise, outer) / t_mil * MIL+}++/// What one via carries: its barrel (plating `plating_um` around a `drill` mm hole) as an inner conductor.+pub fn via_amps(drill: f64, plating_um: f64, rise: f64) -> f64 {+ let t = plating_um / 1000.0;+ let a_mil2 = std::f64::consts::PI * (drill + t) * t / (MIL * MIL);+ 0.024 * rise.powf(0.44) * a_mil2.powf(0.725)+}++/// routing_state's layer name to its index in the stack (F.Cu first, In<n>.Cu, B.Cu last).+fn stack_index(layer: &str, copper: usize) -> Option<usize> {+ match layer {+ "F.Cu" => Some(0),+ "B.Cu" => Some(copper.saturating_sub(1)),+ l => l.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse().ok()),+ }+}++/// Per net asked: each layer it is routed on with its narrowest track and the width the current needs. Pure.+pub fn check(state: &Value, copper_um: &[f64], nets: &Map<String, Value>, rise: f64, plating_um: f64) -> Vec<Value> {+ let tracks = state["tracks"].as_array().cloned().unwrap_or_default();+ let arcs = state["arcs"].as_array().cloned().unwrap_or_default();+ let vias = state["vias"].as_array().cloned().unwrap_or_default();+ let polys = state["polygons"].as_array().cloned().unwrap_or_default();+ let n = copper_um.len().max(2);+ nets.iter().map(|(net, amps)| {+ let amps = f(amps);+ let same = |v: &Value| v.as_str().map(|s| s.eq_ignore_ascii_case(net)).unwrap_or(false);+ // the narrowest piece of copper on each layer, tracks and arcs alike+ let mut narrow: std::collections::BTreeMap<String, (f64, Value)> = Default::default();+ let mut see = |layer: &str, w: f64, at: Value| {+ let e = narrow.entry(layer.to_string()).or_insert((f64::MAX, Value::Null));+ if w < e.0 { *e = (w, at); }+ };+ for t in tracks.iter().filter(|t| same(&t["net"])) { see(t["layer"].as_str().unwrap_or("?"), f(&t["width"]), json!([t["start"]["x"], t["start"]["y"]])); }+ for a in arcs.iter().filter(|a| same(&a["net"])) { see(a["layer"].as_str().unwrap_or("?"), f(&a["width"]), json!([a["center"]["x"], a["center"]["y"]])); }+ let mut short = Vec::new();+ let layers: Vec<Value> = narrow.into_iter().filter(|(l, _)| l.ends_with(".Cu")).map(|(layer, (w, at))| {+ let i = stack_index(&layer, n).unwrap_or(0);+ let outer = i == 0 || i + 1 == n;+ let cu = copper_um.get(i).copied().filter(|c| *c > 0.0).unwrap_or(35.0);+ let need = width_for(amps, rise, cu, outer);+ if w + 1e-9 < need { short.push(layer.clone()); }+ json!({"layer": layer, "copperUm": cu, "narrowestMm": r3(w), "at": at, "neededMm": r3(need), "ok": w + 1e-9 >= need})+ }).collect();+ let net_vias: Vec<&Value> = vias.iter().filter(|v| same(&v["net"])).collect();+ let smallest = net_vias.iter().map(|v| f(&v["drill"])).fold(f64::MAX, f64::min);+ let via = if net_vias.is_empty() { Value::Null } else {+ let each = via_amps(smallest, plating_um, rise);+ json!({"count": net_vias.len(), "smallestDrillMm": r3(smallest), "ampsEach": r2(each), "neededPerLayerChange": (amps / each).ceil() as i64})+ };+ let pours: Vec<Value> = polys.iter().filter(|p| same(&p["net"])).map(|p| p["layer"].clone()).collect();+ json!({"net": net, "amps": amps, "layers": layers, "shortOn": short, "ok": short.is_empty(), "vias": via, "poursOn": pours})+ }).collect()+}++/// current_capacity: input {board, stack?, args: {nets: {net: amps}, riseC?, platingUm?}}. Without a stack every layer+/// is taken as 35 um (1 oz) copper, and the reply says so.+pub fn current_capacity(input: &Value) -> Value {+ let args = &input["args"];+ let nets = match args.get("nets").and_then(|v| v.as_object()) {+ Some(m) if !m.is_empty() && m.values().all(|v| v.as_f64().map(|a| a > 0.0).unwrap_or(false)) => m.clone(),+ _ => return json!({"success": false, "errorCode": "bad_args", "error": "nets is {net: amps}, e.g. {\"VIN\": 3, \"GND\": 3}"}),+ };+ let rise = args.get("riseC").and_then(|v| v.as_f64()).unwrap_or(10.0);+ let plating = args.get("platingUm").and_then(|v| v.as_f64()).unwrap_or(25.0);+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let missing: Vec<&String> = nets.keys().filter(|k| !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(k)).unwrap_or(false))).unwrap_or(false)).collect();+ if !missing.is_empty() { return json!({"success": false, "errorCode": "no_such_net", "error": format!("not nets on this board: {missing:?}"), "nets": st["nets"]}); }+ let stack = crate::input::stack(input);+ let copper: Vec<f64> = stack.as_ref().and_then(|s| s["layers"].as_array().cloned()).unwrap_or_default().iter().map(|l| f(&l["copperUm"])).collect();+ let rows = check(&st, &copper, &nets, rise, plating);+ let all_ok = rows.iter().all(|r| r["ok"].as_bool() == Some(true));+ json!({"success": true, "riseC": rise, "platingUm": plating, "nets": rows, "ok": all_ok,+ "assumed": if copper.is_empty() { json!("no stack in the input: every layer taken as 35 um (1 oz) copper") } else { Value::Null },+ "_hint": if all_ok { "Every routed layer of these nets carries its current within the temperature rise (IPC-2221). Vias: a layer change needs neededPerLayerChange of them side by side." }+ else { "shortOn names the layers whose narrowest copper is under neededMm: widen it (set_track_width, or a width rule with add_rule) or pour the net (apply_pours). A pour on the same layer (poursOn) may already carry the current; the check reads tracks only." }})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn ipc_2221_matches_its_chart() {+ // 1 oz (35 um) outer copper, 10 C rise: 1 A needs about 0.30 mm (12 mil), 3 A about 1.4 mm+ let w1 = width_for(1.0, 10.0, 35.0, true);+ assert!((0.25..0.35).contains(&w1), "{w1}");+ let w3 = width_for(3.0, 10.0, 35.0, true);+ assert!((1.2..1.6).contains(&w3), "{w3}");+ // inner layers need about 2.6x the width+ assert!(width_for(1.0, 10.0, 35.0, false) / w1 > 2.4);+ // a 0.3 mm via with 25 um plating carries roughly 1 to 1.5 A at 10 C+ let v = via_amps(0.3, 25.0, 10.0);+ assert!((0.8..1.6).contains(&v), "{v}");+ }++ #[test]+ fn check_finds_the_narrow_layer() {+ let st = json!({"tracks": [{"net": "VIN", "layer": "F.Cu", "width": 1.5, "start": {"x": 0.0, "y": 0.0}}, {"net": "VIN", "layer": "B.Cu", "width": 0.3, "start": {"x": 4.0, "y": 1.0}}],+ "vias": [{"net": "VIN", "drill": 0.3}, {"net": "VIN", "drill": 0.4}], "polygons": [{"net": "VIN", "layer": "B.Cu"}]});+ let nets = json!({"VIN": 3.0}).as_object().unwrap().clone();+ let r = &check(&st, &[35.0, 35.0], &nets, 10.0, 25.0)[0];+ assert_eq!(r["shortOn"], json!(["B.Cu"]));+ assert_eq!(r["vias"]["smallestDrillMm"], 0.3);+ assert!(r["vias"]["neededPerLayerChange"].as_i64().unwrap() >= 2);+ assert_eq!(r["poursOn"], json!(["B.Cu"]));+ }++ #[test]+ fn the_verb_says_what_it_assumed_and_refuses_unknown_nets() {+ let st = json!({"nets": ["VIN"], "tracks": [{"net": "VIN", "layer": "F.Cu", "width": 0.2, "start": {"x": 0.0, "y": 0.0}}]});+ let r = current_capacity(&json!({"board": st, "args": {"nets": {"VIN": 2}}}));+ assert_eq!(r["ok"], json!(false));+ assert!(r["assumed"].as_str().unwrap().contains("35 um"));+ assert_eq!(current_capacity(&json!({"board": st, "args": {"nets": {"VOUT": 1}}}))["errorCode"], "no_such_net");+ assert_eq!(current_capacity(&json!({"board": st, "args": {}}))["errorCode"], "bad_args");+ }+}+
engine/src/fab.rsadded+294@@ -0,0 +1,294 @@+//! The board's fab-relevant minimums, measured from the board model, and how they compare with a fab's limits: the+//! numbers a fab's order form and capability page ask about, so the AI can check a board before ordering. Fab limits+//! are never kept here: they come in as a record from adom/pcb-design-rules (`fab`), or as explicit `limits`.+use serde_json::{json, Map, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// The nearest copper (track, arc, pad, via) to the real outline, edge of copper to the outline's line, mm. Pour+/// boundaries are left out: they are drawn on the edge and the tool pulls the poured copper back by its rules.+pub fn edge_distance(state: &Value) -> Option<(f64, Value)> {+ let ring = crate::coppermap::outline_ring(state);+ if ring.len() < 3 { return None; }+ let seg = |x: f64, y: f64| -> f64 {+ (0..ring.len()).map(|k| {+ let (a, b) = (ring[k], ring[(k + 1) % ring.len()]);+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 > 0.0 { (((x - a.0) * dx + (y - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+ ((a.0 + t * dx - x).powi(2) + (a.1 + t * dy - y).powi(2)).sqrt()+ }).fold(f64::MAX, f64::min)+ };+ let copper = |l: &Value| l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false);+ let mut pts: Vec<(f64, f64, f64, Value)> = Vec::new();+ for t in state["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| copper(&t["layer"])) {+ for p in [&t["start"], &t["end"]] { pts.push((f(&p["x"]), f(&p["y"]), f(&t["width"]) / 2.0, json!({"track": t["net"], "layer": t["layer"]}))); }+ }+ for v in state["vias"].as_array().cloned().unwrap_or_default() { pts.push((f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]) / 2.0, json!({"via": v["net"]}))); }+ for p in state["pads"].as_array().cloned().unwrap_or_default() {+ let (rf, nm) = (p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+ pts.push((f(&p["x"]), f(&p["y"]), f(&p["sizeX"]).max(f(&p["sizeY"])) / 2.0, json!({"pad": if rf.is_empty() { nm.to_string() } else { format!("{rf}.{nm}") }})));+ }+ pts.into_iter().map(|(x, y, half, mut what)| { what["at"] = json!([r3(x), r3(y)]); (r3(seg(x, y) - half), what) })+ .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())+}++/// The smallest of each feature with where it is. Pure, for the tests.+pub fn measure(state: &Value) -> Map<String, Value> {+ let mut m = Map::new();+ let mut min_by = |key: &str, items: Vec<(f64, Value)>| {+ if let Some((v, at)) = items.into_iter().filter(|(v, _)| *v > 0.0).min_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) {+ m.insert(key.into(), json!({"mm": r3(v), "at": at}));+ }+ };+ // copper only: outline and drawing lines on mechanical layers (L57...) are not tracks a fab etches+ let copper = |l: &Value| l.as_str().map(|s| s == "F.Cu" || s == "B.Cu" || (s.starts_with("In") && s.ends_with(".Cu"))).unwrap_or(false);+ min_by("track", state["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| copper(&t["layer"])).map(|t| (f(&t["width"]), json!({"net": t["net"], "layer": t["layer"], "from": [t["start"]["x"], t["start"]["y"]]}))).collect());+ let vias = state["vias"].as_array().cloned().unwrap_or_default();+ min_by("viaDrill", vias.iter().map(|v| (f(&v["drill"]), json!({"net": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect());+ min_by("viaDiameter", vias.iter().map(|v| (f(&v["size"]), json!({"net": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect());+ min_by("viaAnnularRing", vias.iter().map(|v| ((f(&v["size"]) - f(&v["drill"])) / 2.0, json!({"net": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect());+ let pads = state["pads"].as_array().cloned().unwrap_or_default();+ let drilled: Vec<&Value> = pads.iter().filter(|p| f(&p["drill"]) > 0.0).collect();+ let label = |p: &Value| json!({"pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "at": [p["x"], p["y"]]});+ min_by("padHole", drilled.iter().map(|p| (f(&p["drill"]), label(p))).collect());+ // a pad's ring on its narrow side (a round hole in an oblong pad)+ min_by("padAnnularRing", drilled.iter().filter(|p| f(&p["sizeX"]) > f(&p["drill"])).map(|p| ((f(&p["sizeX"]).min(f(&p["sizeY"])) - f(&p["drill"])) / 2.0, label(p))).collect());+ min_by("smdPad", pads.iter().filter(|p| f(&p["drill"]) == 0.0).map(|p| (f(&p["sizeX"]).min(f(&p["sizeY"])), label(p))).collect());+ if let Some((x0, y0, x1, y1)) = crate::geom::outline_bbox(state) {+ m.insert("boardSize".into(), json!({"mm": [r3(x1 - x0), r3(y1 - y0)]}));+ let _ = (x0, y0, x1, y1);+ if let Some((d, mut at)) = edge_distance(state) { at["mm"] = json!(d); m.insert("edgeClearance".into(), at); }+ }+ // drilled holes: the largest, and the closest pair edge to edge (via to via, and any pair with a pad hole)+ let mut holes: Vec<(f64, f64, f64, bool, Value)> = vias.iter().map(|v| (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["drill"]), false, json!({"via": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect();+ holes.extend(drilled.iter().map(|p| (f(&p["x"]), f(&p["y"]), f(&p["drill"]), true, label(p))));+ if let Some(h) = holes.iter().max_by(|a, b| a.2.partial_cmp(&b.2).unwrap()) { m.insert("largestDrill".into(), json!({"mm": r3(h.2), "at": h.4})); }+ holes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+ let (mut vv, mut pp): (Option<(f64, Value)>, Option<(f64, Value)>) = (None, None);+ for i in 0..holes.len() {+ for j in i + 1..holes.len() {+ let (a, b) = (&holes[i], &holes[j]);+ if b.0 - a.0 > 10.0 { break; }+ let d = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() - (a.2 + b.2) / 2.0;+ if (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6 { continue; } // a via in a pad+ let slot = if a.3 || b.3 { &mut pp } else { &mut vv };+ if slot.as_ref().map(|s| d < s.0).unwrap_or(true) { *slot = Some((d, json!([a.4.clone(), b.4.clone()]))); }+ }+ }+ if let Some((d, at)) = vv { m.insert("holeToHoleVia".into(), json!({"mm": r3(d), "at": at})); }+ if let Some((d, at)) = pp { m.insert("holeToHolePad".into(), json!({"mm": r3(d), "at": at})); }+ m.insert("counts".into(), json!({"tracks": state["tracks"].as_array().map(|a| a.len()), "vias": vias.len(), "pads": pads.len(), "drilledPads": drilled.len()}));+ m+}++/// The measured values against the caller's limits; each limit names its feature (min*, or max for board and layers).+pub fn compare(meas: &Map<String, Value>, limits: &Map<String, Value>, clearance: Option<f64>, layers: Option<i64>) -> Vec<Value> {+ let mut out = Vec::new();+ let got = |k: &str| meas.get(k).and_then(|v| v["mm"].as_f64());+ for (limit, feature) in [("minTrack", "track"), ("minViaDrill", "viaDrill"), ("minViaDiameter", "viaDiameter"), ("minViaAnnularRing", "viaAnnularRing"),+ ("minPadHole", "padHole"), ("minPadAnnularRing", "padAnnularRing"), ("minSmdPad", "smdPad"),+ ("minEdgeClearance", "edgeClearance"), ("minHoleToHoleVia", "holeToHoleVia"), ("minHoleToHolePad", "holeToHolePad"),+ ("minSilkLine", "silkLine"), ("minSilkText", "silkText")] {+ if let (Some(l), Some(v)) = (limits.get(limit).and_then(|x| x.as_f64()), got(feature)) {+ if v + 1e-9 < l { out.push(json!({"limit": limit, "limitMm": l, "boardMm": v, "at": meas[feature]["at"]})); }+ }+ }+ if let (Some(l), Some(c)) = (limits.get("minClearance").and_then(|x| x.as_f64()), clearance) {+ if c + 1e-9 < l { out.push(json!({"limit": "minClearance", "limitMm": l, "boardMm": c, "at": "the board's clearance rule"})); }+ }+ if let (Some(l), Some(n)) = (limits.get("maxLayers").and_then(|x| x.as_i64()), layers) {+ if n > l { out.push(json!({"limit": "maxLayers", "limit_": l, "board": n})); }+ }+ if let (Some(l), Some(v)) = (limits.get("maxDrill").and_then(|x| x.as_f64()), got("largestDrill")) {+ if v > l + 1e-9 { out.push(json!({"limit": "maxDrill", "limitMm": l, "boardMm": v, "at": meas["largestDrill"]["at"], "note": "larger holes are milled (routed) instead"})); }+ }+ if let (Some(lim), Some(sz)) = (limits.get("minBoardMm").and_then(|x| x.as_array()), meas.get("boardSize").and_then(|b| b["mm"].as_array())) {+ let (lw, lh) = (lim.first().and_then(|x| x.as_f64()).unwrap_or(0.0), lim.get(1).and_then(|x| x.as_f64()).unwrap_or(0.0));+ let (w, h) = (sz[0].as_f64().unwrap_or(0.0), sz[1].as_f64().unwrap_or(0.0));+ if w.min(h) + 1e-9 < lw.min(lh) || w.max(h) + 1e-9 < lw.max(lh) { out.push(json!({"limit": "minBoardMm", "limitMm": [lw, lh], "boardMm": [w, h]})); }+ }+ if let (Some(lim), Some(sz)) = (limits.get("maxBoardMm").and_then(|x| x.as_array()), meas.get("boardSize").and_then(|b| b["mm"].as_array())) {+ let (lw, lh) = (lim.first().and_then(|x| x.as_f64()).unwrap_or(f64::MAX), lim.get(1).and_then(|x| x.as_f64()).unwrap_or(f64::MAX));+ let (w, h) = (sz[0].as_f64().unwrap_or(0.0), sz[1].as_f64().unwrap_or(0.0));+ // either orientation fits+ if !((w <= lw && h <= lh) || (w <= lh && h <= lw)) { out.push(json!({"limit": "maxBoardMm", "limitMm": [lw, lh], "boardMm": [w, h]})); }+ }+ out+}++/// The smallest clearance among the board's enabled clearance rules, in mm. A rule gives it as a number+/// (`clearanceMm`, `gapMm` or `minMm`) or in a descriptor the tool wrote ("Gap=10mil", "Gap=0.2mm": Altium's form).+pub fn min_clearance(rules: &[Value]) -> Option<f64> {+ rules.iter().filter(|r| r["kind"].as_i64() == Some(0) || r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("clearance")).unwrap_or(false)+ || r["kindName"].as_str() == Some("Clearance") || r["name"].as_str().map(|n| n.to_lowercase().contains("clearance")).unwrap_or(false))+ .filter(|r| r["enabled"].as_bool() != Some(false))+ .filter_map(|r| {+ if let Some(v) = ["clearanceMm", "gapMm", "minMm"].iter().find_map(|k| r[*k].as_f64()) { return Some(v); }+ let d = r["descriptor"].as_str()?;+ let i = d.find("Gap=")? + 4;+ let num: String = d[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();+ let v: f64 = num.parse().ok()?;+ let unit = &d[i + num.len()..];+ Some(if unit.starts_with("mil") { v * 0.0254 } else { v })+ }).reduce(f64::min).map(r3)+}++/// A pcb-design-rules record (one entry of `rules/records/<vendor>.json`'s `records`, with the file's `vendor`) as the+/// limits this check compares, plus what to say about where they came from. Limits the record does not give are left+/// out, never guessed. Pure.+pub fn limits_from_record(rec: &Value, vendor: &str) -> (Map<String, Value>, Value) {+ let mut l = Map::new();+ let mut put = |k: &str, v: Option<f64>| { if let Some(v) = v { l.insert(k.into(), json!(v)); } };+ put("minTrack", rec["minTraceWidth"]["mm"].as_f64());+ put("minClearance", rec["minCopperClearance"]["mm"].as_f64().or(rec["minSpacing"]["mm"].as_f64()));+ put("minViaDrill", rec["minVia"]["drillMm"].as_f64());+ put("minViaDiameter", rec["minVia"]["padMm"].as_f64());+ put("minPadHole", rec["minDrill"]["mm"].as_f64());+ // the record's annular ring is the plated-through-hole ring ("PTH ... absolute minimum")+ put("minPadAnnularRing", rec["annularRing"]["viaMm"].as_f64());+ put("minEdgeClearance", rec["copperToBoardEdge"]["mm"].as_f64());+ put("minHoleToHoleVia", rec["holeToHole"]["mm"].as_f64());+ put("minSilkLine", rec["silkLineWidth"]["mm"].as_f64());+ if let (Some(x), Some(y)) = (rec["maxBoardSize"]["xMm"].as_f64(), rec["maxBoardSize"]["yMm"].as_f64()) { l.insert("maxBoardMm".into(), json!([x, y])); }+ if let Some(n) = rec["layerCount"].as_i64() { l.insert("maxLayers".into(), json!(n)); }+ let src = rec["sources"].as_array().and_then(|s| s.first()).cloned().unwrap_or(Value::Null);+ let conditions: Map<String, Value> = rec.as_object().map(|o| o.iter().filter_map(|(k, v)| v.get("condition").map(|c| (k.clone(), c.clone()))).collect()).unwrap_or_default();+ let fab = json!({"name": vendor, "process": rec["process"], "layers": rec["layerCount"], "source": src["url"], "read": src["dateRead"],+ "conditions": conditions, "notes": rec["notes"]});+ (l, fab)+}++/// fab_check: input {board, stack?, rules?, silk?, fab?, args: {limits?, copperOz?}}. `fab` is a pcb-design-rules+/// record (one of a vendor file's `records`, with `vendor` beside it or given as args.vendor); `limits` adds or+/// overrides any limit by name, in mm.+pub fn fab_check(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let mut meas = measure(&st);+ let rule_list = crate::input::rules(input);+ let clearance = min_clearance(&rule_list);+ if let Some(c) = clearance { meas.insert("clearanceRule".into(), json!({"mm": c})); }+ let stack = crate::input::stack(input);+ let layers = stack.as_ref().and_then(|s| s["copperLayers"].as_i64());+ if let Some(n) = layers { meas.insert("copperLayers".into(), json!(n)); }+ let mut limits = Map::new();+ let mut fab = Value::Null;+ if let Some(rec) = input.get("fab").filter(|r| !r.is_null()) {+ let vendor = rec["vendor"].as_str().or(args["vendor"].as_str()).unwrap_or("the fab").to_string();+ let (l, mut fv) = limits_from_record(rec, &vendor);+ if let (Some(want), Some(n)) = (rec["layerCount"].as_i64(), layers) {+ if want != n { fv["mismatch"] = json!(format!("the record is for {want} layers; the board has {n}: pass the {n}-layer record")); }+ }+ // the record's limits hold for its stated copper (1 oz outer as a rule); heavier copper needs wider tracks+ let outer_um = stack.as_ref().and_then(|s| s["layers"][0]["copperUm"].as_f64()).unwrap_or(35.0);+ let oz = args["copperOz"].as_f64().unwrap_or(outer_um / 35.0);+ fv["copperOz"] = json!(r3(oz));+ if oz >= 1.75 { fv["copperNote"] = json!("the board's outer copper is heavier than the record's 1 oz condition: its notes give the wider track and gap limits for heavier copper; pass them as limits"); }+ limits = l;+ fab = fv;+ }+ for (k, v) in args["limits"].as_object().cloned().unwrap_or_default() { limits.insert(k, v); }+ // silkscreen sizes from silk_state, only when a limit asks and the input has it+ if limits.contains_key("minSilkText") || limits.contains_key("minSilkLine") {+ if let Some(silk) = crate::input::part(input, "silk") {+ let mut texts: Vec<(f64, Value)> = silk["texts"].as_array().cloned().unwrap_or_default().iter().filter(|t| t["side"] != "other").map(|t| (f(&t["size"]), json!({"text": t["text"], "at": [t["x"], t["y"]]}))).collect();+ for c in silk["components"].as_array().cloned().unwrap_or_default() {+ for k in ["name", "comment"] { if c[k]["shown"].as_bool() == Some(true) { texts.push((f(&c[k]["size"]), json!({"ref": c["ref"], "text": c[k]["text"], "at": [c[k]["x"], c[k]["y"]]}))); } }+ }+ if let Some((v, at)) = texts.into_iter().filter(|t| t.0 > 0.0).min_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) { meas.insert("silkText".into(), json!({"mm": r3(v), "at": at})); }+ if let Some(l) = silk["lines"].as_array().cloned().unwrap_or_default().iter().filter(|l| l["side"] != "other" && f(&l["width"]) > 0.0).min_by(|a, b| f(&a["width"]).partial_cmp(&f(&b["width"])).unwrap()) {+ meas.insert("silkLine".into(), json!({"mm": r3(f(&l["width"])), "at": [l["x1"], l["y1"]]}));+ }+ }+ }+ let mut fails = compare(&meas, &limits, clearance, layers);+ // each finding names where its limit came from+ if !fab.is_null() { for x in fails.iter_mut() { x["source"] = json!({"name": fab["name"], "url": fab["source"], "read": fab["read"]}); } }+ let unseen: Vec<&str> = [("minClearance", clearance.is_some()), ("minSilkText", meas.contains_key("silkText")), ("minSilkLine", meas.contains_key("silkLine")), ("maxLayers", layers.is_some())]+ .iter().filter(|(k, seen)| limits.contains_key(*k) && !seen).map(|(k, _)| *k).collect();+ json!({"success": true, "fab": fab, "measured": meas, "limitsChecked": limits.keys().collect::<Vec<_>>(), "outsideLimits": fails,+ "notChecked": unseen, "fits": if limits.is_empty() { Value::Null } else { json!(fails.is_empty()) },+ "costlierOrBelowRecommended": [],+ "_hint": if limits.is_empty() { "Measured only. Pass a fab's record from adom/pcb-design-rules as fab, or limits:{minTrack, minClearance, minViaDrill, minViaDiameter, minViaAnnularRing, minPadHole, minPadAnnularRing, minSmdPad, minEdgeClearance, minHoleToHoleVia, minHoleToHolePad, maxDrill, minSilkLine, minSilkText, maxLayers, minBoardMm:[w,h], maxBoardMm:[w,h]} in mm, to check them." }+ else { "outsideLimits names each feature below the fab's limit, where it is, and the page the limit came from. notChecked lists limits the input gave nothing to measure against (pass rules, stack or silk). Read fab.notes: costlier options and recommended sizes are there. Fix the board (set_track_width, add_rule, a footprint change) or choose the fab's advanced service; then run run_drc." }})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn board() -> Value {+ json!({"tracks": [{"width": 0.25, "net": "A", "layer": "F.Cu", "start": {"x": 0.0, "y": 0.0}}, {"width": 0.1, "net": "B", "layer": "B.Cu", "start": {"x": 5.0, "y": 5.0}}, {"width": 0.05, "net": "", "layer": "L57", "start": {"x": 0.0, "y": 0.0}}],+ "vias": [{"size": 0.6, "drill": 0.3, "net": "GND", "position": {"x": 1.0, "y": 1.0}}, {"size": 0.45, "drill": 0.25, "net": "B", "position": {"x": 2.0, "y": 2.0}}],+ "pads": [{"ref": "J1", "name": "1", "x": 0.0, "y": 0.0, "sizeX": 1.7, "sizeY": 1.7, "drill": 1.0}, {"ref": "U1", "name": "3", "x": 3.0, "y": 0.0, "sizeX": 0.3, "sizeY": 1.0, "drill": 0.0}],+ "outline": [[0, 0], [50, 0], [50, 30], [0, 30]]})+ }++ #[test]+ fn the_smallest_features_are_found_with_where_they_are() {+ let m = measure(&board());+ assert_eq!(m["track"]["mm"], json!(0.1));+ assert_eq!(m["track"]["at"]["net"], json!("B"));+ assert_eq!((m["viaDrill"]["mm"].clone(), m["viaDiameter"]["mm"].clone(), m["viaAnnularRing"]["mm"].clone()), (json!(0.25), json!(0.45), json!(0.1)));+ assert_eq!((m["padHole"]["mm"].clone(), m["padAnnularRing"]["mm"].clone(), m["smdPad"]["mm"].clone()), (json!(1.0), json!(0.35), json!(0.3)));+ assert_eq!(m["boardSize"]["mm"], json!([50.0, 30.0]));+ }++ #[test]+ fn arc_edges_count_toward_the_board_size() {+ let b = json!({"outline": [[0, 0], [10, 0]], "outlineEdges": [{"kind": "arc", "center": [5.0, 0.0], "radius": 5.0, "angle1": 0.0, "angle2": 180.0}]});+ assert_eq!(measure(&b)["boardSize"]["mm"], json!([10.0, 5.0]));+ }++ #[test]+ fn limits_name_what_falls_short_and_a_board_fits_either_way_round() {+ let m = measure(&board());+ let lim = json!({"minTrack": 0.127, "minViaDrill": 0.3, "maxBoardMm": [30, 60], "maxLayers": 2, "minClearance": 0.127});+ let f = compare(&m, lim.as_object().unwrap(), Some(0.1), Some(2));+ let names: Vec<&str> = f.iter().map(|x| x["limit"].as_str().unwrap()).collect();+ assert_eq!(names, vec!["minTrack", "minViaDrill", "minClearance"]);+ }++ #[test]+ fn clearance_is_read_from_rule_descriptors_in_mil_or_mm() {+ let rules = vec![json!({"kind": 0, "descriptor": "Clearance Constraint (Gap=10mil) (All),(All)", "enabled": true}),+ json!({"kind": 0, "descriptor": "Clearance Constraint (Gap=0.15mm) (InNet('SW')),(All)", "enabled": true}),+ json!({"kind": 0, "descriptor": "Clearance Constraint (Gap=0.05mm) (All),(All)", "enabled": false})];+ assert_eq!(min_clearance(&rules), Some(0.15));+ }+ /// JLCPCB's 2-layer record as adom/pcb-design-rules gives it (rules/records/jlcpcb.json, read 2026-09-03), trimmed+ fn jlc_2l() -> Value {+ json!({"vendor": "JLCPCB", "process": "standard", "layerCount": 2, "minTraceWidth": {"mm": 0.1, "condition": "1 oz copper outer layer"},+ "minSpacing": {"mm": 0.1}, "minDrill": {"mm": 0.15}, "minVia": {"drillMm": 0.15, "padMm": 0.25}, "annularRing": {"viaMm": 0.18},+ "copperToBoardEdge": {"mm": 0.2}, "holeToHole": {"mm": 0.2}, "silkLineWidth": {"mm": 0.15}, "minCopperClearance": {"mm": 0.1},+ "maxBoardSize": {"xMm": 670.0, "yMm": 600.0}, "notes": "Min drill 0.15 mm costs more.",+ "sources": [{"url": "https://jlcpcb.com/capabilities/pcb-capabilities", "dateRead": "2026-09-03"}]})+ }++ #[test]+ fn a_pcb_design_rules_record_becomes_limits_with_its_source() {+ let (l, fab) = limits_from_record(&jlc_2l(), "JLCPCB");+ assert_eq!((l["minTrack"].clone(), l["minClearance"].clone(), l["minPadAnnularRing"].clone()), (json!(0.1), json!(0.1), json!(0.18)));+ assert_eq!(l["maxBoardMm"], json!([670.0, 600.0]));+ assert!(!l.contains_key("minSilkText"), "a limit the record does not give is not guessed");+ assert_eq!((fab["source"].as_str(), fab["read"].as_str()), (Some("https://jlcpcb.com/capabilities/pcb-capabilities"), Some("2026-09-03")));+ // a 0.08 mm track fails, holes 0.15 mm apart edge to edge fail the via rule; each finding names the page+ let st = json!({"tracks": [{"width": 0.08, "net": "A", "layer": "F.Cu", "start": {"x": 1.0, "y": 1.0}}],+ "vias": [{"drill": 0.3, "size": 0.6, "net": "A", "position": {"x": 0.0, "y": 0.0}}, {"drill": 0.3, "size": 0.6, "net": "B", "position": {"x": 0.45, "y": 0.0}}],+ "outline": [[-5, -5], [5, -5], [5, 5], [-5, 5]]});+ let r = fab_check(&json!({"board": st, "stack": {"copperLayers": 2, "layers": [{"copperUm": 35}, {"copperUm": 35}]}, "fab": jlc_2l()}));+ let fails: Vec<&str> = r["outsideLimits"].as_array().unwrap().iter().map(|f| f["limit"].as_str().unwrap()).collect();+ assert!(fails.contains(&"minTrack") && fails.contains(&"minHoleToHoleVia"), "{fails:?}");+ assert_eq!(r["outsideLimits"][0]["source"]["read"], "2026-09-03");+ assert_eq!(r["fits"], json!(false));+ // no rules in the input: the clearance limit is reported as not checked, never passed+ assert!(r["notChecked"].as_array().unwrap().contains(&json!("minClearance")));+ }++ #[test]+ fn clearance_from_a_standard_rule() {+ assert_eq!(min_clearance(&[json!({"kind": "clearance", "clearanceMm": 0.127}), json!({"kind": "clearance", "gapMm": 0.2})]), Some(0.127));+ }+}
engine/src/geom.rsadded+23@@ -0,0 +1,23 @@+//! Board geometry shared by the checks: the outline's bounding box with its arc edges sampled.+use serde_json::Value;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }++/// The outline's bounding box (x0, y0, x1, y1) in mm, arc edges included.+pub fn outline_bbox(state: &Value) -> Option<(f64, f64, f64, f64)> {+ let mut o: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect();+ // an arc edge bulges past its end points: sample it (the Bluetooth Sentinel example measured 80 x 4.5 mm by its+ // vertices alone)+ for e in state["outlineEdges"].as_array().cloned().unwrap_or_default() {+ if e["kind"].as_str() != Some("arc") { continue; }+ let (cx, cy, r) = (f(&e["center"][0]), f(&e["center"][1]), f(&e["radius"]));+ let (a1, mut a2) = (f(&e["angle1"]), f(&e["angle2"]));+ if a2 <= a1 { a2 += 360.0; }+ let mut a = a1;+ while a <= a2 + 1e-9 { o.push((cx + r * a.to_radians().cos(), cy + r * a.to_radians().sin())); a += 2.0; }+ }+ if o.is_empty() { return None; }+ let (x0, x1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ Some((x0, y0, x1, y1))+}
engine/src/impedance.rsadded+234@@ -0,0 +1,234 @@+//! Controlled impedance from the board's stack-up (the layer_stack companion): the track width (and pair gap) each copper layer needs for a+//! target impedance, by closed-form models (Hammerstad-Jensen microstrip, Wheeler stripline, IPC-2141 coupling). Within+//! a few percent of a field solver for ordinary FR-4 geometry; the fab's stack-up and its solver have the last word.+use serde_json::{json, Value};+use std::f64::consts::PI;++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }++/// Free-space impedance of a microstrip of width ratio u (Hammerstad-Jensen).+fn z01(u: f64) -> f64 {+ let f = 6.0 + (2.0 * PI - 6.0) * (-(30.666 / u).powf(0.7528)).exp();+ 60.0 * (f / u + (1.0 + 4.0 / (u * u)).sqrt()).ln()+}++fn eeff0(u: f64, er: f64) -> f64 {+ let a = 1.0 + ((u.powi(4) + (u / 52.0).powi(2)) / (u.powi(4) + 0.432)).ln() / 49.0 + (1.0 + (u / 18.1).powi(3)).ln() / 18.7;+ let b = 0.564 * ((er - 0.9) / (er + 3.0)).powf(0.053);+ (er + 1.0) / 2.0 + (er - 1.0) / 2.0 * (1.0 + 10.0 / u).powf(-a * b)+}++/// Uncoated microstrip: width w over a plane h below, copper t thick, dielectric er (any length unit).+pub fn microstrip(w: f64, h: f64, t: f64, er: f64) -> f64 {+ let u = w / h;+ let tn = t / h;+ let coth = |x: f64| x.cosh() / x.sinh();+ let du1 = if tn > 0.0 { tn / PI * (1.0 + 4.0 * std::f64::consts::E / (tn * coth((6.517 * u).sqrt()).powi(2))).ln() } else { 0.0 };+ let dur = 0.5 * (1.0 + 1.0 / (er - 1.0).sqrt().cosh()) * du1;+ let (u1, ur) = (u + du1, u + dur);+ let ee = eeff0(ur, er) * (z01(u1) / z01(ur)).powi(2);+ z01(ur) / ee.sqrt()+}++/// Centred stripline (Wheeler 1978, thickness corrected): width w between planes b apart, copper t.+fn stripline_sym(w: f64, b: f64, t: f64, er: f64) -> f64 {+ let x = t / b;+ let bt = b - t;+ let dw = if x > 0.0 {+ let n = 2.0 / (1.0 + 2.0 * x / (3.0 * (1.0 - x)));+ bt * (x / (PI * (1.0 - x))) * (1.0 - 0.5 * ((x / (2.0 - x)).powi(2) + (0.0796 * x / (w / b + 1.1 * x)).powf(n)).ln())+ } else { 0.0 };+ let m = (w + dw) / bt;+ let k = 8.0 / (PI * m);+ 30.0 / er.sqrt() * (1.0 + 4.0 / (PI * m) * (k + (k * k + 6.27).sqrt())).ln()+}++/// Stripline h1 above and h2 below the track (planes h1 + t + h2 apart): Cohn's combination of the two centred cases.+pub fn stripline(w: f64, h1: f64, h2: f64, t: f64, er: f64) -> f64 {+ let za = stripline_sym(w, 2.0 * h1 + t, t, er);+ let zb = stripline_sym(w, 2.0 * h2 + t, t, er);+ 2.0 * za * zb / (za + zb)+}++/// Edge-coupled differential impedance (IPC-2141): the single-ended z0, gap s, and h (microstrip) or plane spacing b+/// (stripline).+pub fn differential(z0: f64, s: f64, hb: f64, strip: bool) -> f64 {+ if strip { 2.0 * z0 * (1.0 - 0.374 * (-2.9 * s / hb).exp()) } else { 2.0 * z0 * (1.0 - 0.48 * (-0.96 * s / hb).exp()) }+}++/// The width (same unit as the geometry) where `z(w)` meets `target`; z falls as w grows. None when out of range.+fn solve(target: f64, lo: f64, hi: f64, z: impl Fn(f64) -> f64) -> Option<f64> {+ if z(lo) < target || z(hi) > target { return None; }+ let (mut a, mut b) = (lo, hi);+ for _ in 0..80 {+ let m = (a + b) / 2.0;+ if z(m) > target { a = m } else { b = m }+ }+ Some((a + b) / 2.0)+}++/// One copper layer's geometry from the stack (mm): the dielectric to the copper above and below, and their Dk.+#[derive(Debug, Clone)]+pub struct Geo { pub name: String, pub t: f64, pub above: Option<(f64, f64)>, pub below: Option<(f64, f64)> }++/// The stack summary (`layer_stack` data) as each copper layer's geometry.+pub fn geometry(stack: &Value) -> Vec<Geo> {+ let ls: Vec<Value> = stack["layers"].as_array().cloned().unwrap_or_default();+ let um = |v: &Value| v.as_f64().unwrap_or(0.0) / 1000.0;+ let diel = |l: &Value| -> Option<(f64, f64)> {+ let h = um(&l["dielectricUm"]);+ if h <= 0.0 { None } else { Some((h, l["dk1000"].as_f64().map(|d| d / 1000.0).unwrap_or(4.3))) }+ };+ (0..ls.len()).map(|i| Geo {+ name: ls[i]["name"].as_str().unwrap_or("").to_string(),+ t: um(&ls[i]["copperUm"]).max(0.0),+ above: if i > 0 { diel(&ls[i - 1]) } else { None },+ below: diel(&ls[i]),+ }).collect()+}++/// Width (and gap) for each layer, mm. `diff` is the differential target; `gap` fixes the pair gap (else gap = width).+pub fn widths(geo: &[Geo], z0: Option<f64>, diff: Option<f64>, gap: Option<f64>) -> Vec<Value> {+ geo.iter().map(|g| {+ let (strip, z): (bool, Box<dyn Fn(f64) -> f64>) = match (g.above, g.below) {+ (Some((h1, e1)), Some((h2, e2))) => {+ let er = (e1 * h1 + e2 * h2) / (h1 + h2);+ let t = g.t;+ (true, Box::new(move |w| stripline(w, h1, h2, t, er)))+ }+ (None, Some((h, er))) | (Some((h, er)), None) => { let t = g.t; (false, Box::new(move |w| microstrip(w, h, t, er))) }+ (None, None) => return json!({"layer": g.name, "error": "no dielectric next to this layer in the stack"}),+ };+ // the spacing the coupling term uses: h for microstrip, plane to plane for stripline+ let hb = match (g.above, g.below) { (Some((a, _)), Some((b, _))) => a + b, (Some((h, _)), None) | (None, Some((h, _))) => h, _ => 1.0 };+ let mut o = json!({"layer": g.name, "model": if strip { "stripline" } else { "microstrip" }, "copperUm": r1(g.t * 1000.0),+ "dielectricMm": [g.above.map(|a| r3(a.0)), g.below.map(|b| r3(b.0))]});+ if let Some(t) = z0 {+ match solve(t, 0.01, 20.0, &z) { Some(w) => { o["width"] = json!(r3(w)); o["z0"] = json!(r1(z(w))); } None => { o["z0Error"] = json!(format!("{t} ohm is out of reach on this layer (0.01 to 20 mm)")); } }+ }+ if let Some(t) = diff {+ let zd = |w: f64| differential(z(w), gap.unwrap_or(w), hb, strip);+ match solve(t, 0.01, 20.0, zd) {+ Some(w) => { o["diffWidth"] = json!(r3(w)); o["diffGap"] = json!(r3(gap.unwrap_or(w))); o["zdiff"] = json!(r1(zd(w))); o["diffZ0"] = json!(r1(z(w))); }+ None => { o["diffError"] = json!(format!("{t} ohm differential is out of reach on this layer with that gap")); }+ }+ }+ o+ }).collect()+}++/// impedance: input {stack, args: {z0?, diff?, gapMm?, layers?, apply?: {scope, name?}}}. Needs only the stack-up.+/// `apply` answers a plan, never writes: one `add_rule` per layer (a width rule, or a diffpair rule for `diff`) scoped+/// by the caller's `scope` in the tool's own query language, at +/-10%, for the bridge to run.+pub fn impedance(input: &Value) -> Value {+ let args = &input["args"];+ let z0 = args.get("z0").and_then(|v| v.as_f64());+ let diff = args.get("diff").and_then(|v| v.as_f64());+ let z0 = if z0.is_none() && diff.is_none() { Some(50.0) } else { z0 };+ let gap = args.get("gapMm").and_then(|v| v.as_f64());+ let Some(stack) = crate::input::stack(input) else { return crate::input::refuse("no_stack", "the input has no stack: pass the layer_stack companion as stack") };+ if stack.get("ok") == Some(&json!(false)) || stack["layers"].as_array().map(|a| a.is_empty()).unwrap_or(true) {+ return crate::input::refuse("no_stack", "the stack has no layers");+ }+ let mut geo = geometry(&stack);+ // each copper layer's board model name beside the tool's own (F.Cu, In1.Cu, B.Cu)+ let board_names: Vec<String> = crate::coppermap::stack_layers(&stack).into_iter().map(|l| l.0).collect();+ let mut named: Vec<(Geo, String)> = geo.drain(..).zip(board_names).collect();+ if let Some(want) = args.get("layers").and_then(|v| v.as_array()) {+ let names: Vec<String> = want.iter().filter_map(|v| v.as_str()).map(|s| s.to_ascii_lowercase()).collect();+ named.retain(|(g, b)| names.contains(&g.name.to_ascii_lowercase()) || names.contains(&b.to_ascii_lowercase()));+ }+ let geo: Vec<Geo> = named.iter().map(|n| n.0.clone()).collect();+ let mut rows = widths(&geo, z0, diff, gap);+ for (row, (_, b)) in rows.iter_mut().zip(&named) { row["boardLayer"] = json!(b); }+ // a rigid-flex stack (the Bluetooth Sentinel example) has a coverlay a few microns thick next to a copper layer:+ // the numbers are right for that geometry and useless for a rigid board, so say which rows to doubt+ let mut warnings = Vec::new();+ for (g, row) in geo.iter().zip(&rows) {+ for d in [g.above, g.below].iter().flatten() {+ if d.0 < 0.05 { warnings.push(format!("{}: a {} mm dielectric next to it (a flex coverlay or a stack-up typo?): check the stack-up in the tool", g.name, r3(d.0))); break; }+ }+ for k in ["width", "diffWidth", "diffGap"] {+ if let Some(w) = row[k].as_f64() { if w < 0.075 { warnings.push(format!("{}: {k} {w} mm is below what most fabs make (0.075 to 0.1 mm)", g.name)); } }+ }+ }+ let mut plan = Value::Null;+ if let Some(ap) = args.get("apply").filter(|a| !a.is_null()) {+ let Some(scope) = ap.get("scope").and_then(|v| v.as_str()) else { return crate::input::refuse("bad_args", "apply.scope is the tool's query for what the rule covers (Altium: InNetClass('RF'); KiCad: A.NetClass == 'RF')") };+ let base = ap.get("name").and_then(|v| v.as_str()).unwrap_or("Impedance");+ let mut rules = Vec::new();+ for row in &rows {+ let layer = row["layer"].as_str().unwrap_or("");+ let common = json!({"name": format!("{base} {layer}"), "scope": scope, "layer": row["boardLayer"], "toolLayer": layer});+ let mut a = if diff.is_some() {+ let (Some(w), Some(s)) = (row["diffWidth"].as_f64(), row["diffGap"].as_f64()) else { continue };+ json!({"kind": "diffpair", "width": w, "gap": s, "minWidth": r3(w * 0.9), "maxWidth": r3(w * 1.1), "minGap": r3(s * 0.9), "maxGap": r3(s * 1.1)})+ } else {+ let Some(w) = row["width"].as_f64() else { continue };+ json!({"kind": "width", "min": r3(w * 0.9), "preferred": w, "max": r3(w * 1.1)})+ };+ for (k, v) in common.as_object().unwrap() { a[k] = v.clone(); }+ rules.push(a);+ }+ plan = json!({"verb": "add_rule", "rules": rules, "note": "A plan only: nothing changed. Run add_rule once per rule; each is scoped to its layer and to scope."});+ }+ json!({"success": true, "targets": {"z0": z0, "diff": diff, "gapMm": gap}, "layers": rows, "apply": plan, "warnings": warnings,+ "stack": {"copperLayers": stack["copperLayers"], "totalUm": stack["totalUm"]},+ "_hint": "Widths are for uncoated copper: solder mask lowers an outer layer's impedance by about 1 to 3 ohm. Inner layers assume planes on both neighbours. Within a few percent of a field solver; send the fab the target and let its stack-up tune it. apply:{scope} answers one width or diffpair rule per layer at +/-10% for add_rule."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn microstrip_matches_the_textbook_50_ohm_line() {+ // FR-4 er 4.4, 1.6 mm, 35 um: a 50 ohm line is about 3.0 mm wide (Pozar, field solvers 2.9 to 3.1)+ let w = solve(50.0, 0.01, 20.0, |w| microstrip(w, 1.6, 0.035, 4.4)).unwrap();+ assert!((2.8..3.2).contains(&w), "{w}");+ }++ #[test]+ fn stripline_matches_the_textbook_case() {+ // centred stripline, planes 1 mm apart, er 4.3, t 17 um: IPC-2141's formula puts 50 ohm near 0.40 mm+ let w = solve(50.0, 0.01, 20.0, |w| stripline(w, 0.4915, 0.4915, 0.017, 4.3)).unwrap();+ assert!((0.38..0.48).contains(&w), "{w}");+ // off centre, the nearer plane pulls the impedance down+ assert!(stripline(0.3, 0.2, 0.8, 0.017, 4.3) < stripline(0.3, 0.5, 0.5, 0.017, 4.3));+ }++ #[test]+ fn geometry_and_widths_from_a_two_layer_stack() {+ let st = json!({"layers": [{"name": "Top Layer", "copperUm": 36, "dielectricUm": 320, "dk1000": 4800}, {"name": "Bottom Layer", "copperUm": 36}]});+ let g = geometry(&st);+ assert_eq!(g.len(), 2);+ assert!(g[1].above.is_some() && g[1].below.is_none());+ let rows = widths(&g, Some(50.0), Some(90.0), None);+ let w = rows[0]["width"].as_f64().unwrap();+ assert!((0.45..0.65).contains(&w), "{w}");+ assert_eq!(rows[0]["model"], "microstrip");+ // gap = width: a 90 ohm pair's tracks come out near the 50 ohm width+ assert!((rows[0]["diffWidth"].as_f64().unwrap() - w).abs() < 0.1);+ assert!(widths(&g, None, Some(90.0), Some(0.2))[0]["diffWidth"].as_f64().unwrap() < rows[0]["diffWidth"].as_f64().unwrap());+ assert!((rows[0]["zdiff"].as_f64().unwrap() - 90.0).abs() < 0.5);+ }++ #[test]+ fn apply_is_a_plan_of_add_rule_args_on_standard_layers() {+ let input = json!({"stack": {"copperLayers": 2, "layers": [{"name": "Top Layer", "copperUm": 36, "dielectricUm": 320, "dk1000": 4800}, {"name": "Bottom Layer", "copperUm": 36}]},+ "args": {"z0": 50, "apply": {"scope": "InNetClass('RF')"}}});+ let r = impedance(&input);+ assert_eq!(r["layers"][0]["boardLayer"], "F.Cu");+ assert_eq!(r["apply"]["verb"], "add_rule");+ let rule = &r["apply"]["rules"][1];+ assert_eq!((rule["kind"].as_str(), rule["layer"].as_str(), rule["toolLayer"].as_str()), (Some("width"), Some("B.Cu"), Some("Bottom Layer")));+ assert_eq!(rule["scope"], "InNetClass('RF')");+ // a filter by either name+ let only = impedance(&json!({"stack": input["stack"], "args": {"layers": ["B.Cu"]}}));+ assert_eq!(only["layers"].as_array().unwrap().len(), 1);+ assert_eq!(impedance(&json!({"args": {}}))["errorCode"], "no_stack");+ assert_eq!(impedance(&json!({"stack": input["stack"], "args": {"apply": {}}}))["errorCode"], "bad_args");+ }+}+
engine/src/input.rsadded+55@@ -0,0 +1,55 @@+//! The input bundle every check takes: `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}`, each the+//! standard model a bridge answers (schema/board.schema.json, schema/companions.schema.json). A bridge's whole reply+//! is accepted too: the model's keys at the top level, or under `data` (the reply envelope keys are ignored).+use serde_json::{json, Value};++/// A refusal in the standard reply envelope.+pub fn refuse(code: &str, error: &str) -> Value {+ json!({"success": false, "errorCode": code, "error": error})+}++/// The companion under `key`, unwrapped from a reply's `data` when it came as one.+pub fn part(input: &Value, key: &str) -> Option<Value> {+ let v = input.get(key)?;+ if v.is_null() { return None; }+ Some(if v.get("data").map(|d| d.is_object()).unwrap_or(false) { v["data"].clone() } else { v.clone() })+}++/// The board model, or `bad_args` when there is none or the bridge's reply was a refusal.+pub fn board(input: &Value) -> Result<Value, Value> {+ let Some(b) = part(input, "board") else { return Err(refuse("bad_args", "the input has no board: pass the board model (routing_state) as board")) };+ if b.get("success") == Some(&json!(false)) { return Err(b); }+ Ok(b)+}++/// The stack-up companion (layer_stack), if given.+pub fn stack(input: &Value) -> Option<Value> { part(input, "stack") }++/// The poured copper (pour_state's `zones`), empty when not given.+pub fn pours(input: &Value) -> Vec<Value> {+ let Some(p) = part(input, "pours") else { return Vec::new() };+ p.get("zones").or(Some(&p)).and_then(|z| z.as_array()).cloned().unwrap_or_default()+}++/// The board's rules (read_rules): a list, or a reply with `rules`.+pub fn rules(input: &Value) -> Vec<Value> {+ let Some(r) = part(input, "rules") else { return Vec::new() };+ r.get("rules").or(Some(&r)).and_then(|x| x.as_array()).cloned().unwrap_or_default()+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn companions_come_bare_or_in_a_reply() {+ let bare = json!({"board": {"outline": []}, "stack": {"layers": [{"name": "Top"}]}, "pours": {"zones": [{"net": "GND"}]}});+ let wrapped = json!({"board": {"success": true, "outline": []}, "stack": {"success": true, "data": {"layers": [{"name": "Top"}]}}, "pours": [{"net": "GND"}]});+ for i in [bare, wrapped] {+ assert!(board(&i).is_ok());+ assert_eq!(stack(&i).unwrap()["layers"][0]["name"], "Top");+ assert_eq!(pours(&i)[0]["net"], "GND");+ }+ assert_eq!(board(&json!({})).unwrap_err()["errorCode"], "bad_args");+ }+}
engine/src/lib.rsadded+29@@ -0,0 +1,29 @@+//! adom-eda-checks: the EDA Bridge Standard's checks tier, computed from the board model and its companions as plain+//! JSON. No bridge calls, no tool, no I/O: a bridge links this crate (or runs the `adom-eda-checks` command) and+//! answers the check under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`,+//! `errorCode`, `error`, `_hint`) and name verbs without a prefix.+//!+//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`).+use serde_json::Value;++pub mod coppermap;+pub mod current;+pub mod fab;+pub mod geom;+pub mod impedance;+pub mod input;++/// The checks this build answers, by standard verb name.+pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "impedance", "return_path"];++/// Run one check by its standard name.+pub fn run(check: &str, input: &Value) -> Value {+ match check {+ "copper_balance" => coppermap::copper_balance(input),+ "current_capacity" => current::current_capacity(input),+ "fab_check" => fab::fab_check(input),+ "impedance" => impedance::impedance(input),+ "return_path" => coppermap::return_path(input),+ _ => input::refuse("not_supported", &format!("{check} is not a check this engine answers (it answers {})", CHECKS.join(", "))),+ }+}
engine/src/main.rsadded+48@@ -0,0 +1,48 @@+//! adom-eda-checks <check> [bundle.json | -]+//! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']+//! Prints the check's reply as JSON. `adom-eda-checks list` names the checks.+use serde_json::{json, Value};+use std::io::Read;++fn read(path: &str) -> Result<Value, String> {+ let text = if path == "-" {+ let mut s = String::new();+ std::io::stdin().read_to_string(&mut s).map_err(|e| format!("stdin: {e}"))?;+ s+ } else {+ std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?+ };+ serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))+}++fn bundle(rest: &[String]) -> Result<Value, String> {+ if rest.len() == 1 { return read(&rest[0]); }+ let mut b = json!({});+ let mut it = rest.iter();+ while let Some(flag) = it.next() {+ let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?;+ match flag.as_str() {+ "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--sheet-parts" | "--netlist" => {+ let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] };+ b[key] = read(val)?;+ }+ "--args" => b["args"] = serde_json::from_str(val).map_err(|e| format!("--args: {e}"))?,+ _ => return Err(format!("unknown flag {flag}")),+ }+ }+ Ok(b)+}++fn main() {+ let argv: Vec<String> = std::env::args().skip(1).collect();+ let out = match argv.first().map(String::as_str) {+ None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS}),+ Some("list") => json!({"success": true, "checks": adom_eda_checks::CHECKS}),+ Some(check) => match bundle(&argv[1..]) {+ Ok(b) => adom_eda_checks::run(check, &b),+ Err(e) => json!({"success": false, "errorCode": "bad_args", "error": e, "_hint": "Pass one bundle file {board, stack, pours, args}, or --board, --stack, --pours and --args."}),+ },+ };+ println!("{}", serde_json::to_string_pretty(&out).unwrap());+ if out.get("success") == Some(&json!(false)) { std::process::exit(1); }+}
engine/tests/fixtures.rsadded+56@@ -0,0 +1,56 @@+//! The engine against a bridge's own answers on the same boards. Fixtures are not kept with the standard: each bridge's+//! page carries its own (Altium's: lane and the Bluetooth Sentinel, from altium-lane, bridge 0.2.76). Point+//! ADOM_EDA_FIXTURES at a folder holding `altium/<board>.<model>.json` and `altium/expected/<board>.<check>.json`;+//! without it these tests are skipped. The result keys must match exactly; hints and notes may differ, since the engine+//! names verbs without a prefix.+use serde_json::{json, Value};+use std::path::PathBuf;++fn dir() -> Option<PathBuf> {+ let d = PathBuf::from(std::env::var_os("ADOM_EDA_FIXTURES")?).join("altium");+ d.is_dir().then_some(d)+}++fn load(p: PathBuf) -> Option<Value> { Some(serde_json::from_str(&std::fs::read_to_string(p).ok()?).unwrap()) }++fn bundle(d: &PathBuf, board: &str) -> Option<Value> {+ Some(json!({"board": load(d.join(format!("{board}.routing_state.json")))?, "stack": load(d.join(format!("{board}.layer_stack.json")))?,+ "pours": load(d.join(format!("{board}.pour_state.json")))?, "rules": load(d.join(format!("{board}.read_rules.json"))),+ "silk": load(d.join(format!("{board}.silk_state.json"))), "args": {}}))+}++fn same(check: &str, keys: &[&str]) {+ let Some(d) = dir() else { eprintln!("skipped: set ADOM_EDA_FIXTURES to a fixtures folder to compare {check} with a bridge"); return };+ let mut ran = 0;+ for board in ["lane", "sentinel"] {+ let (Some(input), Some(want)) = (bundle(&d, board), load(d.join(format!("expected/{board}.{check}.json")))) else { continue };+ let got = adom_eda_checks::run(check, &input);+ assert_eq!(got["success"], json!(true), "{board} {check}: {got}");+ for k in keys {+ let ptr = format!("/{}", k.replace('.', "/"));+ let optional = k.starts_with("measured.") || (k.starts_with("fix.") && want["fix"].is_null());+ assert!(want.pointer(&ptr).is_some() || optional, "{board} {check}: the Altium reply has no {k}");+ assert_eq!(got.pointer(&ptr), want.pointer(&ptr), "{board} {check}: {k} differs from the Altium bridge");+ }+ ran += 1;+ }+ assert!(ran > 0, "no fixtures for {check} in {}", d.display());+}++#[test]+fn copper_balance_matches_altium() {+ same("copper_balance", &["boardMm2", "resolutionMm", "layers", "pairs", "balanced", "poursWithoutRegions"]);+}++#[test]+fn return_path_matches_altium() {+ same("return_path", &["referenceNets", "layersWithNoReferenceCopper", "nets", "ok", "resolutionMm",+ "fix.args", "fix.patches", "fix.closes", "fix.floating"]);+}++#[test]+fn fab_check_measures_what_altium_measures() {+ same("fab_check", &["measured.boardSize", "measured.clearanceRule", "measured.copperLayers", "measured.counts", "measured.edgeClearance", "measured.holeToHolePad",+ "measured.holeToHoleVia", "measured.largestDrill", "measured.padHole", "measured.smdPad", "measured.track",+ "measured.viaAnnularRing", "measured.viaDiameter", "measured.viaDrill", "outsideLimits", "limitsChecked"]);+}
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.3.1",+ "version": "0.4.0", "type": "skill", "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool", "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
page.json+1−1@@ -4,7 +4,7 @@ "type": "skill", "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool", "brief": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",- "version": "0.3.1",+ "version": "0.4.0", "hero": { "type": "billboard", "headline": "One standard for every EDA bridge",
verbs/coverage.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.3.1",+ "standard": "0.4.0", "reference": { "bridge": "altium", "version": "0.2.76"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.3.1",+ "standard": "0.4.0", "reference": { "bridge": "altium", "version": "0.2.76"