← Commit history
README.md+9−2
@@ -62,10 +62,10 @@ verb by verb: [docs/coverage.md](docs/coverage.md). |---|---|---|---|---| | **core** | 93 | 93 | 90 | 79 | | **native** | 46 | 46 | 43 | 29 |-| **checks** | 37 | 33 | 35 | 15 |+| **checks** | 37 | 33 | 35 | 18 | | **app** | 60 | 60 | 36 | 37 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 236 | 232 | 204 | 160 |+| standard (all but platform) | 236 | 232 | 204 | 163 | <!-- coverage:end -->  The **checks** tier is where the other bridges are furthest behind, and it is the cheapest to close: those verbs are@@ -158,6 +158,13 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.13.0: the engine (0.10.0) fixes what fusion-lane found placing plans on a real board (#21, #22):+- plan_route checks a through via on every copper layer, not only the planned ones.+- It keeps the strictest board-wide clearance rule, not the smallest.+- It names other nets' pours a route crosses (`crossesPours`), and can avoid them (`avoidPours`).+- check_clearance lists stacked parts apart, as placement_check does.+- A cap place_decoupling cannot move keeps its spot, and `icPowerNets` lists supplies only.+ Version 0.12.0: the engine (0.9.0) adds `estimate_cost` from the KiCad bridge's fab.rs, without its hard-coded rates: prices come in as a record read from the fab's own page with its date (`prices`), and without one the reply is the board's numbers. On the Bluetooth Sentinel those numbers match the Altium bridge's own fab_check.
docs/coverage.md+7−7
@@ -1,6 +1,6 @@ # Coverage: every standard verb on each bridge -Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.74, Fusion 360 bridge 1.13.4. 54 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.+Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.75, Fusion 360 bridge 1.13.5. 54 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*. `exact` is the same job under the same or an equivalent name; `partial` does part of it or does it differently (`verbs/coverage.json` names what in `note`); `unsupported` answers `not_supported` for the whole job and is not counted; blank is missing. Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand. @@ -8,10 +8,10 @@ Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand |---|---|---|---|---| | **core** | 93 | 93 | 90 | 79 | | **native** | 46 | 46 | 43 | 29 |-| **checks** | 37 | 33 | 35 | 15 |+| **checks** | 37 | 33 | 35 | 18 | | **app** | 60 | 60 | 36 | 37 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 236 | 232 | 204 | 160 |+| standard (all but platform) | 236 | 232 | 204 | 163 |  ## core @@ -180,7 +180,7 @@ Engineering computed from the board model, not by the tool: one shared engine, s | `build_board` | Build a NEW board from a parts list and a netlist: outline (rounded corners allowed), every footprint from one PcbLib placed by its own origin, pad nets... | `altium_build_board` | `kicad_build_board` |  | | `build_footprint` | Build a complete footprint (pads, silkscreen, courtyard, arcs) in a PcbLib from a kicad2altium spec file on this machine, and return a proof frame. | `altium_build_footprint` |  |  | | `build_schematic` | Draw a design's schematic from its netlist with the project library's symbols, inside a new project (.PrjPcb with the board and libraries), compile it, and... | `altium_build_schematic` | `kicad_build_schematic` | `fusion_schematic_plan / fusion_schematic_run` (partial) |-| `check_clearance` *proposed* | Measure the gaps between parts, courtyard to courtyard or pad copper to pad copper, for the listed parts or the whole board, and report every pair under a... | `altium_check_clearance` | `kicad_check_clearance` |  |+| `check_clearance` *proposed* | Measure the gaps between parts, courtyard to courtyard or pad copper to pad copper, for the listed parts or the whole board, and report every pair under a... | `altium_check_clearance` | `kicad_check_clearance` | `fusion_check_clearance` | | `check_orientation` | Advisory: 2-pad parts whose rotation could be better. Each orthogonal rotation is scored in place by airline length (a routable net's MST with the pad... | `altium_check_orientation` | `kicad_check_orientation` |  | | `copper_balance` | How much of each copper layer is copper (tracks, pads, vias and what Altium actually poured, inside the outline; a plane counts full), and how far apart... | `altium_copper_balance` | `kicad_copper_balance` | `fusion_copper_balance` | | `cross_check` | Compare one schematic sheet's designators with a board's. Altium holds ONE current document, so the verb activates the board, reads it, then activates the... | `altium_cross_check` | `kicad_analyze_connections` (partial) | `fusion_cross_check` |@@ -196,10 +196,10 @@ Engineering computed from the board model, not by the tool: one shared engine, s | `length_tune` | Lengthen a routed net to a target length, or to match another net (a pair's skew, a DDR byte lane): a serpentine in its longest straight track that has room... | `altium_length_tune` | `kicad_length_tune` |  | | `net_lengths` | Routed length of every net (or the ones named) on the live board, by layer with its vias; the skew inside each differential pair; and the spread of... | `altium_net_lengths` | `kicad_net_lengths` |  | | `pin_pad_check` | Do the footprints fit their symbols? Part by part, the symbol's pin numbers (from the sheet files) against the footprint's pad names (from the board): pins... | `altium_pin_pad_check` | `kicad_pin_pad_check` |  |-| `place_decoupling` *proposed* | Plan positions for decoupling capacitors beside an IC's power pins (each cap just outside the courtyard, its power pad facing the pin), answered as... |  | `kicad_place_decoupling` |  |+| `place_decoupling` *proposed* | Plan positions for decoupling capacitors beside an IC's power pins (each cap just outside the courtyard, its power pad facing the pin), answered as... |  | `kicad_place_decoupling` | `fusion_place_decoupling` | | `placement_check` | Review the placement before routing: parts whose courtyards (pads and body, grown by a margin) overlap on the same side, parts off the board or over its... | `altium_placement_check` | `kicad_placement_check` | `fusion_placement_check` |-| `plan_fanout` *proposed* | Plan the fanout of a BGA or QFN/QFP: the pad pitch, dogbone or via-in-pad per pad, the escape layer, surface stubs for the outer ring and a via grid for an... |  | `kicad_plan_fanout` |  |-| `plan_route` *proposed* | Plan a path for one net between two pads without changing the board: a grid search around the existing copper that keeps the clearance rules, on the allowed... |  | `kicad_plan_route` | `fusion_plan_route` (partial) |+| `plan_fanout` *proposed* | Plan the fanout of a BGA or QFN/QFP: the pad pitch, dogbone or via-in-pad per pad, the escape layer, surface stubs for the outer ring and a via grid for an... |  | `kicad_plan_fanout` | `fusion_plan_fanout` |+| `plan_route` *proposed* | Plan a path for one net between two pads without changing the board: a grid search around the existing copper that keeps the clearance rules, on the allowed... |  | `kicad_plan_route` | `fusion_plan_route` | | `read_sheet_file` | Read a schematic sheet straight from its .SchDoc file, without Altium (it need not be installed or running): every part with its designator, library... | `altium_read_sheet_file` | `kicad_read_sheet_file` | `fusion_read_sheet_file` | | `return_path` | Where signal tracks lose their reference: for every signal net, the length of track on each layer that runs over no pour or plane copper on either... | `altium_return_path` | `kicad_return_path` | `fusion_return_path` | | `review_board` | Design-review checks (14) over one board read; schematic checks with includeSchematic. | `altium_review_board` | `kicad_lint_board` (partial) |  |
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.9.0"+version = "0.10.0" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.9.0"+version = "0.10.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
engine/README.md+4−4
@@ -11,7 +11,7 @@ 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 |+| `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. A part under a raised one (pads apart, bodies apart in 3D) is listed in `stacked`, not `pairs`, as placement_check lists it. 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`) | | `estimate_cost` | board, stack?, rules?, prices? | the numbers a fab quote needs (size, copper layers, thickness from the stack, smallest track, gap from the clearance rule, smallest drill, holes, SMD and through-hole parts and joints, unique parts, assembled sides) and, with a `prices` record, a rough table at `quantities` (default 5, 10, 30, 100), with assembly when asked, every assumption stated and the record named in `source`. The engine carries no rates: without `prices` the reply is the numbers only. From the KiCad bridge's fab.rs |@@ -19,10 +19,10 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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 |+| `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 | | `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 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 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 | | `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 | @@ -95,7 +95,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.9.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.10.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/fab.rs+28−10
@@ -135,27 +135,45 @@ fn length_mm(text: &str) -> Option<f64> { /// A clearance rule that names another object than copper of another net: the board edge, solder mask, silkscreen, /// holes, keepouts, the courtyard, or the same net (Fusion's Solder-Mask Clearance is a mask expansion, often 0.05 mm). const NOT_COPPER_CLEARANCE: &[&str] = &["dimension", "edge", "board", "mask", "silk", "paste", "hole", "restrict", "keepout", "keep-out",-                                        "courtyard", "component", "body", "same net", "samenet", "same-net"];+                                        "courtyard", "component", "body", "plane", "same net", "samenet", "same-net"];  /// The smallest copper-to-copper clearance among the board's enabled rules, in mm. A rule gives it as a number /// (`clearanceMm`, `gapMm` or `minMm`), as a `value` with its unit ("0.1mm": Fusion's form), or in a descriptor the tool /// wrote ("Gap=10mil", "Gap=0.2mm": Altium's form). Clearance rules about anything but copper of another net are left out. pub fn min_clearance(rules: &[Value]) -> Option<f64> {+    clearances(rules).into_iter().map(|c| c.0).reduce(f64::min).map(r3)+}++/// The copper clearance a route or a via must keep: the strictest board-wide copper rule (a pad-to-via 0.15 beside a+/// wire-to-wire 0.1 means 0.15 near a pad), so a plan passes the board's DRC. A rule scoped to some nets only (a high+/// voltage class) is left out unless no board-wide rule exists, then the smallest scoped one counts (fusion-lane #21).+pub fn routing_clearance(rules: &[Value]) -> Option<f64> {+    let all = clearances(rules);+    all.iter().filter(|c| c.1).map(|c| c.0).reduce(f64::max).or_else(|| all.iter().map(|c| c.0).reduce(f64::min)).map(r3)+}++/// Every enabled copper-to-copper clearance rule as (mm, board-wide).+fn clearances(rules: &[Value]) -> Vec<(f64, bool)> {     let text = |r: &Value, k: &str| r[k].as_str().map(|s| s.to_lowercase()).unwrap_or_default();+    // board-wide: no scope, or Altium's All on both sides+    let wide = |r: &Value| ["scope1", "scope2", "scope"].iter().all(|k| r[*k].as_str().map(|s| s.is_empty() || s.eq_ignore_ascii_case("all")).unwrap_or(true));     rules.iter().filter(|r| r["kind"].as_i64() == Some(0) || text(r, "kind").contains("clearance")             || r["kindName"].as_str() == Some("Clearance") || text(r, "name").contains("clearance"))         .filter(|r| !["kind", "name", "kindName"].iter().any(|k| { let t = text(r, k); NOT_COPPER_CLEARANCE.iter().any(|w| t.contains(w)) }))         .filter(|r| r["enabled"].as_bool() != Some(false))         .filter_map(|r| {-            if let Some(v) = ["clearanceMm", "gapMm", "minMm"].iter().find_map(|k| r[*k].as_f64()) { return Some(v); }-            if let Some(v) = r["value"].as_str().and_then(length_mm) { return Some(v); }-            let d = r["descriptor"].as_str()?;-            let i = d.find("Gap=")? + 4;-            let num: String = d[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();-            let v: f64 = num.parse().ok()?;-            let unit = &d[i + num.len()..];-            Some(if unit.starts_with("mil") { v * 0.0254 } else { v })-        }).reduce(f64::min).map(r3)+            let v = if let Some(v) = ["clearanceMm", "gapMm", "minMm"].iter().find_map(|k| r[*k].as_f64()) { v }+            else if let Some(v) = r["value"].as_str().and_then(length_mm) { v }+            else {+                let d = r["descriptor"].as_str()?;+                let i = d.find("Gap=")? + 4;+                let num: String = d[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();+                let v: f64 = num.parse().ok()?;+                let unit = &d[i + num.len()..];+                if unit.starts_with("mil") { v * 0.0254 } else { v }+            };+            Some((v, wide(r)))+        }).collect() }  /// A pcb-design-rules record (one entry of `rules/records/<vendor>.json`'s `records`, with the file's `vendor`) as the
engine/src/placement.rs+18−14
@@ -97,6 +97,23 @@ pub fn review(boxes: &BTreeMap<String, (bool, Rect)>, pads: &[Value], ring: &[(f     json!({"parts": boxes.len(), "ok": ok, "courtyardOverlaps": overlaps, "offOrOverEdge": off, "onMountingHoles": on_holes, "nearEdge": near, "marginMm": margin, "edgeMm": edge}) } +/// Whether two parts that overlap in plan view are a stack (a part under a raised one: an Adom molecule on pins, a+/// connector's overhang): their pads stay `margin` apart and no body of one meets a body of the other in 3D. A part with+/// no body cannot be shown stacked. Pure.+pub fn is_stacked(pads: &[Value], bs: &[crate::bodies::BodyBox], a: &str, b: &str, margin: f64) -> bool {+    // each pad's own box, turned with it: a molecule's pins span the part under it, so the union of its pads would too+    let pad_rects = |r: &str| -> Vec<Rect> { pads.iter().filter(|p| p["ref"].as_str() == Some(r)).map(|p| {+        let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+        let (hx, hy) = ((f(&p["sizeX"]) * c + f(&p["sizeY"]) * s) / 2.0, (f(&p["sizeX"]) * s + f(&p["sizeY"]) * c) / 2.0);+        (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy)+    }).collect() };+    let (pa, pb) = (pad_rects(a), pad_rects(b));+    if pa.is_empty() || pb.is_empty() || pa.iter().any(|x| pb.iter().any(|y| hit(grow(*x, margin), grow(*y, margin)))) { return false; }+    let (ba, bb): (Vec<_>, Vec<_>) = (bs.iter().filter(|x| x.part == a).collect(), bs.iter().filter(|x| x.part == b).collect());+    if ba.is_empty() || bb.is_empty() { return false; }+    !ba.iter().any(|p| bb.iter().any(|q| hit(grow((p.x0, p.y0, p.x1, p.y1), margin), grow((q.x0, q.y0, q.x1, q.y1), margin)) && p.z0.max(q.z0) < p.z1.min(q.z1)))+}+ /// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads /// only, and the reply says so (`bodiesUsed: false`). pub fn placement_check(input: &Value) -> Value {@@ -122,23 +139,10 @@ pub fn placement_check(input: &Value) -> Value {     // their pads stay clear and no body of one meets a body of the other in 3D, it is a stack, listed apart, not an     // overlap (fusion-lane #19). A part with no body cannot be shown stacked, so its overlaps stand.     if let Some(bs) = &given {-        // each pad's own box, turned with it: a molecule's pins span the part under it, so the union of its pads would too-        let pad_rects = |r: &str| -> Vec<Rect> { pads.iter().filter(|p| p["ref"].as_str() == Some(r)).map(|p| {-            let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());-            let (hx, hy) = ((f(&p["sizeX"]) * c + f(&p["sizeY"]) * s) / 2.0, (f(&p["sizeX"]) * s + f(&p["sizeY"]) * c) / 2.0);-            (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy)-        }).collect() };-        let meets = |a: &str, b: &str| {-            let (ba, bb): (Vec<_>, Vec<_>) = (bs.iter().filter(|x| x.part == a).collect(), bs.iter().filter(|x| x.part == b).collect());-            if ba.is_empty() || bb.is_empty() { return true; }-            ba.iter().any(|p| bb.iter().any(|q| hit(grow((p.x0, p.y0, p.x1, p.y1), margin), grow((q.x0, q.y0, q.x1, q.y1), margin)) && p.z0.max(q.z0) < p.z1.min(q.z1)))-        };         let (mut real, mut stacked) = (Vec::new(), Vec::new());         for o in out["courtyardOverlaps"].as_array().cloned().unwrap_or_default() {             let (a, b) = (o["parts"][0].as_str().unwrap_or(""), o["parts"][1].as_str().unwrap_or(""));-            let (pa, pb) = (pad_rects(a), pad_rects(b));-            let pads_meet = pa.is_empty() || pb.is_empty() || pa.iter().any(|x| pb.iter().any(|y| hit(grow(*x, margin), grow(*y, margin))));-            if pads_meet || meets(a, b) { real.push(o) } else { stacked.push(o) }+            if is_stacked(&pads, bs, a, b, margin) { stacked.push(o) } else { real.push(o) }         }         if !stacked.is_empty() {             out["ok"] = json!(real.is_empty() && out["offOrOverEdge"].as_array().map(|a| a.is_empty()).unwrap_or(true) && out["onMountingHoles"].as_array().map(|a| a.is_empty()).unwrap_or(true));
engine/src/placetools.rs+108−67
@@ -121,8 +121,11 @@ pub fn check_clearance(input: &Value) -> Value {     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;+    let mut stacked = Vec::new();     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();+        let bodies = crate::bodies::input_boxes(input).unwrap_or_default();+        let pads = st["pads"].as_array().cloned().unwrap_or_default();         for i in 0..boxes.len() {             for j in i + 1..boxes.len() {                 let ((ra, sa, a), (rb, sb, b)) = (boxes[i], boxes[j]);@@ -131,7 +134,9 @@ pub fn check_clearance(input: &Value) -> Value {                 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}));+                    let row = json!({"a": x, "b": y, "side": if sa { "bottom" } else { "top" }, "distanceMm": r3(d), "overlapping": d == 0.0});+                    // a part under a raised one (pads apart, no bodies meeting in 3D) is a stack, as placement_check says (fusion-lane #22)+                    if crate::placement::is_stacked(&pads, &bodies, ra, rb, threshold / 2.0) { stacked.push(row) } else { pairs.push(row) }                 }             }         }@@ -165,7 +170,7 @@ pub fn check_clearance(input: &Value) -> Value {     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,+        "pairs": pairs, "truncated": total > limit, "clean": total == 0, "stacked": stacked,         "_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." }}) }@@ -199,24 +204,28 @@ pub fn place_decoupling(input: &Value) -> Value {     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 mut ic_nets: Vec<String> = ic.pads.iter().map(net).filter(|n| !n.is_empty() && !is_ground(n)).collect();+    ic_nets.sort();+    ic_nets.dedup();     let roles: BTreeMap<&String, (Value, String)> = m.iter().filter_map(|(r, p)| Some((r, decoupling_role(r, p)?))).collect();+    // 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; }+    let supply = |n: &str| power_name(n) || across.get(n).copied().unwrap_or(0) >= 2;+    let supplies: Vec<String> = ic_nets.iter().filter(|n| supply(n)).cloned().collect();+    // caps given by name may sit on any of the IC's nets+    let power = if args["caps"].is_array() { ic_nets.clone() } else { supplies.clone() };     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 {+                if !ic_nets.contains(n) { continue; }+                if !supply(n) {                     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;                 }@@ -232,68 +241,77 @@ pub fn place_decoupling(input: &Value) -> Value {     }     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"]))); }+        return json!({"success": false, "errorCode": "no_caps", "error": format!("no capacitor across ground and a supply of {ic_ref}: pass caps:[...]"), "icPowerNets": supplies, "leftOut": left_out});     }+    // a cap the plan cannot place stays where it is, so its spot stays taken: plan again with it fixed until none is new+    // (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));-    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 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 {+        let moving: Vec<String> = cap_refs.iter().map(|r| r.to_uppercase()).filter(|r| !stay.contains(r)).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 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; }+        (used, rows, moves, skipped) = (Vec::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 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 new: Vec<String> = skipped.iter().filter_map(|x| x["cap"].as_str()).filter(|c| find(&m, c).is_some()).map(|c| c.to_uppercase()).filter(|c| !stay.contains(c)).collect();+        if new.is_empty() { break; }+        stay.extend(new);     }     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,+    json!({"success": true, "ic": ic_ref, "icPowerNets": supplies, "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."})@@ -339,7 +357,7 @@ fn median(mut v: Vec<f64>) -> Option<f64> { }  /// The board's copper layers, top to bottom: from the stack's count when given, else the layers its copper uses.-fn copper_layers(st: &Value, input: &Value) -> Vec<String> {+pub(crate) fn copper_layers(st: &Value, input: &Value) -> Vec<String> {     if let Some(n) = crate::input::stack(input).and_then(|s| s["copperLayers"].as_u64()).filter(|n| *n >= 2) {         let mut v = vec!["F.Cu".to_string()];         v.extend((1..n - 1).map(|i| format!("In{i}.Cu")));@@ -396,7 +414,7 @@ pub fn plan_fanout(input: &Value) -> Value {     let via_drill = args["viaDrillMm"].as_f64().unwrap_or(if via_size <= 0.45 { 0.2 } else { 0.3 });     if via_drill >= via_size { return crate::input::refuse("bad_args", "viaDrillMm must be smaller than viaMm"); }     let width = args["widthMm"].as_f64().unwrap_or(if fine { 0.1 } else { 0.15 });-    let rule = crate::fab::min_clearance(&crate::input::rules(input));+    let rule = crate::fab::routing_clearance(&crate::input::rules(input));     let clearance = args["clearanceMm"].as_f64().unwrap_or(0.0).max(rule.unwrap_or(0.1));     let pad_d = median(signal.iter().map(|&i| f(&pads[i]["sizeX"]).max(f(&pads[i]["sizeY"]))).collect()).unwrap_or(0.0);     let room = r3(pitch / 2f64.sqrt() - pad_d / 2.0 - via_size / 2.0);@@ -543,6 +561,21 @@ mod tests {         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}");+        // U1's supplies only: XIN has one cap and is not named like a supply (fusion-lane #22)+        assert_eq!(r["icPowerNets"], json!(["VDD"]));+    }++    #[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();+        b["components"][3]["x"] = json!(3.4); b["components"][3]["y"] = json!(1.0);+        b["pads"][9] = pad("C2", "1", 2.9, 1.0, "OTHER");+        b["pads"][10] = pad("C2", "2", 3.9, 1.0, "GND");+        let r = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1", "C2"]}}));+        assert_eq!(r["skipped"][0]["why"], "no_shared_power_net", "{r}");+        assert!(f(&r["placements"][0]["slidMm"]).abs() > 0.0, "{r}");+        assert_eq!(r["validation"]["ok"], json!(true), "{r}");     }      #[test]@@ -588,6 +621,14 @@ mod tests {         // 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");+        // a part under a raised one: pads apart, bodies apart in height, listed as stacked like placement_check does+        let st = json!({"components": [{"ref": "M1", "x": 0, "y": 0, "rotation": 0, "side": "top"}, {"ref": "R1", "x": 0, "y": 0, "rotation": 0, "side": "top"}],+            "pads": [pad("M1", "1", -3.0, 0.0, "A"), pad("M1", "2", 3.0, 0.0, "B"), pad("R1", "1", -0.5, 0.0, "C"), pad("R1", "2", 0.5, 0.0, "D")]});+        let bodies = json!({"bodies": [{"ref": "M1", "side": "top", "x0": -3.5, "y0": -2.0, "x1": 3.5, "y1": 2.0, "z0": 2.0, "z1": 3.0},+                                       {"ref": "R1", "side": "top", "x0": -0.8, "y0": -0.4, "x1": 0.8, "y1": 0.4, "z0": 0.0, "z1": 0.5}]});+        let c = check_clearance(&json!({"board": st, "bodies": bodies, "args": {}}));+        assert_eq!((c["pairs"].as_array().unwrap().len(), c["stacked"].as_array().unwrap().len()), (0, 1), "{c}");+        assert_eq!(check_clearance(&json!({"board": st, "args": {}}))["pairs"].as_array().unwrap().len(), 1);         assert_eq!(check_clearance(&json!({"board": board(), "args": {"mode": "x"}}))["errorCode"], "bad_args");     } 
engine/src/route.rs+63−9
@@ -132,20 +132,34 @@ pub fn plan_route(input: &Value) -> Value {     let allow_vias = args["allowVias"].as_bool().unwrap_or(true) && layers.len() > 1;     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(&rules);+    // the board's strictest board-wide copper rule decides (fusion-lane #21: a via planned at the 0.1 wire rule landed+    // 0.109 mm from a pad where 0.15 applies); a caller's clearanceMm can only raise it+    let rule = crate::fab::routing_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);     if ring.len() < 3 { return crate::input::refuse("no_outline", "the board has no outline"); } +    // a through via passes every copper layer of the board, not only the ones the route may use (fusion-lane #21: a via+    // planned on F.Cu and In2.Cu landed 0.094 mm from a B.Cu track)+    let mut copper = crate::placetools::copper_layers(&st, input);+    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);+    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| {+            let ring: Vec<P> = p["points"].as_array()?.iter().map(|q| (f(&q[0]), f(&q[1]))).collect();+            (ring.len() >= 3).then(|| (p["net"].as_str().unwrap_or("").to_string(), p["layer"].as_str().unwrap_or("").to_string(), Shape::Poly(ring)))+        }).collect();     // copper of other nets, by layer; every pad for the via-in-pad rule. Net-less tracks and arcs are a hatched pour's     // pieces (Altium lists 8777 of the Bluetooth Sentinel's 9378 tracks with no net) and pours re-pour round new copper,     // so neither blocks a route: repour after placing it (the reply says so)     let same = |v: &Value| v["net"].as_str().map(|n| n.eq_ignore_ascii_case(net) || (n.is_empty() && v.get("start").or(v.get("center")).is_some())).unwrap_or(false);     let mut ob: Vec<(Shape, Vec<String>, (f64, f64, f64, f64))> = Vec::new();     let mut push = |s: Shape, ls: Vec<String>| { let b = s.bounds(); ob.push((s, ls, b)); };-    for p in pads.iter().filter(|p| !same(p)) { push(pad_shape(p), pad_layers(p, &layers)); }+    for p in pads.iter().filter(|p| !same(p)) { push(pad_shape(p), pad_layers(p, &copper)); }     for t in st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| !same(t)) {         push(Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0), vec![t["layer"].as_str().unwrap_or("").to_string()]);     }@@ -159,7 +173,7 @@ pub fn plan_route(input: &Value) -> Value {     }     for v in st["vias"].as_array().cloned().unwrap_or_default().iter().filter(|v| !same(v)) {         let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));-        push(Shape::Cap(c, c, f(&v["size"]) / 2.0), layers.clone());+        push(Shape::Cap(c, c, f(&v["size"]) / 2.0), copper.clone());     }     let all_pads: Vec<Shape> = pads.iter().map(pad_shape).collect(); @@ -204,9 +218,10 @@ pub fn plan_route(input: &Value) -> Value {         let ok = if u.2 != v.2 {             // a through via: clear on every layer, inside the outline, in no pad             let r = via_size / 2.0;-            edge_ok(a, a, r) && layers.iter().all(|l| clear_on(a, a, r, l)) && all_pads.iter().all(|s| s.dist(a, a) >= r + clearance - 1e-9)+            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))         };         cache.insert(k, ok);         ok@@ -259,20 +274,27 @@ pub fn plan_route(input: &Value) -> Value {     let mut way = vec![json!({"pad": key(&ends[0])})];     let mut vias = Vec::new();     let mut length = 0.0;+    let mut crosses: Vec<Value> = Vec::new();     let last = keep.len() - 1;     for (i, w) in keep.windows(2).enumerate() {         let (a, b) = (w[0], w[1]);         let (pa, pb) = ((xs[a.0], ys[a.1]), (xs[b.0], ys[b.1]));-        if a.2 != b.2 { vias.push(json!([pa.0, pa.1])); } else { length += ((pb.0 - pa.0).powi(2) + (pb.1 - pa.1).powi(2)).sqrt(); }+        if a.2 != b.2 { vias.push(json!([pa.0, pa.1])); } else {+            length += ((pb.0 - pa.0).powi(2) + (pb.1 - pa.1).powi(2)).sqrt();+            for (n, l, s) in &pours {+                if *l == layers[a.2] && s.dist(pa, pb) < width / 2.0 && !crosses.iter().any(|c: &Value| c["net"] == json!(n) && c["layer"] == json!(l)) { crosses.push(json!({"net": n, "layer": l})); }+            }+        }         // every corner and layer change; the goal itself is the closing {pad} waypoint         if i + 1 < last { way.push(json!({"x": pb.0, "y": pb.1, "layer": layers[b.2]})); }     }     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*", "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."})+        "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,+        "_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." }}) }  #[cfg(test)]@@ -350,4 +372,36 @@ mod tests {         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");     }+    #[test]+    fn a_through_via_clears_every_copper_layer_not_only_the_planned_ones() {+        // a wall on F.Cu forces two vias to In1.Cu; a GND track on B.Cu along y 5 is on no planned layer, but every via+        // passes through B.Cu (fusion-lane #21)+        let t = json!([{"net": "GND", "layer": "F.Cu", "width": 0.5, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}},+                       {"net": "GND", "layer": "B.Cu", "width": 0.5, "start": {"x": 1.0, "y": 5.0}, "end": {"x": 19.0, "y": 5.0}}]);+        let r = plan_route(&json!({"board": board(t), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "layers": ["F.Cu", "In1.Cu"]}}));+        assert_eq!(r["found"], json!(true), "{r}");+        assert_eq!(r["vias"], 2);+        for v in r["viaAt"].as_array().unwrap() { assert!((f(&v[1]) - 5.0).abs() >= 0.25 + 0.3 + 0.2 - 1e-9, "{v}"); }+        assert!(r["planner"]["viasCheckedOn"].as_array().unwrap().contains(&json!("B.Cu")));+    }++    #[test]+    fn the_strictest_board_wide_clearance_rule_decides() {+        let rules = json!([{"kind": "clearance", "name": "wire-wire", "clearanceMm": 0.1}, {"kind": "clearance", "name": "pad-via", "clearanceMm": 0.15},+                           {"name": "Clearance HV", "scope1": "InNet('HV')", "scope2": "All", "descriptor": "Clearance Constraint (Gap=0.5mm) (InNet('HV')),(All)"}]);+        let r = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!(r["planner"]["clearanceMm"], json!(0.15), "{r}");+        // fab_check still compares the smallest with a fab's limits+        assert_eq!(crate::fab::min_clearance(rules.as_array().unwrap()), Some(0.1));+    }++    #[test]+    fn a_route_names_the_pours_it_crosses_or_keeps_out_of_them() {+        let mut b = board(json!([]));+        b["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[8.0, 0.0], [12.0, 0.0], [12.0, 10.0], [8.0, 10.0]]}]);+        let r = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        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}");+    } }
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.12.0",+  "version": "0.13.0",   "type": "skill",   "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool",   "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
verbs/coverage.json+16−8
@@ -1,13 +1,13 @@ {- "standard": "0.12.0",+ "standard": "0.13.0",  "reference": {   "bridge": "altium",   "version": "0.2.79"  },  "proposed": 54,  "compared": {-  "kicad": "1.0.74",-  "fusion360": "1.13.4"+  "kicad": "1.0.75",+  "fusion360": "1.13.5"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",@@ -2432,7 +2432,10 @@     "verb": "kicad_check_clearance",     "match": "exact"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_check_clearance",+    "match": "exact"+   }   },   {    "name": "check_orientation",@@ -2665,7 +2668,10 @@     "verb": "kicad_place_decoupling",     "match": "exact"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_place_decoupling",+    "match": "exact"+   }   },   {    "name": "placement_check",@@ -2693,7 +2699,10 @@     "verb": "kicad_plan_fanout",     "match": "exact"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_plan_fanout",+    "match": "exact"+   }   },   {    "name": "plan_route",@@ -2706,8 +2715,7 @@    },    "fusion360": {     "verb": "fusion_plan_route",-    "match": "partial",-    "note": "two-layer boards only: a four-layer board is refused with unsupported_board"+    "match": "exact"    }   },   {
verbs/standard.json+3−3
@@ -1,13 +1,13 @@ {- "standard": "0.12.0",+ "standard": "0.13.0",  "reference": {   "bridge": "altium",   "version": "0.2.79"  },  "proposed": 54,  "compared": {-  "kicad": "1.0.74",-  "fusion360": "1.13.4"+  "kicad": "1.0.75",+  "fusion360": "1.13.5"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",