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.10.0: engine 0.7.0 adds check_clearance and place_decoupling (KiCad's place_tools)
11 files changed
+482−26
README.md+7−3@@ -60,12 +60,12 @@ verb by verb: [docs/coverage.md](docs/coverage.md). <!-- coverage:start (tools/generate.py writes this) --> | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---|-| **core** | 93 | 93 | 89 | 79 |+| **core** | 93 | 93 | 90 | 79 | | **native** | 46 | 46 | 43 | 29 |-| **checks** | 37 | 33 | 34 | 15 |+| **checks** | 37 | 33 | 35 | 15 | | **app** | 60 | 60 | 36 | 37 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 236 | 232 | 202 | 160 |+| standard (all but platform) | 236 | 232 | 204 | 160 | <!-- coverage:end --> The **checks** tier is where the other bridges are furthest behind, and it is the cheapest to close: those verbs are@@ -158,6 +158,10 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.10.0: the engine (0.7.0) adds `check_clearance` and `place_decoupling` from the KiCad bridge's place_tools.+`place_decoupling` answers move_parts arguments and checks them with placement_check. On the Bluetooth Sentinel it places+U1's, U3's and U4's caps, each cap with one IC, leaving the crystal caps out, and every cap keeps 0.3 mm from other+copper (checked independently). Version 0.9.1: the engine (0.6.1)'s `plan_route` takes its track width and via from the board's width and via rules unless the args give them, so a planned route passes the board's DRC; `planner` says where each came from (altium-lane PR #4). Version 0.9.0: the engine (0.6.0) plans a route between two pads (`plan_route`), from the Fusion bridge's grid search, on
docs/coverage.md+6−6@@ -1,17 +1,17 @@ # Coverage: every standard verb on each bridge -Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.73, Fusion 360 bridge 1.13.4. 54 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.+Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.74, Fusion 360 bridge 1.13.4. 54 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*. `exact` is the same job under the same or an equivalent name; `partial` does part of it or does it differently (`verbs/coverage.json` names what in `note`); `unsupported` answers `not_supported` for the whole job and is not counted; blank is missing. Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand. | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---|-| **core** | 93 | 93 | 89 | 79 |+| **core** | 93 | 93 | 90 | 79 | | **native** | 46 | 46 | 43 | 29 |-| **checks** | 37 | 33 | 34 | 15 |+| **checks** | 37 | 33 | 35 | 15 | | **app** | 60 | 60 | 36 | 37 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 236 | 232 | 202 | 160 |+| standard (all but platform) | 236 | 232 | 204 | 160 | ## core @@ -41,7 +41,7 @@ Read and edit the design. Every bridge provides these: they are how an AI sees a | `edit_parts` *proposed* | Edit placed parts: value, designator, any field (add, change, remove), do-not-populate, exclusion from the BOM or position files, mounting type and the... | `altium_edit_parts` | `kicad_edit_parts` | `fusion_edit_parts` (partial) | | `find_symbol` | Find symbols (or footprints with kind:'footprint') by part of their name across libraries: the given ones, or every installed .SchLib/.PcbLib. | `altium_find_symbol` | `kicad_find_symbol` | `fusion_find_symbol` (partial) | | `footprint_primitives` | Geometry dump of one PLACED component: its tracks, arcs, pads (with solder and paste mask expansion in um) and texts in mils with layer ids; other primitive... | `altium_footprint_primitives` | `kicad_footprint_graphics` (partial) | `fusion_footprint_primitives` |-| `generate_body` *proposed* | Make a part's 3D body from its package family and its datasheet dimensions (chip, SOIC, QFN, QFP, BGA, SOT-23, DFN, MELF, electrolytic can, crystal, pin... | `altium_generate_body` | | `fusion_generate_body` |+| `generate_body` *proposed* | Make a part's 3D body from its package family and its datasheet dimensions (chip, SOIC, QFN, QFP, BGA, SOT-23, DFN, MELF, electrolytic can, crystal, pin... | `altium_generate_body` | `kicad_generate_body` | `fusion_generate_body` | | `import_svg` *proposed* | Import SVG artwork (a logo, a label) as filled polygons on a silkscreen, copper, mask or user layer, at a position and size; curves flattened, holes kept.... | `altium_import_svg` | `kicad_import_svg` | `fusion_import_svg` (partial) | | `layer_stack` | The CURRENT board's stack-up: copper layers in order with copper thickness and the dielectric under each (height, constant, material), and the total... | `altium_layer_stack` | `kicad_layer_stack` | `fusion_board_stackup` | | `layers` | The current board's layer set with ids, names, kind and whether each is displayed. | `altium_layers` | `kicad_layers` | `fusion_layers` |@@ -199,7 +199,7 @@ Engineering computed from the board model, not by the tool: one shared engine, s | `place_decoupling` *proposed* | Plan positions for decoupling capacitors beside an IC's power pins (each cap just outside the courtyard, its power pad facing the pin), answered as... | | `kicad_place_decoupling` | | | `placement_check` | Review the placement before routing: parts whose courtyards (pads and body, grown by a margin) overlap on the same side, parts off the board or over its... | `altium_placement_check` | `kicad_placement_check` | `fusion_placement_check` | | `plan_fanout` *proposed* | Plan the fanout of a BGA or QFN/QFP: the pad pitch, dogbone or via-in-pad per pad, the escape layer, surface stubs for the outer ring and a via grid for an... | | `kicad_plan_fanout` | |-| `plan_route` *proposed* | Plan a path for one net between two pads without changing the board: a grid search around the existing copper that keeps the clearance rules, on the allowed... | | | `fusion_plan_route` (partial) |+| `plan_route` *proposed* | Plan a path for one net between two pads without changing the board: a grid search around the existing copper that keeps the clearance rules, on the allowed... | | `kicad_plan_route` | `fusion_plan_route` (partial) | | `read_sheet_file` | Read a schematic sheet straight from its .SchDoc file, without Altium (it need not be installed or running): every part with its designator, library... | `altium_read_sheet_file` | `kicad_read_sheet_file` | `fusion_read_sheet_file` | | `return_path` | Where signal tracks lose their reference: for every signal net, the length of track on each layer that runs over no pour or plane copper on either... | `altium_return_path` | `kicad_return_path` | `fusion_return_path` | | `review_board` | Design-review checks (14) over one board read; schematic checks with includeSchematic. | `altium_review_board` | `kicad_lint_board` (partial) | |
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.6.1"+version = "0.7.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.6.1"+version = "0.7.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
engine/README.md+3−1@@ -11,12 +11,14 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | Check | Reads | What it answers | |---|---|---|+| `check_clearance` | board, bodies? | pairs of parts closer than `thresholdMm`, closest first: `mode: courtyard` (default 0.25) measures part boxes (pads, and 3D bodies when given) on the same side; `mode: pads` (default 0.2) measures pad copper of different parts that shares a layer, by its real outline, skipping same-net pairs unless `includeSameNet`; `refs` keeps pairs involving those parts. From the KiCad bridge's place_tools | | `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 | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body |+| `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools | | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's clearance from other nets' copper (pads by their real outline), inside the outline, no via in a pad. `routeNet` is route_net's arguments, unchanged; pours are not obstacles (repour after) | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments |@@ -90,7 +92,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.6.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.7.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
engine/src/lib.rs+4−1@@ -16,6 +16,7 @@ pub mod geom; pub mod impedance; pub mod input; pub mod placement;+pub mod placetools; pub mod route; pub mod writers; @@ -24,7 +25,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "placement_check", "plan_route", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["check_clearance", "copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "place_decoupling", "placement_check", "plan_route", "return_path", "thermal_vias", "via_fence"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -36,11 +37,13 @@ pub fn run(check: &str, input: &Value) -> Value { fn answer(check: &str, input: &Value) -> Value { if let Some(out) = writers::run(check, input) { return out; } match check {+ "check_clearance" => placetools::check_clearance(input), "copper_balance" => coppermap::copper_balance(input), "current_capacity" => current::current_capacity(input), "fab_check" => fab::fab_check(input), "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),+ "place_decoupling" => placetools::place_decoupling(input), "placement_check" => placement::placement_check(input), "plan_route" => route::plan_route(input), "return_path" => coppermap::return_path(input),
engine/src/placetools.rsadded+430@@ -0,0 +1,430 @@+//! Placement helpers from the KiCad bridge's place_tools.rs: the gaps between parts (their boxes or their pad copper)+//! and a plan for decoupling capacitors beside an IC's power pins, answered as move_parts arguments. The board model has+//! no courtyards, so a part is the box of its pads and, with the bodies companion, of its 3D body, as placement_check+//! measures it. Pure.+use crate::route::{pad_shape, Shape};+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type P = (f64, f64);+type Rect = (f64, f64, f64, f64);++/// The gap between two boxes, 0 when they touch or overlap.+fn rect_gap(a: Rect, b: Rect) -> f64 {+ let dx = (b.0 - a.2).max(a.0 - b.2).max(0.0);+ let dy = (b.1 - a.3).max(a.1 - b.3).max(0.0);+ (dx * dx + dy * dy).sqrt()+}++fn union(a: Rect, b: Rect) -> Rect { (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3)) }++/// References and pad names in natural order: C2 before C10.+fn ref_key(r: &str) -> (String, u64, String) {+ let head: String = r.chars().take_while(|c| !c.is_ascii_digit()).collect();+ let rest = &r[head.len()..];+ let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+ (head.to_uppercase(), num.parse().unwrap_or(0), rest[num.len()..].to_string())+}++fn prefix(r: &str) -> String { r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_uppercase() }++pub fn is_ground(net: &str) -> bool {+ let n = net.trim_start_matches('/').to_uppercase();+ n.contains("GND") || n.starts_with("VSS") || n == "0V" || n.ends_with("_VSS")+}++/// A net named like a supply: +5V, VCC, VDD_CORE, VBAT, 3V3, 1V8.+fn power_name(net: &str) -> bool {+ let n = net.trim_start_matches('/').to_uppercase();+ let c: Vec<char> = n.chars().collect();+ n.starts_with('+') || n.starts_with('V') || ["VCC", "VDD", "VBAT", "VIN", "VBUS", "PWR", "SUPPLY"].iter().any(|k| n.contains(k))+ || (1..c.len()).any(|i| c[i] == 'V' && c[i - 1].is_ascii_digit())+}++/// The pad's box, turned with it.+fn pad_box(at: P, sx: f64, sy: f64, rot: f64) -> Rect {+ let (c, s) = (rot.to_radians().cos().abs(), rot.to_radians().sin().abs());+ let (hx, hy) = ((sx * c + sy * s) / 2.0, (sx * s + sy * c) / 2.0);+ (at.0 - hx, at.1 - hy, at.0 + hx, at.1 + hy)+}++fn turn(p: P, deg: f64) -> P {+ let (c, s) = (deg.to_radians().cos(), deg.to_radians().sin());+ (p.0 * c - p.1 * s, p.0 * s + p.1 * c)+}++/// One part: where it is, its pads and its bodies' boxes.+struct Part { at: P, rot: f64, bottom: bool, pads: Vec<Value>, bodies: Vec<Rect> }++impl Part {+ /// A point of the part, after moving the part to `at` turned to `rot` (counterclockwise as seen).+ fn moved(&self, p: P, at: P, rot: f64) -> P {+ let q = turn((p.0 - self.at.0, p.1 - self.at.1), rot - self.rot);+ (at.0 + q.0, at.1 + q.1)+ }+ /// The part's box at `at` turned to `rot`: its pads, and its bodies' boxes turned with it.+ fn box_at(&self, at: P, rot: f64) -> Option<Rect> {+ let d = rot - self.rot;+ let mut b: Option<Rect> = None;+ for p in &self.pads {+ let r = pad_box(self.moved((f(&p["x"]), f(&p["y"])), at, rot), f(&p["sizeX"]), f(&p["sizeY"]), f(&p["rotation"]) + d);+ b = Some(b.map(|x| union(x, r)).unwrap_or(r));+ }+ for x in &self.bodies {+ for c in [(x.0, x.1), (x.2, x.1), (x.2, x.3), (x.0, x.3)] {+ let q = self.moved(c, at, rot);+ let r = (q.0, q.1, q.0, q.1);+ b = Some(b.map(|x| union(x, r)).unwrap_or(r));+ }+ }+ b+ }+}++/// The board's parts by reference (as given), with their bodies when the companion came.+fn parts(st: &Value, input: &Value) -> BTreeMap<String, Part> {+ let mut m: BTreeMap<String, Part> = BTreeMap::new();+ for c in st["components"].as_array().cloned().unwrap_or_default() {+ let Some(r) = c["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+ m.insert(r.to_string(), Part { at: (f(&c["x"]), f(&c["y"])), rot: f(&c["rotation"]), bottom: c["side"] == "bottom", pads: Vec::new(), bodies: Vec::new() });+ }+ for p in st["pads"].as_array().cloned().unwrap_or_default() {+ if let Some(part) = p["ref"].as_str().and_then(|r| m.get_mut(r)) { part.pads.push(p); }+ }+ for b in crate::bodies::input_boxes(input).unwrap_or_default() {+ if let Some(part) = m.get_mut(&b.part) { part.bodies.push((b.x0, b.y0, b.x1, b.y1)); }+ }+ m+}++fn find<'a>(m: &'a BTreeMap<String, Part>, r: &str) -> Option<(&'a String, &'a Part)> { m.iter().find(|(k, _)| k.eq_ignore_ascii_case(r)) }++/// check_clearance: input {board, bodies?, args: {refs?, mode?: courtyard|pads, thresholdMm?, includeSameNet?, maxPairs?}}.+/// courtyard: part boxes (pads, and 3D bodies when given) on the same side; pads: pad copper of different parts that+/// shares a copper layer, by its real outline.+pub fn check_clearance(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let mode = args["mode"].as_str().unwrap_or("courtyard");+ if mode != "courtyard" && mode != "pads" { return crate::input::refuse("bad_args", &format!("mode is courtyard or pads, not {mode}")); }+ let threshold = args.get("thresholdMm").or_else(|| args.get("threshold")).and_then(|v| v.as_f64()).unwrap_or(if mode == "pads" { 0.2 } else { 0.25 }).max(0.0);+ let limit = args["maxPairs"].as_u64().unwrap_or(200) as usize;+ let m = parts(&st, input);+ let refs: Option<Vec<String>> = args["refs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).map(|s| s.trim().to_uppercase()).collect());+ if let Some(rs) = &refs {+ let unknown: Vec<&String> = rs.iter().filter(|r| find(&m, r).is_none()).collect();+ if !unknown.is_empty() { return crate::input::refuse("no_such_part", &format!("not parts on this board: {unknown:?}")); }+ }+ let wanted = |r: &str| refs.as_ref().map(|rs| rs.iter().any(|x| x.eq_ignore_ascii_case(r))).unwrap_or(true);+ let order = |a: &str, b: &str| if ref_key(a) <= ref_key(b) { (a.to_string(), b.to_string()) } else { (b.to_string(), a.to_string()) };+ let (mut pairs, mut checked) = (Vec::new(), 0usize);+ let measured;+ if mode == "courtyard" {+ let boxes: Vec<(&String, bool, Rect)> = m.iter().filter_map(|(r, p)| Some((r, p.bottom, p.box_at(p.at, p.rot)?))).collect();+ for i in 0..boxes.len() {+ for j in i + 1..boxes.len() {+ let ((ra, sa, a), (rb, sb, b)) = (boxes[i], boxes[j]);+ if sa != sb || !(wanted(ra) || wanted(rb)) { continue; }+ checked += 1;+ let d = rect_gap(a, b);+ if d < threshold {+ let (x, y) = order(ra, rb);+ pairs.push(json!({"a": x, "b": y, "side": if sa { "bottom" } else { "top" }, "distanceMm": r3(d), "overlapping": d == 0.0}));+ }+ }+ }+ measured = if crate::bodies::input_boxes(input).is_some() { "part box: its pads and 3D body" } else { "part box: its pads only (pass bodies to add the 3D bodies)" };+ } else {+ let same_net = args["includeSameNet"].as_bool().unwrap_or(false);+ let pads: Vec<&Value> = m.values().flat_map(|p| p.pads.iter()).collect();+ let shapes: Vec<(Shape, Rect)> = pads.iter().map(|p| { let s = pad_shape(p); let b = s.bounds(); (s, b) }).collect();+ let through = |p: &Value| p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0;+ for i in 0..pads.len() {+ for j in i + 1..pads.len() {+ let (a, b) = (pads[i], pads[j]);+ let (ra, rb) = (a["ref"].as_str().unwrap_or(""), b["ref"].as_str().unwrap_or(""));+ if ra == rb || !(wanted(ra) || wanted(rb)) { continue; }+ let (na, nb) = (a["net"].as_str().unwrap_or(""), b["net"].as_str().unwrap_or(""));+ if !same_net && !na.is_empty() && na == nb { continue; }+ if !(through(a) || through(b) || a["layer"] == b["layer"]) { continue; }+ checked += 1;+ if rect_gap(shapes[i].1, shapes[j].1) >= threshold { continue; }+ let d = shapes[i].0.gap(&shapes[j].0);+ if d < threshold {+ let (ka, kb) = (format!("{ra}.{}", a["name"].as_str().unwrap_or("")), format!("{rb}.{}", b["name"].as_str().unwrap_or("")));+ let ((x, nx), (y, ny)) = if ref_key(&ka) <= ref_key(&kb) { ((ka, na), (kb, nb)) } else { ((kb, nb), (ka, na)) };+ pairs.push(json!({"a": x, "b": y, "netA": nx, "netB": ny, "distanceMm": r3(d), "touching": d == 0.0}));+ }+ }+ }+ measured = "pads: their real copper (copperPoints when given, round pads as circles or stadiums, others as their turned rectangle)";+ }+ pairs.sort_by(|x, y| f(&x["distanceMm"]).partial_cmp(&f(&y["distanceMm"])).unwrap_or(std::cmp::Ordering::Equal));+ let total = pairs.len();+ pairs.truncate(limit);+ json!({"success": true, "mode": mode, "thresholdMm": threshold, "measuredOn": measured, "pairsChecked": checked, "violationCount": total,+ "pairs": pairs, "truncated": total > limit, "clean": total == 0,+ "_hint": if total == 0 { "No pair is closer than thresholdMm. The tool's DRC (run_drc) is the authority on copper clearance." }+ else { "pairs are closest first. Move parts apart with move_parts (find_space finds room); overlapping or touching pairs come first. The tool's DRC (run_drc) is the authority on copper clearance." }})+}++/// The side of the box nearest a pin: its outward normal and name (y up: top is +y).+fn side_of(b: Rect, pin: P) -> (P, &'static str) {+ let d = [(pin.0 - b.0, (-1.0, 0.0), "left"), (b.2 - pin.0, (1.0, 0.0), "right"), (pin.1 - b.1, (0.0, -1.0), "bottom"), (b.3 - pin.1, (0.0, 1.0), "top")];+ let best = d.iter().min_by(|a, b| a.0.partial_cmp(&b.0).unwrap()).unwrap();+ (best.1, best.2)+}++/// A capacitor across ground and one other net: (its pad on that net, the net).+fn decoupling_role(r: &str, p: &Part) -> Option<(Value, String)> {+ if prefix(r) != "C" || p.pads.len() != 2 { return None; }+ let net = |q: &Value| q["net"].as_str().unwrap_or("").to_string();+ if p.pads.iter().filter(|q| is_ground(&net(q))).count() != 1 { return None; }+ p.pads.iter().find(|q| !net(q).is_empty() && !is_ground(&net(q))).map(|q| (q.clone(), net(q)))+}++/// place_decoupling: input {board, bodies?, args: {ic, caps?, gapMm?}}. Each cap goes `gapMm` outside the IC's box on the+/// side of the power pin it serves, turned so its power pad faces the pin and lined up with it; a spot taken by another+/// part slides along that side. Answers `moveArgs` for move_parts and checks the plan with placement_check.+pub fn place_decoupling(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let Some(ic_ref) = args.get("ic").or_else(|| args.get("ref")).and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()) else {+ return crate::input::refuse("bad_args", "ic is required: the IC's reference (\"U1\")");+ };+ let gap = args.get("gapMm").or_else(|| args.get("gap")).and_then(|v| v.as_f64()).unwrap_or(0.3).max(0.0);+ let m = parts(&st, input);+ let Some((ic_ref, ic)) = find(&m, ic_ref) else { return crate::input::refuse("no_such_part", &format!("{ic_ref} is not a part on this board")) };+ let Some(ic_box) = ic.box_at(ic.at, ic.rot) else { return crate::input::refuse("no_pads", &format!("{ic_ref} has no pads in the board model")) };+ let net = |q: &Value| q["net"].as_str().unwrap_or("").to_string();+ let mut power: Vec<String> = ic.pads.iter().map(net).filter(|n| !n.is_empty() && !is_ground(n)).collect();+ power.sort();+ power.dedup();+ let roles: BTreeMap<&String, (Value, String)> = m.iter().filter_map(|(r, p)| Some((r, decoupling_role(r, p)?))).collect();+ let pad_at = |q: &Value| (f(&q["x"]), f(&q["y"]));+ let (mut left_out, mut cap_refs): (Vec<Value>, Vec<String>) = (Vec::new(), Vec::new());+ match args["caps"].as_array() {+ Some(a) => cap_refs = a.iter().filter_map(|v| v.as_str()).map(String::from).collect(),+ None => {+ // a supply: named like one, or two or more caps across it to ground (one cap on a signal is a crystal's+ // load or a filter, not decoupling)+ let mut across: BTreeMap<&str, usize> = BTreeMap::new();+ for (_, n) in roles.values() { *across.entry(n.as_str()).or_insert(0) += 1; }+ // the other ICs, for caps that serve the same supply nearer another one+ let ics: Vec<(&String, &Part)> = m.iter().filter(|(r, p)| matches!(prefix(r).as_str(), "U" | "IC") && p.pads.len() >= 3 && p.bottom == ic.bottom).collect();+ for (r, (pp, n)) in &roles {+ if !power.contains(n) { continue; }+ if !power_name(n) && across.get(n.as_str()).copied().unwrap_or(0) < 2 {+ left_out.push(json!({"cap": r, "net": n, "why": "signal_net", "detail": format!("{n} has one cap to ground and is not named like a supply: a crystal's load or a filter. Pass caps to place it anyway")}));+ continue;+ }+ let near = |p: &Part| p.pads.iter().filter(|q| net(q) == *n).map(|q| { let (a, b) = (pad_at(q), pad_at(pp)); ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }).fold(f64::MAX, f64::min);+ let own = near(ic);+ if let Some((other, _)) = ics.iter().filter(|(o, _)| *o != ic_ref).map(|(o, p)| (o, near(p))).filter(|(_, d)| *d < own).min_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) {+ left_out.push(json!({"cap": r, "net": n, "why": "nearer_another_ic", "detail": format!("{r} is nearer {other}'s {n} pin; it decouples that IC. Pass caps to place it here anyway")}));+ continue;+ }+ cap_refs.push(r.to_string());+ }+ }+ }+ cap_refs.sort_by_key(|r| ref_key(r));+ if cap_refs.is_empty() {+ return json!({"success": false, "errorCode": "no_caps", "error": format!("no capacitor across ground and a supply of {ic_ref}: pass caps:[...]"), "icPowerNets": power, "leftOut": left_out});+ }+ let moving: Vec<String> = cap_refs.iter().map(|r| r.to_uppercase()).collect();+ let mut taken: Vec<Rect> = m.iter().filter(|(r, p)| p.bottom == ic.bottom && !moving.contains(&r.to_uppercase())).filter_map(|(_, p)| p.box_at(p.at, p.rot)).collect();+ // a part on the other side still takes the spot of its through-hole pads, and so do the board's own holes+ for p in st["pads"].as_array().into_iter().flatten() {+ let through = p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0;+ let other_side = p["ref"].as_str().and_then(|r| m.get(r)).map(|q| q.bottom != ic.bottom).unwrap_or(true);+ if through && other_side { taken.push(pad_box(pad_at(p), f(&p["sizeX"]).max(f(&p["drill"])), f(&p["sizeY"]).max(f(&p["drill"])), f(&p["rotation"]))); }+ }+ let ring = crate::coppermap::outline_ring(&st);+ let on_board = |b: Rect| ring.len() < 3 || [(b.0, b.1), (b.2, b.1), (b.2, b.3), (b.0, b.3)].iter().all(|c| crate::route::inside(*c, &ring));+ let (mut used, mut rows, mut moves, mut skipped) = (Vec::<String>::new(), Vec::new(), Vec::new(), Vec::new());+ for cr in &cap_refs {+ let Some((cr, cap)) = find(&m, cr) else { skipped.push(json!({"cap": cr, "why": "no_such_part"})); continue };+ if cap.bottom != ic.bottom {+ skipped.push(json!({"cap": cr, "why": "other_side", "detail": format!("{cr} is on the other side from {ic_ref}: move it over first (move_parts with side)")}));+ continue;+ }+ let Some(pp) = cap.pads.iter().find(|q| power.contains(&net(q))) else { skipped.push(json!({"cap": cr, "why": "no_shared_power_net"})); continue };+ let n = net(pp);+ let mut pins: Vec<&Value> = ic.pads.iter().filter(|q| net(q) == n).collect();+ pins.sort_by_key(|q| (used.contains(&q["name"].as_str().unwrap_or("").to_string()), ref_key(q["name"].as_str().unwrap_or(""))));+ let pin = pins[0];+ let pin_name = pin["name"].as_str().unwrap_or("").to_string();+ used.push(pin_name.clone());+ let pin_at = pad_at(pin);+ let (nv, side) = side_of(ic_box, pin_at);+ // the turn that points the power pad at the IC+ let rel = |rot: f64| cap.moved(pad_at(pp), (0.0, 0.0), rot);+ let rot = [0.0, 90.0, 180.0, 270.0].into_iter().max_by(|a, b| {+ let (va, vb) = (rel(*a), rel(*b));+ (-(va.0 * nv.0 + va.1 * nv.1)).partial_cmp(&(-(vb.0 * nv.0 + vb.1 * nv.1))).unwrap()+ }).unwrap();+ let Some(cy) = cap.box_at((0.0, 0.0), rot) else { skipped.push(json!({"cap": cr, "why": "no_pads"})); continue };+ let v = rel(rot);+ let base = match side {+ "left" => (ic_box.0 - gap - cy.2, pin_at.1 - v.1),+ "right" => (ic_box.2 + gap - cy.0, pin_at.1 - v.1),+ "top" => (pin_at.0 - v.0, ic_box.3 + gap - cy.1),+ _ => (pin_at.0 - v.0, ic_box.1 - gap - cy.3),+ };+ let along = if nv.0 != 0.0 { (0.0, 1.0) } else { (1.0, 0.0) };+ let step = if nv.0 != 0.0 { cy.3 - cy.1 } else { cy.2 - cy.0 } + gap;+ let mut chosen = None;+ for k in 0..13 {+ let s = if k == 0 { 0.0 } else if k % 2 == 1 { ((k + 1) / 2) as f64 * step } else { -((k / 2) as f64) * step };+ let at = (r3(base.0 + along.0 * s), r3(base.1 + along.1 * s));+ let Some(b) = cap.box_at(at, rot) else { continue };+ if taken.iter().all(|t| rect_gap(*t, b) >= gap - 1e-3) && on_board(b) { chosen = Some((at, b, s)); break; }+ }+ let Some((at, b, slid)) = chosen else { skipped.push(json!({"cap": cr, "why": "no_free_spot", "pin": format!("{ic_ref}.{pin_name}"), "side": side})); continue };+ taken.push(b);+ let pa = cap.moved(pad_at(pp), at, rot);+ rows.push(json!({"cap": cr, "net": n, "pin": format!("{ic_ref}.{pin_name}"), "side": side, "x": at.0, "y": at.1, "rotation": rot,+ "powerPad": format!("{cr}.{}", pp["name"].as_str().unwrap_or("")), "padToPinMm": r3(((pa.0 - pin_at.0).powi(2) + (pa.1 - pin_at.1).powi(2)).sqrt()),+ "slidMm": r3(slid), "from": {"x": cap.at.0, "y": cap.at.1, "rotation": cap.rot}}));+ moves.push((cr.clone(), at, rot));+ }+ let validation = validate(&st, input, &m, &moves);+ let parts_arg: Vec<Value> = moves.iter().map(|(r, at, rot)| json!({"ref": r, "x": at.0, "y": at.1, "rotation": rot})).collect();+ json!({"success": true, "ic": ic_ref, "icPowerNets": power, "gapMm": gap, "placements": rows, "skipped": skipped, "leftOut": left_out,+ "moveArgs": {"parts": parts_arg, "expectedRevision": st["revision"]}, "validation": validation, "revision": st["revision"],+ "measuredOn": if crate::bodies::input_boxes(input).is_some() { "part boxes: pads and 3D bodies" } else { "part boxes: pads only (pass bodies to keep clear of 3D bodies too)" },+ "_hint": "Read the plan, then send moveArgs to move_parts. The caps' old tracks stay where they were: route the stubs again (plan_route, route_net) and repour. leftOut lists caps the default left for another IC or a signal net; name them in caps to place them here."})+}++/// placement_check on the board with the caps moved, keeping what concerns them.+fn validate(st: &Value, input: &Value, m: &BTreeMap<String, Part>, moves: &[(String, P, f64)]) -> Value {+ if moves.is_empty() { return json!({"ok": false, "reason": "nothing to place"}); }+ let mut board = st.clone();+ let mut bodies = crate::input::part(input, "bodies");+ for (r, at, rot) in moves {+ let p = &m[r];+ let d = rot - p.rot;+ for c in board["components"].as_array_mut().into_iter().flatten().filter(|c| c["ref"] == json!(r)) {+ c["x"] = json!(at.0); c["y"] = json!(at.1); c["rotation"] = json!(rot);+ }+ for q in board["pads"].as_array_mut().into_iter().flatten().filter(|q| q["ref"] == json!(r)) {+ let n = p.moved((f(&q["x"]), f(&q["y"])), *at, *rot);+ q["x"] = json!(n.0); q["y"] = json!(n.1); q["rotation"] = json!((f(&q["rotation"]) + d).rem_euclid(360.0));+ q.as_object_mut().map(|o| o.remove("copperPoints"));+ }+ if let Some(b) = bodies.as_mut() {+ let rows = if b.get("bodies").is_some() { b["bodies"].as_array_mut() } else { b.as_array_mut() };+ for x in rows.into_iter().flatten().filter(|x| x["ref"] == json!(r)) {+ let cs: Vec<P> = [(f(&x["x0"]), f(&x["y0"])), (f(&x["x1"]), f(&x["y0"])), (f(&x["x1"]), f(&x["y1"])), (f(&x["x0"]), f(&x["y1"]))].iter().map(|c| p.moved(*c, *at, *rot)).collect();+ x["x0"] = json!(cs.iter().map(|c| c.0).fold(f64::MAX, f64::min)); x["x1"] = json!(cs.iter().map(|c| c.0).fold(f64::MIN, f64::max));+ x["y0"] = json!(cs.iter().map(|c| c.1).fold(f64::MAX, f64::min)); x["y1"] = json!(cs.iter().map(|c| c.1).fold(f64::MIN, f64::max));+ }+ }+ }+ let r = crate::placement::placement_check(&json!({"board": board, "bodies": bodies, "args": {}}));+ if r["success"] != json!(true) { return json!({"ok": false, "check": r}); }+ let ours = |e: &Value| moves.iter().any(|(m, _, _)| e["ref"] == json!(m) || e["parts"].as_array().map(|a| a.contains(&json!(m))).unwrap_or(false));+ let keep = |k: &str| -> Vec<Value> { r[k].as_array().cloned().unwrap_or_default().into_iter().filter(|e| ours(e)).collect() };+ let (ov, off, holes, near) = (keep("courtyardOverlaps"), keep("offOrOverEdge"), keep("onMountingHoles"), keep("nearEdge"));+ json!({"ok": ov.is_empty() && off.is_empty() && holes.is_empty(), "by": "placement_check on the board with the caps moved", "courtyardOverlaps": ov, "offOrOverEdge": off, "onMountingHoles": holes, "nearEdge": near})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn pad(r: &str, name: &str, x: f64, y: f64, net: &str) -> Value {+ json!({"ref": r, "name": name, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": net, "shape": "rect"})+ }++ /// U1: a 4-pin part at the origin with VDD on its right; C1 (VDD/GND) far away, C2 on a crystal net, C3 near U2.+ fn board() -> Value {+ json!({"revision": "r1", "outline": [[-20, -20], [20, -20], [20, 20], [-20, 20]], "nets": ["VDD", "GND", "XIN"],+ "components": [{"ref": "U1", "x": 0, "y": 0, "rotation": 0, "side": "top"}, {"ref": "U2", "x": 12, "y": 0, "rotation": 0, "side": "top"},+ {"ref": "C1", "x": -10, "y": 10, "rotation": 0, "side": "top"}, {"ref": "C2", "x": -10, "y": -10, "rotation": 0, "side": "top"},+ {"ref": "C3", "x": 12, "y": 3, "rotation": 0, "side": "top"}],+ "pads": [pad("U1", "1", -2.0, 1.0, "XIN"), pad("U1", "2", -2.0, -1.0, "GND"), pad("U1", "3", 2.0, -1.0, "GND"), pad("U1", "4", 2.0, 1.0, "VDD"),+ pad("U2", "1", 11.0, 1.0, "VDD"), pad("U2", "2", 11.0, -1.0, "GND"), pad("U2", "3", 13.0, 0.0, "GND"),+ pad("C1", "1", -10.5, 10.0, "VDD"), pad("C1", "2", -9.5, 10.0, "GND"),+ pad("C2", "1", -10.5, -10.0, "XIN"), pad("C2", "2", -9.5, -10.0, "GND"),+ pad("C3", "1", 11.5, 3.0, "VDD"), pad("C3", "2", 12.5, 3.0, "GND")]})+ }++ #[test]+ fn a_cap_lands_beside_its_pin_with_the_power_pad_facing_it() {+ let r = place_decoupling(&json!({"board": board(), "args": {"ic": "U1"}}));+ assert_eq!(r["success"], json!(true), "{r}");+ // C1 only: C2 sits on a crystal net, C3 is nearer U2's VDD pin+ assert_eq!(r["placements"].as_array().unwrap().len(), 1, "{r}");+ let p = &r["placements"][0];+ assert_eq!((p["cap"].as_str(), p["pin"].as_str(), p["side"].as_str()), (Some("C1"), Some("U1.4"), Some("right")));+ // U1's box ends at x 2.3; 0.3 out, the power pad (now the cap's left pad) at 2.9, level with the pin+ assert_eq!(p["rotation"], json!(0.0));+ assert!((f(&p["x"]) - 3.4).abs() < 1e-9 && (f(&p["y"]) - 1.0).abs() < 1e-9, "{p}");+ assert!((f(&p["padToPinMm"]) - 0.9).abs() < 1e-9);+ let why: Vec<&str> = r["leftOut"].as_array().unwrap().iter().map(|l| l["why"].as_str().unwrap()).collect();+ assert_eq!(why, vec!["signal_net", "nearer_another_ic"]);+ assert_eq!(r["moveArgs"]["parts"][0], json!({"ref": "C1", "x": 3.4, "y": 1.0, "rotation": 0.0}));+ assert_eq!(r["moveArgs"]["expectedRevision"], "r1");+ assert_eq!(r["validation"]["ok"], json!(true), "{r}");+ }++ #[test]+ fn a_cap_turns_to_face_the_pin_and_slides_off_a_taken_spot() {+ // C1 turned round (power pad on its right) must turn 180 to face U1's right side; a part on the spot pushes it along+ let mut b = board();+ b["pads"][7]["x"] = json!(-9.5); b["pads"][8]["x"] = json!(-10.5);+ b["components"].as_array_mut().unwrap().push(json!({"ref": "R9", "x": 3.4, "y": 1.0, "rotation": 0, "side": "top"}));+ b["pads"].as_array_mut().unwrap().push(pad("R9", "1", 3.4, 1.0, ""));+ let r = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}));+ let p = &r["placements"][0];+ assert_eq!(p["rotation"], json!(180.0), "{r}");+ assert!(f(&p["slidMm"]).abs() > 0.0, "{r}");+ assert_eq!(r["validation"]["ok"], json!(true), "{r}");+ // a bottom part's through-hole pad on the spot pushes it along too; its surface pads do not+ let mut b = board();+ b["components"].as_array_mut().unwrap().push(json!({"ref": "J9", "x": 3.4, "y": 1.0, "rotation": 0, "side": "bottom"}));+ b["pads"].as_array_mut().unwrap().push(pad("J9", "1", 3.4, 1.0, ""));+ assert_eq!(place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}))["placements"][0]["slidMm"], json!(0.0));+ b["pads"][13]["layer"] = json!("*.Cu");+ b["pads"][13]["drill"] = json!(0.4);+ assert!(f(&place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}))["placements"][0]["slidMm"]).abs() > 0.0);+ }++ #[test]+ fn the_verb_names_what_it_cannot_do() {+ assert_eq!(place_decoupling(&json!({"board": board(), "args": {}}))["errorCode"], "bad_args");+ assert_eq!(place_decoupling(&json!({"board": board(), "args": {"ic": "U7"}}))["errorCode"], "no_such_part");+ let mut b = board();+ b["components"][2]["side"] = json!("bottom");+ let r = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}));+ assert_eq!(r["skipped"][0]["why"], "other_side");+ }++ #[test]+ fn clearance_by_part_box_and_by_pad_copper() {+ // C3's box sits 1.4 mm above U2's+ let r = check_clearance(&json!({"board": board(), "args": {"thresholdMm": 1.5}}));+ assert_eq!(r["pairs"][0]["a"], "C3");+ assert_eq!(r["pairs"][0]["b"], "U2");+ assert!((f(&r["pairs"][0]["distanceMm"]) - 1.4).abs() < 1e-9, "{r}");+ let p = check_clearance(&json!({"board": board(), "args": {"mode": "pads", "refs": ["C3"], "thresholdMm": 2.0}}));+ // same-net VDD pads are skipped; C3.2 (GND) to U2.1 (VDD) is the closest+ assert_eq!((p["pairs"][0]["a"].as_str(), p["pairs"][0]["b"].as_str()), (Some("C3.2"), Some("U2.1")), "{p}");+ assert_eq!(check_clearance(&json!({"board": board(), "args": {"refs": ["Q1"]}}))["errorCode"], "no_such_part");+ assert_eq!(check_clearance(&json!({"board": board(), "args": {"mode": "x"}}))["errorCode"], "bad_args");+ }++ #[test]+ fn pad_copper_gaps_are_exact() {+ let a = pad_shape(&json!({"x": 0.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "rect"}));+ let b = pad_shape(&json!({"x": 2.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "round"}));+ assert!((a.gap(&b) - 1.0).abs() < 1e-9);+ let inner = pad_shape(&json!({"x": 0.0, "y": 0.0, "sizeX": 0.2, "sizeY": 0.2, "shape": "rect"}));+ assert_eq!(a.gap(&inner), 0.0);+ assert_eq!(inner.gap(&a), 0.0);+ }+}
engine/src/route.rs+17−6@@ -21,13 +21,13 @@ fn seg_point(p: P, a: P, b: P) -> f64 { } /// The distance between two segments (0 when they cross).-fn seg_seg(a: P, b: P, c: P, d: P) -> f64 {+pub(crate) fn seg_seg(a: P, b: P, c: P, d: P) -> f64 { let cross = |p: P, q: P, r: P| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0); if cross(a, b, c) * cross(a, b, d) < 0.0 && cross(c, d, a) * cross(c, d, b) < 0.0 { return 0.0; } seg_point(a, c, d).min(seg_point(b, c, d)).min(seg_point(c, a, b)).min(seg_point(d, a, b)) } -fn inside(p: P, ring: &[P]) -> bool {+pub(crate) fn inside(p: P, ring: &[P]) -> bool { let mut c = false; for i in 0..ring.len() { let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);@@ -39,7 +39,7 @@ fn inside(p: P, ring: &[P]) -> bool { /// Copper as a shape: a capsule (a segment with a half-width: tracks, round pads, vias) or a polygon (rect pads, a /// custom pad's copperPoints). #[derive(Clone)]-enum Shape { Cap(P, P, f64), Poly(Vec<P>) }+pub(crate) enum Shape { Cap(P, P, f64), Poly(Vec<P>) } impl Shape { /// Edge-to-edge distance from the segment a..b (its centreline) to this copper.@@ -52,7 +52,18 @@ impl Shape { } } }- fn bounds(&self) -> (f64, f64, f64, f64) {+ /// Edge-to-edge distance between two pieces of copper, 0 when they touch or one holds the other.+ pub(crate) fn gap(&self, o: &Shape) -> f64 {+ match o {+ Shape::Cap(c, d, r) => (self.dist(*c, *d) - r).max(0.0),+ Shape::Poly(ring) => {+ let held = match self { Shape::Cap(c, _, _) => inside(*c, ring), Shape::Poly(own) => own.first().map(|p| inside(*p, ring)).unwrap_or(false) };+ if held { return 0.0; }+ (0..ring.len()).map(|i| self.dist(ring[i], ring[(i + 1) % ring.len()])).fold(f64::MAX, f64::min).max(0.0)+ }+ }+ }+ pub(crate) fn bounds(&self) -> (f64, f64, f64, f64) { match self { Shape::Cap(c, d, r) => (c.0.min(d.0) - r, c.1.min(d.1) - r, c.0.max(d.0) + r, c.1.max(d.1) + r), Shape::Poly(ring) => ring.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1))),@@ -61,7 +72,7 @@ impl Shape { } /// A pad's copper: copperPoints when the bridge sent them, else its shape turned with it.-fn pad_shape(p: &Value) -> Shape {+pub(crate) fn pad_shape(p: &Value) -> Shape { let (x, y, sx, sy, rot) = (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]), f(&p["sizeY"]), f(&p["rotation"]).to_radians()); let pts: Vec<P> = p["copperPoints"].as_array().cloned().unwrap_or_default().iter().map(|q| (f(&q[0]), f(&q[1]))).collect(); if pts.len() >= 3 { return Shape::Poly(pts); }@@ -77,7 +88,7 @@ fn pad_shape(p: &Value) -> Shape { } /// The copper layers a pad is on: every one for a through-hole pad (`*.Cu` or a drill), else its own.-fn pad_layers(p: &Value, all: &[String]) -> Vec<String> {+pub(crate) fn pad_layers(p: &Value, all: &[String]) -> Vec<String> { let l = p["layer"].as_str().unwrap_or(""); if l == "*.Cu" || f(&p["drill"]) > 0.0 { all.to_vec() } else { vec![l.to_string()] } }
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.9.1",+ "version": "0.10.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.",
verbs/coverage.json+10−4@@ -1,12 +1,12 @@ {- "standard": "0.9.1",+ "standard": "0.10.0", "reference": { "bridge": "altium", "version": "0.2.79" }, "proposed": 54, "compared": {- "kicad": "1.0.73",+ "kicad": "1.0.74", "fusion360": "1.13.4" }, "tiers": {@@ -397,7 +397,10 @@ "verb": "altium_generate_body", "match": "exact" },- "kicad": null,+ "kicad": {+ "verb": "kicad_generate_body",+ "match": "exact"+ }, "fusion360": { "verb": "fusion_generate_body", "match": "exact"@@ -2697,7 +2700,10 @@ "tier": "checks", "source": "proposed", "altium": null,- "kicad": null,+ "kicad": {+ "verb": "kicad_plan_route",+ "match": "exact"+ }, "fusion360": { "verb": "fusion_plan_route", "match": "partial",
verbs/standard.json+2−2@@ -1,12 +1,12 @@ {- "standard": "0.9.1",+ "standard": "0.10.0", "reference": { "bridge": "altium", "version": "0.2.79" }, "proposed": 54, "compared": {- "kicad": "1.0.73",+ "kicad": "1.0.74", "fusion360": "1.13.4" }, "tiers": {