← All Pull Requests

kicad-lane: 1.0.83, schematic_review, engine 0.16.0 #44

Merged opened by Colby Knox 2026-10-29

kicad-lane: 1.0.83, kicad_schematic_review (the standard's schematic_review, exact) and engine 0.16.0.

  • kicad_core::netlist::review_inputs turns KiCad's netlist into the engine's sheetParts and netlist:
    • each part with its pins (number, name, electrical type mapped: power_in/power_out to power, bidirectional to io, tri_state to hiz, open_collector/open_emitter to open_collector, free/passive to passive, +no_connect to not_connected);
    • the nets without KiCad's single-pin unconnected-(...) nets, so a pin wired to nothing reads as unconnected. There is a unit test.
  • kicad_schematic_review {projectPath | filePath} (verbs_netlist): kicad-cli's netlist of the saved schematic, reviewed by the engine's schematic_review.
  • Live (KiCad 10.0.7): the wired RC filter is ok. The unwired rp2040 sample gives 5 errors (power pins on no net, such as ADC_AVDD and VREG_VIN), 77 unconnected-pin warnings and 2 no_decoupling warnings (DVDD, IOVDD).
  • Engine: adom-eda-checks 0.16.0 (standard 0.19.0, 1f430023), vendored unchanged. KiCad keeps its own route_pair, length_tune, auto_classes, silk_designators, pin_pad_check and find_space (real courtyards, live editor).
  • Battery: schematic_review: the RC is clean, and the rp2040 sample's power pins are caught.

Full battery: 313/0/1. Unit tests: 695/0.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/bridge.json+++ b/bridge.json@@ -1,423 +1,425 @@⋯ 313 unchanged lines ⋯     "kicad_dialog_log",     "kicad_license_status",     "kicad_run_script",-    "kicad_eval"+    "kicad_eval",+    "kicad_schematic_review"   ],   "statusVerb": "kicad_status",   "timeouts": {⋯ 100 unchanged lines ⋯   "releasedAt": "2026-06-26T16:00:00Z",   "uninstall": "kicad_uninstall" }+--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯  [[package]] name = "adom-eda-checks"-version = "0.12.2"+version = "0.16.0" dependencies = [  "roxmltree",  "serde_json",⋯ 302 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.82"+version = "1.0.83" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.82"+version = "1.0.83" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.82"+version = "1.0.83" 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.82"+version = "1.0.83" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/rust/crates/kicad-bridge/src/verbs_netlist.rs+++ b/rust/crates/kicad-bridge/src/verbs_netlist.rs@@ -1,329 +1,367 @@⋯ 12 unchanged lines ⋯  pub static VERBS: &[Verb] = &[     Verb {+        name: "kicad_schematic_review",+        summary: "Review the schematic before updating the board: every pin (with its electrical type) against KiCad's own netlist of the design. Errors: power pins on no net, outputs driving each other. Warnings: pins on no net, one-pin nets, inputs nothing drives, IC supplies with no decoupling capacitor to ground.",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 120,+        input: "{\"projectPath\": \"C:/.../x.kicad_pro\" | \"filePath\": \"C:/.../x.kicad_sch\" (the root sheet)}",+        example: "kicad_schematic_review {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\"}",+        hint: "ok is true with no errors. errors and warnings are [{check, pin|net, ...}]; decoupling lists each IC supply net with its ICs and capacitors. KiCad's single-pin unconnected-(...) nets count as no net; a pin with a no-connect flag is expected to be unconnected. The standard's schematic_review, computed by its engine (engine says which). kicad_run_erc is KiCad's own ERC with its rules.",+        related: &["kicad_run_erc", "kicad_extract_netlist", "kicad_pin_pad_check", "kicad_update_pcb_from_schematic"],+        pitfalls: &["Pin types come from the symbols: a passive pin is never 'undriven'.", "Reads the saved schematic (kicad-cli): save it in KiCad first."],+    },+    Verb {         name: "kicad_extract_netlist",         summary: "Extract the full netlist from a schematic: every component and every net with its nodes (read-only, via kicad-cli).",         mechanism: Mechanism::Cli, risk: "read", timeout_sec: 150,⋯ 48 unchanged lines ⋯ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {     Some(match command {         "kicad_extract_netlist" => extract_netlist(state, args),+        "kicad_schematic_review" => schematic_review(state, args),         "kicad_netlist" => standard_netlist(state, args),         "kicad_trace_net" => trace_net(state, args),         "kicad_find_connections" => find_connections(state, args),⋯ 254 unchanged lines ⋯     let rest = s[head.len() + digits.len()..].to_string();     (head, digits.parse().unwrap_or(0), rest) }++/// kicad_schematic_review: KiCad's netlist of the design as the standard's sheetParts and netlist+/// (netlist::review_inputs), reviewed by the shared engine.+fn schematic_review(state: &mut State, args: &Value) -> Value {+    let mut a = args.clone();+    if arg_str(args, "filePath").is_none() {+        match arg_str(args, "projectPath") {+            Some(p) => a["filePath"] = json!(norm(&PathBuf::from(p).with_extension("kicad_sch"))),+            None => return fail("missing_arg", "projectPath or filePath (the root .kicad_sch) is required", "Pass the project; its root sheet is the .kicad_sch beside the .kicad_pro."),+        }+    }+    let (model, sch) = match model_for(state, &a) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let (parts, nets) = netlist::review_inputs(&model);+    let mut v = adom_eda_checks::run("schematic_review", &json!({"sheetParts": parts, "netlist": nets}));+    if v["success"] == json!(true) {+        v["sheets"] = json!([sch]);+        v["source"] = json!("kicad-cli netlist (the saved schematic)");+        let hint = v["_hint"].as_str().unwrap_or("").to_string();+        v["_hint"] = json!(format!("{hint} In KiCad: sch_connect is kicad_sch_connect_to_net, sch_place_part is kicad_sch_place_symbol, update_pcb is kicad_update_pcb_from_schematic."));+    }+    v+}++--- a/rust/crates/kicad-core/src/netlist.rs+++ b/rust/crates/kicad-core/src/netlist.rs@@ -1,810 +1,861 @@⋯ 279 unchanged lines ⋯     Miss { candidates: Vec<String> }, } +/// The standard's schematic_review inputs (sheetParts, netlist) from KiCad's own netlist: each part with its pins+/// (number, name, electrical type in the engine's words), and the nets without KiCad's single-pin+/// `unconnected-(...)` nets, so a pin wired to nothing reads as unconnected. A pin KiCad marks no-connect+/// (`+no_connect`) is `not_connected`.+pub fn review_inputs(model: &Netlist) -> (serde_json::Value, serde_json::Value) {+    use serde_json::json;+    let kind = |t: &str| -> &'static str {+        if t.contains("no_connect") {+            return "not_connected";+        }+        match t {+            "power_in" | "power_out" => "power",+            "input" => "input",+            "output" => "output",+            "bidirectional" => "io",+            "tri_state" => "hiz",+            "open_collector" | "open_emitter" => "open_collector",+            "passive" | "free" => "passive",+            _ => "unknown",+        }+    };+    let mut parts: std::collections::BTreeMap<String, Vec<serde_json::Value>> = std::collections::BTreeMap::new();+    let mut nets = serde_json::Map::new();+    for n in &model.nets {+        for nd in &n.nodes {+            parts.entry(nd.reference.clone()).or_default().push(json!({"number": nd.pin, "name": nd.pinfunction, "electrical": kind(&nd.pintype)}));+        }+        if !n.name.starts_with("unconnected-(") {+            nets.insert(n.name.trim_start_matches('/').to_string(), json!(n.nodes.iter().map(|nd| format!("{}.{}", nd.reference, nd.pin)).collect::<Vec<_>>()));+        }+    }+    let comps: Vec<serde_json::Value> = parts.into_iter().map(|(r, pins)| json!({"designator": r, "pins": pins})).collect();+    (json!({"components": comps}), json!({"nets": nets}))+}+ pub fn trace_net<'a>(model: &'a Netlist, net_name: &str) -> TraceResult<'a> {     if let Some(n) = model.nets.iter().find(|n| n.name == net_name) {         return TraceResult::Found(n);⋯ 283 unchanged lines ⋯  #[cfg(test)] mod tests {++    #[test]+    fn review_inputs_drop_kicads_unconnected_nets_and_map_pin_types() {+        let m = Netlist { components: vec![], nets: vec![+            Net { name: "/VCC".into(), code: "1".into(), nodes: vec![Node { reference: "U1".into(), pin: "8".into(), pinfunction: "VCC".into(), pintype: "power_in".into() }, Node { reference: "C1".into(), pin: "1".into(), pinfunction: "".into(), pintype: "passive".into() }] },+            Net { name: "unconnected-(U1-NC-Pad3)".into(), code: "2".into(), nodes: vec![Node { reference: "U1".into(), pin: "3".into(), pinfunction: "NC".into(), pintype: "input+no_connect".into() }] },+            Net { name: "unconnected-(U1-IN-Pad2)".into(), code: "3".into(), nodes: vec![Node { reference: "U1".into(), pin: "2".into(), pinfunction: "IN".into(), pintype: "tri_state".into() }] },+        ] };+        let (parts, nets) = review_inputs(&m);+        assert_eq!(nets["nets"]["VCC"], serde_json::json!(["U1.8", "C1.1"]));+        assert_eq!(nets["nets"].as_object().unwrap().len(), 1, "KiCad's unconnected-(...) nets are not nets");+        let u1 = parts["components"].as_array().unwrap().iter().find(|c| c["designator"] == "U1").unwrap().clone();+        let ty = |n: &str| u1["pins"].as_array().unwrap().iter().find(|p| p["number"] == n).unwrap()["electrical"].clone();+        assert_eq!((ty("8"), ty("3"), ty("2")), (serde_json::json!("power"), serde_json::json!("not_connected"), serde_json::json!("hiz")));+    }     use super::*;      /// Hand-written in the shape kicad-cli 8/9/10 emit for --format kicadxml.⋯ 233 unchanged lines ⋯         assert!(RAW_FORMATS.windows(2).all(|w| w[0] < w[1]), "RAW_FORMATS is sorted, matching Python's allowed list");     } }+--- 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.12.2"+version = "0.16.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,112 +1,121 @@⋯ 11 unchanged lines ⋯ | Check | Reads | What it answers | |---|---|---| | `assembly_check` | board, stack? | the board against JLCPCB's two PCBA tiers as published (with the page and its read date): size (or `panelMm`), thickness and layers from the stack, one or two sides, the smallest two-pad part and the finest IC pitch, fiducials, and parts without an LCSC number (`lcscMissing` from the bridge's BOM). Pads sharing one centre are one land, and a component with no pads is no part. `service` is the cheaper tier every rule allows. Matches the Altium bridge's answers on its test boards. From its assembly.rs |+| `auto_classes` | board | net classes from the names the nets already have (GND, Power, Clock, HighSpeed) and differential pairs from their suffixes (`_P`/`_N`, `DP`/`DN`, `+`/`-`), with `applyArgs`: the net_classes and diff_pairs calls that write them (Power with a `powerWidthMm` width rule). From the Altium bridge's autoclass.rs | | `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 |+| `check_orientation` | board | advisory: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there, distance to the nearest pad of a plane-like net such as GND); turns saving `minImprovementMm` (0.6) or more are suggested, nothing moves. From the Altium bridge's design.rs | | `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 |+| `find_space` | board, bodies? | free spots for a part (`part`, or `sizeMm`) near another (`near`) or a point (`at`): clear of other part boxes on that side by `clearanceMm`, of that side's routed tracks and the vias, `edgeMm` inside the outline (arcs sampled), nearest first, as where the part's origin goes; `check: true` lists parts that overlap (and, with bodies, those `stacked` under a raised one) or have pads off the outline, as placement_check judges them. Pours do not block (they re-pour). From the Altium bridge's design.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 |+| `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools |+| `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs | | `net_lengths` | board | the routed length of each net (tracks and arcs, by layer, with its vias), the skew of each differential pair (`pairs`, else found by net name: `_P`/`_N`, `+`/`-`) and the spread of each matched-length group against its tolerance (`groups`). From the Altium bridge's lengths.rs | | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools | | `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`); with no path, `ends` names the avoided pours at each end pad and whether they are exempt, and `withoutAvoid` says whether a path exists through them. The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) |+| `route_pair` | board, stack?, rules? | a differential pair planned as one centreline (pad midpoints through `via` waypoints) with each net offset half a pitch to its own side, mitred corners and a `fanMm` fan-out; width and gap given, or from `diffOhms` and the stack. `problems` names a crossing or other copper (pads by outline, routed tracks, vias) closer than the clearance; `routeNet` holds both nets' route_net arguments and `widthRule` the pair's add_rule. From the Altium bridge's pairroute.rs |+| `schematic_review` | sheetParts, netlist | every placed pin, with its electrical type, against the compiled netlist: power pins on no net and outputs driving each other (errors); unconnected pins, one-pin nets, inputs nothing drives and IC supplies with no capacitor to ground (warnings; a regulator's switch node on an inductor is no missing capacitor). From the Altium bridge's schreview.rs |+| `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs | | `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 | ⋯ 67 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.12.2"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.16.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.15.2-- Standard commit: 3a8c1a955b01ed5302d755c059fa95d222ff9388-- Crate version: 0.12.2+- Standard version: 0.19.0+- Standard commit: 1f43002359e5ba5d169ce17766118de8120b25d1+- Crate version: 0.16.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/autoclass.rs+++ b/rust/vendor/adom-eda-checks/src/autoclass.rs@@ -0,0 +1,85 @@+//! Net classes from the names a designer already gave the nets: ground, power, clocks, the fast interfaces (USB,+//! Ethernet, HDMI, PCIe, DDR, MIPI, LVDS, SATA), and the differential pairs their _P/_N (+/-, DP/DN) suffixes spell;+//! proposed, with the net_classes and diff_pairs arguments that write them. From the Altium bridge's autoclass.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++/// The class a net's name puts it in, if any. Pure.+pub fn classify(net: &str) -> Option<&'static str> {+    let n = net.to_ascii_uppercase();+    let core = n.trim_start_matches(['+', '-', '/', '\\']);+    if crate::placetools::is_ground(&n) || core.starts_with("GND") { return Some("GND"); }+    let power = core.contains("VBUS") || core.starts_with("VCC") || core.starts_with("VDD") || core.starts_with("VBAT") || core.starts_with("VIN") || core.starts_with("VBUS")+        || core.starts_with("VSYS") || core.starts_with("VREF") || core.starts_with("PWR") || core.starts_with("VCORE") || core.starts_with("VOUT")+        || (core.starts_with('V') && core.len() > 1 && core.as_bytes()[1].is_ascii_digit())+        || core.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) && (core.contains('V'));+    // a supply's name wins over what it feeds (VDD_XTAL is the crystal's supply, not a clock)+    if power { return Some("Power"); }+    let fast = ["USB", "ETH", "RGMII", "RMII", "SGMII", "HDMI", "PCIE", "DDR", "MIPI", "CSI", "DSI", "LVDS", "SATA", "SERDES", "QSPI", "SDIO", "EMMC"];+    if fast.iter().any(|f| core.contains(f)) { return Some("HighSpeed"); }+    if core.contains("CLK") || core.contains("XTAL") || core.contains("OSC") || core.ends_with("SCK") { return Some("Clock"); }+    None+}++/// Differential pairs by name: X_P/X_N, XP/XN, X+/X-, X_DP/X_DM. Pure.+pub fn pairs(nets: &[String]) -> Vec<(String, String, String)> {+    let set: BTreeMap<String, &String> = nets.iter().map(|n| (n.to_ascii_uppercase(), n)).collect();+    let mut out = Vec::new();+    for (u, orig) in &set {+        // longest suffixes first: USB_DP must pair as USB (DP/DN), not as USB_D (P/N)+        for (p, q) in [("_DP", "_DN"), ("_DP", "_DM"), ("DP", "DN"), ("DP", "DM"), ("_P", "_N"), ("+", "-"), ("P", "N")] {+            if let Some(stem) = u.strip_suffix(p) {+                if stem.is_empty() { continue; }+                if let Some(neg) = set.get(&format!("{stem}{q}")) {+                    let name = stem.trim_end_matches('_').to_string();+                    if !out.iter().any(|(n, _, _): &(String, String, String)| *n == name) { out.push((name, (*orig).clone(), (*neg).clone())); }+                    break;+                }+            }+        }+    }+    out+}++/// auto_classes: input {board, args: {powerWidthMm?}}. A proposal from the net names, and `applyArgs`: one net_classes+/// call per class (Power with a width rule) and one diff_pairs call per pair, for the bridge to run.+pub fn auto_classes(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let nets: Vec<String> = st["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|n| n.as_str().map(String::from)).collect();+    let mut classes: BTreeMap<&str, Vec<String>> = BTreeMap::new();+    for n in &nets { if let Some(c) = classify(n) { classes.entry(c).or_default().push(n.clone()); } }+    let prs = pairs(&nets);+    let width = input["args"]["powerWidthMm"].as_f64().unwrap_or(0.5);+    let net_classes: Vec<Value> = classes.iter().map(|(c, members)| { let mut a = json!({"name": c, "nets": members}); if *c == "Power" { a["width"] = json!(width); } a }).collect();+    let diff_pairs: Vec<Value> = prs.iter().map(|(n, p, q)| json!({"name": n, "pos": p, "neg": q})).collect();+    json!({"success": true, "applied": false, "proposal": {"classes": classes, "pairs": diff_pairs, "unclassified": nets.iter().filter(|n| classify(n).is_none()).count()},+        "applyArgs": {"net_classes": net_classes, "diff_pairs": diff_pairs},+        "_hint": "A proposal from the net names. Check it, then run each applyArgs.net_classes entry through net_classes (Power gets a width rule, powerWidthMm) and each applyArgs.diff_pairs entry through diff_pairs. Then impedance sizes the pairs and current_capacity checks the power nets."})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn names_sort_into_classes_and_pairs() {+        for (n, c) in [("GND", Some("GND")), ("AGND", Some("GND")), ("+3V3", Some("Power")), ("VDD_CORE", Some("Power")), ("5V", Some("Power")), ("VBUS", Some("Power")),+                       ("USB_DP", Some("HighSpeed")), ("ETH_TX_P", Some("HighSpeed")), ("MCLK", Some("Clock")), ("VDD_XTAL", Some("Power")), ("USB_VBUS", Some("Power")), ("XTAL_IN", Some("Clock")), ("SPI_SCK", Some("Clock")), ("N1", None), ("LED_RED", None)] {+            assert_eq!(classify(n), c, "{n}");+        }+        let nets: Vec<String> = ["USB_DP", "USB_DN", "ETH_TX_P", "ETH_TX_N", "LVDS0+", "LVDS0-", "SDA"].iter().map(|s| s.to_string()).collect();+        let p = pairs(&nets);+        assert_eq!(p.len(), 3);+        assert!(p.iter().any(|(n, a, b)| n == "USB" && a == "USB_DP" && b == "USB_DN"));+        assert!(p.iter().any(|(n, a, b)| n == "LVDS0" && a == "LVDS0+" && b == "LVDS0-"));+    }++    #[test]+    fn the_verb_answers_the_arguments_that_write_them() {+        let r = auto_classes(&json!({"board": {"nets": ["GND", "+3V3", "USB_DP", "USB_DN", "LED"]}, "args": {"powerWidthMm": 0.6}}));+        assert_eq!(r["proposal"]["unclassified"], json!(1));+        assert!(r["applyArgs"]["net_classes"].as_array().unwrap().iter().any(|c| c["name"] == "Power" && c["width"] == json!(0.6)));+        assert_eq!(r["applyArgs"]["diff_pairs"], json!([{"name": "USB", "pos": "USB_DP", "neg": "USB_DN"}]));+    }+}+--- a/rust/vendor/adom-eda-checks/src/lengthtune.rs+++ b/rust/vendor/adom-eda-checks/src/lengthtune.rs@@ -0,0 +1,139 @@+//! Length tuning: a serpentine in the net's longest straight track that has room beside it, its bumps sized to add+//! exactly the length a target (or another net's length) asks for, every bump clear of other copper and inside the+//! board, or a refusal that says why. From the Altium bridge's lengthtune.rs; the plan is answered as the track to+//! remove and the route_net arguments that replace it, for the bridge to apply in one undo step.+use crate::route::{pad_shape, Shape};+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type P = (f64, f64);++/// The serpentine's points replacing a track a->b: `n` rectangular bumps of height `amp` to the side `side` (+1 left,+/// -1 right), each `pitch` long along the track, centred on it. Pure.+pub fn meander(a: P, b: P, n: usize, amp: f64, pitch: f64, side: f64) -> Vec<P> {+    let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();+    let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+    let (nx, ny) = (-uy * side, ux * side);+    let at = |s: f64, h: f64| (a.0 + ux * s + nx * h, a.1 + uy * s + ny * h);+    let start = (len - n as f64 * pitch) / 2.0;+    let mut pts = vec![a];+    for k in 0..n {+        let s = start + k as f64 * pitch;+        pts.push(at(s, 0.0));+        pts.push(at(s, amp));+        pts.push(at(s + pitch / 2.0, amp));+        pts.push(at(s + pitch / 2.0, 0.0));+    }+    pts.push(b);+    pts.dedup_by(|p, q| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9);+    pts+}++/// How many bumps and how tall, to add `extra` mm along a track `len` long: as few bumps as fit at `max_amp`, each+/// `pitch` long, leaving `margin` at both ends. None when it cannot fit. Pure.+pub fn sizing(extra: f64, len: f64, pitch: f64, max_amp: f64, margin: f64) -> Option<(usize, f64)> {+    if extra <= 0.0 { return None; }+    let room = ((len - 2.0 * margin) / pitch).floor() as usize;+    if room == 0 { return None; }+    let n = ((extra / (2.0 * max_amp)).ceil() as usize).max(1);+    if n > room { return None; }+    Some((n, extra / (2.0 * n as f64)))+}++/// length_tune: input {board, rules?, args: {net, targetMm? | matchTo?, maxAmplitudeMm?, pitchMm?, clearance?}}.+pub fn length_tune(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let Some(net) = args["net"].as_str().map(String::from) else { return crate::input::refuse("bad_args", "net is the net to lengthen") };+    let lens = crate::lengths::lengths(&st);+    let len_of = |n: &str| lens.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| f(&v["lengthMm"]));+    let Some(now) = len_of(&net) else { return crate::input::refuse("not_routed", &format!("{net} has no routed track")) };+    let target = match (args["targetMm"].as_f64(), args["matchTo"].as_str()) {+        (Some(t), _) => t,+        (None, Some(m)) => match len_of(m) { Some(l) => l, None => return crate::input::refuse("not_routed", &format!("{m} has no routed track to match")) },+        _ => return crate::input::refuse("bad_args", "targetMm (mm) or matchTo (a net) says how long it should be"),+    };+    let extra = target - now;+    if extra <= 0.01 { return json!({"success": true, "changed": false, "net": net, "lengthMm": r3(now), "targetMm": r3(target), "_hint": "Already at or past the target: tuning only lengthens. Shorten the other net, or route this one more directly."}); }+    let num = |k: &str, d: f64| args[k].as_f64().unwrap_or(d);+    let rule = crate::fab::routing_clearance(&crate::input::rules(input));+    let clearance = num("clearance", 0.0).max(rule.unwrap_or(0.2));+    let same = |v: &Value| v["net"].as_str().map(|n| n.eq_ignore_ascii_case(&net)).unwrap_or(false);+    // other nets' copper by layer ("*" every layer): routed tracks (a hatched pour's net-less pieces re-pour round the+    // bumps), vias, and pads by their real outline+    let mut other: Vec<(String, Shape)> = Vec::new();+    for t in st["tracks"].as_array().into_iter().flatten().filter(|t| !same(t) && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {+        other.push((t["layer"].as_str().unwrap_or("").into(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)));+    }+    for v in st["vias"].as_array().into_iter().flatten().filter(|v| !same(v)) {+        let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+        other.push(("*".into(), Shape::Cap(c, c, f(&v["size"]) / 2.0)));+    }+    for p in st["pads"].as_array().into_iter().flatten().filter(|p| !same(p)) {+        let l = if p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0 { "*".to_string() } else { p["layer"].as_str().unwrap_or("").to_string() };+        other.push((l, pad_shape(p)));+    }+    let ring = crate::coppermap::outline_ring(&st);+    // the net's tracks, longest first; for each, a side where the serpentine fits+    let mut tracks: Vec<Value> = st["tracks"].as_array().cloned().unwrap_or_default().into_iter().filter(same).collect();+    let tlen = |t: &Value| ((f(&t["end"]["x"]) - f(&t["start"]["x"])).powi(2) + (f(&t["end"]["y"]) - f(&t["start"]["y"])).powi(2)).sqrt();+    tracks.sort_by(|a, b| tlen(b).partial_cmp(&tlen(a)).unwrap());+    let mut why = Vec::new();+    for t in &tracks {+        let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));+        let w = f(&t["width"]);+        let layer = t["layer"].as_str().unwrap_or("F.Cu").to_string();+        let pitch = num("pitchMm", (4.0 * w).max(4.0 * clearance + 2.0 * w));+        let Some((n, amp)) = sizing(extra, tlen(t), pitch, num("maxAmplitudeMm", 3.0), 2.0 * w) else { why.push(json!({"track": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "why": "too short for the bumps this needs at this amplitude"})); continue };+        for side in [1.0, -1.0] {+            let pts = meander(a, b, n, amp, pitch, side);+            let clear = pts.windows(2).all(|s| other.iter().filter(|o| o.0 == layer || o.0 == "*").all(|o| o.1.dist(s[0], s[1]) >= w / 2.0 + clearance - 1e-9))+                && (ring.len() < 3 || pts.iter().all(|p| crate::route::inside(*p, &ring)));+            if !clear { continue; }+            let added: f64 = pts.windows(2).map(|s| ((s[1].0 - s[0].0).powi(2) + (s[1].1 - s[0].1).powi(2)).sqrt()).sum::<f64>() - tlen(t);+            let way: Vec<Value> = pts.iter().map(|p| json!({"x": r3(p.0), "y": r3(p.1), "layer": layer})).collect();+            return json!({"success": true, "changed": false, "net": net, "lengthMm": r3(now), "targetMm": r3(target), "expectedMm": r3(now + added), "clearanceMm": clearance,+                "plan": {"layer": layer, "replaces": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "bumps": n, "amplitudeMm": r3(amp), "pitchMm": r3(pitch), "side": if side > 0.0 { "left" } else { "right" }, "addsMm": r3(added)},+                "remove": {"tracks": [{"net": net, "layer": layer, "start": {"x": a.0, "y": a.1}, "end": {"x": b.0, "y": b.1}}]},+                "routeNet": {"net": net, "layer": layer, "width": w, "paths": [way]}, "revision": st["revision"],+                "_hint": "A plan: remove the one track in `remove` and route `routeNet` in its place, in one undo step, with expectedRevision: revision. net_lengths then shows the new length; every bump keeps clearanceMm from other nets' pads, routed tracks and vias."});+        }+        why.push(json!({"track": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "why": "no clear room on either side for the bumps"}));+    }+    json!({"success": false, "errorCode": "no_room", "error": format!("no track of {net} has room for {:.2} mm more", extra), "lengthMm": r3(now), "targetMm": r3(target), "tried": why,+           "_hint": "Lower maxAmplitudeMm for more, shorter bumps, raise it for fewer, or reroute the net with a longer straight run in open space."})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn a_meander_adds_exactly_twice_its_height_per_bump() {+        let (n, amp) = sizing(6.0, 30.0, 1.2, 1.0, 0.4).unwrap();+        assert_eq!(n, 3);+        assert!((amp - 1.0).abs() < 1e-9);+        let pts = meander((0.0, 0.0), (30.0, 0.0), n, amp, 1.2, 1.0);+        let len: f64 = pts.windows(2).map(|s| ((s[1].0 - s[0].0).powi(2) + (s[1].1 - s[0].1).powi(2)).sqrt()).sum();+        assert!((len - 36.0).abs() < 1e-9, "{len}");+        assert!(sizing(100.0, 5.0, 1.2, 1.0, 0.4).is_none());+        assert!(sizing(-1.0, 30.0, 1.2, 1.0, 0.4).is_none());+    }++    #[test]+    fn the_verb_plans_round_other_copper_to_match_a_net() {+        let t = |net: &str, y: f64, x1: f64| json!({"net": net, "layer": "F.Cu", "width": 0.2, "start": {"x": 2.0, "y": y}, "end": {"x": x1, "y": y}});+        // A is 30 mm, B 24 mm; another net runs just above A, so the bumps go below it+        let b = json!({"outline": [[0, -20], [40, -20], [40, 20], [0, 20]], "revision": "r3",+            "tracks": [t("A", 0.0, 32.0), t("B", 10.0, 26.0), json!({"net": "X", "layer": "F.Cu", "width": 0.2, "start": {"x": 2.0, "y": 0.6}, "end": {"x": 32.0, "y": 0.6}})]});+        let r = length_tune(&json!({"board": b, "args": {"net": "B", "matchTo": "A"}}));+        assert_eq!(r["success"], json!(true), "{r}");+        assert!((f(&r["expectedMm"]) - 30.0).abs() < 1e-6, "{r}");+        let a = length_tune(&json!({"board": b, "args": {"net": "A", "targetMm": 34.0}}));+        assert_eq!(a["plan"]["side"], "right", "{a}");+        assert_eq!(length_tune(&json!({"board": b, "args": {"net": "A", "targetMm": 20.0}}))["changed"], json!(false));+        assert_eq!(length_tune(&json!({"board": b, "args": {"net": "Q", "targetMm": 20.0}}))["errorCode"], "not_routed");+    }+}+--- a/rust/vendor/adom-eda-checks/src/lib.rs+++ b/rust/vendor/adom-eda-checks/src/lib.rs@@ -1,71 +1,86 @@⋯ 7 unchanged lines ⋯ use serde_json::Value;  pub mod assembly;+pub mod autoclass; pub mod bodies; pub mod coppermap; pub mod cost;⋯ 4 unchanged lines ⋯ pub mod impedance; pub mod input; pub mod lengths;+pub mod lengthtune;+pub mod pairroute; pub mod placement; pub mod placetools; pub mod route;+pub mod schreview;+pub mod silkplace;+pub mod space; pub mod writers;  /// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand⋯ 1 unchanged line ⋯ pub const VERSION: &str = env!("CARGO_PKG_VERSION");  /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "check_clearance", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "height_check", "impedance", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "route_pair", "schematic_review", "silk_designators", "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 {⋯ 6 unchanged lines ⋯     if let Some(out) = writers::run(check, input) { return out; }     match check {         "assembly_check" => assembly::assembly_check(input),+        "auto_classes" => autoclass::auto_classes(input),         "check_clearance" => placetools::check_clearance(input),+        "check_orientation" => space::check_orientation(input),         "copper_balance" => coppermap::copper_balance(input),         "current_capacity" => current::current_capacity(input),         "estimate_cost" => cost::estimate_cost(input),+        "find_space" => space::find_space(input),         "fab_check" => fab::fab_check(input),         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),+        "length_tune" => lengthtune::length_tune(input),         "net_lengths" => lengths::net_lengths(input),+        "pin_pad_check" => schreview::pin_pad_check(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),+        "route_pair" => pairroute::route_pair(input),+        "schematic_review" => schreview::schematic_review(input),+        "silk_designators" => silkplace::silk_designators(input),         "thermal_vias" => features::thermal_vias(input),         "via_fence" => features::via_fence(input),         _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))),⋯ 9 unchanged lines ⋯         assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]);     } }+--- a/rust/vendor/adom-eda-checks/src/pairroute.rs+++ b/rust/vendor/adom-eda-checks/src/pairroute.rs@@ -0,0 +1,196 @@+//! A differential pair planned as a pair: one centreline from the midpoint of the source pads through waypoints to the+//! midpoint of the destination pads, each net offset half a pitch (width + gap) to its own side with mitred corners, a+//! short fan-out from each pad, both lengths and the skew, and what the plan would run into. From the Altium bridge's+//! pairroute.rs; the plan is answered as route_net arguments (and the pair's add_rule width rule), for the bridge to+//! apply.+use crate::route::{pad_shape, Shape};+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type P = (f64, f64);++fn sub(a: P, b: P) -> P { (a.0 - b.0, a.1 - b.1) }+fn norm(a: P) -> P { let l = (a.0 * a.0 + a.1 * a.1).sqrt(); if l > 0.0 { (a.0 / l, a.1 / l) } else { (0.0, 0.0) } }+fn left(d: P) -> P { (-d.1, d.0) }++/// The polyline offset `d` to the left (negative: right), corners mitred. Pure.+pub fn offset(path: &[P], d: f64) -> Vec<P> {+    let n = path.len();+    (0..n).map(|i| {+        let din = if i > 0 { norm(sub(path[i], path[i - 1])) } else { norm(sub(path[1], path[0])) };+        let dout = if i + 1 < n { norm(sub(path[i + 1], path[i])) } else { din };+        let (n1, n2) = (left(din), left(dout));+        let m = norm((n1.0 + n2.0, n1.1 + n2.1));+        let k = (m.0 * n1.0 + m.1 * n1.1).max(0.2); // a hairpin would blow the mitre up: cap it at 5x+        (path[i].0 + m.0 * d / k, path[i].1 + m.1 * d / k)+    }).collect()+}++fn length(p: &[P]) -> f64 { p.windows(2).map(|w| ((w[1].0 - w[0].0).powi(2) + (w[1].1 - w[0].1).powi(2)).sqrt()).sum() }++/// Both nets' points from pad to pad: the centreline from the pad midpoints through `via`, each net on the side its+/// source pad is on, `fan` mm of fan-out at each end. Pure.+pub fn plan(pos: (P, P), neg: (P, P), via: &[P], width: f64, gap: f64, fan: f64) -> (Vec<P>, Vec<P>) {+    let s = ((pos.0 .0 + neg.0 .0) / 2.0, (pos.0 .1 + neg.0 .1) / 2.0);+    let e = ((pos.1 .0 + neg.1 .0) / 2.0, (pos.1 .1 + neg.1 .1) / 2.0);+    let mut centre = vec![s];+    centre.extend_from_slice(via);+    centre.push(e);+    // pull the ends in by the fan-out so each pad has room to reach its own side+    let d0 = norm(sub(centre[1], centre[0]));+    let k = centre.len();+    let d1 = norm(sub(centre[k - 1], centre[k - 2]));+    centre[0] = (s.0 + d0.0 * fan, s.1 + d0.1 * fan);+    centre[k - 1] = (e.0 - d1.0 * fan, e.1 - d1.1 * fan);+    let half = (width + gap) / 2.0;+    // the positive net goes on the side its source pad is on+    let side = { let v = sub(pos.0, s); let n = left(d0); if v.0 * n.0 + v.1 * n.1 >= 0.0 { 1.0 } else { -1.0 } };+    let mut p = vec![pos.0]; p.extend(offset(&centre, half * side)); p.push(pos.1);+    let mut n = vec![neg.0]; n.extend(offset(&centre, -half * side)); n.push(neg.1);+    (p, n)+}++fn crosses(a: P, b: P, c: P, d: P) -> bool {+    let o = |p: P, q: P, r: P| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+    let (d1, d2, d3, d4) = (o(c, d, a), o(c, d, b), o(a, b, c), o(a, b, d));+    (d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0)+}++/// What is wrong with a planned pair before it touches the board: the two nets crossing each other (the pads swap sides+/// between the ends), or a segment closer than the clearance to another net's copper on the layer. `copper` is+/// (what, net, shape). Pure.+pub(crate) fn check(p: &[P], n: &[P], copper: &[(String, String, Shape)], nets: (&str, &str), width: f64, clearance: f64) -> Vec<Value> {+    let mut out = Vec::new();+    for i in 0..p.len().saturating_sub(1) {+        for j in 0..n.len().saturating_sub(1) {+            if crosses(p[i], p[i + 1], n[j], n[j + 1]) {+                out.push(json!({"problem": "pair_crosses", "at": [r3((p[i].0 + p[i + 1].0) / 2.0), r3((p[i].1 + p[i + 1].1) / 2.0)],+                                "fix": "the pads are on swapped sides at the two ends: route to a via pair and cross on another layer, or swap the pins"}));+                return out;+            }+        }+    }+    for (path, net) in [(p, nets.0), (n, nets.1)] {+        // the first and last segments leave their own pads: those pads are the net's own, so any copper they touch is checked too+        'segs: for w in path.windows(2) {+            for (what, on, s) in copper {+                if on.eq_ignore_ascii_case(net) { continue; }+                let d = s.dist(w[0], w[1]);+                if d < width / 2.0 + clearance - 1e-9 {+                    out.push(json!({"problem": if what.contains('.') { "over_a_pad" } else { "over_copper" }, "net": net, "with": what, "withNet": on, "gapMm": r3((d - width / 2.0).max(0.0)),+                                    "fix": "add waypoints (via) that lead the pair out of the part and round other copper"}));+                    break 'segs;+                }+            }+        }+    }+    out+}++/// route_pair: input {board, stack?, rules?, args: {pos, neg, from: {pos, neg}, to: {pos, neg}, via?: [[x, y]], layer?,+/// width?, gap?, diffOhms?, fanMm?, clearance?}}. The plan: `routeNet` for each net, the pair's `widthRule`, lengths,+/// skew, and `problems` (the plan answers success: false while there are any).+pub fn route_pair(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let s = |k: &str| args[k].as_str().map(String::from);+    let (Some(pn), Some(nn)) = (s("pos"), s("neg")) else { return crate::input::refuse("bad_args", "pos and neg are the pair's two nets") };+    let pad = |o: &str, k: &str| args[o][k].as_str().map(String::from);+    let (Some(pf), Some(nf), Some(pt), Some(nt)) = (pad("from", "pos"), pad("from", "neg"), pad("to", "pos"), pad("to", "neg")) else {+        return crate::input::refuse("bad_args", "from: {pos: \"U1.3\", neg: \"U1.4\"} and to: {pos, neg} name the pads at each end");+    };+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let key = |p: &Value| format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+    let at = |k: &str| pads.iter().find(|p| key(p).eq_ignore_ascii_case(k)).map(|p| (f(&p["x"]), f(&p["y"])));+    let (Some(a), Some(b), Some(c), Some(d)) = (at(&pf), at(&nf), at(&pt), at(&nt)) else {+        return crate::input::refuse("no_such_pad", &format!("one of {pf}, {nf}, {pt}, {nt} is not a pad on the board"));+    };+    let layer = args["layer"].as_str().unwrap_or("F.Cu").to_string();+    // width and gap: given, or solved from the stack-up for diffOhms on this layer+    let (mut width, mut gap, mut sized) = (args["width"].as_f64(), args["gap"].as_f64(), Value::Null);+    if let (Some(z), Some(stack)) = (args["diffOhms"].as_f64(), crate::input::stack(input)) {+        let geo = crate::impedance::geometry(&stack);+        let names = crate::coppermap::stack_layers(&stack);+        if let Some(i) = names.iter().position(|n| n.0 == layer) {+            if i < geo.len() {+                let rows = crate::impedance::widths(&geo[i..=i], None, Some(z), gap);+                if let (Some(w), Some(g)) = (rows[0]["diffWidth"].as_f64(), rows[0]["diffGap"].as_f64()) { width = width.or(Some(w)); gap = gap.or(Some(g)); sized = rows[0].clone(); }+            }+        }+    }+    let (Some(width), Some(gap)) = (width, gap) else { return crate::input::refuse("bad_args", "give width and gap (mm), or diffOhms with the stack to solve them") };+    let via: Vec<P> = args["via"].as_array().map(|a| a.iter().map(|p| (f(&p[0]), f(&p[1]))).collect()).unwrap_or_default();+    let fan = args["fanMm"].as_f64().unwrap_or(1.0);+    let (pp, np) = plan((a, c), (b, d), &via, width, gap, fan);+    // other nets' copper on the layer: pads by their outline, routed tracks and vias (a hatched pour's net-less pieces+    // re-pour round the pair)+    let rule = crate::fab::routing_clearance(&crate::input::rules(input));+    let clearance = args["clearance"].as_f64().unwrap_or(0.0).max(rule.unwrap_or(0.15));+    let mut copper: Vec<(String, String, Shape)> = pads.iter().filter(|p| p["layer"] == layer || p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0)+        .map(|p| (key(p), p["net"].as_str().unwrap_or("").to_string(), pad_shape(p))).collect();+    for t in st["tracks"].as_array().into_iter().flatten().filter(|t| t["layer"] == json!(layer) && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {+        copper.push(("track".into(), t["net"].as_str().unwrap_or("").into(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)));+    }+    for v in st["vias"].as_array().into_iter().flatten() {+        let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+        copper.push(("via".into(), v["net"].as_str().unwrap_or("").into(), Shape::Cap(c, c, f(&v["size"]) / 2.0)));+    }+    let problems = check(&pp, &np, &copper, (&pn, &nn), width, clearance);+    let path = |v: &[P], first: &str, last: &str| -> Value {+        let mut out = vec![json!({"pad": first})];+        for p in &v[1..v.len() - 1] { out.push(json!({"x": r3(p.0), "y": r3(p.1), "layer": layer})); }+        out.push(json!({"pad": last}));+        json!([out])+    };+    let (lp, ln) = (length(&pp), length(&np));+    let ok = problems.is_empty();+    json!({"success": ok, "errorCode": if ok { Value::Null } else { json!("pair_plan_blocked") }, "error": if ok { Value::Null } else { json!("the planned pair would run into copper or cross itself") },+        "problems": problems, "widthMm": r3(width), "gapMm": r3(gap), "sizedFor": sized, "layer": layer, "clearanceMm": clearance,+        "routeNet": [{"net": pn, "layer": layer, "width": r3(width), "paths": path(&pp, &pf, &pt)}, {"net": nn, "layer": layer, "width": r3(width), "paths": path(&np, &nf, &nt)}],+        "widthRule": {"kind": "width", "name": format!("Pair {pn} {nn}"), "scope1": format!("InNet('{pn}') Or InNet('{nn}')"), "min": r3(width * 0.9), "preferred": r3(width), "max": r3(width * 1.1)},+        "lengthsMm": {pn.clone(): r3(lp), nn.clone(): r3(ln)}, "skewMm": r3((lp - ln).abs()), "revision": st["revision"],+        "_hint": if ok { "A plan: send each routeNet entry to route_net (with expectedRevision: revision), then widthRule to add_rule so the board's default width rule does not flag the pair. skewMm is the length difference from the fan-outs and corners: net_lengths measures it routed, length_tune evens it." }+                 else { "Nothing to apply while problems is not empty: add waypoints (via) that lead the pair out of the parts and round other copper, or change layer where the pair must cross, and plan again." }})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn offset_keeps_the_pitch_round_a_corner() {+        let l = offset(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], 0.5);+        assert!((l[0].1 - 0.5).abs() < 1e-9);+        assert!((l[1].0 - 9.5).abs() < 1e-9 && (l[1].1 - 0.5).abs() < 1e-9);+        assert!((l[2].0 - 9.5).abs() < 1e-9);+    }++    #[test]+    fn plan_puts_each_net_on_its_pads_side_and_check_catches_crossings_and_copper() {+        let (p, n) = plan(((0.0, 0.5), (20.0, 0.5)), ((0.0, -0.5), (20.0, -0.5)), &[], 0.2, 0.15, 1.0);+        assert!(p[1].1 > 0.0 && n[1].1 < 0.0);+        assert!((p[1].1 - n[1].1 - 0.35).abs() < 1e-9);+        assert!((length(&p) - length(&n)).abs() < 1e-9);+        let (x, y) = plan(((0.0, 0.5), (20.0, -0.5)), ((0.0, -0.5), (20.0, 0.5)), &[], 0.2, 0.15, 1.0);+        assert_eq!(check(&x, &y, &[], ("P", "N"), 0.2, 0.15)[0]["problem"], "pair_crosses");+        let pad = pad_shape(&json!({"x": 10.0, "y": 0.175, "sizeX": 0.6, "sizeY": 0.6, "shape": "round"}));+        assert_eq!(check(&p, &n, &[("U1.8".into(), "X".into(), pad)], ("P", "N"), 0.2, 0.15)[0]["problem"], "over_a_pad");+        let track = Shape::Cap((10.0, -3.0), (10.0, 3.0), 0.1);+        assert_eq!(check(&p, &n, &[("track".into(), "X".into(), track)], ("P", "N"), 0.2, 0.15)[0]["problem"], "over_copper");+        assert!(check(&p, &n, &[], ("P", "N"), 0.2, 0.15).is_empty());+    }++    #[test]+    fn the_verb_answers_route_net_arguments_for_both_nets() {+        let pad = |r: &str, n: &str, x: f64, y: f64, net: &str| json!({"ref": r, "name": n, "x": x, "y": y, "sizeX": 0.3, "sizeY": 0.3, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": net, "shape": "rect"});+        let b = json!({"revision": "r7", "pads": [pad("U1", "1", 0.0, 0.5, "USB_P"), pad("U1", "2", 0.0, -0.5, "USB_N"), pad("J1", "1", 20.0, 0.5, "USB_P"), pad("J1", "2", 20.0, -0.5, "USB_N")]});+        let r = route_pair(&json!({"board": b, "args": {"pos": "USB_P", "neg": "USB_N", "from": {"pos": "U1.1", "neg": "U1.2"}, "to": {"pos": "J1.1", "neg": "J1.2"}, "width": 0.2, "gap": 0.15}}));+        assert_eq!(r["success"], json!(true), "{r}");+        assert_eq!(r["routeNet"][0]["paths"][0][0], json!({"pad": "U1.1"}));+        assert_eq!(r["skewMm"], json!(0.0));+        assert_eq!(r["widthRule"]["preferred"], json!(0.2));+        assert_eq!(route_pair(&json!({"board": b, "args": {"pos": "USB_P", "neg": "USB_N", "from": {"pos": "U9.1", "neg": "U1.2"}, "to": {"pos": "J1.1", "neg": "J1.2"}, "width": 0.2, "gap": 0.15}}))["errorCode"], "no_such_pad");+    }+}+--- a/rust/vendor/adom-eda-checks/src/route.rs+++ b/rust/vendor/adom-eda-checks/src/route.rs@@ -1,533 +1,534 @@⋯ 42 unchanged lines ⋯  impl Shape {     /// Edge-to-edge distance from the segment a..b (its centreline) to this copper.-    fn dist(&self, a: P, b: P) -> f64 {+    pub(crate) fn dist(&self, a: P, b: P) -> f64 {         match self {             Shape::Cap(c, d, r) => seg_seg(a, b, *c, *d) - r,             Shape::Poly(ring) => {⋯ 481 unchanged lines ⋯         assert!((f(&grid["lengthMm"]) - 10.0).abs() < 1e-9);     } }+--- a/rust/vendor/adom-eda-checks/src/schreview.rs+++ b/rust/vendor/adom-eda-checks/src/schreview.rs@@ -0,0 +1,239 @@+//! A schematic review before the board is updated: every placed pin from the sheets (with its electrical type) against+//! the compiled netlist, and the symbols' pins against the footprints' pads. From the Altium bridge's schreview.rs. Unconnected pins (power pins as errors), nets with one pin, outputs driving each+//! other, inputs nothing drives, and IC power pins with no decoupling capacitor to ground.+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, BTreeSet};++/// Altium's pin electrical types (the ELECTRICAL field).+pub fn electrical(n: i64) -> &'static str {+    match n { 0 => "input", 1 => "io", 2 => "output", 3 => "open_collector", 4 => "passive", 5 => "hiz", 6 => "open_emitter", 7 => "power", _ => "unknown" }+}++/// A pin's type from the sheet model: Altium's code, or the name the standard uses (Fusion also sends `supply`, a+/// supply symbol's pin that drives its net, and `not_connected`).+pub fn pin_type(v: &Value) -> String {+    match v {+        Value::Number(n) => electrical(n.as_i64().unwrap_or(-1)).to_string(),+        Value::String(t) => match t.to_ascii_lowercase().replace(['-', ' '], "_").as_str() {+            "bidirectional" | "bidi" | "i/o" => "io".into(),+            "pwr" => "power".into(),+            "sup" => "supply".into(),+            "nc" => "not_connected".into(),+            "oc" | "opencollector" => "open_collector".into(),+            "pas" => "passive".into(),+            "tri" | "tristate" | "3state" => "hiz".into(),+            "in" => "input".into(),+            "out" => "output".into(),+            o => o.to_string(),+        },+        _ => "unknown".into(),+    }+}++pub fn is_ground(net: &str) -> bool {+    let n = net.to_ascii_uppercase();+    n == "GND" || n == "0V" || n == "VSS" || n == "AGND" || n == "DGND" || n == "PGND" || n == "GNDA" || n == "GNDD" || n.starts_with("GND_") || n.ends_with("_GND")+}++fn prefix(des: &str) -> String { des.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase() }++/// The review, from the parts (read_sheet_file components: designator, pins with number/name/electrical) and the nets+/// ({net: ["U1.8", ...]}). Pure.+pub fn review(parts: &[Value], nets: &Map<String, Value>) -> Value {+    // pin -> net+    let mut net_of: BTreeMap<String, String> = BTreeMap::new();+    for (n, ps) in nets { for p in ps.as_array().cloned().unwrap_or_default() { if let Some(s) = p.as_str() { net_of.insert(s.to_ascii_uppercase(), n.clone()); } } }+    // every placed pin, once (a multi-part chip appears once per part on the sheets)+    let mut pins: BTreeMap<String, (String, String, String, String)> = BTreeMap::new(); // key -> (designator, number, name, type)+    for c in parts {+        let des = c["designator"].as_str().unwrap_or("").to_string();+        if des.is_empty() || des.contains('?') { continue; }+        for p in c["pins"].as_array().cloned().unwrap_or_default() {+            let num = p["number"].as_str().unwrap_or("").to_string();+            pins.entry(format!("{des}.{num}").to_ascii_uppercase()).or_insert((des.clone(), num, p["name"].as_str().unwrap_or("").to_string(), pin_type(&p["electrical"])));+        }+    }+    let nc = |name: &str| { let n = name.to_ascii_uppercase(); n == "NC" || n == "N/C" || n == "DNC" || n.starts_with("NC_") || n == "NO CONNECT" };+    let mut errors = Vec::new();+    let mut warnings = Vec::new();+    for (key, (des, num, name, ty)) in &pins {+        if net_of.contains_key(key) || nc(name) || ty == "not_connected" { continue; }+        let f = json!({"check": "unconnected_pin", "pin": format!("{des}.{num}"), "name": name, "type": ty});+        if ty == "power" { errors.push(f) } else { warnings.push(f) }+    }+    for (n, ps) in nets {+        let members = ps.as_array().map(|a| a.len()).unwrap_or(0);+        if members == 1 { warnings.push(json!({"check": "single_pin_net", "net": n, "pin": ps[0]})); }+        let kinds: Vec<&str> = ps.as_array().cloned().unwrap_or_default().iter().filter_map(|p| p.as_str()).filter_map(|p| pins.get(&p.to_ascii_uppercase())).map(|t| t.3.as_str()).collect();+        let outs = kinds.iter().filter(|k| **k == "output").count();+        if outs >= 2 { errors.push(json!({"check": "outputs_together", "net": n, "outputs": outs})); }+        let driven = kinds.iter().any(|k| matches!(*k, "output" | "io" | "power" | "supply" | "passive" | "open_collector" | "open_emitter" | "hiz" | "unknown"));+        if !kinds.is_empty() && kinds.iter().all(|k| *k == "input") && !driven && members > 0 {+            warnings.push(json!({"check": "undriven_input", "net": n, "inputs": kinds.len()}));+        }+    }+    // decoupling: each IC power pin on a supply net (not ground) wants a capacitor between that net and a ground+    let caps_between: BTreeMap<String, Vec<String>> = {+        let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();+        let mut cap_nets: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+        for (key, (des, ..)) in &pins {+            if prefix(des) == "C" { if let Some(n) = net_of.get(key) { cap_nets.entry(des.clone()).or_default().insert(n.clone()); } }+        }+        for (c, ns) in cap_nets {+            if ns.iter().any(|n| is_ground(n)) { for n in ns.iter().filter(|n| !is_ground(n)) { m.entry(n.clone()).or_default().push(c.clone()); } }+        }+        m+    };+    let mut supplies: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+    for (key, (des, _, _, ty)) in &pins {+        if ty != "power" || !matches!(prefix(des).as_str(), "U" | "IC") { continue; }+        if let Some(n) = net_of.get(key).filter(|n| !is_ground(n)) { supplies.entry(n.clone()).or_default().insert(des.clone()); }+    }+    let mut decoupling = Vec::new();+    for (net, ics) in &supplies {+        let caps = caps_between.get(net).cloned().unwrap_or_default();+        // an inductor on a 'supply' pin's net makes it a switch node (a regulator's SW/LX pin): no capacitor belongs+        // there (the Bluetooth Sentinel's SMPS_LX read as missing decoupling)+        let switch = nets.get(net).and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|p| p.as_str()).any(|p| prefix(p.split('.').next().unwrap_or("")) == "L")).unwrap_or(false);+        let row = json!({"net": net, "ics": ics, "capacitors": caps, "ok": !caps.is_empty() || switch, "switchNode": switch});+        if caps.is_empty() && !switch { warnings.push(json!({"check": "no_decoupling", "net": net, "ics": ics})); }+        decoupling.push(row);+    }+    json!({"pins": pins.len(), "nets": nets.len(), "errors": errors, "warnings": warnings, "decoupling": decoupling, "ok": errors.is_empty()})+}++/// Symbol pins against footprint pads, part by part: pins with no pad (a net that can never reach copper), pads with no+/// pin (copper no net reaches: fine for mounting and mechanical pads), and parts on only one side. Pure.+pub fn pin_pad(parts: &[Value], pads: &[Value], board_refs: &[String]) -> Value {+    let mut pins: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+    for c in parts {+        let des = c["designator"].as_str().unwrap_or("").to_string();+        if des.is_empty() || des.contains('?') { continue; }+        let e = pins.entry(des).or_default();+        for p in c["pins"].as_array().cloned().unwrap_or_default() { if let Some(n) = p["number"].as_str() { e.insert(n.to_ascii_uppercase()); } }+    }+    let mut padset: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+    for p in pads {+        let (Some(r), Some(n)) = (p["ref"].as_str().filter(|r| !r.is_empty()), p["name"].as_str()) else { continue };+        padset.entry(r.to_string()).or_default().insert(n.to_ascii_uppercase());+    }+    let mut mismatches = Vec::new();+    let mut ok = 0;+    for (des, ps) in &pins {+        let Some(pd) = padset.get(des) else { continue };+        let no_pad: Vec<&String> = ps.iter().filter(|n| !pd.contains(*n)).collect();+        // unnamed or '0' pads and mounting pads (MH*, EP*, SH*, MP*) carry no pin by design+        let no_pin: Vec<&String> = pd.iter().filter(|n| !ps.contains(*n) && !n.is_empty() && *n != "0" && !["MH", "MP", "SH", "EP", "PAD", "TAB"].iter().any(|m| n.starts_with(m))).collect();+        if no_pad.is_empty() && no_pin.is_empty() { ok += 1; continue; }+        mismatches.push(json!({"ref": des, "pinsWithoutPad": no_pad, "padsWithoutPin": no_pin, "pins": ps.len(), "pads": pd.len()}));+    }+    let on_board: BTreeSet<String> = board_refs.iter().cloned().collect();+    let sheet_only: Vec<&String> = pins.keys().filter(|d| !on_board.contains(*d) && !padset.contains_key(*d)).collect();+    let board_only: Vec<&String> = on_board.iter().filter(|d| !pins.contains_key(*d)).collect();+    json!({"partsChecked": ok + mismatches.len(), "matching": ok, "mismatches": mismatches, "onSheetsOnly": sheet_only, "onBoardOnly": board_only,+           "ok": mismatches.iter().all(|m| m["pinsWithoutPad"].as_array().map(|a| a.is_empty()).unwrap_or(true))})+}++/// The sheet parts companion (read_sheet_file's components, each with its pins): a list, or a reply with `components`.+fn sheet_parts(input: &Value) -> Option<Vec<Value>> {+    let p = crate::input::part(input, "sheetParts")?;+    p.get("components").or(Some(&p)).and_then(|x| x.as_array()).cloned()+}++/// schematic_review: input {sheetParts, netlist}. The netlist companion is {nets: {net: ["U1.8", ...]}} (or the map).+pub fn schematic_review(input: &Value) -> Value {+    let Some(parts) = sheet_parts(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (read_sheet_file with pins, every sheet) as sheetParts") };+    let Some(n) = crate::input::part(input, "netlist") else { return crate::input::refuse("bad_args", "pass the compiled netlist ({nets: {net: [\"U1.8\", ...]}}) as netlist") };+    let nets = n.get("nets").or(Some(&n)).and_then(|x| x.as_object()).cloned().unwrap_or_default();+    let mut out = review(&parts, &nets);+    out["success"] = json!(true);+    out["_hint"] = json!("errors stop a board (a power pin on no net, two outputs on one net); warnings deserve a look (pins on no net, one-pin nets, inputs nothing drives, an IC supply with no capacitor to ground). Pin types come from the symbols: a passive pin is never 'undriven'. Fix on the sheets (sch_connect, sch_place_part), then update_pcb.");+    out+}++/// pin_pad_check: input {board, sheetParts}. The symbols' pins against the placed footprints' pads.+pub fn pin_pad_check(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let Some(parts) = sheet_parts(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (read_sheet_file with pins, every sheet) as sheetParts") };+    let refs: Vec<String> = st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(String::from)).collect();+    let mut out = pin_pad(&parts, st["pads"].as_array().map(|a| a.as_slice()).unwrap_or(&[]), &refs);+    out["success"] = json!(true);+    out["_hint"] = json!("pinsWithoutPad is a part whose footprint cannot carry every pin's net (a wrong footprint, or pin numbers that do not match): fix the footprint or the symbol (swap_footprint, build_footprint) before routing. padsWithoutPin is copper no net reaches. onSheetsOnly / onBoardOnly differ until update_pcb.");+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    fn part(des: &str, pins: &[(&str, &str, i64)]) -> Value {+        json!({"designator": des, "pins": pins.iter().map(|(n, nm, e)| json!({"number": n, "name": nm, "electrical": e})).collect::<Vec<_>>()})+    }++    #[test]+    fn pin_types_come_as_codes_or_names() {+        assert_eq!(pin_type(&json!(7)), "power");+        assert_eq!(pin_type(&json!("Bidirectional")), "io");+        assert_eq!(pin_type(&json!("sup")), "supply");+        let parts = vec![json!({"designator": "U1", "pins": [+            {"number": "1", "name": "IN", "electrical": "input"}, {"number": "2", "name": "X", "electrical": "not_connected"}]}),+            json!({"designator": "P1", "pins": [{"number": "1", "name": "VCC", "electrical": "supply"}]})];+        let nets = json!({"VCC": ["U1.1", "P1.1"]});+        let r = review(&parts, nets.as_object().unwrap());+        assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}");+    }++    #[test]+    fn the_review_finds_each_kind_of_problem() {+        let parts = vec![+            part("U1", &[("1", "VCC", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "NC", 0), ("5", "EN", 0)]),+            part("U2", &[("1", "VDD", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "IN", 0)]),+            part("C1", &[("1", "", 4), ("2", "", 4)]),+            part("R1", &[("1", "", 4), ("2", "", 4)]),+            part("U3", &[("1", "SW", 7)]), part("L1", &[("1", "", 4), ("2", "", 4)]),+        ];+        let nets = json!({"3V3": ["U1.1", "C1.1"], "VDD": ["U2.1"], "LX": ["U3.1", "L1.1"], "GND": ["U1.2", "U2.2", "C1.2"], "BUS": ["U1.3", "U2.3"], "SENSE": ["U2.4"], "R": ["R1.1", "R1.2"]});+        let r = review(&parts, nets.as_object().unwrap());+        let checks = |k: &str| r[k].as_array().unwrap().iter().map(|f| f["check"].as_str().unwrap().to_string()).collect::<Vec<_>>();+        // U1.5 EN floats (input, warning); NC is skipped; two outputs share BUS (error)+        assert!(checks("errors").contains(&"outputs_together".to_string()));+        assert!(checks("warnings").contains(&"unconnected_pin".to_string()));+        assert!(!r["warnings"].as_array().unwrap().iter().any(|f| f["pin"] == "U1.4"));+        // VDD has one pin and no capacitor; SENSE is an input nothing drives+        assert!(checks("warnings").contains(&"single_pin_net".to_string()));+        assert!(checks("warnings").contains(&"no_decoupling".to_string()));+        assert!(checks("warnings").contains(&"undriven_input".to_string()));+        let dec = r["decoupling"].as_array().unwrap();+        assert!(dec.iter().any(|d| d["net"] == "3V3" && d["ok"] == json!(true)));+        assert!(dec.iter().any(|d| d["net"] == "VDD" && d["ok"] == json!(false)));+        // a regulator's switch pin on an inductor is not a missing capacitor+        assert!(dec.iter().any(|d| d["net"] == "LX" && d["switchNode"] == json!(true) && d["ok"] == json!(true)));+    }+    #[test]+    fn pin_pad_finds_footprints_that_do_not_fit_their_symbols() {+        let parts = vec![part("U1", &[("1", "A", 0), ("2", "B", 0), ("3", "C", 0)]), part("R1", &[("1", "", 4), ("2", "", 4)]), part("C9", &[("1", "", 4), ("2", "", 4)])];+        let pad = |r: &str, n: &str| json!({"ref": r, "name": n});+        let pads = vec![pad("U1", "1"), pad("U1", "2"), pad("U1", "4"), pad("U1", "EP"), pad("R1", "1"), pad("R1", "2"), pad("J9", "1")];+        let r = pin_pad(&parts, &pads, &["U1".into(), "R1".into(), "J9".into()]);+        assert_eq!(r["matching"], 1);+        let m = &r["mismatches"][0];+        assert_eq!(m["ref"], "U1");+        assert_eq!(m["pinsWithoutPad"], json!(["3"]));+        assert_eq!(m["padsWithoutPin"], json!(["4"]));+        assert_eq!(r["onSheetsOnly"], json!(["C9"]));+        assert_eq!(r["onBoardOnly"], json!(["J9"]));+        assert_eq!(r["ok"], json!(false));+    }++    #[test]+    fn the_verbs_take_the_companions_bare_or_in_a_reply() {+        let parts = json!({"success": true, "components": [part("U1", &[("1", "VCC", 7), ("2", "GND", 7)]), part("C1", &[("1", "", 4), ("2", "", 4)])]});+        let r = schematic_review(&json!({"sheetParts": parts, "netlist": {"nets": {"3V3": ["U1.1", "C1.1"], "GND": ["U1.2", "C1.2"]}}}));+        assert_eq!((r["success"].clone(), r["ok"].clone(), r["pins"].clone()), (json!(true), json!(true), json!(4)), "{r}");+        assert_eq!(schematic_review(&json!({"netlist": {}}))["errorCode"], "bad_args");+        let b = json!({"components": [{"ref": "U1"}, {"ref": "C1"}], "pads": [{"ref": "U1", "name": "1"}, {"ref": "U1", "name": "2"}, {"ref": "C1", "name": "1"}, {"ref": "C1", "name": "2"}]});+        let p = pin_pad_check(&json!({"board": b, "sheetParts": parts}));+        assert_eq!((p["matching"].clone(), p["ok"].clone()), (json!(2), json!(true)), "{p}");+    }+}+--- a/rust/vendor/adom-eda-checks/src/silkplace.rs+++ b/rust/vendor/adom-eda-checks/src/silkplace.rs@@ -0,0 +1,221 @@+//! Reference designators placed the way assembly houses want them: one size for all, upright, beside their own part,+//! clear of every pad (JLCPCB: silkscreen 0.15 mm from pads), every other part's body and every designator already+//! placed, and inside the board. From the Altium bridge's silkplace.rs: a plan here, which the bridge applies with+//! apply_silkscreen and checks against the tool's own text boxes after.+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++type Rect = (f64, f64, f64, f64);++fn rect_of(v: &Value) -> Option<Rect> {+    let a = v.as_array()?;+    if a.len() != 4 { return None; }+    let n: Vec<f64> = a.iter().map(f).collect();+    Some((n[0].min(n[2]), n[1].min(n[3]), n[0].max(n[2]), n[1].max(n[3])))+}+fn grow(r: Rect, d: f64) -> Rect { (r.0 - d, r.1 - d, r.2 + d, r.3 + d) }+fn hit(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }+fn union(a: Rect, b: Rect) -> Rect { (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3)) }++/// A pad's copper as a rectangle (its rotated extent).+fn pad_rect(p: &Value) -> Rect {+    let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+    let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"]));+    let (hx, hy) = ((sx * c + sy * s) / 2.0, (sx * s + sy * c) / 2.0);+    (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy)+}++fn on_side(layer: &Value, bottom: bool) -> bool {+    match layer.as_str() { Some("*.Cu") => true, Some("B.Cu") => bottom, Some("F.Cu") => !bottom, _ => false }+}++/// Where each designator goes: [{ref, x, y, size, rotation, side, at}] (x, y the text's lower-left, its right on the+/// bottom side where Altium mirrors it) and the parts with no clear spot. Pure: silk_state's components, the board's+/// pads and outline bounding box, and the boxes of labels that stay put.+pub fn plan(comps: &[Value], pads: &[Value], board: Rect, size: f64, min_size: f64, gap: f64, pad_clear: f64, fixed: &[(bool, Rect)]) -> (Vec<Value>, Vec<Value>) {+    // labels that stay where they are (parts left out, or with no spot) are obstacles like the ones placed+    let mut placed: Vec<(bool, Rect)> = fixed.to_vec();+    let (mut out, mut unplaced) = (Vec::new(), Vec::new());+    // the part's own outline: its body box and its own pads+    let own = |c: &Value| -> Option<Rect> {+        let r = c["ref"].as_str().unwrap_or("");+        let mut b = rect_of(&c["body"]);+        for p in pads.iter().filter(|p| p["ref"].as_str() == Some(r)) { b = Some(b.map(|x| union(x, pad_rect(p))).unwrap_or(pad_rect(p))); }+        b+    };+    let bodies: Vec<(String, bool, Rect)> = comps.iter().filter_map(|c| own(c).map(|b| (c["ref"].as_str().unwrap_or("").to_string(), c["side"] == "bottom", b))).collect();+    // big parts first: they have the fewest places to put a label and the small ones fit round them+    let mut order: Vec<&Value> = comps.iter().filter(|c| c["name"]["shown"].as_bool() != Some(false)).collect();+    order.sort_by(|a, b| {+        let area = |c: &Value| own(c).map(|r| (r.2 - r.0) * (r.3 - r.1)).unwrap_or(0.0);+        area(b).partial_cmp(&area(a)).unwrap()+    });+    let inside = grow(board, -0.2);+    for c in order {+        let rf = c["ref"].as_str().unwrap_or("").to_string();+        let bottom = c["side"] == "bottom";+        let Some(b) = own(c) else { unplaced.push(json!({"ref": rf, "why": "no body or pads to place it by"})); continue };+        if b.2 < board.0 || b.0 > board.2 || b.3 < board.1 || b.1 > board.3 {+            unplaced.push(json!({"ref": rf, "why": "the part is off the board: place it first (move_parts)"}));+            continue;+        }+        // the label's size at the new height, from Altium's own box at the old one+        // Altium's box is the text plus half its stroke all round (a 1.0 mm designator measures 1.285 mm tall on the+        // Bluetooth Sentinel example): plan the text, check the box+        let old = f(&c["name"]["size"]);+        let turned = (f(&c["name"]["rotation"]).rem_euclid(180.0) - 90.0).abs() < 1.0;+        let (m, w) = match rect_of(&c["name"]["box"]) {+            Some(r) if old > 0.0 && r.2 > r.0 => {+                let (bw, bh) = if turned { (r.3 - r.1, r.2 - r.0) } else { (r.2 - r.0, r.3 - r.1) };+                let m = ((bh - old) / 2.0).clamp(0.0, 0.3);+                (m, (bw - 2.0 * m).max(old * 0.5) * size / old)+            }+            _ => (0.15, rf.len() as f64 * size * 0.8),+        };+        let free = |text: Rect| -> bool {+            let r = grow(text, m);+            r.0 >= inside.0 && r.1 >= inside.1 && r.2 <= inside.2 && r.3 <= inside.3+                && !pads.iter().any(|p| on_side(&p["layer"], bottom) && hit(r, grow(pad_rect(p), pad_clear)))+                && !bodies.iter().any(|(o, side, ob)| *o != rf && *side == bottom && hit(r, *ob))+                && !placed.iter().any(|(side, pr)| *side == bottom && hit(r, grow(*pr, 0.1)))+        };+        // the sizes to try: `size`, then down in 0.1 mm steps to `min_size`+        let mut found = None;+        let mut s = size;+        while found.is_none() && s >= min_size - 1e-9 {+            found = spots(b, w * s / size, s, gap, bottom).into_iter().find(|(r, _, _)| free(*r)).map(|(r, rot, at)| (r, rot, at, s));+            s -= 0.1;+        }+        match found {+            Some((r, rot, at, s)) => {+                placed.push((bottom, grow(r, m)));+                // the text's anchor: lower left upright; turned 90 its letters lie left of the anchor (lower right of the+                // box); mirrored on the bottom it is the text's right end+                let (ax, ay) = if rot == 90 { (r.2, r.1) } else if bottom { (r.2, r.1) } else { (r.0, r.1) };+                out.push(json!({"ref": rf, "x": r3(ax), "y": r3(ay), "size": r3(s), "rotation": rot, "side": if bottom { "bottom" } else { "top" }, "at": at}));+            }+            None => unplaced.push(json!({"ref": rf, "why": "no clear spot beside the part", "triedSizes": [size, min_size]})),+        }+    }+    (out, unplaced)+}++/// Places a w x h label could go round the part box b, nearest first: centred on each side, then slid along it, at+/// `gap` and then 3 x `gap`; turned 90 degrees beside the left and right sides (top side only: Altium mirrors bottom+/// text). Each is (box, rotation, where).+fn spots(b: Rect, w: f64, h: f64, gap: f64, bottom: bool) -> Vec<(Rect, i64, String)> {+    let mut v = Vec::new();+    let (cx, cy) = ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0);+    for g in [gap, 3.0 * gap] {+        // slide offsets along a side: centre first, then outwards both ways in 0.5 mm steps+        let slide = |len: f64| -> Vec<f64> {+            let mut o = vec![0.0];+            let mut k = 0.5;+            while k <= len / 2.0 + 0.5 { o.push(-k); o.push(k); k += 0.5; }+            o+        };+        for d in slide(b.2 - b.0) {+            v.push(((cx - w / 2.0 + d, b.3 + g, cx + w / 2.0 + d, b.3 + g + h), 0, "above".to_string()));+            v.push(((cx - w / 2.0 + d, b.1 - g - h, cx + w / 2.0 + d, b.1 - g), 0, "below".to_string()));+        }+        for d in slide(b.3 - b.1) {+            v.push(((b.2 + g, cy - h / 2.0 + d, b.2 + g + w, cy + h / 2.0 + d), 0, "right".to_string()));+            v.push(((b.0 - g - w, cy - h / 2.0 + d, b.0 - g, cy + h / 2.0 + d), 0, "left".to_string()));+            if !bottom {+                v.push(((b.2 + g, cy - w / 2.0 + d, b.2 + g + h, cy + w / 2.0 + d), 90, "right, turned".to_string()));+                v.push(((b.0 - g - h, cy - w / 2.0 + d, b.0 - g, cy + w / 2.0 + d), 90, "left, turned".to_string()));+            }+        }+    }+    v+}++/// Designators whose box (Altium's own) still touches a pad, closer than `pad_clear`. Pure.+pub fn on_pads(comps: &[Value], pads: &[Value], pad_clear: f64) -> Vec<Value> {+    comps.iter().filter(|c| c["name"]["shown"].as_bool() != Some(false)).filter_map(|c| {+        let r = rect_of(&c["name"]["box"])?;+        let bottom = c["side"] == "bottom";+        let p = pads.iter().find(|p| on_side(&p["layer"], bottom) && hit(r, grow(pad_rect(p), pad_clear - 0.01)))?;+        Some(json!({"ref": c["ref"], "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))}))+    }).collect()+}++/// silk_designators: input {board, silk, args: {size?, minSize?, gap?, padClearance?, refs?}}. The plan: `planned`,+/// and `applyArgs` ready for apply_silkscreen (x, y the text's lower-left; its right end on the bottom side, where the+/// text is mirrored), the parts with no clear spot, and the labels on pads before.+pub fn silk_designators(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);+    let (size, gap, clear) = (num("size", 1.0), num("gap", 0.2), num("padClearance", 0.15));+    if size < 0.5 { return crate::input::refuse("bad_args", "size is the text height in mm, 0.5 or more (JLCPCB reads 1.0 mm and up reliably)"); }+    let Some(bb) = crate::geom::outline_bbox(&st) else { return crate::input::refuse("no_outline", "the board has no outline") };+    let Some(silk) = crate::input::part(input, "silk") else { return crate::input::refuse("bad_args", "pass silk_state as silk: each part's body box and designator") };+    let all = silk["components"].as_array().cloned().unwrap_or_default();+    let mut comps = all.clone();+    if let Some(want) = args.get("refs").and_then(|v| v.as_array()) {+        let w: Vec<&str> = want.iter().filter_map(|v| v.as_str()).collect();+        comps.retain(|c| c["ref"].as_str().map(|r| w.iter().any(|x| x.eq_ignore_ascii_case(r))).unwrap_or(false));+    }+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let before = on_pads(&comps, &pads, clear);+    let min_size = num("minSize", size).min(size);+    // the labels of parts left out stay put; so do the labels of parts with no spot, so plan again round those+    let label = |c: &Value| -> Option<(bool, Rect)> { if c["name"]["shown"].as_bool() == Some(false) { None } else { rect_of(&c["name"]["box"]).map(|r| (c["side"] == "bottom", r)) } };+    let mut fixed: Vec<(bool, Rect)> = all.iter().filter(|c| !comps.iter().any(|k| k["ref"] == c["ref"])).filter_map(label).collect();+    let mut moving = comps.clone();+    let (mut names, mut unplaced) = (Vec::new(), Vec::new());+    // every round pins at least one more label, so this ends; the last round leaves nothing unpinned+    for _ in 0..=comps.len() {+        let (n, u) = plan(&moving, &pads, bb, size, min_size, gap, clear, &fixed);+        names = n;+        if u.is_empty() { break; }+        let stuck: Vec<Value> = u.iter().map(|x| x["ref"].clone()).collect();+        fixed.extend(moving.iter().filter(|c| stuck.contains(&c["ref"])).filter_map(label));+        moving.retain(|c| !stuck.contains(&c["ref"]));+        unplaced.extend(u);+    }+    let apply: Vec<Value> = names.iter().map(|n| json!({"ref": n["ref"], "x": n["x"], "y": n["y"], "size": n["size"], "rotation": n["rotation"]})).collect();+    json!({"success": true, "planned": names, "unplaced": unplaced, "onPadsBefore": before, "sizeMm": size, "applyArgs": {"names": apply},+        "_hint": "A plan: every label upright (or turned 90 beside the part), one size, beside its own part, clear of pads by padClearance and of other bodies and labels. Send applyArgs to apply_silkscreen, then read silk_state back and check no label touches a pad with the tool's own text boxes. unplaced parts had no clear spot: try minSize for them (refs), move the part, or place the label by hand."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn comp(r: &str, body: [f64; 4]) -> Value {+        json!({"ref": r, "side": "top", "body": body, "name": {"text": r, "size": 1.0, "shown": true, "box": [0.0, 0.0, 2.0, 1.0]}})+    }++    #[test]+    fn labels_go_beside_their_part_clear_of_pads_and_each_other() {+        // C1 sits just above U1, where U1's label would go+        let comps = vec![comp("U1", [10.0, 10.0, 16.0, 16.0]), comp("C1", [12.0, 16.4, 14.0, 17.4])];+        let pads = vec![json!({"ref": "C1", "name": "1", "x": 12.5, "y": 16.9, "sizeX": 0.8, "sizeY": 0.8, "rotation": 0, "layer": "F.Cu"}),+                        json!({"ref": "C1", "name": "2", "x": 13.5, "y": 16.9, "sizeX": 0.8, "sizeY": 0.8, "rotation": 0, "layer": "F.Cu"})];+        let (out, un) = plan(&comps, &pads, (0.0, 0.0, 40.0, 40.0), 1.0, 1.0, 0.2, 0.15, &[]);+        assert!(un.is_empty());+        // U1 above would land on C1's body and pads: it goes below+        assert_eq!(out[0]["ref"], "U1");+        assert_eq!(out[0]["at"], "below");+        assert_eq!(out[1]["ref"], "C1");+        assert_eq!(out[1]["at"], "above");+        assert_eq!(out[1]["y"], 17.6);+        assert_eq!(out[0]["y"], 8.8);+    }++    #[test]+    fn a_part_boxed_in_is_reported_not_forced() {+        let comps = vec![comp("R1", [1.0, 1.0, 3.0, 2.0])];+        let (out, un) = plan(&comps, &[], (0.0, 0.0, 4.0, 3.0), 1.0, 1.0, 0.2, 0.15, &[]);+        assert!(out.is_empty());+        assert_eq!(un[0]["ref"], "R1");+        let on = on_pads(&[json!({"ref": "R1", "side": "top", "name": {"shown": true, "box": [0.0, 0.0, 2.0, 1.0]}})],+                         &[json!({"ref": "R1", "name": "1", "x": 1.0, "y": 0.5, "sizeX": 0.5, "sizeY": 0.5, "rotation": 0, "layer": "F.Cu"})], 0.15);+        assert_eq!(on.len(), 1);+    }+}+--- a/rust/vendor/adom-eda-checks/src/space.rs+++ b/rust/vendor/adom-eda-checks/src/space.rs@@ -0,0 +1,240 @@+//! Room on the board: free spots for a part near another (clear of every other part on that side, of that side's+//! copper, and inside the outline), and two-pad parts a quarter turn would shorten the airlines of. From the Altium+//! bridge's design.rs; a part is the box of its pads and, with the bodies companion, of its 3D body.+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type Rect = (f64, f64, f64, f64);+fn grow(r: Rect, g: f64) -> Rect { (r.0 - g, r.1 - g, r.2 + g, r.3 + g) }+fn hits(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }+fn dist(a: (f64, f64), b: (f64, f64)) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }++/// The parts' boxes and sides, with their bodies when the companion came.+fn part_boxes(st: &Value, input: &Value) -> BTreeMap<String, (bool, Rect)> {+    let mut bodies: BTreeMap<String, Rect> = BTreeMap::new();+    for b in crate::bodies::input_boxes(input).unwrap_or_default() {+        let e = bodies.entry(b.part.clone()).or_insert((b.x0, b.y0, b.x1, b.y1));+        *e = (e.0.min(b.x0), e.1.min(b.y0), e.2.max(b.x1), e.3.max(b.y1));+    }+    crate::placement::part_boxes(st, &bodies)+}++/// find_space: input {board, bodies?, args: {near? | at?: {x, y}, part? | sizeMm?: [w, h], side?, clearanceMm?, edgeMm?,+/// count?, radiusMm?, copper?, check?}}. Free spots nearest first, as where the part's origin goes; with check: true,+/// the parts that already overlap or leave the outline.+pub fn find_space(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let s = |k: &str| args[k].as_str().map(String::from);+    let ring = crate::coppermap::outline_ring(&st);+    let boxes = part_boxes(&st, input);+    let parts: Vec<(String, Rect, bool)> = boxes.iter().map(|(r, (b, x))| (r.clone(), *x, *b)).collect();+    let clr = args["clearanceMm"].as_f64().unwrap_or(0.25);+    let edge = args["edgeMm"].as_f64().unwrap_or(0.3);+    let seg = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| {+        let (dx, dy) = (b.0 - a.0, b.1 - a.1);+        let l2 = dx * dx + dy * dy;+        let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+        dist(p, (a.0 + t * dx, a.1 + t * dy))+    };+    let in_outline = |r: Rect| -> bool {+        if ring.len() < 3 { return true; }+        [(r.0, r.1), (r.2, r.1), (r.2, r.3), (r.0, r.3)].iter().all(|&c| crate::route::inside(c, &ring) && (0..ring.len()).all(|i| seg(c, ring[i], ring[(i + 1) % ring.len()]) >= edge))+    };+    if args["check"].as_bool() == Some(true) {+        // the same tests placement_check makes (fusion-lane): a part under a raised one (an Adom molecule on pins) whose+        // pads stay apart and whose bodies do not meet in 3D is stacked, not overlapping; a part is off the outline by+        // its pads' centres, not its box (a round board's edge cuts a box corner off a part well inside it)+        let pads = st["pads"].as_array().cloned().unwrap_or_default();+        let bodies = crate::bodies::input_boxes(input);+        let (mut overlaps, mut stacked) = (Vec::new(), Vec::new());+        for i in 0..parts.len() { for j in i + 1..parts.len() {+            if parts[i].2 != parts[j].2 || !hits(parts[i].1, parts[j].1) { continue; }+            let pair = json!([parts[i].0, parts[j].0]);+            match &bodies { Some(bs) if crate::placement::is_stacked(&pads, bs, &parts[i].0, &parts[j].0, 0.0) => stacked.push(pair), _ => overlaps.push(pair) }+        } }+        let outside: Vec<&String> = parts.iter().filter(|p| {+            let own: Vec<&Value> = pads.iter().filter(|q| q["ref"].as_str() == Some(p.0.as_str())).collect();+            ring.len() >= 3 && !own.is_empty() && own.iter().any(|q| !crate::route::inside((f(&q["x"]), f(&q["y"])), &ring))+        }).map(|p| &p.0).collect();+        return json!({"success": true, "overlaps": overlaps, "stacked": stacked, "outsideOutline": outside, "parts": parts.len(), "bodiesUsed": bodies.is_some(),+            "_hint": "Overlaps compare part boxes (pads, and 3D bodies when given) on the same side; placement_check adds margins, stacked parts and mounting holes. find_space {near, part} finds a free spot; move_parts moves it."});+    }+    let comp = |d: &str| st["components"].as_array().and_then(|a| a.iter().find(|c| c["ref"].as_str().map(|r| r.eq_ignore_ascii_case(d)).unwrap_or(false)).cloned());+    // what to place: an existing part (its extent and origin) or a size+    let me = s("part").and_then(|p| parts.iter().find(|x| x.0.eq_ignore_ascii_case(&p)).cloned());+    let (w, h, off, my_bottom) = match (&me, args["sizeMm"].as_array()) {+        (Some((d, r, b)), _) => {+            let c = comp(d).unwrap_or(Value::Null);+            let (ox, oy) = (c["x"].as_f64().unwrap_or((r.0 + r.2) / 2.0), c["y"].as_f64().unwrap_or((r.1 + r.3) / 2.0));+            (r.2 - r.0, r.3 - r.1, (ox - (r.0 + r.2) / 2.0, oy - (r.1 + r.3) / 2.0), *b)+        }+        (None, Some(sz)) if sz.len() == 2 => (f(&sz[0]).max(0.01), f(&sz[1]).max(0.01), (0.0, 0.0), false),+        _ => return crate::input::refuse("bad_args", "part (a designator on the board) or sizeMm [w, h] is required"),+    };+    let bottom = match s("side").as_deref() { Some("bottom") => true, Some("top") => false, _ => my_bottom };+    let (cx, cy) = if let Some(n) = s("near") {+        match parts.iter().find(|x| x.0.eq_ignore_ascii_case(&n)) { Some((_, r, _)) => ((r.0 + r.2) / 2.0, (r.1 + r.3) / 2.0), None => return crate::input::refuse("no_such_part", &format!("{n} is not on the board")) }+    } else if args["at"].is_object() {+        (f(&args["at"]["x"]), f(&args["at"]["y"]))+    } else { return crate::input::refuse("bad_args", "near (a designator) or at {x, y} is required") };+    let mine = me.as_ref().map(|m| m.0.clone()).unwrap_or_default();+    let mut blocks: Vec<Rect> = parts.iter().filter(|p| p.2 == bottom && !p.0.eq_ignore_ascii_case(&mine)).map(|p| grow(p.1, clr)).collect();+    if args["copper"].as_bool() != Some(false) {+        let layer = if bottom { "B.Cu" } else { "F.Cu" };+        // net-less tracks are a hatched pour's pieces, which re-pour round a new part (6473 of them on the Bluetooth+        // Sentinel left no spot anywhere)+        for t in st["tracks"].as_array().into_iter().flatten().filter(|t| t["layer"] == layer && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {+            let (a, b, hw) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0);+            blocks.push(grow((a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)), hw + clr));+        }+        for v in st["vias"].as_array().into_iter().flatten() {+            let (x, y, r) = (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]) / 2.0);+            blocks.push(grow((x - r, y - r, x + r, y + r), clr));+        }+    }+    let count = args["count"].as_u64().unwrap_or(5) as usize;+    let radius = args["radiusMm"].as_f64().unwrap_or(10.0);+    let step = 0.25;+    let n = (radius / step).ceil() as i64;+    let mut cands: Vec<(f64, f64, f64)> = Vec::new();+    for i in -n..=n { for j in -n..=n {+        let (x, y) = (cx + i as f64 * step, cy + j as f64 * step);+        let d = dist((x, y), (cx, cy));+        if d <= radius { cands.push((d, x, y)); }+    } }+    cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+    let (mut spots, mut taken): (Vec<Value>, Vec<Rect>) = (Vec::new(), Vec::new());+    for (d, x, y) in cands {+        let r = (x - w / 2.0, y - h / 2.0, x + w / 2.0, y + h / 2.0);+        if blocks.iter().any(|b| hits(r, *b)) || taken.iter().any(|t| hits(r, *t)) || !in_outline(r) { continue; }+        spots.push(json!({"x": r3(x + off.0), "y": r3(y + off.1), "distanceMm": r3(d), "extent": [r3(r.0), r3(r.1), r3(r.2), r3(r.3)]}));+        taken.push(grow(r, clr));+        if spots.len() >= count { break; }+    }+    if spots.is_empty() { return json!({"success": false, "errorCode": "no_space", "error": format!("no free {} x {} mm spot within {radius} mm", r3(w), r3(h)), "spots": []}); }+    json!({"success": true, "spots": spots, "part": if mine.is_empty() { Value::Null } else { json!(mine) }, "sizeMm": [r3(w), r3(h)], "side": if bottom { "bottom" } else { "top" },+        "_hint": "spots[].x/y are where the part's origin goes: move_parts {parts:[{ref, x, y, rotation}]}, then repour (pours are not avoided: they clear round the new pads). Each spot keeps clearanceMm from other part boxes on that side, from that side's tracks and vias, and edgeMm from the outline; nearest first. find_space {check: true} lists parts that already overlap."})+}++/// Minimum spanning tree length of points (Prim's, O(n^2): nets are small).+fn mst(pts: &[(f64, f64)]) -> f64 {+    if pts.len() < 2 { return 0.0; }+    let (mut inside, mut best) = (vec![false; pts.len()], vec![f64::MAX; pts.len()]);+    best[0] = 0.0;+    let mut total = 0.0;+    for _ in 0..pts.len() {+        let mut u = usize::MAX;+        for i in 0..pts.len() { if !inside[i] && (u == usize::MAX || best[i] < best[u]) { u = i; } }+        inside[u] = true;+        total += best[u];+        for v in 0..pts.len() { if !inside[v] { let d = dist(pts[u], pts[v]); if d < best[v] { best[v] = d; } } }+    }+    total+}++/// check_orientation: input {board, args: {parts?, minImprovementMm?, planeNetPads?}}. Advisory: each two-pad part's+/// quarter turns scored in place, a routable net by its airline (MST) length with the pad there, a plane-like net+/// (more than planeNetPads pads, GND) by the distance to its nearest same-net pad. Turns that save minImprovementMm or+/// more are reported; nothing moves.+pub fn check_orientation(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let min_gain = args["minImprovementMm"].as_f64().unwrap_or(0.6);+    let plane_pads = args["planeNetPads"].as_u64().unwrap_or(12) as usize;+    let want: Vec<String> = args["parts"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_ascii_uppercase())).collect()).unwrap_or_default();+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let mut net_pads: HashMap<String, Vec<(String, (f64, f64))>> = HashMap::new();+    for p in &pads {+        let n = p["net"].as_str().unwrap_or("");+        if !n.is_empty() { net_pads.entry(n.to_string()).or_default().push((p["ref"].as_str().unwrap_or("").to_string(), (f(&p["x"]), f(&p["y"])))); }+    }+    let (mut findings, mut checked) = (Vec::new(), 0);+    for c in st["components"].as_array().into_iter().flatten() {+        let r = c["ref"].as_str().unwrap_or("").to_string();+        if !want.is_empty() && !want.contains(&r.to_ascii_uppercase()) { continue; }+        let mine: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(&r)).collect();+        if mine.len() != 2 { continue; }+        checked += 1;+        let (cx, cy, rot) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]));+        // the airlines with the part turned by delta degrees (counterclockwise, y up)+        let score = |delta: f64| -> f64 {+            let a = delta.to_radians();+            let mut total = 0.0;+            for p in &mine {+                let net = p["net"].as_str().unwrap_or("");+                if net.is_empty() { continue; }+                let (px, py) = (f(&p["x"]) - cx, f(&p["y"]) - cy);+                let at = (cx + px * a.cos() - py * a.sin(), cy + px * a.sin() + py * a.cos());+                let others: Vec<(f64, f64)> = net_pads.get(net).map(|v| v.iter().filter(|(o, _)| o != &r).map(|(_, q)| *q).collect()).unwrap_or_default();+                if others.is_empty() { continue; }+                total += if others.len() + 1 > plane_pads { others.iter().map(|q| dist(at, *q)).fold(f64::MAX, f64::min) } else { let mut pts = others.clone(); pts.push(at); mst(&pts) };+            }+            total+        };+        let now = score(0.0);+        let mut best = (0.0, now);+        for d in [90.0, 180.0, 270.0] { let sc = score(d); if sc < best.1 - 1e-9 { best = (d, sc); } }+        if now - best.1 >= min_gain {+            findings.push(json!({"ref": r, "rotation": rot, "suggested": (rot + best.0).rem_euclid(360.0), "airlineNowMm": r3(now), "airlineThenMm": r3(best.1), "savesMm": r3(now - best.1)}));+        }+    }+    findings.sort_by(|a, b| f(&b["savesMm"]).partial_cmp(&f(&a["savesMm"])).unwrap_or(std::cmp::Ordering::Equal));+    json!({"success": true, "checked": checked, "suggestions": findings,+        "_hint": "Advisory only: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there; distance to the nearest pad of a plane-like net such as GND). Turn with move_parts {parts:[{ref, rotation}]} (position kept), then reroute those nets. Routed copper is not considered: on a routed board, treat these as hints."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn pad(r: &str, n: &str, x: f64, y: f64, net: &str) -> Value {+        json!({"ref": r, "name": n, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": net, "shape": "rect"})+    }++    fn board() -> Value {+        // R1 lies along x, but its nets go up and down: a quarter turn shortens both airlines+        json!({"outline": [[0, 0], [20, 0], [20, 20], [0, 20]],+            "components": [{"ref": "R1", "x": 10.0, "y": 10.0, "rotation": 0.0, "side": "top"}, {"ref": "U1", "x": 10.0, "y": 15.0, "rotation": 0.0, "side": "top"}, {"ref": "U2", "x": 10.0, "y": 5.0, "rotation": 0.0, "side": "top"}],+            "pads": [pad("R1", "1", 9.0, 10.0, "A"), pad("R1", "2", 11.0, 10.0, "B"), pad("U1", "1", 10.0, 15.0, "A"), pad("U1", "2", 11.0, 15.0, "X"), pad("U2", "1", 10.0, 5.0, "B"), pad("U2", "2", 11.0, 5.0, "Y")]})+    }++    #[test]+    fn a_quarter_turn_that_shortens_the_airlines_is_suggested() {+        let r = check_orientation(&json!({"board": board(), "args": {}}));+        // U1 and U2 have two pads too, but turning them about the pad at their origin gains nothing+        assert_eq!((r["checked"].clone(), r["suggestions"].as_array().unwrap().len()), (json!(3), 1), "{r}");+        let s = &r["suggestions"][0];+        // pad 1 (net A) has to come up to U1: a quarter turn clockwise, 270 counterclockwise+        assert_eq!((s["ref"].as_str(), s["suggested"].as_f64()), (Some("R1"), Some(270.0)), "{r}");+        assert!(f(&s["savesMm"]) > 0.6);+    }++    #[test]+    fn free_spots_avoid_parts_copper_and_the_edge() {+        let mut b = board();+        b["tracks"] = json!([{"layer": "F.Cu", "net": "A", "width": 0.25, "start": {"x": 12.0, "y": 0.0}, "end": {"x": 12.0, "y": 20.0}}]);+        let r = find_space(&json!({"board": b, "args": {"near": "R1", "sizeMm": [2.0, 1.0], "count": 3}}));+        assert_eq!(r["success"], json!(true), "{r}");+        for s in r["spots"].as_array().unwrap() {+            let e: Vec<f64> = s["extent"].as_array().unwrap().iter().map(f).collect();+            // clear of R1's box (8.7..11.3 x 9.7..10.3) by 0.25, of the track at x 12 by 0.125 + 0.25+            assert!(e[2] <= 8.7 - 0.25 + 1e-9 || e[0] >= 11.3 + 0.25 - 1e-9 || e[3] <= 9.7 - 0.25 + 1e-9 || e[1] >= 10.3 + 0.25 - 1e-9, "{s}");+            assert!(e[2] <= 12.0 - 0.375 + 1e-9 || e[0] >= 12.0 + 0.375 - 1e-9, "{s}");+        }+        let chk = find_space(&json!({"board": board(), "args": {"check": true}}));+        assert_eq!((chk["overlaps"].as_array().unwrap().len(), chk["outsideOutline"].as_array().unwrap().len()), (0, 0));+        // a part under a raised molecule: pads apart, bodies apart in height, so stacked, not an overlap (fusion-lane)+        let mut m = board();+        m["components"].as_array_mut().unwrap().push(json!({"ref": "M1", "x": 10.0, "y": 10.0, "rotation": 0.0, "side": "top"}));+        m["pads"].as_array_mut().unwrap().extend([pad("M1", "1", 6.0, 10.0, "P"), pad("M1", "2", 14.0, 10.0, "Q")]);+        let bodies = json!({"bodies": [{"ref": "M1", "side": "top", "x0": 5.5, "y0": 8.0, "x1": 14.5, "y1": 12.0, "z0": 3.0, "z1": 4.0},+                                       {"ref": "R1", "side": "top", "x0": 8.7, "y0": 9.7, "x1": 11.3, "y1": 10.3, "z0": 0.0, "z1": 0.5}]});+        let s = find_space(&json!({"board": m, "bodies": bodies, "args": {"check": true}}));+        assert_eq!((s["overlaps"].as_array().unwrap().len(), s["stacked"].clone(), s["bodiesUsed"].clone()), (0, json!([["M1", "R1"]]), json!(true)), "{s}");+        assert_eq!(find_space(&json!({"board": board(), "args": {"near": "Q9", "sizeMm": [1, 1]}}))["errorCode"], "no_such_part");+    }+}+--- a/rust/vendor/adom-eda-checks/src/writers/ipc2581.rs+++ b/rust/vendor/adom-eda-checks/src/writers/ipc2581.rs@@ -1,398 +1,399 @@⋯ 16 unchanged lines ⋯ fn ascii(t: &str) -> String {     t.chars().map(|c| match c {         c if (c as u32) < 128 => c.to_string(),-        'Ω' | 'Ω' => "ohm".into(), 'µ' | 'μ' => "u".into(), '±' => "+/-".into(), '°' => "deg".into(), '–' | '—' => "-".into(),+        '\u{03A9}' | '\u{2126}' => "ohm".into(), '\u{00B5}' | '\u{03BC}' => "u".into(), '\u{00B1}' => "+/-".into(), '\u{00B0}' => "deg".into(), '\u{2013}' | '\u{2014}' => "-".into(),         _ => "?".into(),     }).collect() }⋯ 372 unchanged lines ⋯         assert!(d.starts_with("20") && d.ends_with('Z') && &d[10..11] == "T", "{d}");     } }+--- a/skills/kicad-bridge-test/run_verb_tests.py+++ b/skills/kicad-bridge-test/run_verb_tests.py@@ -1,1745 +1,1752 @@⋯ 196 unchanged lines ⋯             "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators",             "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic",             "library_views", "generate_body", "plan_route", "export_spice", "simulate",-            "license_status", "eval", "run_script", "dialog_log", "foreground")},+            "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review")},         "panelize": dict(phase=8, scenario=True),         # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files         "add_footprint": dict(phase=8, scenario=True),⋯ 1410 unchanged lines ⋯                 t = (-3.0 - db[i]) / (db[i + 1] - db[i])                 f3 = 10 ** (math.log10(f[i]) + t * (math.log10(f[i + 1]) - math.log10(f[i])))                 break+        # 1.0.83: the standard's schematic_review from KiCad's netlist (engine 0.16.0): the wired RC is clean, the rp2040 sample+        # (mostly unwired) has power pins on no net+        rv1 = C("schematic_review", {"filePath": rc}, 200)+        rv2 = C("schematic_review", {"filePath": w["sch"]}, 200)+        own("schematic_review", {"rc": rv1, "rp2040": rv2}, ok(rv1) and rv1.get("ok") is True and ok(rv2) and rv2.get("ok") is False+            and any(e.get("check") == "unconnected_pin" and e.get("type") == "power" for e in rv2.get("errors") or []), "the RC was not clean, or the unwired sample's power pins were not caught")         bad = C("simulate", {"schPath": rc, "analysis": ".dc V9 0 1 0.1"}, 200)         own("simulate", {"ac": ac, "bad": bad}, ok(ac) and sig.get("signal") == "V(OUT)" and sig.get("yUnit") == "dB" and str(ac.get("engine") or "").startswith("ngspice ") and f3 is not None and abs(f3 - 1591.5) / 1591.5 < 0.02 and ac.get("csvPath") and bad.get("errorCode") == "simulation_failed",             f"the RC's -3 dB point was {f3} Hz, not about 1592, or a failing analysis did not fail")⋯ 123 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+

Comments

No comments yet.

Log in to comment.