← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.29\ No newline at end of file+1.13.30\ No newline at end of file
CHANGELOG.md+7
@@ -3,6 +3,13 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.13.30 (2026-10-30)++- Engine 0.22.0 (standard 0.27.0): `fusion_schematic_review` runs KiCad's design checks too (I2C pull-ups, LED series+  resistors, regulator input and bulk capacitors, dangling wires, floating labels, shorted net names, overlapping+  parts), and `fusion_placement_check` gives a placement score. sheetParts now carry each part's `value` and `libRef`+  (its deviceset, EAGLE's device name), which those checks read.+ ## 1.13.29 (2026-10-30)  - `fusion_inspect_bodies` brings the design's 3D board forward and reads again when the 2D board or schematic is in
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.29",+  "version": "1.13.30",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.29"+version = "1.13.30" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.29"+version = "1.13.30" edition = "2021"  [dependencies]
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.13.29"+DASH_VERSION="1.13.30" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.29",+  "version": "1.13.30",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.29",+  "version": "1.13.30",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -152,4 +152,4 @@   "confirmed_user_skills": [     "fusion-design-efficiency"   ]-}\ No newline at end of file+}
rust/Cargo.lock+6−6
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-eda-checks"-version = "0.21.1"+version = "0.22.0" dependencies = [  "roxmltree",  "serde_json",@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.29"+version = "1.13.30" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.29"+version = "1.13.30" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.29"+version = "1.13.30" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.29"+version = "1.13.30" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.29"+version = "1.13.30" dependencies = [  "fusion-core",  "windows-sys 0.59.0",
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.13.29"+version = "1.13.30" edition = "2021" license = "MIT" 
rust/bridge/src/checks.rs+2−1
@@ -328,7 +328,8 @@ fn sheet_parts(sd: &Value) -> Vec<Value> {         // the standard's params [{name, value}], empty values left out (EAGLE libraries carry many blank fields)         let params: Vec<Value> = c["params"].as_object().into_iter().flatten()             .filter_map(|(k, v)| v.as_str().map(str::trim).filter(|v| !v.is_empty()).map(|v| json!({"name": k, "value": v}))).collect();-        json!({"designator": c["designator"], "pins": pins, "params": params})+        // value and libRef (the deviceset: EAGLE's device name) feed the engine's design checks+        json!({"designator": c["designator"], "value": c["comment"], "libRef": c["libReference"], "pins": pins, "params": params})     }).collect() } 
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.21.1"+version = "0.22.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"
rust/vendor/adom-eda-checks/README.md+2−2
@@ -31,12 +31,12 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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 | | `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` | | `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 |
rust/vendor/adom-eda-checks/VENDOR.md+3−3
@@ -3,9 +3,9 @@ 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.27.0+- Standard commit: a5acc89911e4d3dea414720d6ee9086ab0f8ebe0+- Crate version: 0.22.0 - Copied: 2026-10-30, by fusion-lane  To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/placement.rs+114−3
@@ -1,6 +1,8 @@ //! 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; @@ -115,7 +117,93 @@ pub fn is_stacked(pads: &[Value], bs: &[crate::bodies::BodyBox], a: &str, b: &st     !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) })) } -/// 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 };@@ -151,9 +239,11 @@ pub fn placement_check(input: &Value) -> Value {         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 } @@ -183,6 +273,27 @@ mod tests {         assert_eq!(r["ok"], json!(false));     } +    #[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});
rust/vendor/adom-eda-checks/src/schreview.rs+299−7
@@ -1,6 +1,8 @@ //! 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}; @@ -24,6 +26,12 @@ pub fn pin_type(v: &Value) -> String {             "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(),@@ -59,8 +67,13 @@ pub fn review(parts: &[Value], nets: &Map<String, Value>) -> Value {     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) }     }@@ -102,7 +115,225 @@ pub fn review(parts: &[Value], nets: &Map<String, Value>) -> Value {         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@@ -146,14 +377,27 @@ fn sheet_parts(input: &Value) -> Option<Vec<Value>> {     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).+/// schematic_review: input {sheetParts, netlist}. The netlist companion is {nets: {net: ["U1.8", ...]}, names?: {net:+/// [every label or port name joined into it]}} (or the nets map). 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."}));+            }+        }+    }     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 } @@ -176,6 +420,51 @@ mod tests {         json!({"designator": des, "pins": pins.iter().map(|(n, nm, e)| json!({"number": n, "name": nm, "electrical": e})).collect::<Vec<_>>()})     } +    #[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");@@ -192,7 +481,7 @@ mod tests {     #[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)]),@@ -201,9 +490,12 @@ mod tests {         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()));