← All Pull Requests

kicad-lane: 1.0.93, schematic_review checks the drawing; PWR_FLAG and escaped label fixes #55

Merged opened by Colby Knox 2026-10-30

1.0.93: kicad_schematic_review checks the drawing too (engine 0.23.0, standard 0.28.0).

  • kicad_schematic_review: sends the engine each sheet's drawing (sheetDrawing): wires, buses, junctions, no-connects, labels, pins and symbol boxes. The engine's drawing checks (dangling wires, floating labels, overlapping symbols, ...) now run on KiCad schematics, and the reply says drawingChecked.
    • Power symbols that name a net count as power labels. Sheet pins count as hierarchical labels.
    • netlist.names lists every label and port name joined into one net, so the engine can flag two names on one net.
    • Checked on three real designs: pic_programmer and StickHub come back clean apart from one real info each (no bulk capacitor on a rail). On the rp2040 sample, the engine's overlapping-symbol pairs are the same 10 that KiCad's own kicad_sch_check reports.
  • Fixed: PWR_FLAG no longer joins nets. A PWR_FLAG was read as a power symbol naming the net "PWR_FLAG", so every net carrying one became one net. This raised a false shorted_nets on pic_programmer and StickHub. Only power symbols whose pin is a power input name a net now.
  • Fixed: escaped label names. KiCad writes a / in a label as {slash} (and : as {colon}, and so on) in some files. kicad_sch_check and kicad_schematic_review compared the raw text, so the labels VPP/MCLR and VPP{slash}MCLR split one net in two (a false "reaches only R18.2" on pic_programmer). Names are now read as KiCad compares them.
  • Engine adom-eda-checks 0.23.0 vendored unchanged (standard 0.28.0).

