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.
← All Pull Requests
kicad-lane: 1.0.77, engine fanout, leaveFanout, fanout conflicts fix #38
Merged opened by Colby Knox 2026-10-28
kicad-lane: 1.0.77.
- kicad_plan_fanout now answers from the standard's engine (adom-eda-checks 0.10.3, standard 0.13.3, d991b05, vendored unchanged).
- The engine version is safe on a routed board (std #22):
includeRouted, conflicts against other nets' pads/tracks/arcs/vias,keptClearOfHolesandcrossesPours. - It gets the board model, the stack-up, and the board's clearance and width rules.
- Each call carries filePath, frame and expectedRevision for kicad_route_net.
- The old argument names (viaSize, viaDrill, width, clearance, pitch) map to the new ones. A refusal gets a
_hint, and the unit test now expects the standard'sno_such_part.
- The engine version is safe on a routed board (std #22):
- kicad_place_decoupling leaveFanout (default true). The IC's own fanout plan (kicad_core plan_fanout, KiCad frame) gives the stubs and vias of its pins, and a candidate spot is refused when a cap pad comes within that plan's clearance of another net's piece.
- The response reports
leaveFanoutandfanoutPieces. - Unit test on the ESC board's U5: no placed cap pad sits on another net's stub or via.
- The response reports
- Fix to 1.0.76's kicad_core fanout_conflicts:
- waypoints given as pad names ("U5.1", {pad}) were dropped, so stubs leaving pads were never measured;
- a stub was measured with
edges_distance, which treats a single segment as a closed outline, so pad corners read as inside it and gave 0 mm gaps across the board. - It now has a shared
waypointresolver and a segment-to-edge minimum, with regression checks: a far stub is clear, and a stub from a named pad is measured. Re-measured on the ESC boards: from 157/93/14 false conflicts to 1/1/1 real near-misses.
- plan_route (engine 0.10.3): avoidPours as a list of nets, and pourCost.
- Battery:
- plan_fanout must come from the engine, with conflicts, crossesPours and route_net-ready calls;
- place_decoupling must report leaveFanout.
Full battery on DESKTOP-BAR6S0F (KiCad 10.0.7): 304/0/1. kicad-core and kicad-bridge unit tests: 667/0.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯ [[package]] name = "adom-eda-checks"-version = "0.10.0"+version = "0.10.3" dependencies = [ "roxmltree", "serde_json",⋯ 302 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.76"+version = "1.0.77" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.76"+version = "1.0.77" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.76"+version = "1.0.77" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.76"+version = "1.0.77" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,1120 +1,1168 @@⋯ 816 unchanged lines ⋯ v } +/// kicad_plan_fanout: the standard's engine plans the fanout on the board model (stack-up, the board's clearance,+/// width and via rules), safe on a routed board: pins whose net already reaches the pad are skipped, conflicts+/// are measured against other nets' pads, tracks, arcs and vias, the exposed-pad grid keeps off holes, and+/// crossesPours names the pours it runs into (std #22). Each call comes back ready for kicad_route_net.+pub(crate) fn plan_fanout(state: &mut State, args: &Value) -> Value {+ let o = match opts(args) { Ok(o) => o, Err(e) => return e };+ let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+ let model = match board_model::build(&r.text, Some(&r.file), &o) {+ Ok(m) => m,+ Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+ };+ let stack = board_model::stackup(&r.text).unwrap_or(Value::Null);+ let class = default_class(&r.file);+ 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}));+ }+ // this verb's earlier argument names, still taken+ let mut a = crate::verbs_checks::engine_args(args);+ for (old, new) in [("viaSize", "viaMm"), ("viaDrill", "viaDrillMm"), ("width", "widthMm"), ("clearance", "clearanceMm"), ("pitch", "pitchMm")] {+ if let Some(v) = a.as_object_mut().and_then(|m| m.remove(old)) {+ a[new] = v;+ }+ }+ let mut v = adom_eda_checks::run("plan_fanout", &json!({"board": model, "stack": stack, "rules": rules, "args": a}));+ if v["success"] != json!(true) {+ if v["_hint"].is_null() {+ v["_hint"] = json!("ref is a BGA or a QFN/QFP/SOIC on this board: kicad_list_designators lists them.");+ }+ return v;+ }+ let frame = if o.y_up { "standard" } else { "kicad" };+ if let Some(calls) = v["calls"].as_array_mut() {+ for c in calls {+ c["filePath"] = json!(r.file);+ c["frame"] = json!(frame);+ c["expectedRevision"] = model["revision"].clone();+ }+ }+ v["revision"] = model["revision"].clone();+ v["frame"] = json!(frame);+ stamp(&mut v, &r);+ let hint = v["_hint"].as_str().unwrap_or("").to_string();+ v["_hint"] = json!(format!("{}{hint} In KiCad: each call goes to kicad_route_net as it is (filePath, frame and expectedRevision are in it); repour with kicad_zone_state {{refill:true}}.", file_note(&r)));+ v+}+ /// The project's Default net class (track_width, via_diameter, via_drill, ...), or null. fn default_class(board_file: &str) -> Value { let pro = PathBuf::from(board_file).with_extension("kicad_pro");⋯ 295 unchanged lines ⋯ assert!(o["checked"].as_u64().unwrap() > 10, "{o}"); } }+@@ -1,274 +1,275 @@⋯ 45 unchanged lines ⋯ name: "kicad_plan_fanout", summary: "Plan the fanout of a BGA or QFN/QFP: detect the pad pitch, pick dogbone or via-in-pad per ball, the escape layer, surface stubs for the outer ring, a via grid for an exposed pad, returned as kicad_route_net-ready calls.", mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,- input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"ref\": \"U3\", \"style\"?: \"auto\" | \"dogbone\" | \"via-in-pad\", \"escapeLayer\"?: \"In1.Cu\", \"viaSize\"?, \"viaDrill\"?, \"width\"?, \"clearance\"?: 0.1, \"pitch\"?, \"viaPerimeter\"?: false, \"stubMm\"?: 0.4, \"surfaceOuterRing\"?: true}",+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"ref\": \"U3\", \"style\"?: \"auto\" | \"dogbone\" | \"via-in-pad\", \"escapeLayer\"?: \"In1.Cu\", \"viaMm\"?, \"viaDrillMm\"?, \"widthMm\"?, \"clearanceMm\"? (the board's rule by default), \"pitchMm\"?, \"viaPerimeter\"?: false, \"stubMm\"?: 0.4, \"surfaceOuterRing\"?: true, \"includeRouted\"?: false, \"frame\"?: \"standard|kicad\"}", example: "kicad_plan_fanout {\"filePath\":\"C:/p/b.kicad_pcb\",\"ref\":\"U3\"}",- hint: "conflicts[] lists every via and stub closer than clearance to another net's pad anywhere on the board (vias on every layer, stubs on their own layer), by the pad's real outline: drop or shorten those calls before routing. package is bga (pads inside the grid) or perimeter. pitchMm is the median nearest-neighbour spacing. style auto picks dogbone when a via fits between four balls with the clearance (dogboneRoomMm), else via-in-pad. pads[] has one row per pad (surface, dogbone, via-in-pad, stub+via, thermal-via-grid, skip). calls[] are kicad_route_net arguments, one per net: add filePath and expectedRevision and send each (dryRun first). minViaToPadMm is the closest via ring to another pad.",+ hint: "Computed by the standard's engine (engine says which), safe on a routed board. conflicts[] lists every via and stub closer than the clearance to another net's pad, track, arc or via (vias on every layer, stubs on the part's side): apply nothing while it is not empty. Pins already reached by their net's copper are skipped (includeRouted plans them anyway); an exposed pad's via grid steps round holes already there (keptClearOfHoles). crossesPours names other nets' pours the plan runs into: refill before trusting DRC. pads[] has one row per pad with its action. calls[] are kicad_route_net's own arguments, one per net, with filePath, frame and expectedRevision in them: send each (dryRun first). The old argument names (viaSize, viaDrill, width, clearance, pitch) still work", related: &["kicad_route_net", "kicad_board_pads", "kicad_routing_state", "kicad_board_rules"],- pitfalls: &["Via-in-pad needs filled and capped vias from the fab (IPC-4761 type VII); say so to the user before committing it.", "Escape layer defaults to the first inner layer, else the opposite outer layer; pass escapeLayer for a plane-heavy stack-up.", "The plan does not check the stubs against other parts' copper; each kicad_route_net call runs KiCad DRC before it commits."],+ pitfalls: &["Via-in-pad needs filled and capped vias from the fab (IPC-4761 type VII); say so to the user before committing it.", "Escape layer defaults to the first inner layer, else the opposite outer layer; pass escapeLayer for a plane-heavy stack-up.", "Each kicad_route_net call still runs KiCad's DRC before it commits; conflicts is the plan's own check, not DRC."], }, ]; ⋯ 1 unchanged line ⋯ Some(match command { "kicad_check_clearance" => read(state, args, |b, a| place_tools::check_clearance(b, a)), "kicad_score_placement" => read(state, args, |b, a| Ok(place_tools::score(b, a))),- "kicad_plan_fanout" => read(state, args, place_tools::plan_fanout),+ "kicad_plan_fanout" => crate::verbs_checks_native::plan_fanout(state, args), "kicad_place_decoupling" => decoupling(state, args), _ => return None, })⋯ 191 unchanged lines ⋯ let f = call("kicad_plan_fanout", json!({"filePath": b, "ref": "U2"})); assert_eq!(f["package"], "bga", "{f}"); let e = call("kicad_plan_fanout", json!({"filePath": b, "ref": "ZZ9"}));- assert_eq!((e["success"].as_bool(), e["errorCode"].as_str()), (Some(false), Some("unknown_footprint")));+ assert_eq!((e["success"].as_bool(), e["errorCode"].as_str()), (Some(false), Some("no_such_part")), "the standard's code: {e}"); assert!(e["_hint"].is_string()); } ⋯ 9 unchanged lines ⋯ assert!(a["plan"]["placements"].is_array()); } }+@@ -1,846 +1,931 @@⋯ 461 unchanged lines ⋯ taken.push(places[i].courtyard); } }+ // std #22 leaveFanout (default true): a cap's pads keep clear of the stubs and vias plan_fanout would give the IC's+ // other nets' pins, so placing caps first does not block the escape (Altium put 5 caps on U4's stubs)+ let leave_fanout = args.get("leaveFanout").and_then(Value::as_bool).unwrap_or(true);+ let (fan, fan_clear) = if leave_fanout { fanout_pieces(board, &ic.reference, &icp.side) } else { (Vec::new(), 0.0) }; let outline = placement::outline(board); let mut used_pins: Vec<String> = Vec::new(); let mut moves = Vec::new();⋯ 45 unchanged lines ⋯ 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 = (pcb::round6(base.0 + along.0 * s), pcb::round6(base.1 + along.1 * s)); let r = placement::courtyard_at(cap, at, rot).0;- let clear = taken.iter().all(|t| !t.overlaps(&r)) && outline.map(|o| o.contains(&r)).unwrap_or(true);+ let off_fanout = fan.is_empty() || cap.pads.iter().all(|pd| {+ let c = placement::to_board(at, rot, (pd.x.unwrap_or(0.0), pd.y.unwrap_or(0.0)));+ let rad = pd.width.unwrap_or(0.0).max(pd.height.unwrap_or(0.0)) / 2.0;+ let pnet = pd.net_name.as_deref().unwrap_or("");+ fan.iter().filter(|(n, ..)| n != pnet || pnet.is_empty()).all(|(_, a, b, hw)| seg_point(c, *a, *b) - rad - hw >= fan_clear - 1e-9)+ });+ let clear = off_fanout && taken.iter().all(|t| !t.overlaps(&r)) && outline.map(|o| o.contains(&r)).unwrap_or(true); if clear { chosen = Some((at, r, s)); break;⋯ 25 unchanged lines ⋯ } } };- Ok(json!({"ic": ic.reference, "icPowerNets": power_nets, "placements": rows, "skipped": skipped, "leftOut": left_out, "moveArgs": {"refs": moves}, "validation": validation, "gapMm": gap}))+ Ok(json!({"ic": ic.reference, "icPowerNets": power_nets, "placements": rows, "skipped": skipped, "leftOut": left_out,+ "leaveFanout": leave_fanout, "fanoutPieces": fan.len(), "moveArgs": {"refs": moves}, "validation": validation, "gapMm": gap})) } // ---------------------------------------------------------------------------⋯ 172 unchanged lines ⋯ })) } +/// A fanout waypoint as a point and its layer: [x, y] (on `side`), {x, y, layer?}, or a pad as "U1.3" / {pad}+/// (its centre, on `side`).+fn waypoint(v: &Value, side: &str, pads: &[pcb::Pad]) -> Option<(P, String)> {+ let pad_at = |name: &str| pads.iter().find(|p| p.name.eq_ignore_ascii_case(name)).map(|p| ((p.x, p.y), side.to_string()));+ match v {+ Value::Array(a) => Some(((a.first()?.as_f64()?, a.get(1)?.as_f64()?), side.to_string())),+ Value::String(n) => pad_at(n),+ Value::Object(o) => match o.get("pad").and_then(Value::as_str) {+ Some(n) => pad_at(n),+ None => Some(((o.get("x")?.as_f64()?, o.get("y")?.as_f64()?), o.get("layer").and_then(Value::as_str).unwrap_or(side).to_string())),+ },+ _ => None,+ }+}++/// The copper plan_fanout would give an IC's pins on its own side, by net: (net, a, b, half-width), a via+/// being a = b; and the clearance that plan keeps. Nothing when the IC is no package plan_fanout plans.+fn fanout_pieces(board: &Board, ic: &str, side: &str) -> (Vec<(String, P, P, f64)>, f64) {+ let Ok(v) = plan_fanout(board, &json!({"ref": ic})) else { return (Vec::new(), 0.0) };+ let pads = pcb::pads(board);+ let mut out = Vec::new();+ for c in v["calls"].as_array().into_iter().flatten() {+ let net = c["net"].as_str().unwrap_or("").to_string();+ let (w, vs) = (c["width"].as_f64().unwrap_or(0.15), c["viaSize"].as_f64().unwrap_or(0.6));+ for path in c["paths"].as_array().into_iter().flatten() {+ let pts: Vec<(P, String)> = path.as_array().into_iter().flatten().filter_map(|q| waypoint(q, side, &pads)).collect();+ for wnd in pts.windows(2) {+ let ((a, la), (b, lb)) = (&wnd[0], &wnd[1]);+ if pcb::dist(*a, *b) < 1e-6 {+ if la != lb {+ out.push((net.clone(), *a, *a, vs / 2.0));+ }+ } else if la == side {+ out.push((net.clone(), *a, *b, w / 2.0));+ }+ }+ }+ }+ (out, v["clearanceMm"].as_f64().unwrap_or(0.1))+}+ /// Every via and stub of a fanout plan against every other net's pad on the board, by the pad's real /// outline (std #22, the engine's plan_fanout does the same): a via against pads on any layer, a stub /// against pads on its own layer (and through-hole pads). A path of waypoints is a stub between two⋯ 1 unchanged line ⋯ /// is a conflict. fn fanout_conflicts(board: &Board, calls: &std::collections::BTreeMap<String, Vec<Value>>, side: &str, width: f64, via_size: f64, clearance: f64) -> Vec<Value> { let shapes = pad_shapes(board);- let wp = |v: &Value| -> Option<(P, String)> {- match v {- Value::Array(a) => Some(((a.first()?.as_f64()?, a.get(1)?.as_f64()?), side.to_string())),- Value::Object(o) => Some(((o.get("x")?.as_f64()?, o.get("y")?.as_f64()?), o.get("layer").and_then(Value::as_str).unwrap_or(side).to_string())),- _ => None,- }- };+ let pads = pcb::pads(board);+ let wp = |v: &Value| waypoint(v, side, &pads); let on_layer = |s: &PadShape, l: &str| s.layers.iter().any(|x| x == l || x == "*.Cu"); let mut out = Vec::new(); for (net, paths) in calls {⋯ 18 unchanged lines ⋯ let core = match s.circle { Some(r) => seg_point(s.center, *a, *b) - r, None if inside_edges(*a, &s.edges) || inside_edges(*b, &s.edges) => 0.0,- None => edges_distance(&[(*a, *b)], &s.edges),+ // a stub is one segment, not a closed outline: edges_distance would test a pad corner+ // as inside it+ None => s.edges.iter().map(|(c, d)| seg_seg(*a, *b, *c, *d)).fold(f64::INFINITY, f64::min), }; (core - width / 2.0).max(0.0) };⋯ 69 unchanged lines ⋯ assert!(c.iter().any(|k| k["what"] == "stub" && k["pad"] == "R12.1" && k["gapMm"] == 0.0), "{c:?}"); assert!(c.iter().any(|k| k["what"] == "via" && k["pad"] == "R12.1"), "{c:?}"); assert!(c.iter().all(|k| k["net"] == "OTHER"), "the pad's own net never conflicts: {c:?}");+ // a stub 5 mm away is clear: a single segment is not an outline a pad corner can sit "inside" (it was, and a+ // +12V stub at U4 conflicted with U5.1 across the board)+ let mut far = std::collections::BTreeMap::new();+ far.insert("OTHER".to_string(), vec![json!([[x - 2.0, y + 5.0], [x + 2.0, y + 5.0]])]);+ assert!(fanout_conflicts(&b, &far, "F.Cu", 0.15, 0.6, 0.1).iter().all(|k| k["pad"] != "R12.1"));+ // a fanout's waypoints start at a pad given by name: the stub from it is measured too+ let mut named = std::collections::BTreeMap::new();+ named.insert("OTHER".to_string(), vec![json!(["R12.2", [x, y]])]);+ assert!(fanout_conflicts(&b, &named, "F.Cu", 0.15, 0.6, 0.1).iter().any(|k| k["pad"] == "R12.1")); // a stub on B.Cu misses an SMD pad on F.Cu let mut back = std::collections::BTreeMap::new(); back.insert("OTHER".to_string(), vec![json!([{"x": x - 2.0, "y": y, "layer": "B.Cu"}, {"x": x + 2.0, "y": y, "layer": "B.Cu"}])]); assert!(fanout_conflicts(&b, &back, "F.Cu", 0.15, 0.6, 0.1).iter().all(|k| k["pad"] != "R12.1")); }++ #[test]+ fn decoupling_leaves_room_for_the_fanout() {+ // std #22 leaveFanout: U5 (LQFP-48) on the unrouted ESC board; no placed cap pad may sit on another net's+ // escape stub or via+ let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../demo/placement/esc-g431-unrouted.kicad_pcb");+ let b = pcb::parse_pcb_text(&std::fs::read_to_string(p).unwrap(), "esc.kicad_pcb").unwrap();+ let v = plan_decoupling(&b, &json!({"ic": "U5"})).unwrap();+ assert_eq!(v["leaveFanout"], true);+ let (fan, clear) = fanout_pieces(&b, "U5", "F.Cu");+ assert!(fan.len() > 40 && v["fanoutPieces"].as_u64().unwrap() as usize == fan.len(), "{}", fan.len());+ for row in v["placements"].as_array().unwrap() {+ let cap = b.footprints.iter().find(|f| f.reference == row["ref"].as_str().unwrap()).unwrap();+ let at = (row["x"].as_f64().unwrap(), row["y"].as_f64().unwrap());+ for pd in &cap.pads {+ let c = placement::to_board(at, row["rotation"].as_f64().unwrap(), (pd.x.unwrap_or(0.0), pd.y.unwrap_or(0.0)));+ let rad = pd.width.unwrap_or(0.0).max(pd.height.unwrap_or(0.0)) / 2.0;+ let pnet = pd.net_name.clone().unwrap_or_default();+ for (n, a, bb, hw) in fan.iter().filter(|(n, ..)| *n != pnet) {+ assert!(seg_point(c, *a, *bb) - rad - hw >= clear - 1e-9, "{} pad {} sits on {n}'s fanout", cap.reference, pd.number);+ }+ }+ }+ let off = plan_decoupling(&b, &json!({"ic": "U5", "leaveFanout": false})).unwrap();+ assert_eq!(off["fanoutPieces"], 0);+ } }+@@ -1,25 +1,26 @@ [package] name = "adom-eda-checks"-version = "0.10.0"+version = "0.10.3" 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"⋯ 16 unchanged lines ⋯ lto = true codegen-units = 1 strip = true+@@ -1,109 +1,110 @@⋯ 18 unchanged lines ⋯ | `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. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools |+| `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. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `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_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad anywhere on the board. From the KiCad bridge's place_tools |-| `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 strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them |+| `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools |+| `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 strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path | | `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 | ⋯ 66 unchanged lines ⋯ 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.10.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.10.3"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests ⋯ 5 unchanged lines ⋯ ``` Fixtures live on each bridge's own page, not here.+@@ -1,13 +1,14 @@⋯ 2 unchanged lines ⋯ 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.13.0-- Standard commit: a466bab9e652f37163e6da96ebaef42b6a45a97a-- Crate version: 0.10.0+- Standard version: 0.13.3+- Standard commit: d991b05865a55a89da697159a8f626006281adbb+- Crate version: 0.10.3 - Copied: 2026-10-28, by kicad-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`, `engine/Cargo.toml` and `engine/README.md` here, record the new version and commit above, and run the bridge's tests. Never edit the copy: fixes go to the standard (an issue titled `kicad-lane: ...`).+@@ -1,691 +1,798 @@⋯ 246 unchanged lines ⋯ // (fusion-lane #22: C32 was planned onto C29, which had no free spot and never moved) 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));+ // room for the IC's fanout: a cap's pads keep clear of the stubs and vias plan_fanout would give the pins of other+ // nets (altium-lane: caps placed first sat on 5 of U4's escape stubs and vias)+ let (fan, fan_clear) = if args["leaveFanout"].as_bool() == Some(false) { (Vec::new(), 0.0) } else { fanout_room(input, ic_ref) };+ let pads_clear = |cap: &Part, at: P, rot: f64| cap.pads.iter().all(|q| {+ let mut moved = q.clone();+ let c = cap.moved(pad_at(q), at, rot);+ moved["x"] = json!(c.0); moved["y"] = json!(c.1); moved["rotation"] = json!(f(&q["rotation"]) + rot - cap.rot);+ moved.as_object_mut().map(|o| o.remove("copperPoints"));+ let s = pad_shape(&moved);+ fan.iter().all(|(n, piece)| *n == net(q) || s.gap(piece) >= fan_clear - 1e-6)+ }); let mut stay: Vec<String> = Vec::new(); let (mut used, mut rows, mut moves, mut skipped): (Vec<String>, Vec<Value>, Vec<(String, P, f64)>, Vec<Value>); loop {⋯ 42 unchanged lines ⋯ 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; }+ if taken.iter().all(|t| rect_gap(*t, b) >= gap - 1e-3) && on_board(b) && pads_clear(cap, at, rot) { 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);⋯ 15 unchanged lines ⋯ "_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."}) } +/// The copper plan_fanout would put down for an IC's pins, by net, and the clearance it keeps; nothing when the IC is no+/// package fanout plans (fewer than four surface pads).+fn fanout_room(input: &Value, ic: &str) -> (Vec<(String, Shape)>, f64) {+ let mut q = input.clone();+ q["args"] = json!({"ref": ic});+ let r = plan_fanout(&q);+ if r["success"] != json!(true) { return (Vec::new(), 0.0); }+ let st = crate::input::board(input).unwrap_or_default();+ let at = |name: &str| st["pads"].as_array().into_iter().flatten().find(|p| format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")) == name).map(|p| (f(&p["x"]), f(&p["y"])));+ let (w, v) = (f(&r["widthMm"]) / 2.0, f(&r["via"]["size"]) / 2.0);+ let mut out = Vec::new();+ for row in r["pads"].as_array().into_iter().flatten() {+ let (Some(pa), Some(n)) = (row["pad"].as_str().and_then(at), row["net"].as_str()) else { continue };+ let pt = |x: &Value| (f(&x[0]), f(&x[1]));+ if row["stubEnd"].is_array() { out.push((n.to_string(), Shape::Cap(pa, pt(&row["stubEnd"]), w))); }+ if row["via"].is_array() { let e = pt(&row["via"]); out.push((n.to_string(), Shape::Cap(pa, e, w))); out.push((n.to_string(), Shape::Cap(e, e, v))); }+ }+ (out, f(&r["clearanceMm"]))+}+ /// 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"}); }⋯ 119 unchanged lines ⋯ calls.entry(n.to_string()).or_default().push(json!(path)); rows.push(row); }+ // the board's routed copper: (net, layer or "" for every layer, shape, kind). Net-less tracks and arcs are a hatched+ // pour's pieces, which re-pour round new copper (altium-lane #22: a plan with "0 conflicts" shorted U4's routing)+ let mut routed: Vec<(String, String, Shape, &'static str)> = Vec::new();+ for t in st["tracks"].as_array().into_iter().flatten().filter(|t| !net(t).is_empty()) {+ routed.push((net(t), t["layer"].as_str().unwrap_or("").to_string(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0), "track"));+ }+ for a in st["arcs"].as_array().into_iter().flatten().filter(|a| !net(a).is_empty()) {+ let (c, rad) = ((f(&a["center"]["x"]), f(&a["center"]["y"])), f(&a["radius"]));+ let (a1, mut a2) = (f(&a["startAngle"]), f(&a["endAngle"]));+ if a2 <= a1 { a2 += 360.0; }+ let k = ((a2 - a1) / 10.0).ceil().max(1.0) as usize;+ let p = |deg: f64| (c.0 + rad * deg.to_radians().cos(), c.1 + rad * deg.to_radians().sin());+ for i in 0..k { routed.push((net(a), a["layer"].as_str().unwrap_or("").to_string(), Shape::Cap(p(a1 + (a2 - a1) * i as f64 / k as f64), p(a1 + (a2 - a1) * (i + 1) as f64 / k as f64), f(&a["width"]) / 2.0), "arc")); }+ }+ let holes: Vec<(P, f64)> = st["vias"].as_array().into_iter().flatten().map(|v| ((f(&v["position"]["x"]), f(&v["position"]["y"])), f(&v["size"]) / 2.0))+ .chain(st["pads"].as_array().into_iter().flatten().filter(|p| f(&p["drill"]) > 0.0).map(|p| (at(p), f(&p["sizeX"]).max(f(&p["sizeY"])).max(f(&p["drill"])) / 2.0))).collect();+ for v in st["vias"].as_array().into_iter().flatten().filter(|v| !net(v).is_empty()) {+ let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+ routed.push((net(v), String::new(), Shape::Cap(c, c, f(&v["size"]) / 2.0), "via"));+ }+ let include_routed = args["includeRouted"].as_bool().unwrap_or(false); for &i in &signal { let p = pads[i]; let (pa, n) = (at(p), net(p)); if n.is_empty() { rows.push(json!({"pad": name(p), "net": "", "action": "skip", "why": "no net"})); continue; }+ // a pin its net's copper already reaches is routed: a stub from it would run into that routing+ if !include_routed {+ let ps = pad_shape(p);+ if routed.iter().any(|(rn, l, s, _)| *rn == n && (l.is_empty() || l == side) && ps.gap(s) <= 1e-6) {+ rows.push(json!({"pad": name(p), "net": n, "action": "skip", "why": "already routed"}));+ continue;+ }+ } if kind == "bga" { let ring = ((pa.0 - bx.0) / pitch).round().min(((bx.2 - pa.0) / pitch).round()).min(((pa.1 - bx.1) / pitch).round()).min(((bx.3 - pa.1) / pitch).round()) as i64; if ring == 0 && args["surfaceOuterRing"].as_bool() != Some(false) {⋯ 36 unchanged lines ⋯ let (w, h) = if (f(&p["rotation"]).rem_euclid(180.0) - 90.0).abs() < 1.0 { (h, w) } else { (w, h) }; let vp = (via_size + 0.5).max(1.0); let (nx, ny) = ((((w - via_size) / vp).floor() as i64 + 1).max(1), (((h - via_size) / vp).floor() as i64 + 1).max(1));- let mut grid = Vec::new();+ let (mut grid, mut kept_clear) = (Vec::new(), 0); for a in 0..nx { for b in 0..ny { let v = (pa.0 + (a as f64 - (nx - 1) as f64 / 2.0) * vp, pa.1 + (b as f64 - (ny - 1) as f64 / 2.0) * vp);+ // clear of the holes already there, as thermal_vias keeps them (a grid on U4's 9 GND vias was hole-to-hole)+ if holes.iter().any(|(c, r)| ((c.0 - v.0).powi(2) + (c.1 - v.1).powi(2)).sqrt() < r + via_size / 2.0 + clearance - 1e-9) { kept_clear += 1; continue; } pieces.push((name(p), n.clone(), "via", Shape::Cap(v, v, via_size / 2.0))); calls.entry(n.clone()).or_default().push(json!([wp(v, side), wp(v, &escape)])); grid.push(json!([r3(v.0), r3(v.1)])); } }- rows.push(json!({"pad": name(p), "net": n, "action": "thermal-via-grid", "layer": escape, "vias": grid, "viaPitchMm": vp}));+ rows.push(json!({"pad": name(p), "net": n, "action": "thermal-via-grid", "layer": escape, "vias": grid, "viaPitchMm": vp, "keptClearOfHoles": kept_clear})); } // each via and stub against every other net's pad on the board (vias on every layer, stubs on the part's side) let all: Vec<(Value, Shape)> = st["pads"].as_array().cloned().unwrap_or_default().into_iter().map(|p| { let s = pad_shape(&p); (p, s) }).collect();⋯ 5 unchanged lines ⋯ let g = s.gap(ps); if *what == "via" { worst = worst.min(g); } if g < clearance - 1e-9 {- conflicts.push(json!({"from": from, "what": what, "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "padNet": p["net"], "gapMm": r3(g)}));+ conflicts.push(json!({"from": from, "what": what, "with": "pad", "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "padNet": p["net"], "gapMm": r3(g)})); } }+ // and against other nets' tracks, arcs and vias: on every layer for a via, on the part's side for a stub+ for (rn, l, rs, kind) in &routed {+ if rn == n || (*what == "stub" && !l.is_empty() && l != side) { continue; }+ let g = s.gap(rs);+ if g < clearance - 1e-9 {+ let (x0, y0, x1, y1) = rs.bounds();+ conflicts.push(json!({"from": from, "what": what, "with": kind, "net": rn, "layer": if l.is_empty() { "every" } else { l.as_str() }, "near": [r3((x0 + x1) / 2.0), r3((y0 + y1) / 2.0)], "gapMm": r3(g)}));+ }+ } }+ // other nets' pours the plan runs into: not conflicts (a pour re-pours round new copper), but until the repour the+ // tool's DRC sees the pour's pieces as shorts (altium-lane #22), and a stub across a pour can cut it in two+ let mut crosses: Vec<Value> = Vec::new();+ for p in st["polygons"].as_array().into_iter().flatten() {+ let (pn, pl) = (net(p), p["layer"].as_str().unwrap_or("").to_string());+ let ring: Vec<P> = p["points"].as_array().into_iter().flatten().map(|q| (f(&q[0]), f(&q[1]))).collect();+ if pn.is_empty() || ring.len() < 3 { continue; }+ let poly = Shape::Poly(ring);+ let hit = pieces.iter().any(|(_, n, what, s)| *n != pn && (*what == "via" || pl == side) && s.gap(&poly) <= 1e-9);+ if hit && !crosses.iter().any(|c| c["net"] == json!(pn) && c["layer"] == json!(pl)) { crosses.push(json!({"net": pn, "layer": pl})); }+ } let via_count = pieces.iter().filter(|x| x.2 == "via").count(); let calls: Vec<Value> = calls.into_iter().map(|(n, paths)| json!({"net": n, "layer": side, "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": paths})).collect(); json!({"success": true, "ref": r, "package": kind, "padCount": pads.len(), "pitchMm": pitch, "side": side, "escapeLayer": escape, "copperLayers": copper, "style": if kind == "bga" { style } else { "stubs" }, "requestedStyle": want, "dogboneRoomMm": room, "dogboneFits": fits, "via": {"size": via_size, "drill": via_drill}, "widthMm": width, "clearanceMm": clearance, "pads": rows, "calls": calls, "callCount": calls.len(), "viaCount": via_count,- "minViaToPadMm": if worst < f64::MAX { json!(r3(worst)) } else { Value::Null }, "conflicts": conflicts, "warnings": warnings, "revision": st["revision"],- "_hint": "A plan only: send each call to route_net with expectedRevision: revision. conflicts lists vias and stubs closer than clearanceMm to another net's pad on the board (vias on every layer, stubs on the part's side): drop or shorten those before routing. Tracks and pours are not checked: run DRC after. Via-in-pad needs filled and capped vias: tell the user first."})+ "minViaToPadMm": if worst < f64::MAX { json!(r3(worst)) } else { Value::Null }, "conflicts": conflicts, "crossesPours": crosses, "warnings": warnings, "revision": st["revision"],+ "_hint": "A plan only: send each call to route_net with expectedRevision: revision. conflicts lists vias and stubs closer than clearanceMm to another net's pad, track, arc or via (vias on every layer, stubs on the part's side): apply nothing while it is not empty. Pins already reached by their net's copper are skipped (includeRouted plans them anyway); the exposed-pad grid steps round holes already there. crossesPours names other nets' pours the plan runs into: repour BEFORE running DRC (until then the pour's pieces read as shorts), then check that none of them split (splitNets). Via-in-pad needs filled and capped vias: tell the user first."}) } #[cfg(test)]⋯ 39 unchanged lines ⋯ } #[test]+ fn a_cap_leaves_room_for_the_ics_fanout() {+ // a second ground pin, U1.5, just under the VDD pin: its fanout via lands where C1 would go (altium-lane)+ let mut b = board();+ b["pads"].as_array_mut().unwrap().push(pad("U1", "5", 2.0, 0.4, "GND"));+ let r = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}));+ assert!(f(&r["placements"][0]["slidMm"]).abs() > 0.0, "{r}");+ let close = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"], "leaveFanout": false}}));+ assert_eq!(close["placements"][0]["slidMm"], json!(0.0), "{close}");+ }++ #[test] fn a_cap_that_cannot_move_keeps_its_spot() { // C2 sits where C1 would go, and cannot be placed (its net is none of U1's): C1 slides past it (fusion-lane #22) let mut b = board();⋯ 104 unchanged lines ⋯ assert_eq!(r["pads"][0]["stubEnd"], json!([-2.8, 0.75])); assert_eq!(r["conflicts"].as_array().unwrap().iter().map(|c| c["pad"].as_str().unwrap()).collect::<Vec<_>>(), vec!["R1.1"], "{r}"); assert_eq!(plan_fanout(&json!({"board": b, "args": {"ref": "R1"}}))["errorCode"], "not_a_package");+ // on a routed board (altium-lane #22): U2.1 already has its track, so it is skipped; another net's track across+ // U2.2's stub is a conflict; a via in the exposed pad's centre keeps the grid off it+ let mut routed = b.clone();+ routed["tracks"] = json!([{"net": "S0", "layer": "F.Cu", "width": 0.2, "start": {"x": -2.0, "y": 0.75}, "end": {"x": -4.0, "y": 0.75}},+ {"net": "Q", "layer": "F.Cu", "width": 0.2, "start": {"x": -2.6, "y": -2.0}, "end": {"x": -2.6, "y": 0.4}}]);+ routed["vias"] = json!([{"net": "GND", "size": 0.6, "drill": 0.3, "position": {"x": 0.0, "y": 0.0}}]);+ let r = plan_fanout(&json!({"board": routed, "args": {"ref": "U2"}}));+ assert_eq!(r["pads"][0]["why"], "already routed", "{r}");+ assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["with"] == "track" && c["net"] == "Q" && c["from"] == "U2.2"), "{r}");+ let grid = r["pads"].as_array().unwrap().iter().find(|p| p["action"] == "thermal-via-grid").unwrap();+ assert!(grid["keptClearOfHoles"].as_u64().unwrap() >= 1, "{grid}");+ assert!(grid["vias"].as_array().unwrap().iter().all(|v| (f(&v[0]).powi(2) + f(&v[1]).powi(2)).sqrt() >= 0.3 + 0.3 + 0.1 - 1e-9));+ assert_eq!(plan_fanout(&json!({"board": routed, "args": {"ref": "U2", "includeRouted": true}}))["pads"][0]["action"], "surface"); assert_eq!(plan_fanout(&json!({"board": b, "args": {"ref": "U2", "escapeLayer": "In3.Cu"}}))["errorCode"], "no_such_layer"); } ⋯ 7 unchanged lines ⋯ assert_eq!(inner.gap(&a), 0.0); } }+@@ -1,407 +1,434 @@⋯ 145 unchanged lines ⋯ for l in &layers { if !copper.contains(l) { copper.push(l.clone()); } } // other nets' pours: not obstacles (they re-pour round the route), but a route across one can cut it in two, so the // reply names each one it crosses, and avoidPours keeps the route out of them- let avoid_pours = args["avoidPours"].as_bool().unwrap_or(false);+ // avoidPours: true keeps out of every other net's pour, a list of nets out of theirs only; pourCost makes each mm run+ // inside a pour cost 1 + pourCost mm instead, a preference rather than a wall (fusion-lane #21: on BQ25792 almost+ // every spot is under some pour, and a hard block found no path)+ let avoid_nets: Option<Vec<String>> = match &args["avoidPours"] {+ Value::Bool(true) => Some(Vec::new()),+ Value::Array(a) => Some(a.iter().filter_map(|v| v.as_str().map(|s| s.to_uppercase())).collect()),+ _ => None,+ };+ let avoids = |n: &str| avoid_nets.as_ref().map(|v| v.is_empty() || v.contains(&n.to_uppercase())).unwrap_or(false);+ let pour_cost = num("pourCost", 0.0).max(0.0); let pours: Vec<(String, String, Shape)> = st["polygons"].as_array().cloned().unwrap_or_default().iter() .filter(|p| p["net"].as_str().map(|n| !n.is_empty() && !n.eq_ignore_ascii_case(net)).unwrap_or(false)) .filter_map(|p| {⋯ 68 unchanged lines ⋯ edge_ok(a, a, r) && copper.iter().all(|l| clear_on(a, a, r, l)) && all_pads.iter().all(|s| s.dist(a, a) >= r + clearance - 1e-9) } else { edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[u.2])- && !(avoid_pours && pours.iter().any(|(_, l, s)| *l == layers[u.2] && s.dist(a, b) < width / 2.0))+ && !pours.iter().any(|(n, l, s)| avoids(n) && *l == layers[u.2] && s.dist(a, b) < width / 2.0) }; cache.insert(k, ok); ok⋯ 19 unchanged lines ⋯ if allow_vias { for l in 0..layers.len() { if l != u.2 { nb.push((u.0, u.1, l)); } } } for v in nb { if seen.contains(&v) || !step_ok(u, v) { continue; }- let step = if v.2 != u.2 { via_cost } else { (xs[v.0] - xs[u.0]).abs() + (ys[v.1] - ys[u.1]).abs() };+ let step = if v.2 != u.2 { via_cost } else {+ let (a, b) = ((xs[u.0], ys[u.1]), (xs[v.0], ys[v.1]));+ let len = (b.0 - a.0).abs() + (b.1 - a.1).abs();+ if pour_cost > 0.0 && pours.iter().any(|(_, l, s)| *l == layers[u.2] && s.dist(a, b) < width / 2.0) { len * (1.0 + pour_cost) } else { len }+ }; let ng = g + step; if ng >= cost.get(&v).copied().unwrap_or(f64::MAX) - 1e-9 { continue; } cost.insert(v, ng);⋯ 38 unchanged lines ⋯ 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*", "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "viasCheckedOn": copper, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},- "crossesPours": crosses, "avoidPours": avoid_pours, "revision": revision,+ "crossesPours": crosses, "avoidPours": args["avoidPours"].clone(), "pourCost": pour_cost, "revision": revision, "_hint": if crosses.is_empty() { "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." }- else { "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). The route runs through the pours in crossesPours: after the repour, check that none of those nets split in two (routing_state's splitNets), or plan again with avoidPours: true to keep out of them. A layer change is two waypoints at the same point, where route_net places a via." }})+ else { "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). The route runs through the pours in crossesPours: after the repour, check that none of those nets split in two (routing_state's splitNets), or plan again keeping out of them: avoidPours: true (or a list of the nets whose pours to keep out of), or pourCost: 2 to prefer going round them where a way exists. A layer change is two waypoints at the same point, where route_net places a via." }}) } #[cfg(test)]⋯ 102 unchanged lines ⋯ assert_eq!((r["vias"].as_u64(), r["crossesPours"].clone()), (Some(0), json!([{"net": "GND", "layer": "F.Cu"}])), "{r}"); let around = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}})); assert_eq!((around["vias"].as_u64(), around["crossesPours"].clone()), (Some(2), json!([])), "{around}");+ // by net: avoiding another net's pours only leaves this one crossable+ assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["VBAT"]}}))["vias"], json!(0));+ assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["gnd"]}}))["vias"], json!(2));+ // a pour over the whole board: a hard block finds no path, a cost still routes (across it)+ let mut all = board(json!([]));+ all["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}, {"net": "GND", "layer": "B.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}]);+ assert_eq!(plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}))["found"], json!(false));+ assert_eq!(plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 2.0}}))["found"], json!(true));+ // and with the cost a short way round a narrow pour beats crossing it+ let mut strip = board(json!([]));+ strip["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[9.0, 2.0], [11.0, 2.0], [11.0, 10.0], [9.0, 10.0]]}]);+ let soft = plan_route(&json!({"board": strip, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 10.0, "allowVias": false}}));+ assert_eq!(soft["crossesPours"], json!([]), "{soft}"); } }+@@ -1,1697 +1,1698 @@⋯ 1258 unchanged lines ⋯ cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2 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) and isinstance(r.get("conflicts"), list), "no fanout plan, or no conflicts list")- 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("plan_fanout", {"filePath": L2, "ref": "U5"}); rec("plan_fanout", r, ok(r) and isinstance(r.get("conflicts"), list) and isinstance(r.get("crossesPours"), list) and str(r.get("engine") or "").startswith("adom-eda-checks") and all(c.get("expectedRevision") for c in r.get("calls") or []), "no engine fanout plan with conflicts, crossesPours and route_net-ready calls")+ 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) and r.get("leaveFanout") is True, "no plan, or no leftOut / leaveFanout (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))⋯ 428 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+
Comments
Log in to comment.
No comments yet.