← All Pull Requests

kicad-lane: 1.0.76, fanout conflicts, engine 0.10.0, tour template (#136) #37

Merged opened by Colby Knox 2026-10-28

kicad-lane: 1.0.76.

  • kicad_plan_fanout conflicts (std #22). fanout_conflicts walks the planned calls: a stub between two waypoints, or a via where two waypoints share a point on different layers. Each is measured against every other net's pad on the board by its real outline (pad_shapes), vias on any layer and stubs on their own layer or through-hole. Anything closer than the clearance is listed and also raises a warning.
    • Unit test on the ESC board: a stub and a via of another net across R12.1 conflict, R12.1's own net never does, and a B.Cu stub misses the F.Cu pad.
  • kicad_place_decoupling. A cap the plan does not move keeps its courtyard as an obstacle: a no_free_spot cap, and a cap on no shared power net.
  • Engine: adom-eda-checks 0.10.0 (standard 0.13.0, a466bab), vendored unchanged.
    • plan_route gets fusion-lane's fixes (#21): crossesPours/avoidPours (pours come from the board model's polygons), through vias on every copper layer, and the strictest clearance rule.
    • kicad_plan_route's input and hint now carry them.
  • wiki #136. tour-pack/template is rebuilt by an ignored test (write_tour_demo_template) with the crate's own schematic and board writers: one AdomTour:R_AdomTour (R1, 10k) at (127, 81.28), a 30x20 board with its footprint, and a project.
    • KiCad 10.0.7's ERC/DRC parse both; the only findings are the unconnected resistor and the AdomTour library, which the tour installs.
    • schematic_edit.rs is re-pinned to the new file: root uuid, version 20260306, R1's uuid, and the field positions after a move.
    • All kicad-core tests pass: 665/0, where 7 failed before.
  • Battery: plan_fanout must return conflicts; plan_route must return crossesPours.

Full battery on DESKTOP-BAR6S0F (KiCad 10.0.7): 304/0/1.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯  [[package]] name = "adom-eda-checks"-version = "0.7.0"+version = "0.10.0" dependencies = [  "roxmltree",  "serde_json",⋯ 302 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.75"+version = "1.0.76" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.75"+version = "1.0.76" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.75"+version = "1.0.76" dependencies = [  "serde",  "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+--- a/rust/Cargo.toml+++ b/rust/Cargo.toml@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"]  [workspace.package]-version = "1.0.75"+version = "1.0.76" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/rust/crates/kicad-bridge/src/verbs_checks_native.rs+++ b/rust/crates/kicad-bridge/src/verbs_checks_native.rs@@ -1,1120 +1,1121 @@⋯ 73 unchanged lines ⋯         name: "kicad_plan_route",         summary: "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 copper layers, with vias where they help. Answered as kicad_route_net's own arguments.",         mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 120,-        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"net\": \"SDA\", \"from\": \"U1.3\", \"to\": \"J2.1\", \"widthMm\"?: (the Default net class), \"layers\"?: [\"F.Cu\", \"B.Cu\"], \"allowVias\"?: true, \"viaSizeMm\"?, \"viaDrillMm\"?, \"gridMm\"?: 0.5, \"viaCost\"?: 5, \"clearanceMm\"?, \"edgeClearanceMm\"?: 0.3, \"maxNodes\"?, \"frame\"?: \"standard|kicad\"}",+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"net\": \"SDA\", \"from\": \"U1.3\", \"to\": \"J2.1\", \"widthMm\"?: (the Default net class), \"layers\"?: [\"F.Cu\", \"B.Cu\"], \"allowVias\"?: true, \"viaSizeMm\"?, \"viaDrillMm\"?, \"gridMm\"?: 0.5, \"viaCost\"?: 5, \"clearanceMm\"?, \"edgeClearanceMm\"?: 0.3, \"avoidPours\"?: false, \"maxNodes\"?, \"frame\"?: \"standard|kicad\"}",         example: "kicad_plan_route {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"net\":\"SDA\",\"from\":\"U1.3\",\"to\":\"J2.1\"}",-        hint: "Read only. found is true with routeNet: pass it to kicad_route_net unchanged (it carries filePath, frame and expectedRevision); run kicad_run_drc after. found false says why (no path within the search, a pad not on the net). The standard's plan_route, computed by its engine (engine says which).",+        hint: "Read only. found is true with routeNet: pass it to kicad_route_net unchanged (it carries filePath, frame and expectedRevision); run kicad_run_drc after. found false says why (no path within the search, a pad not on the net). crossesPours names other nets' pours the route runs through: after the refill check none split (kicad_routing_state), or plan again with avoidPours:true. Through vias are checked on every copper layer, and the board's strictest clearance rule decides. The standard's plan_route, computed by its engine (engine says which).",         related: &["kicad_route_net", "kicad_routing_state", "kicad_run_drc", "kicad_autoroute"],         pitfalls: &["Between fine-pitch pads the default 0.5 mm grid can miss a path: gridMm 0.1 finds it, slower.", "Pours are not obstacles: refill after routing (kicad_zone_state {refill:true}).", "The plan holds for the revision it read: kicad_route_net refuses it with stale_board once the board changes."],     },⋯ 1035 unchanged lines ⋯         assert!(o["checked"].as_u64().unwrap() > 10, "{o}");     } }+--- a/rust/crates/kicad-bridge/src/verbs_place_helpers.rs+++ b/rust/crates/kicad-bridge/src/verbs_place_helpers.rs@@ -1,274 +1,275 @@⋯ 47 unchanged lines ⋯         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}",         example: "kicad_plan_fanout {\"filePath\":\"C:/p/b.kicad_pcb\",\"ref\":\"U3\"}",-        hint: "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: "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.",         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."],     },⋯ 217 unchanged lines ⋯         assert!(a["plan"]["placements"].is_array());     } }+--- a/rust/crates/kicad-core/src/place_tools.rs+++ b/rust/crates/kicad-core/src/place_tools.rs@@ -1,759 +1,847 @@⋯ 453 unchanged lines ⋯     // a through-hole pad of a part on the other side comes through to this side too     let other_side: Vec<String> = places.iter().filter(|p| p.side != icp.side).map(|p| p.reference.clone()).collect();     taken.extend(pad_shapes(board).into_iter().filter(|s| other_side.contains(&s.reference) && (s.layers.iter().any(|l| l == "*.Cu") || (s.layers.iter().any(|l| l == "F.Cu") && s.layers.iter().any(|l| l == "B.Cu")))).map(|s| s.bbox));+    // a cap that will not move keeps its spot (std #22: a skipped cap was left out of the taken spots): the ones+    // on another side are on another side's courtyards already; one on no shared power net stays put here+    for (i, f) in board.footprints.iter().enumerate() {+        if moving.contains(&f.reference.to_uppercase()) && places[i].side == icp.side+            && !f.pads.iter().any(|p| p.net_name.as_deref().map(|n| power_nets.iter().any(|x| x == n)).unwrap_or(false)) {+            taken.push(places[i].courtyard);+        }+    }     let outline = placement::outline(board);     let mut used_pins: Vec<String> = Vec::new();     let mut moves = Vec::new();⋯ 53 unchanged lines ⋯         }         let Some((at, r, slid)) = chosen else {             skipped.push(json!({"ref": cap.reference, "reason": "no_free_spot", "pin": pin.name, "side": side}));+            taken.push(places[ci].courtyard); // it stays where it is (std #22)             continue;         };         taken.push(r);⋯ 180 unchanged lines ⋯             worst = worst.min(d - w.max(h) / 2.0 - via_size / 2.0);         }     }+    let conflicts = fanout_conflicts(board, &calls, side, width, via_size, clearance);+    if !conflicts.is_empty() {+        warnings.push(format!("{} via(s) or stub(s) come closer than {clearance} mm to another net's pad: see conflicts, and drop or shorten them before routing", conflicts.len()));+    }     let calls: Vec<Value> = calls.into_iter().map(|(net, paths)| json!({"net": net, "paths": paths, "width": width, "viaSize": via_size, "viaDrill": via_drill})).collect();     let _ = fx;     Ok(json!({⋯ 1 unchanged line ⋯         "style": style, "requestedStyle": want, "dogboneRoomMm": dogbone_room, "dogboneFits": dogbone_fits,         "via": {"size": via_size, "drill": via_drill}, "width": width, "clearanceMm": clearance,         "pads": rows, "calls": calls, "callCount": calls.len(), "viaCount": vias.len(),-        "minViaToPadMm": if worst.is_finite() { json!(r3(worst)) } else { Value::Null },+        "minViaToPadMm": if worst.is_finite() { json!(r3(worst)) } else { Value::Null }, "conflicts": conflicts,         "warnings": warnings,     })) } +/// 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+/// points, or a via where two waypoints share a point on different layers. Closer than `clearance`+/// 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 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 {+        for path in paths {+            let pts: Vec<(P, String)> = path.as_array().into_iter().flatten().filter_map(wp).collect();+            for w in pts.windows(2) {+                let ((a, la), (b, lb)) = (&w[0], &w[1]);+                let via = pcb::dist(*a, *b) < 1e-6;+                if via && la == lb {+                    continue;+                }+                for s in shapes.iter().filter(|s| s.net != *net || net.is_empty()) {+                    if !via && !on_layer(s, la) {+                        continue;+                    }+                    let d = if via {+                        match s.circle {+                            Some(r) => (pcb::dist(*a, s.center) - r - via_size / 2.0).max(0.0),+                            None => point_to_poly(*a, &s.edges, via_size / 2.0),+                        }+                    } else {+                        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),+                        };+                        (core - width / 2.0).max(0.0)+                    };+                    if d < clearance - 1e-9 {+                        out.push(json!({"net": net, "what": if via { "via" } else { "stub" }, "at": [pcb::round6(a.0), pcb::round6(a.1)], "layer": if via { Value::Null } else { json!(la) },+                            "pad": format!("{}.{}", s.reference, s.pad), "padNet": s.net, "gapMm": r3(d)}));+                    }+                }+            }+        }+    }+    out+}+ #[cfg(test)] mod tests {     use super::*;⋯ 39 unchanged lines ⋯         let v = plan_decoupling(&b, &json!({"ic": "U1", "caps": ["C14"]})).unwrap();         assert_eq!(v["leftOut"], json!([]));     }++    #[test]+    fn fanout_conflicts_measure_other_nets_pads() {+        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb");+        let b = pcb::parse_pcb_text(&std::fs::read_to_string(p).unwrap(), "esc.kicad_pcb").unwrap();+        let r12 = pcb::pads(&b).into_iter().find(|q| q.reference == "R12" && q.pad == "1").unwrap();+        let (x, y) = (r12.x, r12.y);+        let mut calls = std::collections::BTreeMap::new();+        // a stub of another net straight across R12.1, and a via of another net on it+        calls.insert("OTHER".to_string(), vec![json!([[x - 2.0, y], [x + 2.0, y]]), json!([{"x": x, "y": y, "layer": "F.Cu"}, {"x": x, "y": y, "layer": "B.Cu"}])]);+        // R12.1's own net touching it is fine+        calls.insert(r12.net_name.clone(), vec![json!([[x, y], [x + 0.01, y]])]);+        let c = fanout_conflicts(&b, &calls, "F.Cu", 0.15, 0.6, 0.1);+        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 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"));+    } }+--- a/rust/crates/kicad-core/tests/schematic_edit.rs+++ b/rust/crates/kicad-core/tests/schematic_edit.rs@@ -1,435 +1,462 @@⋯ 188 unchanged lines ⋯ #[test] fn reads_what_the_sheet_holds() {     let sch = Schematic::parse(&tour_demo()).unwrap();-    assert_eq!(sch.root_uuid().as_deref(), Some("7b5608be-31c7-4010-bca1-370a9e35dce5"));-    assert_eq!(sch.version().as_deref(), Some("20231120"));+    assert_eq!(sch.root_uuid().as_deref(), Some("29d2ee99-c9f8-4d10-bfe1-baa46f373d38"));+    assert_eq!(sch.version().as_deref(), Some("20260306"));     assert!(sch.has_lib_symbol("AdomTour:R_AdomTour"));     let syms = sch.symbols();     assert_eq!(syms.len(), 1);     assert_eq!(syms[0].reference, "R1");     assert_eq!(syms[0].at, [127.0, 81.28]);     assert_eq!(sch.project_name(), "tour-demo");-    assert_eq!(sch.instance_path().unwrap(), "/7b5608be-31c7-4010-bca1-370a9e35dce5");+    assert_eq!(sch.instance_path().unwrap(), "/29d2ee99-c9f8-4d10-bfe1-baa46f373d38");     assert_eq!(Schematic::parse(&rp2040()).unwrap().symbols().len(), 20); } ⋯ 21 unchanged lines ⋯     assert!(block.contains("(property \"Value\" \"4k7\""));     assert!(block.contains("(property \"Footprint\" \"AdomTour:R_0805_AdomTour\""));     assert!(block.contains("\t\t(pin \"1\"\n\t\t\t(uuid \""));-    assert!(block.contains("(instances\n\t\t\t(project \"tour-demo\"\n\t\t\t\t(path \"/7b5608be-31c7-4010-bca1-370a9e35dce5\"\n\t\t\t\t\t(reference \"R5\")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n"));+    assert!(block.contains("(instances\n\t\t\t(project \"tour-demo\"\n\t\t\t\t(path \"/29d2ee99-c9f8-4d10-bfe1-baa46f373d38\"\n\t\t\t\t\t(reference \"R5\")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n"));     let again = Schematic::parse(&text).unwrap();     assert_eq!(again.symbols().len(), 2);     assert_eq!(again.to_text(), text);⋯ 52 unchanged lines ⋯      // Move R1 from (127, 81.28) to (150, 90): the anchor and every field shift together.     let (uuid, from) = sch.move_symbol("R1", (150.0, 90.0)).unwrap();-    assert_eq!(uuid, "f8e0a1ff-cdf3-4b10-8a45-de5c5807717e");+    assert_eq!(uuid, "72781317-3f2d-4a60-bab0-9ae1064c96fe");     assert_eq!(from, (127.0, 81.28));     let text = sch.to_text();     assert!(text.contains("(lib_id \"AdomTour:R_AdomTour\")\n\t\t(at 150 90 0)"));-    assert!(text.contains("(property \"Reference\" \"R1\"\n\t\t\t(at 152.54 88.7299 0)"), "{text}");-    assert!(text.contains("(property \"Footprint\" \"AdomTour:R_0805_AdomTour\"\n\t\t\t(at 148.222 90 90)"));+    assert!(text.contains("(property \"Reference\" \"R1\"\n\t\t\t(at 152.54 88.73 0)"), "{text}");+    assert!(text.contains("(property \"Footprint\" \"AdomTour:R_0805_AdomTour\"\n\t\t\t(at 150 90 0)"));     assert!(sch.move_symbol("R99", (0.0, 0.0)).is_err());      // Set an existing field, add a new one, rename the reference (instances follow).⋯ 137 unchanged lines ⋯     assert!(unconnected.is_empty(), "wires and labels did not connect the pins: {unconnected:?}");     let _ = std::fs::remove_dir_all(&dir); }++/// Writes tour-pack/template (wiki #136: the template was lost; the default kicad-tour pack and the tests+/// above read it): tour-demo.kicad_sch with one AdomTour:R_AdomTour (R1, 10k) at (127, 81.28), a 30 x 20 mm+/// tour-demo.kicad_pcb with R1's footprint, and tour-demo.kicad_pro. Run once with+/// `cargo test -p kicad-core --test schematic_edit -- --ignored write_tour_demo_template`.+#[test]+#[ignore]+fn write_tour_demo_template() {+    let pack = repo_file("tour-pack");+    let dir = pack.join("template");+    std::fs::create_dir_all(&dir).unwrap();+    let lib = std::fs::read_to_string(pack.join("AdomTour.kicad_sym")).unwrap();+    let (text, root) = kicad_core::project::blank_schematic(Some("Adom Tour"));+    let mut sch = Schematic::parse(&text).unwrap();+    sch.project_hint = Some("tour-demo".into());+    let block = flatten_lib_symbol(&lib, "R_AdomTour").unwrap();+    sch.place_symbol(&PlaceSymbol { lib_id: "AdomTour:R_AdomTour", reference: "R1", at: (127.0, 81.28), rotation: 0.0, unit: 1, value: Some("10k"), footprint: None }, Some(&block)).unwrap();+    std::fs::write(dir.join("tour-demo.kicad_sch"), sch.to_text()).unwrap();+    std::fs::write(dir.join("tour-demo.kicad_pro"), kicad_core::project::project_json("tour-demo", &root, &Default::default())).unwrap();+    let fp = std::fs::read_to_string(pack.join("AdomTour.pretty/R_0805_AdomTour.kicad_mod")).unwrap();+    let blank = kicad_core::project::blank_board(Some("Adom Tour"), 2).unwrap();+    let (outline, _) = kicad_core::board_build::outline_items(&serde_json::json!({"rect": [112.0, 71.28, 30.0, 20.0], "cornerRadius": 1.0}), false).unwrap();+    let part = kicad_core::board_build::Placed { reference: "R1".into(), lib_id: "AdomTour:R_0805_AdomTour".into(), text: fp, x: 127.0, y: 81.28, rotation: 0.0, back: false, value: Some("10k".into()) };+    let (pcb, _) = kicad_core::board_build::build(&blank, &outline, &[part], &Default::default()).unwrap();+    std::fs::write(dir.join("tour-demo.kicad_pcb"), pcb).unwrap();+}+--- a/rust/vendor/adom-eda-checks/Cargo.toml+++ b/rust/vendor/adom-eda-checks/Cargo.toml@@ -1,25 +1,26 @@ [package] name = "adom-eda-checks"-version = "0.7.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"⋯ 16 unchanged lines ⋯ lto = true codegen-units = 1 strip = true+--- a/rust/vendor/adom-eda-checks/README.md+++ b/rust/vendor/adom-eda-checks/README.md@@ -1,106 +1,110 @@⋯ 10 unchanged lines ⋯  | 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 | | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body |-| `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools |+| `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_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_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 | | `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 | ⋯ 34 unchanged lines ⋯ | `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` (a `value` with its unit, `"0.1mm"`, works too). Only copper-to-copper clearance counts: a rule whose kind or name names the board edge, mask, silk, paste, holes, a keepout or restrict area, the courtyard or the same net is skipped | | `silk` | `<prefix>_silk_state` | | `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` |+| `prices` | a price record read from the fab's own page: `{vendor, url, read, currency, pcb: [{layers, setup, perM2, promo?: {maxMm: [w, h], prices: [[qty, price]]}}], thickness?: {standardMm, factor}, assembly?: {setupPerSide, stencilPerSide, perSmdJoint, perThtJoint, perExtendedPart}, excludes?}`. The pcb row used is the one for the fewest copper layers that covers the board | | `args` | the check's own arguments, as the standard verb documents them |  Each part may be the bare model or the bridge's whole reply: the model's keys at the top level, or under `data`.⋯ 25 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.7.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 ⋯ 5 unchanged lines ⋯ ```  Fixtures live on each bridge's own page, not here.+--- a/rust/vendor/adom-eda-checks/VENDOR.md+++ b/rust/vendor/adom-eda-checks/VENDOR.md@@ -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.10.0-- Standard commit: fcc00d06eddf285e2d30813f28b0cda07c3c31a0-- Crate version: 0.7.0+- Standard version: 0.13.0+- Standard commit: a466bab9e652f37163e6da96ebaef42b6a45a97a+- Crate version: 0.10.0 - Copied: 2026-10-28, by kicad-lane  To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`, `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: ...`).+--- a/rust/vendor/adom-eda-checks/src/cost.rs+++ b/rust/vendor/adom-eda-checks/src/cost.rs@@ -0,0 +1,183 @@+//! The numbers a fab quote needs, from the board model (size, copper layers, thickness, smallest track, gap and drill,+//! holes, parts and joints), and a rough price table at the quantities asked. From the KiCad bridge's fab.rs, with one+//! change: the engine has no rates of its own. Prices come in as a record read from the fab's own page with its date+//! (`prices`), and without one the reply is the numbers only. Pure.+use serde_json::{json, Value};+use std::collections::BTreeSet;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// The board's numbers. `layers` from the stack when given, else the copper layers the board uses; the thickness from+/// the stack only.+pub fn board_numbers(st: &Value, stack: Option<&Value>, rules: &[Value]) -> Value {+    let (w, h) = crate::geom::outline_bbox(st).map(|(x0, y0, x1, y1)| (x1 - x0, y1 - y0)).unwrap_or((0.0, 0.0));+    let mut used: BTreeSet<String> = BTreeSet::new();+    for k in ["tracks", "arcs", "pads", "polygons"] {+        for x in st[k].as_array().into_iter().flatten() {+            if let Some(l) = x["layer"].as_str().filter(|l| l.ends_with(".Cu") && *l != "*.Cu") { used.insert(l.to_string()); }+        }+    }+    let layers = stack.and_then(|s| s["copperLayers"].as_u64()).unwrap_or((used.len() as u64).max(2));+    let thickness = stack.and_then(|s| s["totalUm"].as_f64()).filter(|t| *t > 0.0).map(|t| r3(t / 1000.0));+    let m = crate::fab::measure(st);+    let mm = |k: &str| m.get(k).and_then(|v| v["mm"].as_f64());+    let min_drill = [mm("viaDrill"), mm("padHole")].into_iter().flatten().fold(None, |a: Option<f64>, b| Some(a.map(|x| x.min(b)).unwrap_or(b)));+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let vias = st["vias"].as_array().map(|a| a.len()).unwrap_or(0);+    let drilled = pads.iter().filter(|p| f(&p["drill"]) > 0.0).count();+    // a part is a component with pads (a logo or a drawing has none); through-hole when any pad is drilled+    let (mut smd, mut tht, mut smd_j, mut tht_j, mut sides, mut unique) = (0, 0, 0, 0, BTreeSet::new(), BTreeSet::new());+    for c in st["components"].as_array().into_iter().flatten() {+        let r = c["ref"].as_str().unwrap_or("");+        let own: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(r)).collect();+        if own.is_empty() { continue; }+        let holes = own.iter().filter(|p| f(&p["drill"]) > 0.0).count();+        if holes > 0 { tht += 1; } else { smd += 1; }+        smd_j += own.len() - holes;+        tht_j += holes;+        sides.insert(c["side"].as_str().unwrap_or("top").to_string());+        unique.insert(c["pattern"].as_str().unwrap_or(r).to_string());+    }+    json!({"widthMm": r3(w), "heightMm": r3(h), "areaMm2": r2(w * h), "layers": layers, "thicknessMm": thickness,+        "minTrackMm": mm("track"), "minGapMm": crate::fab::min_clearance(rules), "minDrillMm": min_drill,+        "holes": vias + drilled, "vias": vias, "drilledPads": drilled, "smdParts": smd, "thtParts": tht, "totalParts": smd + tht,+        "smdJoints": smd_j, "thtJoints": tht_j, "uniqueParts": unique.len(), "assemblySides": sides.into_iter().collect::<Vec<_>>()})+}++/// The price table from a price record: `{vendor, url, read, currency, pcb: [{layers, setup, perM2, promo?: {maxMm: [w,+/// h], prices: [[qty, price]]}}], thickness?: {standardMm, factor}, assembly?: {setupPerSide, stencilPerSide, perSmdJoint,+/// perThtJoint, perExtendedPart}, excludes?: [..]}`. The pcb row is the one for the fewest copper layers that still+/// covers the board.+pub fn price_table(n: &Value, prices: &Value, quantities: &[u64], assembly: bool, basic_parts: Option<u64>) -> Result<Value, String> {+    let layers = n["layers"].as_u64().unwrap_or(2);+    let rows = prices["pcb"].as_array().ok_or("the price record has no pcb rows")?;+    let row = rows.iter().filter(|r| r["layers"].as_u64().map(|l| l >= layers).unwrap_or(false)).min_by_key(|r| r["layers"].as_u64())+        .ok_or_else(|| format!("the price record has no row for {layers} copper layers"))?;+    let (setup, per_m2) = (f(&row["setup"]), f(&row["perM2"]));+    let (w, h) = (f(&n["widthMm"]), f(&n["heightMm"]));+    let area_m2 = w * h / 1_000_000.0;+    let promo = row.get("promo").filter(|p| {+        let max = (f(&p["maxMm"][0]), f(&p["maxMm"][1]));+        (w <= max.0 && h <= max.1) || (w <= max.1 && h <= max.0)+    });+    let factor = match (prices.get("thickness"), n["thicknessMm"].as_f64()) {+        (Some(t), Some(mm)) if (mm - f(&t["standardMm"])).abs() > 0.05 => f(&t["factor"]).max(1.0),+        _ => 1.0,+    };+    let a = &prices["assembly"];+    if assembly && a.is_null() { return Err("assembly was asked for, but the price record has no assembly rates".into()); }+    let sides = n["assemblySides"].as_array().map(|s| s.len()).unwrap_or(1).max(1) as f64;+    let unique = n["uniqueParts"].as_u64().unwrap_or(0);+    let extended = basic_parts.map(|b| unique.saturating_sub(b)).unwrap_or(unique);+    let mut out = Vec::new();+    for &q in quantities {+        let qf = q as f64;+        // the promotional price for the smallest quantity it covers+        let promo_price = promo.and_then(|p| p["prices"].as_array().and_then(|ps| ps.iter().filter(|e| e[0].as_u64().map(|x| x >= q).unwrap_or(false)).min_by_key(|e| e[0].as_u64()).map(|e| f(&e[1]))));+        let pcb = r2(promo_price.unwrap_or(setup + per_m2 * area_m2 * qf) * factor);+        let mut r = json!({"quantity": q, "board": pcb});+        let mut total = pcb;+        if assembly && n["totalParts"].as_u64().unwrap_or(0) > 0 {+            let asm = r2((f(&a["setupPerSide"]) + f(&a["stencilPerSide"])) * sides + (f(&n["smdJoints"]) * f(&a["perSmdJoint"]) + f(&n["thtJoints"]) * f(&a["perThtJoint"])) * qf + f(&a["perExtendedPart"]) * extended as f64);+            r["assembly"] = json!(asm);+            total += asm;+        }+        r["total"] = json!(r2(total));+        r["perBoard"] = json!(r2(total / qf.max(1.0)));+        r["currency"] = prices["currency"].clone();+        out.push(r);+    }+    let mut assumptions = vec![format!("Board: {setup} setup plus {per_m2} per square metre of boards, from the {}-layer row.", row["layers"])];+    if let Some(p) = promo { assumptions.push(format!("Boards up to {} x {} mm take the promotional prices {} where they cover the quantity.", p["maxMm"][0], p["maxMm"][1], p["prices"])); }+    if factor > 1.0 { assumptions.push(format!("A thickness other than {} mm multiplies the board price by {factor}.", prices["thickness"]["standardMm"])); }+    if assembly {+        assumptions.push(format!("Assembly: setup and stencil per assembled side ({sides} side(s)), per joint, and a loading fee per extended part ({extended} of {unique} unique parts counted as extended{}).", if basic_parts.is_some() { "" } else { "; pass basicParts to count the basic ones" }));+        assumptions.push("Component cost is not included: price the BOM with adom-parts-search.".into());+    }+    for e in prices["excludes"].as_array().into_iter().flatten() { if let Some(s) = e.as_str() { assumptions.push(format!("Not included: {s}.")); } }+    Ok(json!({"prices": out, "assumptions": assumptions}))+}++/// estimate_cost: input {board, stack?, rules?, prices?, args: {quantities?, assembly?, basicParts?}}.+pub fn estimate_cost(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let quantities: Vec<u64> = args["quantities"].as_array().map(|a| a.iter().filter_map(|v| v.as_u64()).filter(|q| *q > 0).collect()).unwrap_or_else(|| vec![5, 10, 30, 100]);+    if quantities.is_empty() { return crate::input::refuse("bad_args", "quantities are whole numbers above 0, e.g. [5, 10, 30, 100]"); }+    let assembly = args["assembly"].as_bool().unwrap_or(false);+    let stack = crate::input::stack(input);+    let n = board_numbers(&st, stack.as_ref(), &crate::input::rules(input));+    let mut notes = Vec::new();+    if n["thicknessMm"].is_null() { notes.push("thicknessMm: no stack in the input, so the thickness is unknown and no thickness surcharge is applied"); }+    if n["minGapMm"].is_null() { notes.push("minGapMm: the board's clearance rule, and no rules came in the input"); }+    let Some(prices) = crate::input::part(input, "prices") else {+        return json!({"success": true, "board": n, "prices": Value::Null, "source": Value::Null, "notes": notes, "quantities": quantities,+            "_hint": "The board's numbers only: the engine carries no rates. Pass a price record as prices ({vendor, url, read, currency, pcb: [{layers, setup, perM2, promo?}], thickness?, assembly?, excludes?}), read from the fab's own page with the date, and the reply prices these quantities from it."});+    };+    match price_table(&n, &prices, &quantities, assembly, args["basicParts"].as_u64()) {+        Ok(t) => json!({"success": true, "board": n, "prices": t["prices"], "assumptions": t["assumptions"], "notes": notes, "rough": true,+            "source": {"name": prices["vendor"], "url": prices["url"], "read": prices["read"]},+            "_hint": "A rough estimate from the price record named in source, as of its read date: get a real quote from the fab before ordering."}),+        Err(e) => crate::input::refuse("bad_prices", &e),+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> Value {+        json!({"outline": [[0, 0], [50, 0], [50, 40], [0, 40]],+            "tracks": [{"layer": "F.Cu", "width": 0.2, "net": "A", "start": {"x": 1, "y": 1}, "end": {"x": 5, "y": 1}}, {"layer": "B.Cu", "width": 0.15, "net": "A", "start": {"x": 1, "y": 2}, "end": {"x": 5, "y": 2}}],+            "vias": [{"drill": 0.3, "size": 0.6, "net": "A", "position": {"x": 5, "y": 1.5}}],+            "components": [{"ref": "U1", "side": "top", "pattern": "SOIC8"}, {"ref": "J1", "side": "top", "pattern": "HDR2"}, {"ref": "C1", "side": "bottom", "pattern": "0603"}, {"ref": "LOGO", "side": "top", "pattern": "LOGO"}],+            "pads": [{"ref": "U1", "drill": 0, "layer": "F.Cu"}, {"ref": "U1", "drill": 0, "layer": "F.Cu"}, {"ref": "J1", "drill": 1.0, "layer": "*.Cu", "x": 10, "y": 10, "sizeX": 1.7, "sizeY": 1.7},+                     {"ref": "J1", "drill": 1.0, "layer": "*.Cu", "x": 12.54, "y": 10, "sizeX": 1.7, "sizeY": 1.7}, {"ref": "C1", "drill": 0, "layer": "B.Cu"}, {"ref": "C1", "drill": 0, "layer": "B.Cu"}]})+    }++    fn record() -> Value {+        json!({"vendor": "Example Fab", "url": "https://fab.example/prices", "read": "2026-10-29", "currency": "USD",+            "pcb": [{"layers": 2, "setup": 2.0, "perM2": 30.0, "promo": {"maxMm": [100, 100], "prices": [[5, 2.0], [10, 5.0]]}}, {"layers": 4, "setup": 8.0, "perM2": 90.0}],+            "thickness": {"standardMm": 1.6, "factor": 1.15},+            "assembly": {"setupPerSide": 8.0, "stencilPerSide": 7.0, "perSmdJoint": 0.0017, "perThtJoint": 0.0173, "perExtendedPart": 3.0},+            "excludes": ["shipping", "tax"]})+    }++    #[test]+    fn the_numbers_come_from_the_board() {+        let n = board_numbers(&board(), None, &[json!({"kind": "clearance", "clearanceMm": 0.15})]);+        assert_eq!((n["widthMm"].as_f64(), n["heightMm"].as_f64(), n["layers"].as_u64()), (Some(50.0), Some(40.0), Some(2)));+        assert_eq!((n["minTrackMm"].as_f64(), n["minGapMm"].as_f64(), n["minDrillMm"].as_f64()), (Some(0.15), Some(0.15), Some(0.3)));+        // the logo has no pads: three parts, J1 through-hole, two sides+        assert_eq!((n["smdParts"].as_u64(), n["thtParts"].as_u64(), n["smdJoints"].as_u64(), n["thtJoints"].as_u64(), n["holes"].as_u64()), (Some(2), Some(1), Some(4), Some(2), Some(3)));+        assert_eq!(n["assemblySides"], json!(["bottom", "top"]));+        assert!(n["thicknessMm"].is_null());+        assert_eq!(board_numbers(&board(), Some(&json!({"copperLayers": 4, "totalUm": 1600})), &[])["thicknessMm"], json!(1.6));+    }++    #[test]+    fn without_a_price_record_it_answers_the_numbers_only() {+        let r = estimate_cost(&json!({"board": board(), "args": {}}));+        assert_eq!(r["success"], json!(true));+        assert!(r["prices"].is_null() && r["source"].is_null());+        assert_eq!(r["board"]["layers"], json!(2));+    }++    #[test]+    fn prices_come_from_the_record_and_name_it() {+        let r = estimate_cost(&json!({"board": board(), "prices": record(), "args": {"quantities": [5, 30], "assembly": true}}));+        assert_eq!(r["source"], json!({"name": "Example Fab", "url": "https://fab.example/prices", "read": "2026-10-29"}));+        // 5 boards: the promotional 2.00; 30: 2 + 30 * 0.002 m2 * 30 = 3.80+        assert_eq!((r["prices"][0]["board"].as_f64(), r["prices"][1]["board"].as_f64()), (Some(2.0), Some(3.8)), "{r}");+        // assembly at 5: (8 + 7) * 2 sides + (4 * 0.0017 + 2 * 0.0173) * 5 + 3 * 3 unique = 39.21+        assert_eq!(r["prices"][0]["assembly"].as_f64(), Some(39.21), "{r}");+        assert!(r["assumptions"].as_array().unwrap().iter().any(|a| a == "Not included: shipping."));+        // a 4-layer stack takes the 4-layer row; 6 layers has none+        let four = estimate_cost(&json!({"board": board(), "stack": {"copperLayers": 4, "totalUm": 1200}, "prices": record(), "args": {"quantities": [10]}}));+        assert_eq!(four["prices"][0]["board"].as_f64(), Some(r2((8.0 + 90.0 * 0.002 * 10.0) * 1.15)), "{four}");+        assert_eq!(estimate_cost(&json!({"board": board(), "stack": {"copperLayers": 6}, "prices": record(), "args": {}}))["errorCode"], "bad_prices");+    }+}+--- a/rust/vendor/adom-eda-checks/src/fab.rs+++ b/rust/vendor/adom-eda-checks/src/fab.rs@@ -1,342 +1,361 @@⋯ 134 unchanged lines ⋯ /// 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⋯ 178 unchanged lines ⋯         assert_eq!(min_clearance(&altium), Some(0.2));     } }+--- a/rust/vendor/adom-eda-checks/src/lib.rs+++ b/rust/vendor/adom-eda-checks/src/lib.rs@@ -1,64 +1,68 @@⋯ 3 unchanged lines ⋯ //! answers the verb under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`, //! `errorCode`, `error`, `_hint`) and name verbs without a prefix. //!-//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`).+//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, args?}` (see `input`). use serde_json::Value;  pub mod bodies; pub mod coppermap;+pub mod cost; pub mod current; pub mod fab; pub mod features;⋯ 10 unchanged lines ⋯ pub const VERSION: &str = env!("CARGO_PKG_VERSION");  /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["check_clearance", "copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "place_decoupling", "placement_check", "plan_route", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["check_clearance", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "height_check", "impedance", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "thermal_vias", "via_fence"];  /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {⋯ 8 unchanged lines ⋯         "check_clearance" => placetools::check_clearance(input),         "copper_balance" => coppermap::copper_balance(input),         "current_capacity" => current::current_capacity(input),+        "estimate_cost" => cost::estimate_cost(input),         "fab_check" => fab::fab_check(input),         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),         "place_decoupling" => placetools::place_decoupling(input),+        "plan_fanout" => placetools::plan_fanout(input),         "placement_check" => placement::placement_check(input),         "plan_route" => route::plan_route(input),         "return_path" => coppermap::return_path(input),⋯ 12 unchanged lines ⋯         assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]);     } }+--- a/rust/vendor/adom-eda-checks/src/main.rs+++ b/rust/vendor/adom-eda-checks/src/main.rs@@ -1,51 +1,52 @@ //! adom-eda-checks <check> [bundle.json | -]-//! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']+//! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--prices r.json] [--args '{...}'] //! adom-eda-checks import_svg --svg art.svg --args '{"at": [10, 10], "widthMm": 20}' //! Prints the check's reply as JSON. `adom-eda-checks list` names the checks. use serde_json::{json, Value};⋯ 17 unchanged lines ⋯     while let Some(flag) = it.next() {         let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?;         match flag.as_str() {-            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--sheet-parts" | "--netlist" => {+            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" => {                 let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] };                 b[key] = read(val)?;             }⋯ 9 unchanged lines ⋯ fn main() {     let argv: Vec<String> = std::env::args().skip(1).collect();     let out = match argv.first().map(String::as_str) {-        None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),+        None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--prices r.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),         Some("list") => json!({"success": true, "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),         Some(check) => match bundle(&argv[1..]) {             Ok(b) => adom_eda_checks::run(check, &b),⋯ 3 unchanged lines ⋯     println!("{}", serde_json::to_string_pretty(&out).unwrap());     if out.get("success") == Some(&json!(false)) { std::process::exit(1); } }+--- a/rust/vendor/adom-eda-checks/src/placement.rs+++ b/rust/vendor/adom-eda-checks/src/placement.rs@@ -1,203 +1,209 @@⋯ 96 unchanged lines ⋯     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 {⋯ 19 unchanged lines ⋯     // 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));⋯ 56 unchanged lines ⋯         assert!(none.get("stacked").is_none());     } }++--- a/rust/vendor/adom-eda-checks/src/placetools.rs+++ b/rust/vendor/adom-eda-checks/src/placetools.rs@@ -1,430 +1,692 @@⋯ 120 unchanged lines ⋯     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]);⋯ 2 unchanged lines ⋯                 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) }                 }             }         }⋯ 27 unchanged lines ⋯     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." }}) }⋯ 27 unchanged lines ⋯     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;                 }⋯ 9 unchanged lines ⋯     }     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."})⋯ 32 unchanged lines ⋯     json!({"ok": ov.is_empty() && off.is_empty() && holes.is_empty(), "by": "placement_check on the board with the caps moved", "courtyardOverlaps": ov, "offOrOverEdge": off, "onMountingHoles": holes, "nearEdge": near}) } +fn median(mut v: Vec<f64>) -> Option<f64> {+    if v.is_empty() { return None; }+    v.sort_by(|a, b| a.partial_cmp(b).unwrap());+    Some(v[v.len() / 2])+}++/// The board's copper layers, top to bottom: from the stack's count when given, else the layers its copper uses.+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")));+        v.push("B.Cu".into());+        return v;+    }+    let mut inner: Vec<u32> = Vec::new();+    for k in ["tracks", "arcs", "pads", "polygons"] {+        for x in st[k].as_array().into_iter().flatten() {+            if let Some(i) = x["layer"].as_str().and_then(|l| l.strip_prefix("In")).and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse().ok()) {+                if !inner.contains(&i) { inner.push(i); }+            }+        }+    }+    inner.sort();+    std::iter::once("F.Cu".to_string()).chain(inner.iter().map(|i| format!("In{i}.Cu"))).chain(std::iter::once("B.Cu".to_string())).collect()+}++/// plan_fanout: input {board, stack?, rules?, args: {ref, style?, escapeLayer?, viaMm?, viaDrillMm?, widthMm?, clearanceMm?,+/// pitchMm?, stubMm?, surfaceOuterRing?, viaPerimeter?}}. A BGA's outer ring escapes on the surface and each inner ball+/// takes a dogbone via (or a via in the pad); a perimeter package's pads take an outward stub (with a via at its end for+/// ground or `viaPerimeter`), an exposed pad a via grid. `calls` are route_net arguments, one per net. Every via and+/// stub is measured against other nets' pads on the whole board (`conflicts`).+pub fn plan_fanout(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let Some(r) = args["ref"].as_str().map(str::trim).filter(|s| !s.is_empty()) else { return crate::input::refuse("bad_args", "ref is required: the BGA or QFN/QFP (\"U3\")") };+    let m = parts(&st, input);+    let Some((r, part)) = find(&m, r) else { return crate::input::refuse("no_such_part", &format!("{r} is not a part on this board")) };+    let side = if part.bottom { "B.Cu" } else { "F.Cu" };+    let copper = copper_layers(&st, input);+    let escape = match args["escapeLayer"].as_str() {+        Some(l) if copper.iter().any(|c| c == l) => l.to_string(),+        Some(l) => return json!({"success": false, "errorCode": "no_such_layer", "error": format!("{l} is not a copper layer of this board"), "copperLayers": copper}),+        None => copper.iter().find(|l| l.starts_with("In")).cloned().unwrap_or_else(|| if part.bottom { "F.Cu".into() } else { "B.Cu".into() }),+    };+    let pads: Vec<&Value> = part.pads.iter().filter(|p| f(&p["drill"]) == 0.0).collect();+    if pads.len() < 4 { return crate::input::refuse("not_a_package", &format!("{r} has {} surface pads: fanout is for a BGA or a QFN/QFP", pads.len())); }+    let at = |p: &Value| (f(&p["x"]), f(&p["y"]));+    let net = |p: &Value| p["net"].as_str().unwrap_or("").to_string();+    let name = |p: &Value| format!("{r}.{}", p["name"].as_str().unwrap_or(""));+    let areas: Vec<f64> = pads.iter().map(|p| f(&p["sizeX"]) * f(&p["sizeY"])).collect();+    let med_area = median(areas.clone()).unwrap_or(0.0);+    let (thermal, signal): (Vec<usize>, Vec<usize>) = (0..pads.len()).partition(|i| areas[*i] > 4.0 * med_area);+    let d = |a: P, b: P| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();+    let nn: Vec<f64> = signal.iter().map(|&i| signal.iter().filter(|&&j| j != i).map(|&j| d(at(pads[i]), at(pads[j]))).fold(f64::MAX, f64::min)).collect();+    let pitch = match args["pitchMm"].as_f64() { Some(v) if v > 0.0 => v, _ => match median(nn) { Some(v) => r3(v), None => return crate::input::refuse("not_a_package", "no pad pitch") } };+    let (cx, cy) = (signal.iter().map(|&i| at(pads[i]).0).sum::<f64>() / signal.len() as f64, signal.iter().map(|&i| at(pads[i]).1).sum::<f64>() / signal.len() as f64);+    let bx = signal.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, &i| { let p = at(pads[i]); (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)) });+    let interior = signal.iter().filter(|&&i| { let p = at(pads[i]); (p.0 - bx.0).min(bx.2 - p.0).min(p.1 - bx.1).min(bx.3 - p.1) > pitch * 0.5 }).count();+    let kind = if interior > 0 { "bga" } else { "perimeter" };+    let fine = pitch < 0.8 - 1e-9;+    let via_size = args.get("viaMm").or_else(|| args.get("viaSizeMm")).and_then(|v| v.as_f64()).unwrap_or(if fine { 0.4 } else if pitch < 1.0 - 1e-9 { 0.45 } else { 0.6 });+    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::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);+    let fits = room + 1e-9 >= clearance;+    let want = args["style"].as_str().unwrap_or("auto");+    let style = match want {+        "auto" => if fits { "dogbone" } else { "via-in-pad" },+        "dogbone" | "via-in-pad" => want,+        o => return crate::input::refuse("bad_args", &format!("style is auto, dogbone or via-in-pad, not {o}")),+    };+    let mut warnings = Vec::new();+    if kind == "bga" && style == "dogbone" && !fits {+        warnings.push(format!("a {via_size} mm dogbone via leaves {room} mm to the balls at {pitch} mm pitch, under the {clearance} mm clearance: use via-in-pad or a smaller via"));+    }+    let via_perimeter = args["viaPerimeter"].as_bool().unwrap_or(false);+    let stub = args["stubMm"].as_f64().unwrap_or(0.4);+    let wp = |p: P, l: &str| json!({"x": r3(p.0), "y": r3(p.1), "layer": l});+    let sign = |v: f64| if v < -1e-9 { -1.0 } else { 1.0 };+    let (mut rows, mut calls): (Vec<Value>, BTreeMap<String, Vec<Value>>) = (Vec::new(), BTreeMap::new());+    // what the plan puts down, to measure: (pad, net, via or stub as a capsule)+    let mut pieces: Vec<(String, String, &'static str, Shape)> = Vec::new();+    type Pieces = Vec<(String, String, &'static str, Shape)>;+    fn via(pieces: &mut Pieces, calls: &mut BTreeMap<String, Vec<Value>>, rows: &mut Vec<Value>, from: String, n: &str, v: P, size: f64, path: Vec<Value>, row: Value) {+        pieces.push((from, n.to_string(), "via", Shape::Cap(v, v, size / 2.0)));+        calls.entry(n.to_string()).or_default().push(json!(path));+        rows.push(row);+    }+    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; }+        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) {+                let (nv, _) = side_of(bx, pa);+                let end = (pa.0 + nv.0 * pitch, pa.1 + nv.1 * pitch);+                pieces.push((name(p), n.clone(), "stub", Shape::Cap(pa, end, width / 2.0)));+                calls.entry(n.clone()).or_default().push(json!([{"pad": name(p)}, wp(end, side)]));+                rows.push(json!({"pad": name(p), "net": n, "ring": ring, "action": "surface", "layer": side, "stubEnd": [r3(end.0), r3(end.1)]}));+            } else if style == "dogbone" {+                let v = (pa.0 + sign(pa.0 - cx) * pitch / 2.0, pa.1 + sign(pa.1 - cy) * pitch / 2.0);+                pieces.push((name(p), n.clone(), "stub", Shape::Cap(pa, v, width / 2.0)));+                via(&mut pieces, &mut calls, &mut rows, name(p), &n, v, via_size, vec![json!({"pad": name(p)}), wp(v, side), wp(v, &escape)],+                    json!({"pad": name(p), "net": n, "ring": ring, "action": "dogbone", "layer": escape, "via": [r3(v.0), r3(v.1)]}));+            } else {+                via(&mut pieces, &mut calls, &mut rows, name(p), &n, pa, via_size, vec![wp(pa, side), wp(pa, &escape)],+                    json!({"pad": name(p), "net": n, "ring": ring, "action": "via-in-pad", "layer": escape, "via": [r3(pa.0), r3(pa.1)], "note": "via-in-pad needs filled and capped vias (IPC-4761 type VII) from the fab"}));+            }+        } else {+            let (nv, edge) = side_of(bx, pa);+            let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+            let half = if nv.0 != 0.0 { f(&p["sizeX"]) * c + f(&p["sizeY"]) * s } else { f(&p["sizeX"]) * s + f(&p["sizeY"]) * c } / 2.0;+            let with_via = via_perimeter || is_ground(&n);+            let reach = half + stub + if with_via { via_size / 2.0 } else { 0.0 };+            let end = (pa.0 + nv.0 * reach, pa.1 + nv.1 * reach);+            pieces.push((name(p), n.clone(), "stub", Shape::Cap(pa, end, width / 2.0)));+            if with_via {+                via(&mut pieces, &mut calls, &mut rows, name(p), &n, end, via_size, vec![json!({"pad": name(p)}), wp(end, side), wp(end, &escape)],+                    json!({"pad": name(p), "net": n, "edge": edge, "action": "stub+via", "layer": escape, "via": [r3(end.0), r3(end.1)]}));+            } else {+                calls.entry(n.clone()).or_default().push(json!([{"pad": name(p)}, wp(end, side)]));+                rows.push(json!({"pad": name(p), "net": n, "edge": edge, "action": "surface", "layer": side, "stubEnd": [r3(end.0), r3(end.1)]}));+            }+        }+    }+    for &i in &thermal {+        let p = pads[i];+        let n = net(p);+        if n.is_empty() { continue; }+        let (pa, w, h) = (at(p), f(&p["sizeX"]), f(&p["sizeY"]));+        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();+        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);+                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}));+    }+    // 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();+    let (mut worst, mut conflicts) = (f64::MAX, Vec::new());+    for (from, n, what, s) in &pieces {+        for (p, ps) in &all {+            if net(p) == *n && !n.is_empty() { continue; }+            if *what == "stub" && !(p["layer"] == side || p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0) { continue; }+            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)}));+            }+        }+    }+    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."})+}+ #[cfg(test)] mod tests {     use super::*;⋯ 32 unchanged lines ⋯         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]     fn a_cap_turns_to_face_the_pin_and_slides_off_a_taken_spot() {         // C1 turned round (power pad on its right) must turn 180 to face U1's right side; a part on the spot pushes it along         let mut b = board();⋯ 36 unchanged lines ⋯         // 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");     } +    fn bga(pitch: f64, ball: f64) -> Value {+        // a 4 x 4 BGA at the origin, nets N0..N15, plus a stray resistor pad+        let mut pads = Vec::new();+        for i in 0..4 { for j in 0..4 {+            let (x, y) = ((i as f64 - 1.5) * pitch, (j as f64 - 1.5) * pitch);+            pads.push(json!({"ref": "U1", "name": format!("{}{}", (b'A' + j as u8) as char, i + 1), "x": x, "y": y, "sizeX": ball, "sizeY": ball, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": format!("N{}", i * 4 + j), "shape": "round"}));+        } }+        json!({"revision": "r2", "outline": [[-10, -10], [10, -10], [10, 10], [-10, 10]], "components": [{"ref": "U1", "x": 0, "y": 0, "rotation": 0, "side": "top"}], "pads": pads})+    }+     #[test]+    fn a_bga_escapes_its_outer_ring_and_dogbones_the_rest() {+        let r = plan_fanout(&json!({"board": bga(1.0, 0.45), "stack": {"copperLayers": 4}, "args": {"ref": "U1"}}));+        assert_eq!((r["package"].as_str(), r["style"].as_str(), r["escapeLayer"].as_str()), (Some("bga"), Some("dogbone"), Some("In1.Cu")), "{r}");+        let count = |a: &str| r["pads"].as_array().unwrap().iter().filter(|p| p["action"] == a).count();+        assert_eq!((count("surface"), count("dogbone")), (12, 4));+        // B2 sits at (-0.5, -0.5): its via goes half a pitch out, away from the centre, and changes layer there+        let b2 = r["calls"].as_array().unwrap().iter().find(|c| c["paths"][0][0]["pad"] == "U1.B2").unwrap();+        assert_eq!(b2["paths"][0][1], json!({"x": -1.0, "y": -1.0, "layer": "F.Cu"}));+        assert_eq!(b2["paths"][0][2], json!({"x": -1.0, "y": -1.0, "layer": "In1.Cu"}));+        assert_eq!(r["conflicts"], json!([]), "{r}");+        // at 0.5 mm pitch the dogbone does not fit: via in pad+        let fine = plan_fanout(&json!({"board": bga(0.5, 0.3), "args": {"ref": "U1"}}));+        assert_eq!((fine["style"].as_str(), fine["escapeLayer"].as_str()), (Some("via-in-pad"), Some("B.Cu")), "{fine}");+        assert_eq!(plan_fanout(&json!({"board": bga(0.5, 0.3), "args": {"ref": "U1", "style": "dogbone"}}))["warnings"].as_array().unwrap().len(), 1);+    }++    #[test]+    fn a_qfn_takes_stubs_a_ground_via_and_a_via_grid_and_names_what_it_would_hit() {+        let mut pads = Vec::new();+        for k in 0..4 { pads.push(json!({"ref": "U2", "name": format!("{}", k + 1), "x": -2.0, "y": 0.75 - k as f64 * 0.5, "sizeX": 0.8, "sizeY": 0.25, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": if k == 3 { "GND".to_string() } else { format!("S{k}") }, "shape": "rect"})); }+        for k in 0..4 { pads.push(json!({"ref": "U2", "name": format!("{}", k + 5), "x": 2.0, "y": -0.75 + k as f64 * 0.5, "sizeX": 0.8, "sizeY": 0.25, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": format!("T{k}"), "shape": "rect"})); }+        pads.push(json!({"ref": "U2", "name": "9", "x": 0.0, "y": 0.0, "sizeX": 2.6, "sizeY": 2.6, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "GND", "shape": "rect"}));+        // R1's pad right where U2.5's stub ends+        pads.push(json!({"ref": "R1", "name": "1", "x": 2.9, "y": -0.75, "sizeX": 0.5, "sizeY": 0.5, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "X", "shape": "rect"}));+        let b = json!({"components": [{"ref": "U2", "x": 0, "y": 0, "rotation": 0, "side": "top"}, {"ref": "R1", "x": 2.9, "y": -0.75, "rotation": 0, "side": "top"}], "pads": pads});+        let r = plan_fanout(&json!({"board": b, "args": {"ref": "U2"}}));+        assert_eq!((r["package"].as_str(), r["pitchMm"].as_f64()), (Some("perimeter"), Some(0.5)), "{r}");+        let action = |pad: &str| r["pads"].as_array().unwrap().iter().find(|p| p["pad"] == pad).unwrap()["action"].clone();+        assert_eq!((action("U2.1"), action("U2.4"), action("U2.9")), (json!("surface"), json!("stub+via"), json!("thermal-via-grid")));+        // U2.1 at x -2 with a 0.8 mm pad: 0.4 + the 0.4 stub reaches x -2.8+        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");+        assert_eq!(plan_fanout(&json!({"board": b, "args": {"ref": "U2", "escapeLayer": "In3.Cu"}}))["errorCode"], "no_such_layer");+    }++    #[test]     fn pad_copper_gaps_are_exact() {         let a = pad_shape(&json!({"x": 0.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "rect"}));         let b = pad_shape(&json!({"x": 2.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "round"}));⋯ 3 unchanged lines ⋯         assert_eq!(inner.gap(&a), 0.0);     } }+--- a/rust/vendor/adom-eda-checks/src/route.rs+++ b/rust/vendor/adom-eda-checks/src/route.rs@@ -1,353 +1,408 @@⋯ 131 unchanged lines ⋯     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()]);     }⋯ 7 unchanged lines ⋯     }     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(); ⋯ 38 unchanged lines ⋯         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⋯ 46 unchanged lines ⋯     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)]⋯ 71 unchanged lines ⋯         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}");+    } }+--- a/skills/kicad-bridge-test/run_verb_tests.py+++ b/skills/kicad-bridge-test/run_verb_tests.py@@ -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))+        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("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))⋯ 323 unchanged lines ⋯             "no SOIC-8 body written, attached and rendered at its 1.75 mm height")         pr = C("plan_route", {"filePath": CK, "source": "file", "net": "/DRV_GHC", "from": "R12.1", "to": "U4.13"}, 200)         rn = pr.get("routeNet") or {}-        own("plan_route", pr, ok(pr) and eng(pr) and pr.get("found") is True and (pr.get("planner") or {}).get("widthFrom") and rn.get("expectedRevision") == pr.get("revision") and rn.get("filePath") and rn.get("paths") and rn["paths"][0][0] == {"pad": "R12.1"},+        own("plan_route", pr, ok(pr) and eng(pr) and pr.get("found") is True and (pr.get("planner") or {}).get("widthFrom") and isinstance(pr.get("crossesPours"), list) and rn.get("expectedRevision") == pr.get("revision") and rn.get("filePath") and rn.get("paths") and rn["paths"][0][0] == {"pad": "R12.1"},             "no route planned for /DRV_GHC, or routeNet is not ready for kicad_route_net")         r = C("panelize", {"pcbPath": w["board"], "panelPath": w["out"] + "/panel/panel-tabs.kicad_pcb", "rows": 2, "cols": 2, "separation": "tabs", "overwrite": True}, 300)         record("panelize_tabs", r, (ok(r) and (r.get("separation") or {}).get("kind") == "tabs" and (r.get("separation") or {}).get("holes", 0) > 0) or r.get("errorCode") == "not_rectangular",⋯ 99 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+--- a/tour-pack/template/tour-demo.kicad_sch+++ b/tour-pack/template/tour-demo.kicad_sch@@ -0,0 +1,213 @@+(kicad_sch+	(version 20260306)+	(generator "eeschema")+	(generator_version "10.0")+	(uuid "29d2ee99-c9f8-4d10-bfe1-baa46f373d38")+	(paper "A4")+	(title_block+		(title "Adom Tour")+	)+	(lib_symbols+		(symbol "AdomTour:R_AdomTour"+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(property "Reference" "R"+				(at 2.032 0 90)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "R_AdomTour"+				(at 0 0 90)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" "AdomTour:R_0805_AdomTour"+				(at -1.778 0 90)+				(effects+					(font+						(size 1.27 1.27)+					)+					(hide yes)+				)+			)+			(property "Datasheet" "~"+				(at 0 0 0)+				(effects+					(font+						(size 1.27 1.27)+					)+					(hide yes)+				)+			)+			(property "Description" "Adom Tour demo resistor"+				(at 0 0 0)+				(effects+					(font+						(size 1.27 1.27)+					)+					(hide yes)+				)+			)+			(property "ki_keywords" "R res resistor adom tour"+				(at 0 0 0)+				(effects+					(font+						(size 1.27 1.27)+					)+					(hide yes)+				)+			)+			(property "ki_fp_filters" "R_*"+				(at 0 0 0)+				(effects+					(font+						(size 1.27 1.27)+					)+					(hide yes)+				)+			)+			(symbol "R_AdomTour_0_1"+				(rectangle+					(start -1.016 -2.54)+					(end 1.016 2.54)+					(stroke+						(width 0.254)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "R_AdomTour_1_1"+				(pin passive line+					(at 0 3.81 270)+					(length 1.27)+					(name "~"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin passive line+					(at 0 -3.81 90)+					(length 1.27)+					(name "~"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+	)+	(symbol+		(lib_id "AdomTour:R_AdomTour")+		(at 127 81.28 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "72781317-3f2d-4a60-bab0-9ae1064c96fe")+		(property "Reference" "R1"+			(at 129.54 80.01 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "10k"+			(at 129.54 82.55 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" "AdomTour:R_0805_AdomTour"+			(at 127 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" "~"+			(at 127 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Adom Tour demo resistor"+			(at 127 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "100eb8e7-e6b7-4e6d-a0a0-2df1736c62b6")+		)+		(pin "2"+			(uuid "de5a2821-ed1e-43a8-b1e1-1135971b97c3")+		)+		(instances+			(project "tour-demo"+				(path "/29d2ee99-c9f8-4d10-bfe1-baa46f373d38"+					(reference "R1")+					(unit 1)+				)+			)+		)+	)+	(sheet_instances+		(path "/"+			(page "1")+		)+	)+	(embedded_fonts no)+)+--- a/tour-pack/template/tour-demo.kicad_pcb+++ b/tour-pack/template/tour-demo.kicad_pcb@@ -0,0 +1,216 @@+(kicad_pcb+	(version 20260206)+	(generator "pcbnew")+	(generator_version "10.0")+	(general+		(thickness 1.6)+		(legacy_teardrops no)+	)+	(paper "A4")+	(title_block+		(title "Adom Tour")+	)+	(layers+		(0 "F.Cu" signal)+		(2 "B.Cu" signal)+		(9 "F.Adhes" user "F.Adhesive")+		(11 "B.Adhes" user "B.Adhesive")+		(13 "F.Paste" user)+		(15 "B.Paste" user)+		(5 "F.SilkS" user "F.Silkscreen")+		(7 "B.SilkS" user "B.Silkscreen")+		(1 "F.Mask" user)+		(3 "B.Mask" user)+		(17 "Dwgs.User" user "User.Drawings")+		(19 "Cmts.User" user "User.Comments")+		(21 "Eco1.User" user "User.Eco1")+		(23 "Eco2.User" user "User.Eco2")+		(25 "Edge.Cuts" user)+		(27 "Margin" user)+		(31 "F.CrtYd" user "F.Courtyard")+		(29 "B.CrtYd" user "B.Courtyard")+		(35 "F.Fab" user)+		(33 "B.Fab" user)+		(39 "User.1" user)+		(41 "User.2" user)+		(43 "User.3" user)+		(45 "User.4" user)+	)+	(setup+		(pad_to_mask_clearance 0)+		(allow_soldermask_bridges_in_footprints no)+		(tenting+			(front yes)+			(back yes)+		)+		(covering+			(front no)+			(back no)+		)+		(plugging+			(front no)+			(back no)+		)+		(capping no)+		(filling no)+		(pcbplotparams+			(layerselection 0x00000000_00000000_55555555_5755f5ff)+			(plot_on_all_layers_selection 0x00000000_00000000_00000000_00000000)+			(disableapertmacros no)+			(usegerberextensions no)+			(usegerberattributes yes)+			(usegerberadvancedattributes yes)+			(creategerberjobfile yes)+			(dashed_line_dash_ratio 12)+			(dashed_line_gap_ratio 3)+			(svgprecision 4)+			(plotframeref no)+			(mode 1)+			(useauxorigin no)+			(pdf_front_fp_property_popups yes)+			(pdf_back_fp_property_popups yes)+			(pdf_metadata yes)+			(pdf_single_document no)+			(dxfpolygonmode yes)+			(dxfimperialunits yes)+			(dxfusepcbnewfont yes)+			(psnegative no)+			(psa4output no)+			(plot_black_and_white yes)+			(sketchpadsonfab no)+			(plotpadnumbers no)+			(hidednponfab no)+			(sketchdnponfab yes)+			(crossoutdnponfab yes)+			(subtractmaskfromsilk no)+			(outputformat 1)+			(mirror no)+			(drillshape 1)+			(scaleselection 1)+			(outputdirectory "")+		)+	)+	(embedded_fonts no)+	(gr_line (start 113 71.28) (end 141 71.28) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "41c4f04a-c15f-4905-8827-410e21d2eb3b"))+	(gr_arc (start 141 71.28) (mid 141.707107 71.572893) (end 142 72.28) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "739169e3-ee8d-4ea4-8e51-bf16394c97ff"))+	(gr_line (start 142 72.28) (end 142 90.28) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "6f934fcc-4eba-4997-b150-f927927e6665"))+	(gr_arc (start 142 90.28) (mid 141.707107 90.987107) (end 141 91.28) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "c97f195d-960a-4993-9b2a-ae63c7399290"))+	(gr_line (start 141 91.28) (end 113 91.28) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "30571700-daf4-495b-a72d-5fa096c107b3"))+	(gr_arc (start 113 91.28) (mid 112.292893 90.987107) (end 112 90.28) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "f396b81f-ce97-490a-b3fb-4698e9db4627"))+	(gr_line (start 112 90.28) (end 112 72.28) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "7e2dfd73-da0e-408c-a7ad-bdf000789c73"))+	(gr_arc (start 112 72.28) (mid 112.292893 71.572893) (end 113 71.28) (stroke (width 0.1) (type default)) (layer "Edge.Cuts") (uuid "b1eaaecf-94a0-485d-bcc7-64f57f275796"))+(footprint "AdomTour:R_0805_AdomTour"+	(layer "F.Cu")+		(uuid "4a8b9daf-ee0b-4372-aa70-93b30bef97e4")+		(at 127 81.28)+	(descr "Resistor SMD 0805 (2012 Metric), square (rectangular) end terminal, IPC-7351 nominal, (Body size source: IPC-SM-782 page 72, https://www.pcb-3d.com/wordpress/wp-content/uploads/ipc-sm-782a_amendment_1_and_2.pdf)")+	(tags "resistor")+	(property "Reference" "R1"+		(at 0 -1.65 0)+		(layer "F.SilkS")+		(effects+			(font+				(size 1 1)+				(thickness 0.15)+			)+		)+	)+	(property "Value" "10k"+		(at 0 1.65 0)+		(layer "F.Fab")+		(effects+			(font+				(size 1 1)+				(thickness 0.15)+			)+		)+	)+	(property "KiLib_Generator" "SMD_2terminal_chip_molded"+		(at 0 0 0)+		(layer "F.SilkS")+		(hide yes)+		(effects+			(font+				(size 1 1)+				(thickness 0.15)+			)+		)+	)+	(attr smd)+	(duplicate_pad_numbers_are_jumpers no)+	(fp_line+		(start -0.227064 -0.735)+		(end 0.227064 -0.735)+		(stroke+			(width 0.12)+			(type solid)+		)+		(layer "F.SilkS")+	)+	(fp_line+		(start -0.227064 0.735)+		(end 0.227064 0.735)+		(stroke+			(width 0.12)+			(type solid)+		)+		(layer "F.SilkS")+	)+	(fp_rect+		(start -1.68 -0.95)+		(end 1.68 0.95)+		(stroke+			(width 0.05)+			(type solid)+		)+		(fill no)+		(layer "F.CrtYd")+	)+	(fp_rect+		(start -1 -0.625)+		(end 1 0.625)+		(stroke+			(width 0.1)+			(type solid)+		)+		(fill no)+		(layer "F.Fab")+	)+	(fp_text user "${REFERENCE}"+		(at 0 0 0)+		(layer "F.Fab")+		(effects+			(font+				(size 0.5 0.5)+				(thickness 0.08)+			)+		)+	)+	(pad "1" smd roundrect+		(at -0.9125 0)+		(size 1.025 1.4)+		(layers "F.Cu" "F.Mask" "F.Paste")+		(roundrect_rratio 0.243902)+	)+	(pad "2" smd roundrect+		(at 0.9125 0)+		(size 1.025 1.4)+		(layers "F.Cu" "F.Mask" "F.Paste")+		(roundrect_rratio 0.243902)+	)+	(embedded_fonts no)+	(model "${KICAD10_3DMODEL_DIR}/Resistor_SMD.3dshapes/R_0805_2012Metric.step"+		(offset+			(xyz 0 0 0)+		)+		(scale+			(xyz 1 1 1)+		)+		(rotate+			(xyz 0 0 0)+		)+	)+)++)+--- a/tour-pack/template/tour-demo.kicad_pro+++ b/tour-pack/template/tour-demo.kicad_pro@@ -0,0 +1,75 @@+{+  "board": {+    "design_settings": {+      "defaults": {},+      "rules": {},+      "track_widths": [],+      "via_dimensions": []+    }+  },+  "meta": {+    "filename": "tour-demo.kicad_pro",+    "version": 3+  },+  "net_settings": {+    "classes": [+      {+        "bus_width": 12,+        "clearance": 0.2,+        "diff_pair_gap": 0.25,+        "diff_pair_via_gap": 0.25,+        "diff_pair_width": 0.2,+        "line_style": 0,+        "microvia_diameter": 0.3,+        "microvia_drill": 0.1,+        "name": "Default",+        "pcb_color": "rgba(0, 0, 0, 0.000)",+        "priority": 2147483647,+        "schematic_color": "rgba(0, 0, 0, 0.000)",+        "track_width": 0.2,+        "via_diameter": 0.6,+        "via_drill": 0.3,+        "wire_width": 6+      }+    ],+    "meta": {+      "version": 5+    },+    "net_colors": null,+    "netclass_assignments": null,+    "netclass_patterns": []+  },+  "pcbnew": {+    "last_paths": {+      "gencad": "",+      "idf": "",+      "netlist": "",+      "plot": "",+      "pos_files": "",+      "specctra_dsn": "",+      "step": "",+      "svg": "",+      "vrml": ""+    },+    "page_layout_descr_file": ""+  },+  "schematic": {+    "legacy_lib_dir": "",+    "legacy_lib_list": [],+    "top_level_sheets": [+      {+        "filename": "tour-demo.kicad_sch",+        "name": "tour-demo",+        "uuid": "29d2ee99-c9f8-4d10-bfe1-baa46f373d38"+      }+    ]+  },+  "sheets": [+    [+      "29d2ee99-c9f8-4d10-bfe1-baa46f373d38",+      "Root"+    ]+  ],+  "text_variables": {}+}+

Comments

No comments yet.

Log in to comment.