Battery 335 pass / 1 fail / 1 skip. The one failure was the new schematic_review_drawing check itself: it counted KiCad's one-part "text over the body" overlap rows. It now compares only two-part overlaps, and on the run's recorded data the 10 pairs match.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯  [[package]] name = "adom-eda-checks"-version = "0.21.1"+version = "0.23.0" dependencies = [  "roxmltree",  "serde_json",⋯ 302 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.92"+version = "1.0.93" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.92"+version = "1.0.93" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.92"+version = "1.0.93" 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.92"+version = "1.0.93" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/skills/kicad-bridge-test/run_verb_tests.py+++ b/skills/kicad-bridge-test/run_verb_tests.py@@ -1,1900 +1,1910 @@⋯ 1663 unchanged lines ⋯         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")+        # 1.0.93: the review also checks the drawing (sheetDrawing): its overlap pairs agree with KiCad's own sch_check, and the+        # clean RC raises no drawing or net-name warning+        oc = C("sch_check", {"filePath": w["sch"], "checks": ["overlap"]}, 200)+        # two parts drawn over each other (KiCad's overlap check also names one part whose text runs over its own body)+        pairs = lambda fs: sorted(tuple(sorted(x.get("refs") or [])) for x in fs if x.get("check") == "overlap" and len(x.get("refs") or []) == 2)+        po, pr = pairs(oc.get("findings") or []), pairs(rv2.get("warnings") or [])+        own("schematic_review_drawing", {"kicadOverlaps": po, "engineOverlaps": pr, "rcWarnings": rv1.get("warnings")},+            ok(oc) and rv1.get("drawingChecked") is True and rv2.get("drawingChecked") is True and po == pr and not (rv1.get("warnings") or []),+            "the drawing was not checked, the engine's overlaps differ from KiCad's sch_check, or the clean RC raised a warning")         # 1.0.88: KiCad 10 design variants on a copy of the rp2040 sample: make Lite with two parts not fitted, put one back,         # KiCad's own netlist for the variant agreeing each time, then remove it         vp = base + "/variants/rp2040-breakout.kicad_pro"⋯ 228 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+--- 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.24.2-- Standard commit: d104b57e76bd000fd5271711ee15c85e01deeaf6-- Crate version: 0.21.1+- Standard version: 0.28.0+- Standard commit: dc0d3953dd8e58d149b4bba145aaa3d7d7292886+- Crate version: 0.23.0 - Copied: 2026-10-29, 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/README.md+++ b/rust/vendor/adom-eda-checks/README.md@@ -1,130 +1,131 @@⋯ 30 unchanged lines ⋯ | `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 | | `lint_library` | library | is a symbol or footprint library fit to ship (standard #28): the shared rules with a fixed severity each (`no_pads`, `zero_size_pad`, `duplicate_pad_number`, `no_courtyard`, `origin_off_part`, `pin_without_pad`, `pad_without_pin`, `no_part_number`, ...; `rules` in the reply lists them), checked from the footprints and symbols the bridge sends; a key left out is not checked, a key sent empty is a finding; the bridge's own findings (`tool_refuses_file`) pass through | | `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 |+| `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; plus a 0-100 score and grade (from kicad-bridge's score_placement): connectors farther than `connectorEdgeMm` from the edge, decoupling capacitors farther than `decouplingMm` from their IC's pin on the same rail, and the ratsnest's length against the board diagonal; a body-less SMD test point counts as flat, so it can sit stacked under a raised module | | `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) | | `review_board` | board, sheetParts?, rules? | small design-review checks, errors first: placeholder values, single-pin nets, duplicate designators, parts with pads off the outline polygon or near the edge (`edgeMm`), parts with no footprint name, off-grid parts (with `gridMm`); from the sheet parts, missing Mouser/DigiKey/LCSC numbers and datasheets and placeholder comments; from the rules, disabled ones, missing clearance/width/hole-size/short-circuit rules and same-priority ties (rules classed from a standard kind, a descriptor, Altium's ordinal or the rule's own name; `alwaysChecked` names classes a tool always checks, like EAGLE's shorts). `skipped` and `notImplemented` say what was not judged. From the Altium bridge's review.rs | | `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 |+| `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; plus the design checks from kicad-bridge's design_review and sch_check: I2C lines with no pull-up, reset and enable pins floating or with nothing setting their level, LEDs with no series resistor, regulators missing input or output capacitors, rails with no bulk capacitor, unannotated or doubled designators, no-connect flags on wired pins, and two names on one net (`netlist.names`); each with `message` and `fix`; with the `sheetDrawing` companion (one entry per sheet: wires, buses, junctions, noConnects, labels, pins, symbol boxes, mm, y down) also dangling wire ends (with the nearest pin or label), labels on no wire or pin, and symbols drawn over each other, which are listed in `unread` without it | | `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 | | `teardrops` | board, rules? | a tapered fill where a track meets a via or pad of its own net (`targets` via, tht, smd), as wide as the pad at its centre (`widthRatio`) and narrowing to the track `lengthRatio` x the pad's size past its edge; each a polygon on the track's layer kept clear of other nets' copper by the board's clearance, the joints with no room skipped and named | | `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 |⋯ 85 unchanged lines ⋯ ```  Fixtures live on each bridge's own page, not here.+--- 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.21.1"+version = "0.23.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/src/schreview.rs+++ b/rust/vendor/adom-eda-checks/src/schreview.rs@@ -1,252 +1,646 @@ //! 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.+//! other, inputs nothing drives, and IC power pins with no decoupling capacitor to ground. The design checks after it+//! (I2C pull-ups, reset and enable pins, LED resistors, regulator and bulk capacitors, annotation, no-connect flags on+//! wired pins, two names on one net) are ported from kicad-bridge's design_review and sch_check (#29). use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet}; ⋯ 17 unchanged lines ⋯             "tri" | "tristate" | "3state" => "hiz".into(),             "in" => "input".into(),             "out" => "output".into(),+            // KiCad's names+            "power_in" => "power".into(),+            "power_out" => "supply".into(),+            "tri_state" => "hiz".into(),+            "no_connect" | "unconnected" => "not_connected".into(),+            "unspecified" | "free" => "unknown".into(),             o => o.to_string(),         },         _ => "unknown".into(),⋯ 29 unchanged lines ⋯     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();+    let mut infos = Vec::new();+    let found = design_checks(parts, nets, &pins, &net_of);+    // a reset or enable pin reported floating is not reported again as an unconnected pin+    let floating: BTreeSet<String> = found.iter().filter(|(_, f)| f["check"] == "reset_enable" && f["floating"] == true).filter_map(|(_, f)| f["pin"].as_str().map(|s| s.to_ascii_uppercase())).collect();+    for (sev, f) in found { match sev { "error" => errors.push(f), "warning" => warnings.push(f), _ => infos.push(f) } }     for (key, (des, num, name, ty)) in &pins {-        if net_of.contains_key(key) || nc(name) || ty == "not_connected" { continue; }+        if net_of.contains_key(key) || nc(name) || ty == "not_connected" || floating.contains(key) { 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) }     }⋯ 35 unchanged lines ⋯         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()})+    json!({"pins": pins.len(), "nets": nets.len(), "errors": errors, "warnings": warnings, "infos": infos, "decoupling": decoupling, "ok": errors.is_empty()}) } +/// A net name's last segment, upper case (KiCad's hierarchical "/sheet/SDA" reads as SDA).+fn tail(net: &str) -> String { net.rsplit('/').next().unwrap_or(net).to_ascii_uppercase() }++/// A supply rail by its name: +3V3, VCC, VDD..., 5V, 3V3, 1V8.+pub fn looks_like_rail(net: &str) -> bool {+    let n = tail(net);+    if n.starts_with('+') || (n.starts_with('-') && n.chars().nth(1).map_or(false, |c| c.is_ascii_digit())) { return true; }+    if ["VCC", "VDD", "VBUS", "VBAT", "VIN", "VSYS", "VREF", "VIO", "VAA", "AVDD", "DVDD", "IOVDD", "VMOT", "VOUT", "VREG"].iter().any(|p| n.starts_with(p)) { return true; }+    let core = n.trim_start_matches('V');+    let digits = core.chars().take_while(|c| c.is_ascii_digit()).count();+    digits > 0 && core.chars().nth(digits) == Some('V')+}++/// A pin name without overbar markup and separators: `~{RESET}` and `/RESET` read RESET.+pub fn norm_pin(name: &str) -> String { name.replace("~{", "").replace(['}', '~', '/', '#', ' '], "").to_ascii_uppercase() }++/// Farads from a value like 100n, 4.7uF, 10µ, 1u0 or 22pF; None when unreadable (a bare number has no unit).+pub fn farads(v: &str) -> Option<f64> {+    let s = v.trim().replace(['µ', 'μ'], "u").to_ascii_lowercase();+    let s = s.split([' ', '/', ',']).next().unwrap_or("").trim_end_matches('f').to_string();+    let (mut num, mut mult, mut unit) = (String::new(), 1.0, false);+    for c in s.chars() {+        if c.is_ascii_digit() || c == '.' { num.push(c); }+        else if !unit {+            mult = match c { 'p' => 1e-12, 'n' => 1e-9, 'u' => 1e-6, 'm' => 1e-3, _ => return None };+            if !num.contains('.') { num.push('.'); }+            unit = true;+        } else { return None; }+    }+    if !unit { return None; }+    num.trim_end_matches('.').parse::<f64>().ok().map(|x| x * mult)+}++fn fmt_farads(f: f64) -> String {+    let (v, u) = if f >= 1e-6 { (f / 1e-6, "u") } else if f >= 1e-9 { (f / 1e-9, "n") } else { (f / 1e-12, "p") };+    format!("{}{u}", ((v * 1000.0).round() / 1000.0))+}++#[derive(Clone, Copy, PartialEq)]+enum Kind { Capacitor, Resistor, Led, Regulator, Ic, Connector, Other }++/// One part with every unit's pins merged: (designator, library id, value, [(number, name, type)]).+struct Part { des: String, lib: String, value: String, pins: Vec<(String, String, String)> }++fn kind(p: &Part) -> Kind {+    let pre = prefix(&p.des);+    let lib = p.lib.to_ascii_lowercase();+    let sym = lib.rsplit(':').next().unwrap_or(&lib).to_string();+    let val = p.value.to_ascii_lowercase();+    if (lib.contains("led") && (pre == "D" || pre == "LED")) || sym.starts_with("led") || pre == "LED" { return Kind::Led; }+    if pre == "C" || sym == "c" || sym.starts_with("c_") || sym == "cp" || sym.starts_with("cp_") { return Kind::Capacitor; }+    if pre == "R" || sym == "r" || sym.starts_with("r_") { return Kind::Resistor; }+    if matches!(pre.as_str(), "J" | "P" | "CN") || lib.starts_with("connector") { return Kind::Connector; }+    if lib.starts_with("regulator_") || lib.contains("regulator") || val.contains("ldo") || val.contains("1117") { return Kind::Regulator; }+    if pre == "U" || pre == "IC" {+        let has_in = p.pins.iter().any(|x| matches!(norm_pin(&x.1).as_str(), "VIN" | "VI" | "IN"));+        let has_out = p.pins.iter().any(|x| matches!(norm_pin(&x.1).as_str(), "VOUT" | "VO" | "OUT") && x.2 == "supply");+        return if has_in && has_out { Kind::Regulator } else { Kind::Ic };+    }+    Kind::Other+}++/// The design checks: (severity, finding). Each finding carries check, refs, nets, message and fix. Pure.+fn design_checks(parts: &[Value], nets: &Map<String, Value>, pins: &BTreeMap<String, (String, String, String, String)>, net_of: &BTreeMap<String, String>) -> Vec<(&'static str, Value)> {+    let s = |v: &Value, k: &[&str]| k.iter().find_map(|k| v[*k].as_str().filter(|x| !x.is_empty())).unwrap_or("").to_string();+    // parts, units merged by designator+    let mut by: BTreeMap<String, Part> = BTreeMap::new();+    for (key, (des, num, name, ty)) in pins {+        let _ = key;+        let e = by.entry(des.clone()).or_insert_with(|| Part { des: des.clone(), lib: String::new(), value: String::new(), pins: Vec::new() });+        e.pins.push((num.clone(), name.clone(), ty.clone()));+    }+    for c in parts {+        let des = s(c, &["designator"]);+        if let Some(e) = by.get_mut(&des) {+            if e.lib.is_empty() { e.lib = s(c, &["libRef", "libId", "lib", "symbol", "library"]); }+            if e.value.is_empty() { e.value = s(c, &["value", "comment"]); }+        }+    }+    let kinds: BTreeMap<String, Kind> = by.values().map(|p| (p.des.clone(), kind(p))).collect();+    let net_pin = |des: &str, num: &str| net_of.get(&format!("{des}.{num}").to_ascii_uppercase()).cloned();+    let members = |net: &str| -> Vec<(String, String)> {+        nets.get(net).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|p| p.as_str()).filter_map(|p| p.rsplit_once('.').map(|(d, n)| (d.to_string(), n.to_string()))).collect()).unwrap_or_default()+    };+    let single = |net: &Option<String>| net.as_ref().map(|n| members(n).len() <= 1).unwrap_or(true);+    // two-terminal parts of a kind on a net: (designator, the net at its other end)+    let two_terminal = |net: &str, k: Kind| -> Vec<(String, String)> {+        by.values().filter(|p| kinds[&p.des] == k && p.pins.len() == 2).filter_map(|p| {+            let (a, b) = (net_pin(&p.des, &p.pins[0].0)?, net_pin(&p.des, &p.pins[1].0)?);+            if a == net { Some((p.des.clone(), b)) } else if b == net { Some((p.des.clone(), a)) } else { None }+        }).collect()+    };+    let caps_to_ground = |net: &str| -> Vec<(String, Option<f64>)> {+        two_terminal(net, Kind::Capacitor).into_iter().filter(|(_, o)| is_ground(o)).map(|(d, _)| { let v = farads(&by[&d].value); (d, v) }).collect()+    };+    let rails: BTreeSet<String> = nets.keys().filter(|n| !is_ground(n) && (looks_like_rail(n) || members(n).iter().any(|(d, k)| pins.get(&format!("{d}.{k}").to_ascii_uppercase()).map(|t| t.3 == "supply").unwrap_or(false)))).cloned().collect();+    let refs_on = |net: &str| -> Vec<String> { members(net).into_iter().map(|(d, _)| d).collect::<BTreeSet<_>>().into_iter().collect() };+    let mut out: Vec<(&'static str, Value)> = Vec::new();+    let mut add = |sev: &'static str, check: &str, refs: Vec<String>, nets: Vec<String>, message: String, fix: String, extra: Value| {+        let mut f = json!({"check": check, "refs": refs, "nets": nets, "message": message, "fix": fix});+        if let Some(o) = extra.as_object() { for (k, v) in o { f[k] = v.clone(); } }+        out.push((sev, f));+    };++    // i2c_pullups: an SDA or SCL line with no resistor to a supply rail+    for net in nets.keys() {+        let t = tail(net);+        let named = t.split(|c: char| !c.is_ascii_alphanumeric()).any(|x| x.starts_with("SDA") || x.starts_with("SCL"));+        let by_pin = members(net).iter().any(|(d, k)| pins.get(&format!("{d}.{k}").to_ascii_uppercase()).map(|t| matches!(norm_pin(&t.2).as_str(), "SDA" | "SCL" | "I2C_SDA" | "I2C_SCL")).unwrap_or(false));+        if !(named || by_pin) || members(net).len() <= 1 { continue; }+        if two_terminal(net, Kind::Resistor).iter().any(|(_, o)| rails.contains(o)) { continue; }+        add("warning", "i2c_pullups", refs_on(net), vec![net.clone()], format!("I2C line {net} has no pull-up resistor to a supply rail"),+            format!("I2C drivers only pull low: add a resistor from {net} to the I/O rail (4.7k for 100 kHz, 2.2k for 400 kHz), once per bus, unless the pull-up is on another board."), json!({}));+    }++    // reset_enable: a reset, enable or boot pin floating, or with nothing setting its level+    const NAMES: &[&str] = &["RESET", "RST", "NRST", "RESETN", "RSTN", "NRESET", "MCLR", "EN", "ENABLE", "CE", "CHIP_EN", "CHIPEN", "SHDN", "NSHDN", "SHDNN", "PWRDN", "ON", "ONOFF", "RUN"];+    for p in by.values().filter(|p| matches!(kinds[&p.des], Kind::Ic | Kind::Regulator)) {+        for (num, name, ty) in &p.pins {+            let n = norm_pin(name);+            if !(NAMES.contains(&n.as_str()) || n.starts_with("RESET") || n.starts_with("NRST") || n.starts_with("RST")) || matches!(ty.as_str(), "output" | "supply" | "not_connected") { continue; }+            let net = net_pin(&p.des, num);+            let pin = format!("{}.{num}", p.des);+            if single(&net) {+                add("error", "reset_enable", vec![p.des.clone()], net.iter().cloned().collect(), format!("{pin} {name} is floating"),+                    format!("Tie {pin} to its active state (the supply for an enable that is always on, or a 10k pull-up with 100 nF to ground for a reset), or drive it from a GPIO."), json!({"pin": pin, "floating": true}));+                continue;+            }+            let net = net.unwrap();+            let defined = rails.contains(&net) || is_ground(&net) || members(&net).iter().any(|(d, k)| {+                let e = pins.get(&format!("{d}.{k}").to_ascii_uppercase()).map(|t| t.3.as_str()).unwrap_or("");+                (*d != p.des && matches!(e, "output" | "io" | "open_collector" | "open_emitter" | "supply" | "hiz")) || matches!(kinds.get(d), Some(Kind::Resistor) | Some(Kind::Connector))+            });+            if !defined {+                add("warning", "reset_enable", vec![p.des.clone()], vec![net.clone()], format!("Nothing sets the level of {pin} {name} (net {net})"),+                    format!("Add a pull resistor on {net} to the inactive level, or drive it from a GPIO or a supervisor."), json!({"pin": pin}));+            }+        }+    }++    // led_resistor: an LED with no series resistor on either side+    for p in by.values().filter(|p| kinds[&p.des] == Kind::Led) {+        let pn: Vec<String> = p.pins.iter().filter_map(|x| net_pin(&p.des, &x.0)).collect();+        let limited = pn.iter().any(|n| members(n).iter().any(|(d, _)| *d != p.des && kinds.get(d) == Some(&Kind::Resistor)));+        if !limited {+            add("warning", "led_resistor", vec![p.des.clone()], pn, format!("LED {} has no series resistor on either side", p.des),+                format!("Put a resistor in series with {}: R = (Vsupply - Vf) / I, for example 330R to 1k at 3.3 V; skip it only if a constant-current driver feeds the LED.", p.des), json!({}));+        }+    }++    // regulator_caps: a regulator with no input or output capacitor, or too little on the output+    for p in by.values().filter(|p| kinds[&p.des] == Kind::Regulator) {+        let outs: BTreeSet<String> = p.pins.iter().filter(|x| x.2 == "supply" || matches!(norm_pin(&x.1).as_str(), "VOUT" | "VO" | "OUT")).filter_map(|x| net_pin(&p.des, &x.0)).collect();+        let ins: BTreeSet<String> = p.pins.iter().filter(|x| x.2 == "power" || matches!(norm_pin(&x.1).as_str(), "VIN" | "VI" | "IN")).filter_map(|x| net_pin(&p.des, &x.0)).filter(|n| !is_ground(n) && !outs.contains(n)).collect();+        for net in ins {+            if caps_to_ground(&net).is_empty() {+                add("warning", "regulator_caps", vec![p.des.clone()], vec![net.clone()], format!("Regulator {} has no input capacitor on {net}", p.des),+                    format!("Add the input capacitor the datasheet asks for (typically 1 uF to 10 uF ceramic) from {net} to ground beside {}.", p.des), json!({}));+            }+        }+        for net in outs {+            let caps = caps_to_ground(&net);+            let biggest = caps.iter().filter_map(|c| c.1).fold(None, |a: Option<f64>, f| Some(a.map_or(f, |x| x.max(f))));+            if caps.is_empty() {+                add("warning", "regulator_caps", vec![p.des.clone()], vec![net.clone()], format!("Regulator {} has no output capacitor on {net}", p.des),+                    format!("Most LDOs need a minimum output capacitance to be stable: add the datasheet's (typically 1 uF to 10 uF ceramic) from {net} to ground beside {}.", p.des), json!({}));+            } else if caps.iter().all(|c| c.1.is_some()) && biggest.map_or(false, |b| b < 0.99e-6) {+                add("warning", "regulator_caps", vec![p.des.clone()], vec![net.clone()], format!("Regulator {} output {net} has only {} of capacitance", p.des, fmt_farads(biggest.unwrap_or(0.0))),+                    format!("Add a 1 uF to 10 uF ceramic from {net} to ground beside {} (check the datasheet's minimum and ESR range).", p.des), json!({}));+            }+        }+    }++    // bulk_capacitance: a rail feeding ICs with no capacitor of 4.7 uF or more+    for net in &rails {+        let loads = members(net).iter().filter(|(d, k)| matches!(kinds.get(d), Some(Kind::Ic) | Some(Kind::Regulator)) && pins.get(&format!("{d}.{k}").to_ascii_uppercase()).map(|t| t.3 == "power").unwrap_or(false)).count();+        if loads == 0 { continue; }+        let caps = caps_to_ground(net);+        if caps.iter().any(|c| c.1.map_or(true, |f| f >= 4.7e-6 * 0.99)) { continue; }+        let biggest = caps.iter().filter_map(|c| c.1).fold(0.0, f64::max);+        let message = if caps.is_empty() { format!("Rail {net} feeds {loads} IC pin(s) but has no capacitance to ground") } else { format!("Rail {net} feeds {loads} IC pin(s) but has no bulk capacitor (largest is {})", fmt_farads(biggest)) };+        add("info", "bulk_capacitance", caps.iter().map(|c| c.0.clone()).collect(), vec![net.clone()], message,+            format!("Add a 10 uF (or larger) capacitor from {net} to ground where the rail enters the board or leaves its regulator."), json!({}));+    }++    // annotation: a designator not yet annotated, or one designator on two different symbols (one footprint on the board)+    let mut unannotated = BTreeSet::new();+    let mut units: BTreeMap<(String, String), usize> = BTreeMap::new();+    let mut pinsets: BTreeMap<String, Vec<BTreeSet<String>>> = BTreeMap::new();+    for c in parts {+        let des = s(c, &["designator"]);+        if des.is_empty() || des.contains('?') { unannotated.insert(if des.is_empty() { "A symbol".to_string() } else { des }); continue; }+        let unit = s(c, &["unit", "partId", "subpart"]);+        if !unit.is_empty() { *units.entry((des.clone(), unit)).or_default() += 1; }+        else { pinsets.entry(des).or_default().push(c["pins"].as_array().map(|a| a.iter().filter_map(|p| p["number"].as_str().map(String::from)).collect()).unwrap_or_default()); }+    }+    for d in unannotated {+        add("error", "annotation", vec![d.clone()], vec![], format!("{d} is not annotated"), "Annotate the schematic, or give the part a designator, before updating the board: an unannotated part never becomes a footprint.".into(), json!({}));+    }+    let mut dups: BTreeMap<String, usize> = units.into_iter().filter(|(_, n)| *n > 1).map(|((d, _), n)| (d, n)).collect();+    // with no unit field, two symbols sharing a designator and a pin number are two parts, not two units of one+    for (d, sets) in pinsets { if sets.len() > 1 && (0..sets.len()).any(|i| (i + 1..sets.len()).any(|j| !sets[i].is_disjoint(&sets[j]))) { dups.insert(d, sets.len()); } }+    for (d, n) in dups {+        add("error", "annotation", vec![d.clone()], vec![], format!("Reference {d} is used by {n} different symbols"), format!("Renumber one of them or re-annotate: two parts with one designator become one footprint and their nets merge."), json!({}));+    }++    // no_connect_connected: a pin flagged no-connect that is wired to something+    for (key, (des, num, name, ty)) in pins {+        if ty != "not_connected" { continue; }+        let Some(net) = net_of.get(key) else { continue };+        if members(net).len() > 1 {+            add("warning", "no_connect_connected", vec![des.clone()], vec![net.clone()], format!("No-connect flag on {des}.{num} {name} but the pin is wired to {net}"),+                "Either remove the no-connect flag or the wire: the pin is marked unused but carries a net.".into(), json!({"pin": format!("{des}.{num}")}));+        }+    }+    out+}+ /// 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 {⋯ 35 unchanged lines ⋯     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).+type Pt = (f64, f64);+fn pt(v: &Value) -> Option<Pt> { Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)) }+fn d2(a: Pt, b: Pt) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }+fn on_seg(p: Pt, a: Pt, b: Pt) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+    d2(p, (a.0 + t * dx, a.1 + t * dy))+}+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }++/// The checks that need a sheet's drawing (kicad-bridge's sch_check, #29), from the sheetDrawing companion: one entry+/// per sheet {sheet, wires [{a, b}], buses?, junctions [[x, y]], noConnects, labels [{text, kind, at}], pins [{ref,+/// number, at}], symbols [{ref, box: [x0, y0, x1, y1]}]}, mm, y down. A point connects when it lies within 0.01 mm of+/// another item; a wire end on another wire's middle connects only with a junction there. Pure.+pub fn drawing_checks(sheets: &[Value]) -> Vec<Value> {+    const TOL: f64 = 0.01;+    let mut out = Vec::new();+    for sh in sheets {+        let name = sh["sheet"].as_str().unwrap_or("").to_string();+        let list = |k: &str| sh[k].as_array().cloned().unwrap_or_default();+        let wires: Vec<(Pt, Pt)> = list("wires").iter().filter_map(|w| Some((pt(&w["a"])?, pt(&w["b"])?))).collect();+        let points = |k: &str| -> Vec<Pt> { list(k).iter().filter_map(pt).collect() };+        let (junctions, ncs) = (points("junctions"), points("noConnects"));+        let labels: Vec<(String, String, Pt)> = list("labels").iter().filter_map(|l| Some((l["text"].as_str()?.to_string(), l["kind"].as_str().unwrap_or("local").to_string(), pt(&l["at"])?))).collect();+        let pins: Vec<(String, Pt)> = list("pins").iter().filter_map(|p| Some((format!("{}.{}", p["ref"].as_str()?, p["number"].as_str().map(String::from).or_else(|| p["number"].as_i64().map(|n| n.to_string()))?), pt(&p["at"])?))).collect();+        let near = |p: Pt, q: Pt| d2(p, q) <= TOL;+        // dangling wire ends+        for (i, (a, b)) in wires.iter().enumerate() {+            for e in [*a, *b] {+                let joined = pins.iter().any(|x| near(e, x.1)) || labels.iter().any(|x| near(e, x.2)) || junctions.iter().any(|j| near(e, *j)) || ncs.iter().any(|n| near(e, *n))+                    || wires.iter().enumerate().any(|(k, (c, d))| k != i && (near(e, *c) || near(e, *d) || (on_seg(e, *c, *d) <= TOL && junctions.iter().any(|j| near(e, *j)))));+                if joined { continue; }+                let mut cand: Vec<(f64, String, Pt)> = pins.iter().map(|x| (d2(e, x.1), format!("pin {}", x.0), x.1)).chain(labels.iter().map(|x| (d2(e, x.2), format!("label {}", x.0), x.2))).filter(|c| c.0 <= 1.27).collect();+                cand.sort_by(|x, y| x.0.total_cmp(&y.0));+                let hint = cand.first().map(|c| format!("; {} is {} mm away at ({}, {})", c.1, r2(c.0), r2(c.2 .0), r2(c.2 .1))).unwrap_or_default();+                out.push(json!({"check": "dangling_wire_end", "sheet": name, "at": [r2(e.0), r2(e.1)], "refs": [], "nets": [],+                    "message": format!("Wire end at ({}, {}) connects to nothing{hint}", r2(e.0), r2(e.1)),+                    "fix": "Extend the wire onto the pin or label it was meant for (a pin's end, not its middle), or delete the stub."}));+            }+        }+        // labels touching no wire or pin+        for (text, kind, at) in &labels {+            if wires.iter().any(|(a, b)| on_seg(*at, *a, *b) <= TOL) || pins.iter().any(|p| near(*at, p.1)) { continue; }+            out.push(json!({"check": "floating_label", "sheet": name, "at": [r2(at.0), r2(at.1)], "refs": [], "nets": [text], "kind": kind,+                "message": format!("Label {text} at ({}, {}) touches no wire or pin", r2(at.0), r2(at.1)),+                "fix": "Move the label onto its wire (its anchor point must sit on the wire), or delete it: a label off its wire names nothing."}));+        }+        // symbols drawn over each other (more than 0.1 mm both ways)+        let syms: Vec<(String, [f64; 4])> = list("symbols").iter().filter_map(|s| {+            let b = s["box"].as_array()?;+            let v: Vec<f64> = b.iter().filter_map(|x| x.as_f64()).collect();+            (v.len() == 4).then(|| (s["ref"].as_str().unwrap_or("").to_string(), [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]))+        }).collect();+        for i in 0..syms.len() {+            for j in i + 1..syms.len() {+                let (a, b) = (&syms[i].1, &syms[j].1);+                let (ox, oy) = (a[2].min(b[2]) - a[0].max(b[0]), a[3].min(b[3]) - a[1].max(b[1]));+                if ox > 0.1 && oy > 0.1 {+                    out.push(json!({"check": "overlap", "sheet": name, "refs": [syms[i].0, syms[j].0], "nets": [],+                        "message": format!("{} and {} overlap", syms[i].0, syms[j].0),+                        "fix": "Move one of them (sch_edit moveBy) so their bodies and pins are apart; overlapping pins can join nets by accident."}));+                }+            }+        }+    }+    out+}++/// schematic_review: input {sheetParts, netlist, sheetDrawing?}. The netlist companion is {nets: {net: ["U1.8", ...]}, names?: {net:+/// [every label or port name joined into it]}} (or the nets map). sheetDrawing (see drawing_checks) adds the checks+/// that need the drawing; without it they are listed in `unread`. Parts may carry value (or comment), libRef (or libId)+/// and unit, which the design checks use. 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);+    // shorted_nets: two different names joined into one net (two labels on one wire)+    if let Some(names) = n.get("names").and_then(|x| x.as_object()) {+        for (net, ns) in names {+            let mut set: Vec<String> = ns.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+            set.sort(); set.dedup();+            if set.len() > 1 {+                out["warnings"].as_array_mut().unwrap().push(json!({"check": "shorted_nets", "refs": [], "nets": [net], "names": set,+                    "message": format!("{} name the same net", set.join(" and ")), "fix": "If they are meant to be one net, keep one name; if not, a wire or label joins two nets by mistake: find it and split them."}));+            }+        }+    }+    // the checks that need the drawing, when the bridge sends it+    match crate::input::part(input, "sheetDrawing") {+        Some(d) => {+            let sheets = d.get("sheets").or(Some(&d)).and_then(|x| x.as_array()).cloned().unwrap_or_default();+            for f in drawing_checks(&sheets) { out["warnings"].as_array_mut().unwrap().push(f); }+            out["drawingChecked"] = json!(true);+        }+        None => { out["drawingChecked"] = json!(false); out["unread"] = json!(["dangling_wire_end", "floating_label", "overlap"]); }+    }     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["_hint"] = json!("errors stop a board (a power pin on no net, two outputs on one net, a floating reset or enable, a designator unannotated or used twice); warnings deserve a look (pins on no net, one-pin nets, inputs nothing drives, an IC supply with no capacitor to ground, I2C with no pull-up, an LED with no resistor, a regulator without its capacitors, a no-connect flag on a wired pin, two names on one net); infos are gaps (a rail with no bulk capacitor). Each design finding has message and fix. 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 } ⋯ 17 unchanged lines ⋯     }      #[test]+    fn the_drawing_checks_find_kicads_examples() {+        let sheet = json!({"sheet": "/", "wires": [{"a": [101.6, 76.2], "b": [127.0, 76.2]}, {"a": [127.0, 76.2], "b": [127.0, 90.0]}, {"a": [60.0, 40.0], "b": [80.0, 40.0]}],+            "junctions": [], "noConnects": [],+            "labels": [{"text": "SDA", "kind": "local", "at": [120.65, 63.5]}, {"text": "SCL", "kind": "local", "at": [70.0, 40.0]}],+            "pins": [{"ref": "U1", "number": "3", "at": [101.6, 75.89]}, {"ref": "R1", "number": "1", "at": [127.0, 90.0]}, {"ref": "R1", "number": "2", "at": [80.0, 40.0]}],+            "symbols": [{"ref": "R3", "box": [10.0, 10.0, 20.0, 15.0]}, {"ref": "C7", "box": [18.0, 12.0, 25.0, 18.0]}, {"ref": "C8", "box": [20.0, 30.0, 25.0, 35.0]}]});+        let f = drawing_checks(&[sheet]);+        let msgs: Vec<&str> = f.iter().map(|x| x["message"].as_str().unwrap()).collect();+        // the wire's left end misses U1.3 by 0.31 mm; the far-left wire starts at (60, 40) on nothing; SDA floats; SCL+        // sits on its wire's middle, so it is fine+        assert!(msgs.contains(&"Wire end at (101.6, 76.2) connects to nothing; pin U1.3 is 0.31 mm away at (101.6, 75.89)"), "{msgs:#?}");+        assert!(msgs.contains(&"Wire end at (60, 40) connects to nothing"), "{msgs:#?}");+        assert!(msgs.contains(&"Label SDA at (120.65, 63.5) touches no wire or pin"));+        assert!(!msgs.iter().any(|m| m.contains("SCL")));+        assert!(msgs.contains(&"R3 and C7 overlap") && !msgs.iter().any(|m| m.contains("C8")));+        assert_eq!(f.len(), 4, "{msgs:#?}");+        // without the drawing the three checks are listed unread+        let r = schematic_review(&json!({"sheetParts": [], "netlist": {"nets": {}}}));+        assert_eq!(r["unread"], json!(["dangling_wire_end", "floating_label", "overlap"]));+    }++    #[test]+    fn the_design_checks_find_kicads_examples() {+        let p = |des: &str, lib: &str, value: &str, pins: &[(&str, &str, &str)]| json!({"designator": des, "libRef": lib, "value": value,+            "pins": pins.iter().map(|(n, nm, e)| json!({"number": n, "name": nm, "electrical": e})).collect::<Vec<_>>()});+        let parts = vec![+            // an MCU on +3V3 with I2C, a reset with only a capacitor on it, and its decoupling+            p("U1", "MCU:X", "", &[("1", "VDD", "power_in"), ("2", "GND", "power_in"), ("3", "SDA", "bidirectional"), ("4", "SCL", "bidirectional"), ("5", "~{RESET}", "input"), ("6", "PA0", "output")]),+            p("C1", "Device:C", "100n", &[("1", "", "passive"), ("2", "", "passive")]),+            p("C2", "Device:C", "100n", &[("1", "", "passive"), ("2", "", "passive")]),+            // an LDO from +12V to +3V3 with no input capacitor and only C1's 100 nF on its output+            p("U2", "Regulator_Linear:AMS1117-3.3", "AMS1117-3.3", &[("1", "GND", "power_in"), ("2", "VO", "power_out"), ("3", "VI", "power_in")]),+            // R1 pulls SCL up; SDA has none+            p("R1", "Device:R", "4k7", &[("1", "", "passive"), ("2", "", "passive")]),+            // an LED straight from a GPIO to ground+            p("D1", "Device:LED", "red", &[("1", "K", "passive"), ("2", "A", "passive")]),+            // two different parts both called C5, and one not annotated+            p("C5", "Device:C", "1u", &[("1", "", "passive"), ("2", "", "passive")]),+            p("C5", "Device:C", "1u", &[("1", "", "passive"), ("2", "", "passive")]),+            p("R?", "Device:R", "10k", &[("1", "", "passive"), ("2", "", "passive")]),+            // a no-connect pin that is wired+            p("U3", "X:Y", "", &[("1", "NC1", "no_connect"), ("2", "IN", "input")]),+        ];+        let nets = json!({"+3V3": ["U1.1", "C1.1", "U2.2", "R1.1", "C5.1"], "/LED": ["U1.6", "D1.2"], "GND": ["U1.2", "C1.2", "U2.1", "C2.2", "D1.1", "C5.2"], "+12V": ["U2.3"],+            "/SDA": ["U1.3", "U3.1"], "/SCL": ["U1.4", "R1.2"], "/NRST": ["U1.5", "C2.1"], "X": ["U3.2"]});+        let r = review(&parts, nets.as_object().unwrap());+        let msgs: Vec<String> = ["errors", "warnings", "infos"].iter().flat_map(|k| r[*k].as_array().unwrap().iter().filter_map(move |f| f["message"].as_str().map(|m| format!("{k}: {m}")))).collect();+        for want in ["warnings: I2C line /SDA has no pull-up resistor to a supply rail",+                     "warnings: Nothing sets the level of U1.5 ~{RESET} (net /NRST)",+                     "warnings: LED D1 has no series resistor on either side",+                     "warnings: Regulator U2 has no input capacitor on +12V",+                     "errors: R? is not annotated",+                     "errors: Reference C5 is used by 2 different symbols",+                     "warnings: No-connect flag on U3.1 NC1 but the pin is wired to /SDA"] {+            assert!(msgs.iter().any(|m| m == want), "missing {want:?} in {msgs:#?}");+        }+        // SCL has its pull-up; the output's biggest capacitor is C5's 1 uF, so no 'only 100n'; +3V3 has no 4.7 uF bulk+        assert!(!msgs.iter().any(|m| m.contains("/SCL")));+        assert!(!msgs.iter().any(|m| m.contains("output +3V3 has only")));+        assert!(msgs.iter().any(|m| m.starts_with("infos: Rail +3V3 feeds")), "{msgs:#?}");+        assert_eq!(farads("4.7uF"), Some(4.7e-6));+        assert_eq!(farads("1u0"), Some(1e-6));+        assert_eq!(farads("100"), None);+        assert!(looks_like_rail("+3V3") && looks_like_rail("3V3") && looks_like_rail("VCC_IO") && !looks_like_rail("SDA"));+    }++    #[test]     fn pin_types_come_as_codes_or_names() {         assert_eq!(pin_type(&json!(7)), "power");         assert_eq!(pin_type(&json!("Bidirectional")), "io");⋯ 9 unchanged lines ⋯     #[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("U1", &[("1", "VCC", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "NC", 0), ("5", "EN", 0), ("6", "DATA", 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)]),⋯ 2 unchanged lines ⋯         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)+        // U1.6 DATA floats (input, warning); U1.5 EN floats (a floating enable, error, not also an unconnected pin);+        // 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["errors"].as_array().unwrap().iter().any(|f| f["check"] == "reset_enable" && f["pin"] == "U1.5"), "{r}");+        assert!(!r["warnings"].as_array().unwrap().iter().any(|f| f["pin"] == "U1.5"));         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()));⋯ 40 unchanged lines ⋯         assert!(r["errors"].as_array().unwrap().is_empty());     } }+--- a/rust/vendor/adom-eda-checks/src/main.rs+++ b/rust/vendor/adom-eda-checks/src/main.rs@@ -1,51 +1,52 @@⋯ 22 unchanged lines ⋯     while let Some(flag) = it.next() {         let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?;         match flag.as_str() {-            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" | "--library" => {-                let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] };+            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" | "--library" | "--sheet-drawing" => {+                let key = match flag.as_str() { "--sheet-parts" => "sheetParts", "--sheet-drawing" => "sheetDrawing", f => &f[2..] };                 b[key] = read(val)?;             }             "--args" => b["args"] = serde_json::from_str(val).map_err(|e| format!("--args: {e}"))?,⋯ 18 unchanged lines ⋯     println!("{}", serde_json::to_string_pretty(&out).unwrap());     if out.get("success") == Some(&json!(false)) { std::process::exit(1); } }+--- a/rust/vendor/adom-eda-checks/src/lib.rs+++ b/rust/vendor/adom-eda-checks/src/lib.rs@@ -1,97 +1,98 @@⋯ 3 unchanged lines ⋯ //! answers the verb under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`, //! `errorCode`, `error`, `_hint`) and name verbs without a prefix. //!-//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, library?, args?}` (see `input`).+//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, sheetDrawing?, library?, args?}` (see `input`). use serde_json::Value;  pub mod assembly;⋯ 84 unchanged lines ⋯         assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]);     } }+--- a/rust/vendor/adom-eda-checks/src/placement.rs+++ b/rust/vendor/adom-eda-checks/src/placement.rs@@ -1,208 +1,338 @@ //! A placement review before routing: parts whose courtyards (pads and body, grown by a margin) overlap on the same //! side, parts off the board or over its edge, pads too close to the edge for assembly and depanelling, and parts on a-//! mounting hole. From the Altium bridge's placement.rs.+//! mounting hole. From the Altium bridge's placement.rs. The score after it (connectors away from the edge, decoupling+//! capacitors far from their IC, the ratsnest's length, and a 0-100 grade) is ported from kicad-bridge's+//! score_placement (#29). use serde_json::{json, Value}; use std::collections::BTreeMap; ⋯ 102 unchanged lines ⋯     }).collect() };     let (pa, pb) = (pad_rects(a), pad_rects(b));     if pa.is_empty() || pb.is_empty() || pa.iter().any(|x| pb.iter().any(|y| hit(grow(*x, margin), grow(*y, margin)))) { return false; }-    let (ba, bb): (Vec<_>, Vec<_>) = (bs.iter().filter(|x| x.part == a).collect(), bs.iter().filter(|x| x.part == b).collect());+    // a body-less SMD test point is flat copper: it stands 0 mm tall (fusion-lane, BQ25792's TP1.. under a module)+    let flat = |r: &str| -> Option<crate::bodies::BodyBox> {+        let ps: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(r)).collect();+        if prefix(r) != "TP" || ps.is_empty() || ps.iter().any(|p| f(&p["drill"]) > 0.0) { return None; }+        let b = pad_rects(r).into_iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, x| (a.0.min(x.0), a.1.min(x.1), a.2.max(x.2), a.3.max(x.3)));+        Some(crate::bodies::BodyBox { part: r.to_string(), top: true, x0: b.0, y0: b.1, x1: b.2, y1: b.3, z0: 0.0, z1: 0.0, turned: None })+    };+    let (mut ba, mut bb): (Vec<crate::bodies::BodyBox>, Vec<crate::bodies::BodyBox>) = (bs.iter().filter(|x| x.part == a).cloned().collect(), bs.iter().filter(|x| x.part == b).cloned().collect());+    if ba.is_empty() { ba.extend(flat(a)); }+    if bb.is_empty() { bb.extend(flat(b)); }     if ba.is_empty() || bb.is_empty() { return false; }     // bodies meet when their footprints come within twice the margin (turned ones by their rectangles) and their heights overlap-    !ba.iter().any(|p| bb.iter().any(|q| { let (h, d) = crate::bodies::xy_meet(p, q); (h || d < 2.0 * margin) && p.z0.max(q.z0) < p.z1.min(q.z1) }))+    // a flat test point (z 0..0) meets a body that reaches the board (z0 at or below 0)+    !ba.iter().any(|p| bb.iter().any(|q| { let (h, d) = crate::bodies::xy_meet(p, q); (h || d < 2.0 * margin) && (p.z0.max(q.z0) < p.z1.min(q.z1) || (p.z1 == p.z0 && q.z0 <= p.z0) || (q.z1 == q.z0 && p.z0 <= q.z0)) })) } -/// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads+fn prefix(r: &str) -> String { r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase() }++/// A connector by designator or footprint name. X is not enough on its own: Altium designs name crystals X1 (the+/// Sentinel's 16 MHz and 32.768 kHz read as connectors far from the edge).+fn is_connector(r: &str, pattern: &str) -> bool {+    let lib = pattern.to_ascii_lowercase();+    matches!(prefix(r).as_str(), "J" | "P" | "CN" | "USB" | "CON") || ["connector", "usb", "pinheader", "pin_header", "terminalblock", "jst", "molex", "barrel"].iter().any(|k| lib.contains(k))+}++/// The score: deductions per rule (per item, capped), the ratsnest's share of the board diagonal, 0-100 and a grade.+/// `overlaps` and `off` are counts from the review. Pure.+pub fn score(st: &Value, boxes: &BTreeMap<String, (bool, Rect)>, ring: &[(f64, f64)], overlaps: usize, off: usize, connector_edge: f64, decoupling: f64) -> Value {+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let (bx0, bx1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (by0, by1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    let pattern: BTreeMap<String, String> = st["components"].as_array().cloned().unwrap_or_default().iter()+        .filter_map(|c| Some((c["ref"].as_str()?.to_string(), c["pattern"].as_str().unwrap_or("").to_string()))).collect();+    // connectors farther than connectorEdgeMm from the board's edge (its box)+    let mut far_conn = Vec::new();+    for (r, (_, b)) in boxes {+        if !is_connector(r, pattern.get(r).map(|s| s.as_str()).unwrap_or("")) { continue; }+        let d = (b.0 - bx0).min(bx1 - b.2).min(b.1 - by0).min(by1 - b.3);+        if d < 0.0 { continue; } // off or over the edge: the review has it+        if d > connector_edge { far_conn.push(json!({"ref": r, "edgeDistanceMm": r3(d)})); }+    }+    // decoupling capacitors (two pads: one on a ground, one on a supply rail an IC also has) farther than decouplingMm+    // from that IC's nearest pin on the same net. The net must look like a rail: a crystal's load capacitor (the+    // Sentinel's C1 on 16M_IN) is a signal to ground, not decoupling.+    let net = |p: &Value| p["net"].as_str().unwrap_or("").to_string();+    let mut by_ref: BTreeMap<String, Vec<&Value>> = BTreeMap::new();+    for p in &pads { if let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) { by_ref.entry(r.to_string()).or_default().push(p); } }+    let is_ic = |r: &str| matches!(prefix(r).as_str(), "U" | "IC") && by_ref.get(r).map(|v| v.len() >= 3).unwrap_or(false);+    let mut far_caps = Vec::new();+    for (r, ps) in &by_ref {+        if prefix(r) != "C" || ps.len() != 2 { continue; }+        let g = ps.iter().filter(|p| crate::schreview::is_ground(&net(p))).count();+        let Some(pw) = ps.iter().find(|p| !net(p).is_empty() && !crate::schreview::is_ground(&net(p))) else { continue };+        if g != 1 || !crate::schreview::looks_like_rail(&net(pw)) { continue; }+        let n = net(pw);+        let best = pads.iter().filter(|q| net(q) == n && q["ref"].as_str().map(|x| x != r && is_ic(x)).unwrap_or(false))+            .map(|q| (((f(&q["x"]) - f(&pw["x"])).powi(2) + (f(&q["y"]) - f(&pw["y"])).powi(2)).sqrt(), q)).min_by(|a, b| a.0.total_cmp(&b.0));+        if let Some((d, q)) = best { if d > decoupling { far_caps.push(json!({"ref": r, "net": n, "nearestPin": format!("{}.{}", q["ref"].as_str().unwrap_or(""), q["name"].as_str().unwrap_or("")), "distanceMm": r3(d)})); } }+    }+    // the ratsnest: each net's minimum spanning tree over its pads' centres+    let mut nets: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();+    for p in &pads { let n = net(p); if !n.is_empty() { nets.entry(n).or_default().push((f(&p["x"]), f(&p["y"]))); } }+    let (mut mst, mut edges) = (0.0, 0usize);+    for pts in nets.values().filter(|v| v.len() > 1) {+        let mut best = vec![f64::MAX; pts.len()];+        let mut done = vec![false; pts.len()];+        best[0] = 0.0;+        for _ in 0..pts.len() {+            let Some(i) = (0..pts.len()).filter(|&i| !done[i]).min_by(|&a, &b| best[a].total_cmp(&best[b])) else { break };+            done[i] = true;+            mst += best[i];+            for j in 0..pts.len() { if !done[j] { let d = ((pts[i].0 - pts[j].0).powi(2) + (pts[i].1 - pts[j].1).powi(2)).sqrt(); if d < best[j] { best[j] = d; } } }+        }+        edges += pts.len() - 1;+    }+    let diag = ((bx1 - bx0).powi(2) + (by1 - by0).powi(2)).sqrt();+    let frac = if edges > 0 && diag > 0.0 { mst / edges as f64 / diag } else { 0.0 };+    let mut deductions = Vec::new();+    let mut total = 0.0;+    let mut push = |rule: &str, per: f64, cap: f64, items: Vec<Value>, detail: &str| {+        let pts = (per * items.len() as f64).min(cap);+        total += pts;+        deductions.push(json!({"rule": rule, "count": items.len(), "points": pts, "perItem": per, "cap": cap, "items": items, "detail": detail}));+    };+    push("courtyard_overlap", 10.0, 40.0, (0..overlaps).map(|_| json!(null)).collect(), "same-side parts that overlap (courtyardOverlaps)");+    push("outside_board", 10.0, 30.0, (0..off).map(|_| json!(null)).collect(), "parts off or over the board edge (offOrOverEdge)");+    push("connector_off_edge", 5.0, 15.0, far_conn.clone(), "connectors farther than connectorEdgeMm from the board edge");+    push("decoupling_far", 3.0, 15.0, far_caps.clone(), "decoupling capacitors farther than decouplingMm from their IC's nearest pin on the same supply");+    let rpts = (((frac - 0.1) / 0.3).clamp(0.0, 1.0) * 200.0).round() / 10.0;+    total += rpts;+    let mean = if edges > 0 { mst / edges as f64 } else { 0.0 };+    deductions.push(json!({"rule": "ratsnest_length", "count": edges, "points": rpts, "cap": 20.0, "items": [],+        "detail": format!("mean ratsnest edge {:.2} mm is {:.1} % of the {:.1} mm board diagonal (0 points at 10 %, 20 at 40 %)", mean, frac * 100.0, diag)}));+    // the overlap and outside items are listed in the review itself+    for d in deductions.iter_mut() { if d["items"].as_array().map(|a| a.iter().all(|x| x.is_null())).unwrap_or(false) { d["items"] = json!([]); } }+    deductions.sort_by(|a, b| b["points"].as_f64().unwrap_or(0.0).total_cmp(&a["points"].as_f64().unwrap_or(0.0)));+    let score = (100.0 - total).max(0.0).round();+    let grade = match score as i64 { 90..=100 => "A", 80..=89 => "B", 70..=79 => "C", 50..=69 => "D", _ => "F" };+    json!({"score": score, "grade": grade, "deductions": deductions, "connectorsOffEdge": far_conn, "decouplingFar": far_caps,+        "ratsnest": {"totalMm": r3(mst), "edges": edges, "meanMm": r3(mean), "boardDiagonalMm": r3(diag), "share": r3(frac)}})+}++/// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?, connectorEdgeMm? (3), decouplingMm? (3)}}. Without bodies the parts' boxes are their pads /// only, and the reply says so (`bodiesUsed: false`). pub fn placement_check(input: &Value) -> Value {     let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };⋯ 29 unchanged lines ⋯         out["courtyardOverlaps"] = json!(real);         out["stacked"] = json!(stacked);     }+    let sc = score(&st, &boxes, &ring, out["courtyardOverlaps"].as_array().map(|a| a.len()).unwrap_or(0), out["offOrOverEdge"].as_array().map(|a| a.len()).unwrap_or(0), num("connectorEdgeMm", 3.0), num("decouplingMm", 3.0));+    for k in ["score", "grade", "deductions", "connectorsOffEdge", "decouplingFar", "ratsnest"] { out[k] = sc[k].clone(); }     out["success"] = json!(true);     out["bodiesUsed"] = json!(given.is_some());-    out["_hint"] = json!("courtyardOverlaps: parts closer than twice marginMm (their pads and bodies): move them apart (move_parts, find_space). offOrOverEdge parts must come onto the board; nearEdge pads risk the router or the V-cut (JLCPCB: 0.4 mm from a V-cut, 0.2 from a routed edge). onMountingHoles: a screw head lands on the part. Without bodies (bodiesUsed false) a part's box is its pads only: pass the bodies companion to count overhanging bodies.");+    out["_hint"] = json!("courtyardOverlaps: parts closer than twice marginMm (their pads and bodies): move them apart (move_parts, find_space). offOrOverEdge parts must come onto the board; nearEdge pads risk the router or the V-cut (JLCPCB: 0.4 mm from a V-cut, 0.2 from a routed edge). onMountingHoles: a screw head lands on the part. Without bodies (bodiesUsed false) a part's box is its pads only: pass the bodies companion to count overhanging bodies. score (0-100, grade A-F) weighs these with connectorsOffEdge (a connector deep inside the board), decouplingFar (a capacitor far from its IC's supply pin: place_decoupling) and the ratsnest's length; ok counts only overlaps, off-board parts and parts on holes.");     out } ⋯ 24 unchanged lines ⋯     }      #[test]+    fn the_score_finds_a_deep_connector_and_a_far_capacitor() {+        let pad = |r: &str, n: &str, x: f64, y: f64, net: &str| json!({"ref": r, "name": n, "x": x, "y": y, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "net": net});+        // a 50 x 30 board: J1 in the middle, C1 10 mm from U1's VDD pin, C2 beside it+        let st = json!({"outline": [[0, 0], [50, 0], [50, 30], [0, 30]],+            "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [50.0, 0.0]}, {"kind": "line", "start": [50.0, 30.0]}, {"kind": "line", "start": [0.0, 30.0]}],+            "components": [{"ref": "J1", "side": "top", "pattern": "PinHeader_1x02"}, {"ref": "U1", "side": "top"}, {"ref": "C1", "side": "top"}, {"ref": "C2", "side": "top"}],+            "pads": [pad("J1", "1", 25.0, 15.0, "SIG"), pad("J1", "2", 27.0, 15.0, "GND"),+                     pad("U1", "VDD", 10.0, 10.0, "+3V3"), pad("U1", "GND", 10.0, 12.0, "GND"), pad("U1", "IO", 12.0, 10.0, "SIG"),+                     pad("C1", "1", 20.0, 10.0, "+3V3"), pad("C1", "2", 21.0, 10.0, "GND"),+                     pad("C2", "1", 10.0, 8.0, "+3V3"), pad("C2", "2", 11.0, 8.0, "GND")]});+        let r = placement_check(&json!({"board": st, "args": {}}));+        assert_eq!(r["connectorsOffEdge"][0]["ref"], "J1", "{r}");+        let far: Vec<&str> = r["decouplingFar"].as_array().unwrap().iter().map(|c| c["ref"].as_str().unwrap()).collect();+        assert_eq!(far, vec!["C1"]);+        assert_eq!(r["decouplingFar"][0]["nearestPin"], "U1.VDD");+        assert!(r["score"].as_f64().unwrap() < 100.0 && r["grade"].is_string());+        assert_eq!(r["ok"], json!(true), "a score is advice: ok counts only overlaps, off-board parts and holes");+        assert!(r["ratsnest"]["edges"].as_u64().unwrap() > 0);+    }++    #[test]     fn a_part_under_a_raised_one_is_stacked_not_overlapping() {         let pad = |r: &str, x: f64, y: f64| json!({"ref": r, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0.0});         // M1: a molecule on two pins at x 0 and 10, its board raised 3 to 4 mm; C1 sits under it, between the pins⋯ 10 unchanged lines ⋯         let real = placement_check(&json!({"board": st, "bodies": {"bodies": [body("M1", -1.0, 11.0, 3.0, 4.0), body("C1", 4.5, 6.5, 0.0, 3.5)]}, "args": {}}));         assert_eq!(real["courtyardOverlaps"].as_array().unwrap().len(), 1);         assert_eq!(real["ok"], json!(false));+        // a body-less SMD test point under the raised board is flat: stacked; under a body that sits on the board: not+        let st2 = json!({"outline": st["outline"], "outlineEdges": st["outlineEdges"], "components": [{"ref": "M1", "side": "top"}, {"ref": "TP1", "side": "top"}],+            "pads": [pad("M1", 0.0, 0.0), pad("M1", 10.0, 0.0), pad("TP1", 5.0, 0.0)]});+        let tp = placement_check(&json!({"board": st2, "bodies": {"bodies": [body("M1", -1.0, 11.0, 3.0, 4.0)]}, "args": {}}));+        assert_eq!(tp["stacked"][0]["parts"], json!(["M1", "TP1"]), "{tp}");+        let tp_low = placement_check(&json!({"board": st2, "bodies": {"bodies": [body("M1", -1.0, 11.0, -0.05, 1.7)]}, "args": {}}));+        assert_eq!(tp_low["courtyardOverlaps"].as_array().unwrap().len(), 1, "{tp_low}");         // no bodies: pads only, no stack possible to show, nothing hidden         let none = placement_check(&json!({"board": st, "args": {}}));         assert_eq!(none["bodiesUsed"], json!(false));         assert!(none.get("stacked").is_none());     } }++--- a/rust/crates/kicad-bridge/src/verbs_netlist.rs+++ b/rust/crates/kicad-bridge/src/verbs_netlist.rs@@ -1,365 +1,393 @@⋯ 353 unchanged lines ⋯         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}));+    // every sheet's drawing (the standard's sheetDrawing): the engine's dangling-wire, floating-label and overlap checks+    let root = PathBuf::from(a["filePath"].as_str().unwrap_or(""));+    // and each net's names (labels, power and global names joined into it) for the engine's shorted_nets+    let mut drawing: Vec<Value> = Vec::new();+    let mut names = serde_json::Map::new();+    if let Ok(h) = kicad_core::sch_hier::Hierarchy::load(&root) {+        for (p, sch) in &h.docs {+            let m = kicad_core::sch_model::extract(sch, None);+            drawing.push(kicad_core::sch_model::drawing(&m, &p.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default()));+            let c = kicad_core::sch_model::Connectivity::build(&m);+            for (gi, g) in c.groups.iter().enumerate() {+                let mut distinct: Vec<&str> = g.names.iter().filter(|(k, _)| k != "sheet_pin").map(|(_, t)| t.as_str()).collect();+                distinct.sort();+                distinct.dedup();+                if distinct.len() > 1 {+                    names.insert(c.net_name(&m, gi), json!(distinct));+                }+            }+        }+    }+    let mut nets = nets;+    nets["names"] = json!(names);+    let mut input = json!({"sheetParts": parts, "netlist": nets});+    if !drawing.is_empty() {+        input["sheetDrawing"] = json!(drawing);+    }+    let mut v = adom_eda_checks::run("schematic_review", &input);     if v["success"] == json!(true) {         v["sheets"] = json!([sch]);         v["source"] = json!("kicad-cli netlist (the saved schematic)");⋯ 2 unchanged lines ⋯     }     v }++--- a/rust/crates/kicad-core/src/netlist.rs+++ b/rust/crates/kicad-core/src/netlist.rs@@ -1,876 +1,879 @@⋯ 317 unchanged lines ⋯         model.component(r).map(|c| c.fields.iter().filter(|(_, v)| !v.trim().is_empty() && v.trim() != "~")             .map(|(k, v)| json!({"name": k, "value": v})).collect()).unwrap_or_default()     };-    let comps: Vec<serde_json::Value> = parts.into_iter().map(|(r, pins)| { let v = value(&r); json!({"designator": r, "pins": pins, "value": v, "comment": v, "params": params(&r)}) }).collect();+    // libRef ("Lib:Symbol") lets the review tell an LED, a regulator or a crystal by its symbol (engine 0.22.0, #29)+    let lib_ref = |r: &str| model.component(r).map(|c| c.libsource.clone()).unwrap_or_default();+    let comps: Vec<serde_json::Value> = parts.into_iter().map(|(r, pins)| { let v = value(&r); json!({"designator": r, "pins": pins, "value": v, "comment": v, "libRef": lib_ref(&r), "params": params(&r)}) }).collect();     (json!({"components": comps}), json!({"nets": nets})) } ⋯ 549 unchanged lines ⋯         assert!(RAW_FORMATS.windows(2).all(|w| w[0] < w[1]), "RAW_FORMATS is sorted, matching Python's allowed list");     } }+--- a/rust/crates/kicad-core/src/sch_model.rs+++ b/rust/crates/kicad-core/src/sch_model.rs@@ -1,819 +1,864 @@⋯ 372 unchanged lines ⋯                 };                 m.labels.push(Label {                     uuid: n.child_value("uuid").unwrap_or_default(),-                    text: n.arg(0).unwrap_or_default(),+                    text: unescape_name(&n.arg(0).unwrap_or_default()),                     kind: kind.into(),                     at,                     rotation,⋯ 19 unchanged lines ⋯     m } +/// The standard's sheetDrawing entry for one sheet (#29: schematic_review's drawing checks): wires, buses, junctions,+/// no-connects, labels (power symbols as labels of kind power, a sheet symbol's pins as hierarchical labels), the parts'+/// pins and their boxes, in mm, origin top-left, y down.+pub fn drawing(m: &SheetModel, sheet: &str) -> serde_json::Value {+    use serde_json::json;+    let p = |q: Pt| json!([r4(q.0), r4(q.1)]);+    let seg = |w: &Wire| json!({"a": p(w.a), "b": p(w.b)});+    let mut labels: Vec<serde_json::Value> = m.labels.iter().map(|l| json!({"text": l.text, "kind": l.kind, "at": p(l.at)})).collect();+    labels.extend(m.pins.iter().filter(|q| q.power_symbol && q.power_net.is_some()).map(|q| json!({"text": q.power_net.clone().unwrap_or_default(), "kind": "power", "at": p(q.at)})));+    labels.extend(m.sheets.iter().flat_map(|s| s.pins.iter()).map(|q| json!({"text": q.name, "kind": "hierarchical", "at": p(q.at)})));+    json!({+        "sheet": sheet, "units": "mm", "frame": "sheet: origin top-left, y down",+        "wires": m.wires.iter().map(seg).collect::<Vec<_>>(),+        "buses": m.buses.iter().map(seg).collect::<Vec<_>>(),+        "junctions": m.junctions.iter().map(|j| p(j.at)).collect::<Vec<_>>(),+        "noConnects": m.no_connects.iter().map(|j| p(j.at)).collect::<Vec<_>>(),+        "labels": labels,+        // a PWR_FLAG names no net: its pin is a connection point like any part's+        "pins": m.pins.iter().filter(|q| (!q.power_symbol || q.power_net.is_none()) && !q.hidden).map(|q| json!({"ref": q.reference, "number": q.number, "at": p(q.at)})).collect::<Vec<_>>(),+        "symbols": m.symbols.iter().filter(|s| !s.is_power).filter_map(|s| s.bbox.map(|b| json!({"ref": s.reference, "box": [r4(b.0), r4(b.1), r4(b.2), r4(b.3)]}))).collect::<Vec<_>>(),+    })+}++/// A net name as KiCad compares it: its file escapes ({slash}, {backslash}, ...) read back, so "VPP{slash}MCLR" and+/// "VPP/MCLR" are one label name, as they are one net in KiCad's netlist.+pub fn unescape_name(s: &str) -> String {+    if !s.contains('{') {+        return s.to_string();+    }+    [("{slash}", "/"), ("{backslash}", "\\"), ("{colon}", ":"), ("{dblquote}", "\""), ("{lt}", "<"), ("{gt}", ">"), ("{space}", " "), ("{tab}", "\t"), ("{return}", "\n"), ("{brace}", "{")]+        .iter().fold(s.to_string(), |t, (k, v)| t.replace(k, v))+}+ pub fn sheet_ref(n: &Node) -> SheetRef {     let (at, _) = at_of(n);     let size = n.child("size").and_then(xy_of).unwrap_or((0.0, 0.0));⋯ 83 unchanged lines ⋯                         if m.pins.iter().any(|p| p.symbol_uuid == uuid && p.number == number && same(p.at, at)) {                             continue;                         }+                        let electrical = g.arg(0).unwrap_or_else(|| "unspecified".into());+                        // a power symbol names its net through its power_in pin (GND, +3V3); PWR_FLAG's pin is+                        // power_out and names nothing, so flags on different nets are not one net+                        let names_net = is_power && electrical == "power_in";                         m.pins.push(Pin {                             reference: reference.clone(),                             symbol_uuid: uuid.clone(),                             number,                             name: g.child_value("name").unwrap_or_default(),-                            electrical: g.arg(0).unwrap_or_else(|| "unspecified".into()),+                            electrical,                             at,                             hidden: is_hidden(g),                             power_symbol: is_power,-                            power_net: if is_power { Some(value.clone()) } else { None },+                            power_net: if names_net { Some(value.clone()) } else { None },                         });                     }                     _ => {}⋯ 312 unchanged lines ⋯         v.sort_by_key(|r| ref_order(r));         assert_eq!(v, vec!["C1", "R1", "R2", "R10"]);     }++    #[test]+    fn escaped_label_names_are_the_names_kicad_compares() {+        assert_eq!(super::unescape_name("VPP{slash}MCLR"), "VPP/MCLR");+        assert_eq!(super::unescape_name("A{colon}B{space}C"), "A:B C");+        assert_eq!(super::unescape_name("PLAIN"), "PLAIN");+    } }+

Comments

No comments yet.

Log in to comment.