app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Merge PR #325: kicad-lane: 1.0.75, decoupling by nearest IC, plan_route rules, engine 0.7.0
14 files changed
+619−43
rust/Cargo.lock+4−4rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/verbs_checks_native.rs+9−10rust/crates/kicad-bridge/src/verbs_place_helpers.rs+2−2rust/crates/kicad-bridge/src/verbs_windows.rs+11−3rust/crates/kicad-core/src/place_tools.rs+78−3rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+4−2rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/features.rs+29rust/vendor/adom-eda-checks/src/lib.rs+4−1rust/vendor/adom-eda-checks/src/placetools.rs+430rust/vendor/adom-eda-checks/src/route.rs+41−11skills/kicad-bridge-test/run_verb_tests.py+2−2rust/Cargo.lock+4−4@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.6.0"+version = "0.7.0" dependencies = [ "roxmltree", "serde_json",@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.74"+version = "1.0.75" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.74"+version = "1.0.75" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.74"+version = "1.0.75" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.74"+version = "1.0.75" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_checks_native.rs+9−10@@ -781,7 +781,7 @@ fn placement_check(state: &mut State, args: &Value) -> Value { } /// kicad_plan_route: the standard's engine searches a path on the board model with the board's-/// clearance rules; width and via default to the project's Default net class. routeNet comes back+/// clearance rules and the Default net class's width and via as rules. routeNet comes back /// ready for kicad_route_net (filePath, frame, expectedRevision added). fn plan_route(state: &mut State, args: &Value) -> Value { let o = match opts(args) { Ok(o) => o, Err(e) => return e };@@ -790,17 +790,16 @@ fn plan_route(state: &mut State, args: &Value) -> Value { Ok(m) => m, Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."), };+ // the project's Default net class as the standard's width and via rules (planner.widthFrom / viaFrom name them) let class = default_class(&r.file);- let mut a = crate::verbs_checks::engine_args(args);- for (k, ck) in [("widthMm", "track_width"), ("viaSizeMm", "via_diameter"), ("viaDrillMm", "via_drill")] {- if a.get(k).is_none() {- if let Some(v) = class[ck].as_f64() {- a[k] = json!(v);- }- }+ let mut rules = crate::verbs_checks::clearance_rules(&r.file);+ if let Some(w) = class["track_width"].as_f64() {+ rules.push(json!({"kind": "width", "name": "Default net class track width", "preferredMm": w})); }- let rules = crate::verbs_checks::clearance_rules(&r.file);- let mut v = adom_eda_checks::run("plan_route", &json!({"board": model, "rules": rules, "args": a}));+ if let (Some(d), Some(h)) = (class["via_diameter"].as_f64(), class["via_drill"].as_f64()) {+ rules.push(json!({"kind": "via", "name": "Default net class via", "sizeMm": d, "drillMm": h}));+ }+ let mut v = adom_eda_checks::run("plan_route", &json!({"board": model, "rules": rules, "args": crate::verbs_checks::engine_args(args)})); if v["success"] != json!(true) { return v; }
rust/crates/kicad-bridge/src/verbs_place_helpers.rs+2−2@@ -38,9 +38,9 @@ pub static VERBS: &[Verb] = &[ mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130, input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"ic\": \"U1\", \"caps\"?: [\"C1\", \"C2\"], \"gap\"?: 0.3, \"dryRun\"?: true, \"expectedRevision\"?: \"<rev>\", \"save\"?: false}", example: "kicad_place_decoupling {\"filePath\":\"C:/p/b.kicad_pcb\",\"ic\":\"U1\"}",- hint: "caps defaults to every capacitor across ground and one of the IC's power nets. placements[] gives each cap's pin, side, x, y, rotation and padToPinMm; skipped[] says why a cap was left (other_side, no_shared_power_net, no_free_spot). moveArgs is the exact kicad_move_footprint batch and validation is that verb's own courtyard and outline check. dryRun:false with expectedRevision (from kicad_placement_state) moves them live; without an editor it is refused and the plan still stands.",+ hint: "caps defaults to every capacitor across ground and one of the IC's power nets, less those in leftOut[]: signal_net (a net not named like a supply with only that one cap to ground, such as a crystal load) and nearer_another_ic (a cap nearer another IC's pin on the same net decouples that IC). Name caps to place any of them anyway. placements[] gives each cap's pin, side, x, y, rotation and padToPinMm; skipped[] says why a cap was left (other_side, no_shared_power_net, no_free_spot). moveArgs is the exact kicad_move_footprint batch and validation is that verb's own courtyard and outline check. dryRun:false with expectedRevision (from kicad_placement_state) moves them live; without an editor it is refused and the plan still stands.", related: &["kicad_move_footprint", "kicad_placement_state", "kicad_score_placement"],- pitfalls: &["Caps on the other side of the board are skipped: side changes are not moved (flip them in KiCad first).", "The plan aligns each power pad with its pin; it does not route. Route the short stubs with kicad_route_net afterwards."],+ pitfalls: &["Caps on the other side of the board are skipped: side changes are not moved (flip them in KiCad first).", "A through-hole pad of a part on the other side takes its spot on this side too.", "The plan aligns each power pad with its pin; it does not route. Route the short stubs with kicad_route_net afterwards."], }, Verb { name: "kicad_plan_fanout",
rust/crates/kicad-bridge/src/verbs_windows.rs+11−3@@ -1720,11 +1720,19 @@ pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Va if open.iter().any(|p| same_board_path(p, &wanted)) { path_verified = true; } else if !open.is_empty() {+ // the API serves one editor: a board of the same file name elsewhere, or another editor's+ // board entirely (a second pcbnew holds the API, and this window cannot be verified)+ let same_name = open.iter().any(|p| p.replace('\\', "/").rsplit('/').next().map(|f| f.eq_ignore_ascii_case(&name)).unwrap_or(false));+ let (code, error) = if same_name {+ ("different_board_same_name", format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")))+ } else {+ ("api_serves_another_board", format!("A PCB editor titled {name} is open, but KiCad's API answers for another editor's board ({}), so this window's board cannot be verified", open.join(", ")))+ }; return json!({- "success": false, "errorCode": "different_board_same_name", "hwnd": w.hwnd, "title": w.title,- "error": format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")),+ "success": false, "errorCode": code, "hwnd": w.hwnd, "title": w.title,+ "error": error, "openBoards": open, "requested": wanted,- "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close that board (save it first if it has changes), or open a copy of yours under a unique file name.",+ "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close the other board (save it first if it has changes), or open a copy of yours under a unique file name.", }); } }
rust/crates/kicad-core/src/place_tools.rs+78−3@@ -261,6 +261,20 @@ pub fn is_ground(net: &str) -> bool { n.contains("GND") || n.starts_with("VSS") || n == "0V" || n.ends_with("_VSS") } +/// A net named like a supply rail: +3V3, 5V, VCC, VDD_IO, VBAT, VIN, VBUS, AVDD, VREF, ...+pub fn is_supply(net: &str) -> bool {+ let n = net.trim_start_matches('/').to_uppercase();+ if n.starts_with('+') || n.starts_with("-") {+ return true;+ }+ if ["VCC", "VDD", "VBAT", "VIN", "VBUS", "VOUT", "VREF", "VSYS", "VMOT", "VM", "PWR", "POWER", "VSUP"].iter().any(|k| n.starts_with(k) || n.contains(&format!("_{k}")) || n.ends_with(k)) {+ return true;+ }+ // 3V3, 5V, 1V8, 12V, 3.3V, V3P3 ...+ let b = n.as_bytes();+ (0..b.len()).any(|i| b[i] == b'V' && i > 0 && b[i - 1].is_ascii_digit()) || n.starts_with("V3P") || n.starts_with("V1P")+}+ pub fn is_connector(fp: &Footprint) -> bool { let p = prefix(&fp.reference); let lib = fp.footprint.to_lowercase();@@ -397,20 +411,49 @@ pub fn plan_decoupling(board: &Board, args: &Value) -> R<Value> { v.dedup(); v };+ // By default the caps across ground and one of the IC's power nets, less (std #22): a net not named like a+ // supply with only one cap to ground (a crystal's load cap is a signal, not decoupling), and a cap nearer+ // another IC's pin on the same net (12 VBAT caps belong to U1, U3 and U4, not all round one of them).+ let mut left_out: Vec<Value> = Vec::new(); let cap_refs: Vec<String> = match args.get("caps").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), None => {- let mut v: Vec<String> = board.footprints.iter().filter(|f| decoupling_role(f).map(|(_, n)| power_nets.contains(&n)).unwrap_or(false)).map(|f| f.reference.clone()).collect();+ let roles: Vec<(&Footprint, String)> = board.footprints.iter().filter_map(|f| decoupling_role(f).map(|(_, n)| (f, n))).collect();+ let ics: Vec<&Footprint> = board.footprints.iter().filter(|f| is_ic(f)).collect();+ let near = |f: &Footprint, p: &pcb::Pad| {+ let (x, y) = f.position.as_ref().map(|q| (q.x, q.y)).unwrap_or((0.0, 0.0));+ pcb::dist((x, y), (p.x, p.y))+ };+ let mut v: Vec<String> = Vec::new();+ for (f, net) in roles.iter().filter(|(_, n)| power_nets.contains(n)) {+ let caps_on_net = roles.iter().filter(|(_, n)| n == net).count();+ if !is_supply(net) && caps_on_net == 1 {+ left_out.push(json!({"ref": f.reference, "reason": "signal_net", "net": net, "detail": format!("{net} is not named like a supply and has only this cap to ground: a signal (a crystal's load, a filter), not decoupling. Pass caps:[\"{}\"] to place it anyway.", f.reference)}));+ continue;+ }+ let mine = ic_pads.iter().filter(|p| &p.net_name == net).map(|p| near(f, p)).fold(f64::INFINITY, f64::min);+ let other = ics.iter().filter(|o| o.reference != ic.reference).filter_map(|o| {+ pads.iter().filter(|p| p.reference == o.reference && &p.net_name == net).map(|p| near(f, p)).reduce(f64::min).map(|d| (o.reference.clone(), d))+ }).min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));+ if let Some((o, d)) = other.filter(|(_, d)| *d < mine) {+ left_out.push(json!({"ref": f.reference, "reason": "nearer_another_ic", "net": net, "ic": o, "detail": format!("{} is {:.1} mm from {o}'s {net} pin and {:.1} mm from {}'s: it decouples {o}. Pass caps:[...] to move it anyway.", f.reference, d, mine, ic.reference)}));+ continue;+ }+ v.push(f.reference.clone());+ } v.sort_by_key(|r| placement::ref_key(r)); v } }; if cap_refs.is_empty() {- return Err(RoutingError::new("no_caps", format!("no capacitor across ground and a power net of {}; pass caps:[...]", ic.reference)).with("icPowerNets", json!(power_nets)));+ return Err(RoutingError::new("no_caps", format!("no capacitor across ground and a power net of {}; pass caps:[...]", ic.reference)).with("icPowerNets", json!(power_nets)).with("leftOut", json!(left_out))); } let moving: Vec<String> = cap_refs.iter().map(|r| r.to_uppercase()).collect(); // Obstacles: every same-side courtyard that is not one of the caps being placed. let mut taken: Vec<Rect> = places.iter().filter(|p| p.side == icp.side && !moving.contains(&p.reference.to_uppercase())).map(|p| p.courtyard).collect();+ // a through-hole pad of a part on the other side comes through to this side too+ let other_side: Vec<String> = places.iter().filter(|p| p.side != icp.side).map(|p| p.reference.clone()).collect();+ taken.extend(pad_shapes(board).into_iter().filter(|s| other_side.contains(&s.reference) && (s.layers.iter().any(|l| l == "*.Cu") || (s.layers.iter().any(|l| l == "F.Cu") && s.layers.iter().any(|l| l == "B.Cu")))).map(|s| s.bbox)); let outline = placement::outline(board); let mut used_pins: Vec<String> = Vec::new(); let mut moves = Vec::new();@@ -493,7 +536,7 @@ pub fn plan_decoupling(board: &Board, args: &Value) -> R<Value> { } } };- Ok(json!({"ic": ic.reference, "icPowerNets": power_nets, "placements": rows, "skipped": skipped, "moveArgs": {"refs": moves}, "validation": validation, "gapMm": gap}))+ Ok(json!({"ic": ic.reference, "icPowerNets": power_nets, "placements": rows, "skipped": skipped, "leftOut": left_out, "moveArgs": {"refs": moves}, "validation": validation, "gapMm": gap})) } // ---------------------------------------------------------------------------@@ -681,4 +724,36 @@ mod tests { assert_eq!(edges_distance(&a, &c), 0.0, "nested"); assert!(is_ground("/GND") && is_ground("VSSA") && !is_ground("+3V3")); }++ #[test]+ fn supply_names() {+ for n in ["+3V3", "/5V_VCC", "VDD_IO", "/VBAT", "VIN", "1V8", "AVDD", "/VBUS", "-12V"] {+ assert!(is_supply(n), "{n}");+ }+ for n in ["/XTAL_IN", "/OSC32_OUT", "/SW", "/DRV_BSTA", "Net-(C1-Pad1)", "/NRST", "/ADC_IN"] {+ assert!(!is_supply(n), "{n}");+ }+ }++ #[test]+ fn decoupling_caps_go_to_their_nearest_ic() {+ // std #22: on the ESC board U1 took every +VBAT cap, including those beside U2+ let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb");+ let b = pcb::parse_pcb_text(&std::fs::read_to_string(p).unwrap(), "esc.kicad_pcb").unwrap();+ let placed = |ic: &str| -> (Vec<String>, Vec<Value>) {+ let v = plan_decoupling(&b, &json!({"ic": ic})).unwrap();+ (v["placements"].as_array().unwrap().iter().map(|r| r["ref"].as_str().unwrap().to_string()).collect(), v["leftOut"].as_array().unwrap().clone())+ };+ let (u1, u1_out) = placed("U1");+ let (u2, _) = placed("U2");+ let (u3, _) = placed("U3");+ assert!(u1.contains(&"C5".to_string()), "C5 sits 4 mm from U1: {u1:?}");+ assert!(u1_out.iter().any(|o| o["ref"] == "C14" && o["reason"] == "nearer_another_ic" && o["ic"] == "U2"), "{u1_out:?}");+ for (a, b, an, bn) in [(&u1, &u2, "U1", "U2"), (&u1, &u3, "U1", "U3"), (&u2, &u3, "U2", "U3")] {+ assert!(a.iter().all(|c| !b.contains(c)), "a cap claimed by both {an} and {bn}: {a:?} {b:?}");+ }+ // caps named explicitly are placed as asked+ let v = plan_decoupling(&b, &json!({"ic": "U1", "caps": ["C14"]})).unwrap();+ assert_eq!(v["leftOut"], json!([]));+ } }
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.6.0"+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"
rust/vendor/adom-eda-checks/README.md+4−2@@ -11,14 +11,16 @@ 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`, 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) |+| `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 | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `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.0"`), 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
rust/vendor/adom-eda-checks/VENDOR.md+3−3@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.9.0-- Standard commit: e7b68d92a32b0a650fe88e15d9eefb2d1ea583b5-- Crate version: 0.6.0+- Standard version: 0.10.0+- Standard commit: fcc00d06eddf285e2d30813f28b0cda07c3c31a0+- Crate version: 0.7.0 - Copied: 2026-10-28, by kicad-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/features.rs+29@@ -188,6 +188,26 @@ pub fn via_style(rules: &[Value]) -> Option<(f64, f64)> { } +/// The board's preferred track width in mm. A standard rule gives it as `{kind: "width", preferredMm}` (or `widthMm`);+/// Altium's Width rule descriptor is read too, e.g. "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)":+/// the board-wide one (scope All), highest priority first, its Preferred else its Min. Pure.+pub fn width_style(rules: &[Value]) -> Option<f64> {+ if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))+ .find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("width")).unwrap_or(false)) {+ if let Some(v) = r["preferredMm"].as_f64().or_else(|| r["widthMm"].as_f64()) { return Some(v); }+ }+ let num = |d: &str, key: &str| -> Option<f64> {+ let i = d.find(&format!("({key}="))? + key.len() + 2;+ let s = &d[i..d[i..].find(')')? + i];+ let v: f64 = s.trim_end_matches(char::is_alphabetic).parse().ok()?;+ Some(if s.ends_with("mil") { v * 0.0254 } else { v })+ };+ let mut rs: Vec<&Value> = rules.iter().filter(|r| r["enabled"] != json!(false) && r["descriptor"].as_str().map(|d| d.starts_with("Width")).unwrap_or(false)).collect();+ rs.sort_by_key(|r| (r["scope1"].as_str() != Some("All"), r["priority"].as_i64().unwrap_or(99)));+ let d = rs.first()?["descriptor"].as_str()?;+ num(d, "Preferred").or_else(|| num(d, "Min")).map(|v| (v * 10000.0).round() / 10000.0)+}+ /// Thermal via grid points inside a pad (x, y, sx, sy, rotation mm): `pitch` apart, `inset` from the pad's edge plus /// the via's radius, centred. Pure. pub fn thermal_grid(x: f64, y: f64, sx: f64, sy: f64, rot: f64, pitch: f64, size: f64, inset: f64) -> Vec<(f64, f64)> {@@ -408,4 +428,13 @@ mod tests { assert_eq!(via_fence(&json!({"board": st, "args": {"viaNet": "NOPE", "nets": ["RF"]}}))["errorCode"], "no_such_net"); assert_eq!(thermal_vias(&json!({"board": st, "args": {"ref": "U9"}}))["errorCode"], "part_not_found"); }+ #[test]+ fn width_style_reads_the_standard_rule_and_altiums_descriptor() {+ assert_eq!(width_style(&[json!({"kind": "width", "preferredMm": 0.2})]), Some(0.2));+ let alt = vec![json!({"enabled": true, "scope1": "InNet('VIN')", "priority": 1, "descriptor": "Width Constraint (Min=20mil) (Max=40mil) (Preferred=30mil) (InNet('VIN'))"}),+ json!({"enabled": true, "scope1": "All", "priority": 2, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"})];+ assert_eq!(width_style(&alt), Some(0.254), "the board-wide rule, not a net's");+ assert_eq!(width_style(&[json!({"descriptor": "Width Constraint (Min=0.15mm) (Max=1mm) (All)", "scope1": "All"})]), Some(0.15));+ assert_eq!(width_style(&[]), None);+ } }
rust/vendor/adom-eda-checks/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),
rust/vendor/adom-eda-checks/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);+ }+}
rust/vendor/adom-eda-checks/src/route.rs+41−11@@ -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,13 +88,14 @@ 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()] } } /// plan_route: input {board, rules?, args: {net, from, to, widthMm?, layers?, allowVias?, viaSizeMm?, viaDrillMm?, gridMm?,-/// viaCost?, clearanceMm?, edgeClearanceMm?, maxNodes?}}. `from` and `to` are pads as Ref.Pad.+/// viaCost?, clearanceMm?, edgeClearanceMm?, maxNodes?}}. `from` and `to` are pads as Ref.Pad. The width and via come from+/// the args, else the board's width and via rules (`rules`), else 0.25 mm and 0.6/0.3; `planner` says which. pub fn plan_route(input: &Value) -> Value { let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e }; let args = &input["args"];@@ -106,8 +118,14 @@ pub fn plan_route(input: &Value) -> Value { if key(&ends[0]).eq_ignore_ascii_case(&key(&ends[1])) { return crate::input::refuse("bad_args", "from and to are the same pad"); } let layers: Vec<String> = args["layers"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["F.Cu".into(), "B.Cu".into()]); if layers.is_empty() || layers.iter().any(|l| !l.ends_with(".Cu") || l == "*.Cu") { return crate::input::refuse("bad_args", "layers are copper layers by name: [\"F.Cu\", \"In1.Cu\", \"B.Cu\"]"); }- let width = num("widthMm", 0.25);- let (via_size, via_drill) = (num("viaSizeMm", 0.6), num("viaDrillMm", 0.3));+ // the board's own width and via rules, so the route passes its DRC (a 0.25 mm default broke a 10 mil Width rule on+ // Altium's lane board, 2026-10-28); args still win+ let rules = crate::input::rules(input);+ let (width_rule, via_rule) = (crate::features::width_style(&rules), crate::features::via_style(&rules));+ let width = num("widthMm", width_rule.unwrap_or(0.25));+ let width_from = if args.get("widthMm").is_some() { "widthMm" } else if width_rule.is_some() { "the board's width rule" } else { "default 0.25" };+ let (via_size, via_drill) = (num("viaSizeMm", via_rule.map(|v| v.0).unwrap_or(0.6)), num("viaDrillMm", via_rule.map(|v| v.1).unwrap_or(0.3)));+ let via_from = if args.get("viaSizeMm").is_some() { "viaSizeMm/viaDrillMm" } else if via_rule.is_some() { "the board's via rule" } else { "default 0.6/0.3" }; if via_drill >= via_size { return crate::input::refuse("bad_args", "viaDrillMm must be smaller than viaSizeMm"); } let grid = num("gridMm", 0.5); if !(0.05..=5.0).contains(&grid) { return crate::input::refuse("bad_args", "gridMm is 0.05 to 5"); }@@ -115,7 +133,7 @@ pub fn plan_route(input: &Value) -> Value { let via_cost = num("viaCost", 5.0); let max_nodes = args["maxNodes"].as_u64().unwrap_or(50_000).clamp(1, 500_000) as usize; // the board's own clearance decides; a caller's clearanceMm can only raise it- let rule = crate::fab::min_clearance(&crate::input::rules(input));+ let rule = crate::fab::min_clearance(&rules); let clearance = num("clearanceMm", 0.0).max(rule.unwrap_or(0.2)); let edge = num("edgeClearanceMm", 0.3); let ring = crate::coppermap::outline_ring(&st);@@ -252,7 +270,7 @@ pub fn plan_route(input: &Value) -> Value { way.push(json!({"pad": key(&ends[1])})); json!({"success": true, "found": true, "lengthMm": r3(length), "vias": vias.len(), "viaAt": vias, "routeNet": {"net": net, "layer": layers[path[0].2], "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": [way]},- "planner": {"algorithm": "bounded orthogonal A*", "gridMm": grid, "layers": layers, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},+ "planner": {"algorithm": "bounded orthogonal A*", "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()}, "revision": revision, "_hint": "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). A layer change is two waypoints at the same point, where route_net places a via. Pours (and a hatched pour's net-less pieces) are not obstacles: repour after placing the route. The path is orthogonal on the grid: a finer gridMm finds tighter paths and searches longer."}) }@@ -320,4 +338,16 @@ mod tests { assert_eq!(three["found"], json!(true), "{three}"); assert!(three["routeNet"]["paths"][0].as_array().unwrap().iter().any(|w| w["layer"] == "In1.Cu")); }+ #[test]+ fn the_track_and_via_follow_the_boards_rules() {+ // Altium's lane board: a 10 mil Width rule and a 0.5/0.3 Routing Via rule+ let rules = json!([{"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"},+ {"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=0.3mm) (PreferedWidth=0.5mm) (All)"}]);+ let r = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+ assert_eq!((r["routeNet"]["width"].clone(), r["routeNet"]["viaSize"].clone()), (json!(0.254), json!(0.5)), "{r}");+ assert_eq!(r["planner"]["widthFrom"], "the board's width rule");+ let given = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "widthMm": 0.4}}));+ assert_eq!(given["routeNet"]["width"], json!(0.4));+ assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}))["planner"]["widthFrom"], "default 0.25");+ } }
skills/kicad-bridge-test/run_verb_tests.py+2−2@@ -1260,7 +1260,7 @@ def main(): r = K("check_clearance", {"filePath": L2, "refs": ["U5"], "mode": "pads"}); rec("check_clearance", r, ok(r)) r = K("score_placement", {"filePath": L2}); rec("score_placement", r, ok(r)) r = K("plan_fanout", {"filePath": L2, "ref": "U5"}); rec("plan_fanout", r, ok(r))- r = K("place_decoupling", {"filePath": L2, "ic": "U5", "dryRun": True}); rec("place_decoupling", r, ok(r) and r.get("mutated") is not True)+ r = K("place_decoupling", {"filePath": L2, "ic": "U5", "dryRun": True}); rec("place_decoupling", r, ok(r) and r.get("mutated") is not True and isinstance(r.get("leftOut"), list), "no plan, or no leftOut list (std #22)") r = K("cross_probe", {"filePath": L2, "reference": "U5"}); rec("cross_probe", r, ok(r) or refused_cleanly(r), "no answer for U5") r = K("select_items", {"filePath": L2, "refs": ["U5"]}); rec("select_items", r, ok(r)) r = K("get_selection", {"filePath": L2}); rec("get_selection", r, ok(r) and "U5" in json.dumps(r))@@ -1589,7 +1589,7 @@ def main(): "no SOIC-8 body written, attached and rendered at its 1.75 mm height") pr = C("plan_route", {"filePath": CK, "source": "file", "net": "/DRV_GHC", "from": "R12.1", "to": "U4.13"}, 200) rn = pr.get("routeNet") or {}- own("plan_route", pr, ok(pr) and eng(pr) and pr.get("found") is True and rn.get("expectedRevision") == pr.get("revision") and rn.get("filePath") and rn.get("paths") and rn["paths"][0][0] == {"pad": "R12.1"},+ own("plan_route", pr, ok(pr) and eng(pr) and pr.get("found") is True and (pr.get("planner") or {}).get("widthFrom") and rn.get("expectedRevision") == pr.get("revision") and rn.get("filePath") and rn.get("paths") and rn["paths"][0][0] == {"pad": "R12.1"}, "no route planned for /DRV_GHC, or routeNet is not ready for kicad_route_net") r = C("panelize", {"pcbPath": w["board"], "panelPath": w["out"] + "/panel/panel-tabs.kicad_pcb", "rows": 2, "cols": 2, "separation": "tabs", "overwrite": True}, 300) record("panelize_tabs", r, (ok(r) and (r.get("separation") or {}).get("kind") == "tabs" and (r.get("separation") or {}).get("holes", 0) > 0) or r.get("errorCode") == "not_rectangular",