← All Pull Requests

kicad-lane: 1.0.85, simulate sweeps and notSimulated, land pattern from the engine, engine 0.19.0 #46

Merged opened by Colby Knox 2026-10-29

1.0.85: simulation sweeps and the parts a run left out, and footprints from a datasheet's land pattern laid out by the standard's engine.

  • kicad_footprint_from_land_pattern: the standard's engine lays the footprint out, and KiCad's own writer turns it into the .kicad_mod and the 3D body box.
    • The layout is the same as before: KiCad's code and the engine's both came from the Altium bridge, line for line. One copy now, so the engine's fixes reach KiCad.
    • It covers pad numbering for chip, dual, SOT, quad and BGA packages, the exposed pad, the courtyard, silkscreen clear of the copper with a pin-1 dot, and a fab outline with a pin-1 chamfer.
    • Replies carry engine. A missing number is still named ("dual needs padLength (mm)").
    • Checked: a 3x3 mm QFN-16 with exposed pad was built live and read back by KiCad with 17 pads and its 3D model.
  • Engine: adom-eda-checks 0.19.0 (EDA Bridge Standard 0.22.1).
    • A width rule with only a minimum (Fusion's EAGLE rules) now counts as the board's track width.
    • The engine's new panelize and teardrops plans are not used here: KiCad keeps its own kicad_teardrops (KiCad's own teardrop generator) and kicad_panelize.
  • kicad_simulate and kicad_export_spice: notSimulated (standard #23, as Altium 0.2.82 reports it). These are the sheet's parts that KiCad left out of the netlist because they have no simulation model.
    • A partly modelled sheet still runs, and the reply says which parts the result does not cover. KiCad writes a part with no model as a stub with no nodes (J1 __J1), which made ngspice refuse the whole circuit; the run now leaves those stubs out.
    • A sheet where no part has a model refuses with no_model instead of an ngspice error.
    • Parts the designer excluded from simulation are left out on purpose and are not listed.
    • Checked: an RC with a connector on its output runs and names J1; a sheet of two connectors refuses no_model.
  • kicad_simulate: sweeps (standard #23, as accepted from Altium). Arguments: sweep {parameter, start, stop, step, scale?} and temperatures {start, stop, step}.
    • ngspice has no .step, so the circuit runs once per value: a part's value ("R4") is set with alter, a model parameter ("Q1.BF") with altermod on the part's model, and the temperature with .temp.
    • Each run's signals carry step ("R1 = 2k", "T = 85"); the reply says how many runs there were (at most 50).
    • A part that isn't in the netlist, or a model the netlist doesn't define, is refused with bad_args, never one run passed off as the sweep.
    • Checked live:
      • R1 at 1k and 2k puts the RC filter's corner at 1588 Hz and 794 Hz.
      • A diode's forward voltage is 753, 696 and 639 mV at -10, 25 and 60 °C.
      • Its emission coefficient N at 1 and 2 gives 0.69 V and 1.38 V.

Files: rust/Cargo.toml, rust/Cargo.lock, rust/crates/kicad-bridge/src/{verbs_land_pattern,verbs_sim,main}.rs, rust/crates/kicad-core/src/{land_pattern,spice}.rs, rust/crates/kicad-platform/src/ngspice.rs, rust/vendor/adom-eda-checks (0.19.0, vendored unchanged), skills/kicad-bridge-test/run_verb_tests.py. Battery 314/0/1.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯  [[package]] name = "adom-eda-checks"-version = "0.17.2"+version = "0.19.0" dependencies = [  "roxmltree",  "serde_json",⋯ 302 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.84"+version = "1.0.85" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.84"+version = "1.0.85" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.84"+version = "1.0.85" 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.84"+version = "1.0.85" 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,1758 +1,1796 @@⋯ 1566 unchanged lines ⋯               "body": {"w": 3.9, "l": 4.9, "height": 1.75}, "overwrite": True}         r0 = C("footprint_from_land_pattern", dict(lp, dryRun=True))         r = C("footprint_from_land_pattern", lp)-        own("footprint_from_land_pattern", {"plan": r0, "write": r}, ok(r0) and (r0.get("summary") or {}).get("padTable", [[None]])[0][0] == "1" and ok(r) and (r.get("body3d") or {}).get("success") is True,-            "no pad table, or the footprint or its body was not written")+        own("footprint_from_land_pattern", {"plan": r0, "write": r}, ok(r0) and (r0.get("summary") or {}).get("padTable", [[None]])[0] == ["1", -2.7, 1.905, 1.55, 0.6]+            and str(r0.get("engine") or "").startswith("adom-eda-checks ") and ok(r) and (r.get("body3d") or {}).get("success") is True,+            "no pad table from the engine (1.0.85: the standard's engine lays it out), or the footprint or its body was not written")         # 1.0.69: a sheet read from its file, boards built from footprints, a mouse-bite panel         r = C("read_sheet_file", {"path": w["sch"], "params": True})         own("read_sheet_file", r, ok(r) and r.get("count", 0) > 0 and any(c.get("pins") for c in r.get("components") or []), "no parts with pins read from the sheet")⋯ 53 unchanged lines ⋯             and any(x.get("check") == "duplicate_designators" and x.get("designator") == "PIN**" for x in rb.get("findings") or []),             "no review, a rule class read as missing, or the duplicate PIN** designator was not found")         bad = C("simulate", {"schPath": rc, "analysis": ".dc V9 0 1 0.1"}, 200)-        own("simulate", {"ac": ac, "bad": bad}, ok(ac) and sig.get("signal") == "V(OUT)" and sig.get("yUnit") == "dB" and str(ac.get("engine") or "").startswith("ngspice ") and f3 is not None and abs(f3 - 1591.5) / 1591.5 < 0.02 and ac.get("csvPath") and bad.get("errorCode") == "simulation_failed",-            f"the RC's -3 dB point was {f3} Hz, not about 1592, or a failing analysis did not fail")+        # 1.0.85 (#23 notSimulated): a connector with no model on the RC's output is left out and named; a sheet of+        # only unmodelled parts refuses no_model+        rcj = w["out"] + "/sim/rcj.kicad_sch"+        C("build_schematic", {"schPath": rcj, "overwrite": True, "title": "RC+J", "parts": [{"ref": "V1", "symbol": "Simulation_SPICE:VSIN"},+          {"ref": "R1", "symbol": "Device:R", "comment": "1k"}, {"ref": "C1", "symbol": "Device:C", "comment": "100n"}, {"ref": "J1", "symbol": "Connector_Generic:Conn_01x02"}],+          "nets": {"IN": ["V1.1", "R1.1"], "OUT": ["R1.2", "C1.1", "J1.1"], "GND": ["V1.2", "C1.2", "J1.2"]}}, 400)+        part = C("simulate", {"schematic": rcj, "analysis": {"type": "op"}, "probes": [{"net": "OUT"}]}, 300)+        jo = w["out"] + "/sim/jonly.kicad_sch"+        C("build_schematic", {"schPath": jo, "overwrite": True, "title": "J", "parts": [{"ref": "J1", "symbol": "Connector_Generic:Conn_01x02"}, {"ref": "J2", "symbol": "Connector_Generic:Conn_01x02"}],+          "nets": {"A": ["J1.1", "J2.1"], "B": ["J1.2", "J2.2"]}}, 400)+        none = C("simulate", {"schematic": jo, "analysis": {"type": "op"}}, 300)+        # 1.0.85 (#23 sweep): R1 at 1k and 2k halves the RC's corner; three temperatures are three runs; an unknown part+        # is refused, never one run passed off as a sweep+        sw = C("simulate", {"schematic": rc, "analysis": {"type": "ac", "sweep": "dec", "points": 20, "start": 10, "stop": 100000}, "probes": [{"net": "OUT"}],+                            "sweep": {"parameter": "R1", "start": 1000, "stop": 2000, "step": 1000}, "points": 500}, 300)+        def corner(sg):+            f, db = sg.get("x") or [], sg.get("y") or []+            for i in range(len(min(f, db, key=len)) - 1):+                if db[i] > -3.0 >= db[i + 1] and f[i] > 0 and f[i + 1] > 0:+                    t = (-3.0 - db[i]) / (db[i + 1] - db[i])+                    return 10 ** (math.log10(f[i]) + t * (math.log10(f[i + 1]) - math.log10(f[i])))+        cs = {sg.get("step"): corner(sg) for sg in sw.get("signals") or []}+        # a diode's forward voltage falls about 2 mV/°C, and doubling its emission coefficient (altermod) about doubles it+        dio = w["out"] + "/sim/diode.kicad_sch"+        C("build_schematic", {"schPath": dio, "overwrite": True, "title": "D", "parts": [{"ref": "V1", "symbol": "Simulation_SPICE:VDC", "comment": "5"},+          {"ref": "R1", "symbol": "Device:R", "comment": "1k"}, {"ref": "D1", "symbol": "Simulation_SPICE:D"}],+          "nets": {"IN": ["V1.1", "R1.1"], "A": ["R1.2", "D1.2"], "GND": ["V1.2", "D1.1"]}}, 400)+        tt = C("simulate", {"schematic": dio, "analysis": {"type": "op"}, "probes": [{"net": "A"}], "temperatures": {"start": -10, "stop": 60, "step": 35}}, 300)+        tv = [((sg.get("y") or [0])[0]) for sg in tt.get("signals") or []]+        nn = C("simulate", {"schematic": dio, "analysis": {"type": "op"}, "probes": [{"net": "A"}], "sweep": {"parameter": "D1.N", "start": 1, "stop": 2, "step": 1}}, 300)+        nv = [((sg.get("y") or [0])[0]) for sg in nn.get("signals") or []]+        nosuch = C("simulate", {"schematic": rc, "analysis": {"type": "op"}, "sweep": {"parameter": "R9", "start": 1, "stop": 2, "step": 1}}, 300)+        own("simulate", {"ac": ac, "bad": bad, "partly": part, "none": none}, ok(ac) and sig.get("signal") == "V(OUT)" and sig.get("yUnit") == "dB" and str(ac.get("engine") or "").startswith("ngspice ") and f3 is not None and abs(f3 - 1591.5) / 1591.5 < 0.02 and ac.get("csvPath") and bad.get("errorCode") == "simulation_failed"+            and ac.get("notSimulated") == [] and ok(part) and part.get("notSimulated") == ["J1"] and none.get("errorCode") == "no_model"+            and ok(sw) and sw.get("runs") == 2 and cs.get("R1 = 1k") and cs.get("R1 = 2k") and abs(cs["R1 = 1k"] / cs["R1 = 2k"] - 2.0) < 0.05+            and ok(tt) and [sg.get("step") for sg in tt.get("signals") or []] == ["T = -10", "T = 25", "T = 60"] and len(tv) == 3 and tv[0] > tv[1] > tv[2] > 0.4+            and ok(nn) and len(nv) == 2 and 1.8 < nv[1] / max(nv[0], 1e-9) < 2.2 and nosuch.get("errorCode") == "bad_args",+            f"the RC's -3 dB point was {f3} Hz, not about 1592, a failing analysis did not fail, the unmodelled connector was not named (notSimulated / no_model), or the sweep went wrong (corners {cs}, diode {tv} over temperature, {nv} over N)")         # 1.0.82: the app tier where KiCad answers: licence, KiCad's own Python (pcbnew), the dialog log, foreground (its refusal         # only: a real foreground would be a grab this battery counts against the bridge)         r = C("license_status", {}); own("license_status", r, ok(r) and r.get("license") == "GPL-3.0-or-later" and r.get("seatRequired") is False)⋯ 120 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.20.2-- Standard commit: 3c0ef8ff5e5bc11c68ba23f8038dc8d2643eea75-- Crate version: 0.17.2-- Copied: 2026-10-28, by kicad-lane+- Standard version: 0.22.1+- Standard commit: b9da2c63e0fffbf6d5e184c81f6db0baa111ff1d+- Crate version: 0.19.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,126 +1,130 @@⋯ 22 unchanged lines ⋯ | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs |+| `footprint_from_land_pattern` | args only | a footprint laid out from a datasheet's recommended land pattern (`family` chip, dual, sot, quad or bga, with pitch, span, pad size, `exposedPad`, `body`): pads numbered the standard way (pin 1 top-left, counterclockwise; BGA rows without I, O, Q, S, X, Z), silkscreen (`overlay`) kept `silkClearance` from copper with a pin-1 dot, a `fab` outline with a pin-1 chamfer and the `courtyard` grown by its margin, in mm, Y up, origin at the centre. A missing number is refused by name. Each bridge writes it in its own library format. From the Altium bridge's footprint.rs | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body |+| `panelize` | board, stack? | a panel plan in panel mm from its lower-left corner: `rows` x `cols` copies (each with its `copyShift`), rails (`railMm`) with three tooling holes and fiducials, and `separation` none, `vscore` (V-CUT lines edge to edge with their labels) or `tabs` (mouse-bite holes and the rout path), with JLCPCB's V-cut or mouse-bite rules checked and copper near the edge or outside the outline named. Refuses tabs when the board draws its outline on a mechanical layer. From the Altium bridge's panel.rs | | `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools | | `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs |⋯ 5 unchanged lines ⋯ | `route_pair` | board, stack?, rules? | a differential pair planned as one centreline (pad midpoints through `via` waypoints) with each net offset half a pitch to its own side, mitred corners and a `fanMm` fan-out; width and gap given, or from `diffOhms` and the stack. `problems` names a crossing or other copper (pads by outline, routed tracks, vias) closer than the clearance; `routeNet` holds both nets' route_net arguments and `widthRule` the pair's add_rule. From the Altium bridge's pairroute.rs | | `schematic_review` | sheetParts, netlist | every placed pin, with its electrical type, against the compiled netlist: power pins on no net and outputs driving each other (errors); unconnected pins, one-pin nets, inputs nothing drives and IC supplies with no capacitor to ground (warnings; a regulator's switch node on an inductor is no missing capacitor). From the Altium bridge's schreview.rs | | `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs |+| `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 | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | ⋯ 71 unchanged lines ⋯  Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.17.2"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.19.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests ⋯ 5 unchanged lines ⋯ ```  Fixtures live on each bridge's own page, not here.+--- a/rust/vendor/adom-eda-checks/Cargo.toml+++ b/rust/vendor/adom-eda-checks/Cargo.toml@@ -1,25 +1,26 @@ [package] name = "adom-eda-checks"-version = "0.17.2"+version = "0.19.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/features.rs+++ b/rust/vendor/adom-eda-checks/src/features.rs@@ -1,440 +1,443 @@⋯ 193 unchanged lines ⋯ pub fn width_style(rules: &[Value]) -> Option<f64> {     if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))         .find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("width")).unwrap_or(false)) {-        if let Some(v) = r["preferredMm"].as_f64().or_else(|| r["widthMm"].as_f64()) { return Some(v); }+        // its preferred width, else its minimum, as Altium's descriptor is read (Fusion sends EAGLE's msWidth as minMm)+        if let Some(v) = r["preferredMm"].as_f64().or_else(|| r["widthMm"].as_f64()).or_else(|| r["minMm"].as_f64()) { return Some(v); }     }     let num = |d: &str, key: &str| -> Option<f64> {         let i = d.find(&format!("({key}="))? + key.len() + 2;⋯ 230 unchanged lines ⋯     #[test]     fn width_style_reads_the_standard_rule_and_altiums_descriptor() {         assert_eq!(width_style(&[json!({"kind": "width", "preferredMm": 0.2})]), Some(0.2));+        assert_eq!(width_style(&[json!({"kind": "width", "name": "minimum width (msWidth)", "minMm": 0.15})]), Some(0.15));         let alt = vec![json!({"enabled": true, "scope1": "InNet('VIN')", "priority": 1, "descriptor": "Width Constraint (Min=20mil) (Max=40mil) (Preferred=30mil) (InNet('VIN'))"}),                        json!({"enabled": true, "scope1": "All", "priority": 2, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"})];         assert_eq!(width_style(&alt), Some(0.254), "the board-wide rule, not a net's");⋯ 1 unchanged line ⋯         assert_eq!(width_style(&[]), None);     } }+--- a/rust/vendor/adom-eda-checks/src/lib.rs+++ b/rust/vendor/adom-eda-checks/src/lib.rs@@ -1,89 +1,96 @@⋯ 17 unchanged lines ⋯ pub mod geom; pub mod impedance; pub mod input;+pub mod landpattern; pub mod lengths; pub mod lengthtune; pub mod pairroute;+pub mod panel; pub mod report; pub mod review; pub mod placement;⋯ 2 unchanged lines ⋯ pub mod schreview; pub mod silkplace; pub mod space;+pub mod teardrops; pub mod writers;  /// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand⋯ 1 unchanged line ⋯ pub const VERSION: &str = env!("CARGO_PKG_VERSION");  /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "net_lengths", "panelize", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "teardrops", "thermal_vias", "via_fence"];  /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {⋯ 14 unchanged lines ⋯         "current_capacity" => current::current_capacity(input),         "estimate_cost" => cost::estimate_cost(input),         "find_space" => space::find_space(input),+        "footprint_from_land_pattern" => landpattern::footprint_from_land_pattern(input),         "fab_check" => fab::fab_check(input),         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),         "length_tune" => lengthtune::length_tune(input),         "net_lengths" => lengths::net_lengths(input),+        "panelize" => panel::panelize(input),         "pin_pad_check" => schreview::pin_pad_check(input),         "place_decoupling" => placetools::place_decoupling(input),         "plan_fanout" => placetools::plan_fanout(input),⋯ 4 unchanged lines ⋯         "route_pair" => pairroute::route_pair(input),         "schematic_review" => schreview::schematic_review(input),         "silk_designators" => silkplace::silk_designators(input),+        "teardrops" => teardrops::teardrops(input),         "thermal_vias" => features::thermal_vias(input),         "via_fence" => features::via_fence(input),         _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))),⋯ 9 unchanged lines ⋯         assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]);     } }+--- a/rust/crates/kicad-bridge/src/verbs_land_pattern.rs+++ b/rust/crates/kicad-bridge/src/verbs_land_pattern.rs@@ -1,94 +1,129 @@⋯ 34 unchanged lines ⋯     let Some(name) = arg_str(args, "name").filter(|n| !n.trim().is_empty()) else {         return fail("missing_arg", "name is required", "The footprint's name, e.g. SOIC-8_3.9x4.9mm_P1.27mm.");     };-    let fp = match land_pattern::layout(args) {+    // the standard's engine lays the footprint out; KiCad's writer below puts it in a .kicad_mod+    let lay = adom_eda_checks::run("footprint_from_land_pattern", &json!({"args": args}));+    if lay["success"] != true {+        return lay;+    }+    let engine = lay["engine"].clone();+    let fp = match land_pattern::from_engine(&lay) {         Ok(f) => f,-        Err(e) => return fail("bad_args", e, "Give the datasheet's RECOMMENDED LAND PATTERN numbers (mm): chip {span, padLength, padWidth}; dual/sot {pins | leftPins+rightPins, pitch, span (pad centre to pad centre across), padLength, padWidth}; quad {pins | pinsPerSideX+pinsPerSideY, pitch, span, spanY?, padLength, padWidth}; bga {rows, cols, pitch, ballPad, missing?}; plus exposedPad {w, h} and body {w, l, height?}."),+        Err(e) => return fail("bad_layout", e, "The engine's layout could not be written in KiCad's terms; report it on adom/kicad-bridge with the arguments."),     };     let desc = arg_str(args, "description").map(str::to_string).unwrap_or_else(|| land_pattern::describe(args, &fp));     let silk = fp.lines.iter().filter(|l| l.0 == "overlay").count();     let summary = json!({"pads": fp.pads.len(), "courtyardMm": [fp.courtyard.0, fp.courtyard.1, fp.courtyard.2, fp.courtyard.3], "silkLines": silk,         "padTable": fp.pads.iter().take(12).map(|p| json!([p.name, p.x, p.y, p.w, p.h])).collect::<Vec<_>>(), "frame": "standard (mm, y up, pin 1 top-left)"});     if args.get("dryRun") == Some(&json!(true)) {-        return json!({"success": true, "dryRun": true, "name": name, "description": desc, "summary": summary,+        return json!({"success": true, "dryRun": true, "name": name, "description": desc, "summary": summary, "engine": engine,             "_hint": "Nothing written. Check padTable (name, x, y, w, h in mm, Y up, pin 1 top-left) against the datasheet, then call again without dryRun."});     }     let Some(lib) = arg_str(args, "libraryPath").filter(|p| p.trim_end_matches(['/', '\\']).to_ascii_lowercase().ends_with(".pretty")) else {⋯ 25 unchanged lines ⋯     r["summary"] = summary;     r["body3d"] = body3d;     r["description"] = json!(desc);+    r["engine"] = engine;     if r["success"] == true {         r["spec"] = json!(format!("{}/{name}.kicad_mod", lib.trim_end_matches(['/', '\\'])));         r["_hint"] = json!("Written. Register the library with kicad_install_library to place it, kicad_fp_info reads it back, kicad_show_footprint shows it in KiCad.");⋯ 9 unchanged lines ⋯     fn it_holds_no_gui_lock() {         assert!(matches!(VERBS[0].mechanism, Mechanism::File));     }++    #[test]+    fn the_engine_lays_it_out_and_kicad_writes_it() {+        let soic = json!({"name": "SOIC-8_test", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9, "height": 1.75}});+        let mut a = soic.clone();+        a["dryRun"] = json!(true);+        let r = dispatch(&mut State::new(), "kicad_footprint_from_land_pattern", &a).unwrap();+        assert_eq!(r["success"], true, "{r}");+        assert!(r["engine"].as_str().unwrap().starts_with("adom-eda-checks "));+        // pin 1 top-left, counter-clockwise: the same table the standard's engine and Altium give+        assert_eq!(r["summary"]["padTable"][0], json!(["1", -2.7, 1.905, 1.55, 0.6]));+        assert_eq!(r["summary"]["padTable"][4], json!(["5", 2.7, -1.905, 1.55, 0.6]));+        assert_eq!((r["summary"]["pads"].as_u64(), r["summary"]["silkLines"].as_u64()), (Some(8), Some(2)));+        let dir = std::env::temp_dir().join(format!("lp-{}", std::process::id()));+        let lib = dir.join("Mine.pretty");+        let mut w = soic.clone();+        w["libraryPath"] = json!(lib.to_string_lossy().replace('\\', "/"));+        let r = dispatch(&mut State::new(), "kicad_footprint_from_land_pattern", &w).unwrap();+        assert_eq!(r["success"], true, "{r}");+        let text = std::fs::read_to_string(lib.join("SOIC-8_test.kicad_mod")).unwrap();+        assert_eq!(text.matches("(pad ").count(), 8);+        assert!(text.contains("F.CrtYd") && text.contains("F.Fab") && dir.join("Mine.3dshapes/SOIC-8_test.wrl").exists());+        let bad = dispatch(&mut State::new(), "kicad_footprint_from_land_pattern", &json!({"name": "x", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6, "dryRun": true})).unwrap();+        assert_eq!(bad["errorCode"], "bad_args");+        assert!(bad["error"].as_str().unwrap().contains("padLength"));+        let _ = std::fs::remove_dir_all(&dir);+    } }+--- a/rust/crates/kicad-bridge/src/main.rs+++ b/rust/crates/kicad-bridge/src/main.rs@@ -1,260 +1,262 @@⋯ 241 unchanged lines ⋯     let _ = req.respond(json_response(body, code)); } -/// `--ngspice-worker`: `{dll, cwd, lines}` on stdin, kicad_platform::ngspice::run's answer (or `{error}`) on stdout.+/// `--ngspice-worker`: `{dll, cwd, lines, codemodels, commands?}` on stdin, kicad_platform::ngspice::run's answer (or `{error}`) on stdout. fn ngspice_worker() -> i32 {     use std::io::Read;     let mut input = String::new();⋯ 1 unchanged line ⋯     let job: Value = serde_json::from_str(&input).unwrap_or(Value::Null);     let lines: Vec<String> = job["lines"].as_array().into_iter().flatten().filter_map(|l| l.as_str().map(String::from)).collect();     let cms: Vec<std::path::PathBuf> = job["codemodels"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(std::path::PathBuf::from)).collect();-    let out = kicad_platform::ngspice::run(std::path::Path::new(job["dll"].as_str().unwrap_or("")), &lines, std::path::Path::new(job["cwd"].as_str().unwrap_or(".")), &cms);+    let cmds: Vec<String> = job["commands"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(String::from)).collect();+    let out = kicad_platform::ngspice::run(std::path::Path::new(job["dll"].as_str().unwrap_or("")), &lines, std::path::Path::new(job["cwd"].as_str().unwrap_or(".")), &cms, &cmds);     let v = match out {         Ok(v) => v,         Err(e) => serde_json::json!({"error": e}),⋯ 1 unchanged line ⋯     println!("{v}");     0 }+--- a/rust/crates/kicad-bridge/src/verbs_sim.rs+++ b/rust/crates/kicad-bridge/src/verbs_sim.rs@@ -1,192 +1,289 @@⋯ 20 unchanged lines ⋯         mechanism: Mechanism::Cli, risk: "write", timeout_sec: 120,         input: "{\"schPath\": \"C:/.../x.kicad_sch\", \"outputPath\"?: \"C:/.../x.cir\" (default beside the sheet), \"format\"?: \"spice\" | \"spicemodel\"}",         example: "kicad_export_spice {\"schPath\":\"C:/Users/john/proj/filter.kicad_sch\"}",-        hint: "path is the .cir; elements counts the parts that made it into the netlist. A part without a SPICE model is left out or becomes a comment: give it Sim fields (Simulation_SPICE sources, or the Sim.* fields in its properties). spicemodel writes a subcircuit instead of a top-level circuit. kicad_simulate runs it.",+        hint: "path is the .cir; elements counts the parts that made it into the netlist. A part without a SPICE model becomes a stub with no nodes (J1 __J1) and is named in notSimulated: give it Sim fields (Simulation_SPICE sources, or the Sim.* fields in its properties) or exclude it from simulation. spicemodel writes a subcircuit instead of a top-level circuit. kicad_simulate runs it.",         related: &["kicad_simulate", "kicad_run_erc", "kicad_extract_netlist"],         pitfalls: &["Ground must be the net 0: KiCad's GND symbol makes it so.", "Model files are referenced by .include with the path the part's Sim.Library field gives."],     },     Verb {         name: "kicad_simulate",-        summary: "Simulate a schematic with SPICE, headless: KiCad's netlist of the sheet run by the ngspice library KiCad installs, with the analysis you give (transient, AC, DC sweep, operating point) or the one the schematic carries; each probe comes back downsampled with its first, last, min and max values (AC as dB and phase), and as a CSV file.",+        summary: "Simulate a schematic with SPICE, headless: KiCad's netlist of the sheet run by the ngspice library KiCad installs, with the analysis you give (transient, AC, DC sweep, operating point) or the one the schematic carries, optionally stepped over a part's value, a model parameter or the temperature; each probe comes back downsampled with its first, last, min and max values (AC as dB and phase), and as a CSV file.",         mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,-        input: "{\"schPath\" | \"schematic\": \"C:/.../x.kicad_sch\", \"analysis\"?: {\"type\": \"op\"} | {\"type\": \"dc\", \"source\", \"start\", \"stop\", \"step\"} | {\"type\": \"ac\", \"sweep\": \"dec|oct|lin\", \"points\", \"start\", \"stop\"} | {\"type\": \"tran\", \"step\", \"stop\", \"start\"?} | {\"type\": \"raw\", \"text\": \".noise ...\"} | \".tran 10u 5m\" (or a list), \"probes\"?: [{\"net\": \"OUT\"}, {\"current\": \"V1\"}] | [\"V(/OUT)\"], \"points\"?: 200, \"outputDir\"?: \"C:/...\", \"timeoutSec\"?: 120}",+        input: "{\"schPath\" | \"schematic\": \"C:/.../x.kicad_sch\", \"analysis\"?: {\"type\": \"op\"} | {\"type\": \"dc\", \"source\", \"start\", \"stop\", \"step\"} | {\"type\": \"ac\", \"sweep\": \"dec|oct|lin\", \"points\", \"start\", \"stop\"} | {\"type\": \"tran\", \"step\", \"stop\", \"start\"?} | {\"type\": \"raw\", \"text\": \".noise ...\"} | \".tran 10u 5m\" (or a list), \"probes\"?: [{\"net\": \"OUT\"}, {\"current\": \"V1\"}] | [\"V(/OUT)\"], \"sweep\"?: {\"parameter\": \"R4\" | \"Q1.BF\", \"start\", \"stop\", \"step\", \"scale\"?: \"lin|dec|oct\"}, \"temperatures\"?: {\"start\", \"stop\", \"step\"}, \"points\"?: 200, \"outputDir\"?: \"C:/...\", \"timeoutSec\"?: 120}",         example: "kicad_simulate {\"schPath\":\"C:/Users/john/proj/filter.kicad_sch\",\"analysis\":{\"type\":\"ac\",\"sweep\":\"dec\",\"points\":20,\"start\":10,\"stop\":1000000},\"probes\":[{\"net\":\"OUT\"}]}",-        hint: "signals[{signal, x, y, xUnit, yUnit, phase?}] is the standard's answer (#23): each probe's own points, at most points of them with the first, last and extreme points kept; AC as dB with unwrapped phase in degrees. engine names the ngspice and KiCad versions. probes are nets ({net: \"OUT\"}, or V(/OUT)) and source currents ({current: \"V1\"}); without probes every node and branch comes back (at most 24). sweep is time, frequency or the DC sweep. An AC probe has db and phaseDeg. csvPath holds the same points. Read log for ngspice's own messages when a run fails.",+        hint: "signals[{signal, x, y, xUnit, yUnit, phase?}] is the standard's answer (#23): each probe's own points, at most points of them with the first, last and extreme points kept; AC as dB with unwrapped phase in degrees. engine names the ngspice and KiCad versions. probes are nets ({net: \"OUT\"}, or V(/OUT)) and source currents ({current: \"V1\"}); without probes every node and branch comes back (at most 24). With sweep (a part's value \"R4\" or a model parameter \"Q1.BF\"; lin steps by step, dec/oct take step points per decade/octave) or temperatures (°C), ngspice runs once per value (alter/altermod, .temp; at most 50 runs) and each run's signals carry step (\"R4 = 300\", \"T = 85\"): group by signal + step. The top-level sweep is time, frequency or the DC sweep of the first run. An AC probe has db and phaseDeg. csvPath holds the same points. Read log for ngspice's own messages when a run fails.",         related: &["kicad_export_spice", "kicad_build_schematic", "kicad_sch_set_property"],-        pitfalls: &["Every part needs a SPICE model: R, C and L work from their values; sources come from the Simulation_SPICE library; ICs need a model file (Sim.Library).", "Runs on Windows through KiCad's ngspice.dll.", "A long transient with a tiny step returns many points: points downsamples the reply, not the run."],+        pitfalls: &["Every part needs a SPICE model: R, C and L work from their values; sources come from the Simulation_SPICE library; ICs need a model file (Sim.Library). A part without one is left out of the run and named in notSimulated; a sheet with no modelled part refuses no_model.", "Runs on Windows through KiCad's ngspice.dll.", "A long transient with a tiny step returns many points: points downsamples the reply, not the run.", "A swept part must be in the netlist, and a model parameter's .model must be in the netlist or a file it includes: otherwise bad_args, never one run passed off as the sweep."],     }, ]; ⋯ 23 unchanged lines ⋯     Ok((text, cli_path)) } +/// The sheet's parts KiCad left out of the netlist for want of a model (#23's notSimulated), and whether the sheet+/// had any part at all. Parts the designer excluded from simulation are left out on purpose and not listed.+fn unmodelled(cli: &Path, sch: &Path, netlist: &str) -> (Vec<String>, usize) {+    let Ok(nl) = kicad_core::netlist::load(cli, sch) else { return (Vec::new(), 0) };+    let refs: Vec<String> = nl.components.iter()+        .filter(|c| !c.reference.starts_with('#') && !c.properties.iter().any(|(k, _)| k == "exclude_from_sim"))+        .map(|c| c.reference.clone()).collect();+    (spice::not_simulated(&spice::strip_unmodelled(netlist).0, &refs), refs.len())+}+ fn sch_arg(args: &Value) -> Result<PathBuf, Value> {     let Some(p) = arg_any(args, &["schPath", "schematic", "filePath"]).map(PathBuf::from) else {         return Err(fail("missing_arg", "schPath is required", "The .kicad_sch to simulate (the root sheet of a project)."));⋯ 11 unchanged lines ⋯         return Err(fail("bad_args", "format is spice or spicemodel", ""));     }     let out = arg_str(args, "outputPath").map(PathBuf::from).unwrap_or_else(|| sch.with_extension("cir"));-    let (text, _) = export(state, &sch, &out, format)?;+    let (text, cli_path) = export(state, &sch, &out, format)?;     let mut v = spice::describe(&text);+    v["notSimulated"] = json!(unmodelled(&cli_path, &sch, &text).0);     v["success"] = json!(true);     v["path"] = json!(norm(&out));     v["format"] = json!(format);⋯ 19 unchanged lines ⋯     let _ = std::fs::create_dir_all(&tmp);     let cir = tmp.join("circuit.cir");     let (text, cli_path) = export(state, &sch, &cir, "spice")?;-    let lines = spice::prepare(&text, &analyses).map_err(|e| fail("bad_args", e, "analysis: \".tran 10u 5m\", \".ac dec 20 10 1meg\", \".dc V1 0 5 0.1\" or \".op\"."))?;+    let (not_simulated, parts) = unmodelled(&cli_path, &sch, &text);+    if parts > 0 && not_simulated.len() == parts {+        let _ = std::fs::remove_dir_all(&tmp);+        let mut v = fail("no_model", format!("none of the sheet's {parts} part(s) has a simulation model"), "R, C and L simulate from their values, sources come from the Simulation_SPICE library, and an IC needs its model file in Sim.Library (Sim.* fields in the symbol's properties).");+        v["notSimulated"] = json!(not_simulated);+        return Err(v);+    }+    // the parts KiCad wrote as stubs are left out of the run (and named in notSimulated)+    let lines = spice::prepare(&spice::strip_unmodelled(&text).0, &analyses).map_err(|e| fail("bad_args", e, "analysis: \".tran 10u 5m\", \".ac dec 20 10 1meg\", \".dc V1 0 5 0.1\" or \".op\"."))?;     let dll = cli_path.parent().unwrap_or(Path::new(".")).join("ngspice.dll");     if !dll.is_file() {         return Err(fail("ngspice_missing", format!("{} is not there", norm(&dll)), "KiCad's simulator library ships with KiCad 7 and later on Windows; reinstall KiCad (kicad_upgrade)."));⋯ 3 unchanged lines ⋯     let cm_dir = cli_path.parent().and_then(Path::parent).map(|b| b.join("lib").join("ngspice")).unwrap_or_default();     let mut codemodels: Vec<String> = std::fs::read_dir(&cm_dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "cm").unwrap_or(false)).map(|p| norm(&p)).collect();     codemodels.sort();-    let job = json!({"dll": norm(&dll), "cwd": norm(&cwd), "lines": lines, "codemodels": codemodels});+    // a sweep (#23): ngspice has no .step, so one run per value, the value set with alter/altermod and the+    // temperature with .temp; each run's signals carry step ("R4 = 300", "T = 85")+    let plan = sweep_plan(args, &text, &cwd).inspect_err(|_| { let _ = std::fs::remove_dir_all(&tmp); })?;     let timeout = Duration::from_secs(args.get("timeoutSec").and_then(Value::as_u64).unwrap_or(120).clamp(5, 280));     let started = Instant::now();-    let run = worker(&job, timeout)?;-    let _ = std::fs::remove_dir_all(&tmp);-    if let Some(e) = run["error"].as_str() {-        let mut v = fail("simulation_failed", e.to_string(), "Read the error: a part without a model, a node with no DC path to ground, or a bad analysis line. kicad_export_spice shows the netlist.");-        v["netlist"] = json!(lines);-        return Err(v);+    let mut runs = Vec::new();+    for (label, temp, commands) in &plan {+        let run_lines = match temp { Some(t) => spice::with_temp(&lines, *t), None => lines.clone() };+        let job = json!({"dll": norm(&dll), "cwd": norm(&cwd), "lines": run_lines, "codemodels": codemodels, "commands": commands});+        let run = worker(&job, timeout).inspect_err(|_| { let _ = std::fs::remove_dir_all(&tmp); })?;+        if let Some(e) = run["error"].as_str() {+            let _ = std::fs::remove_dir_all(&tmp);+            let at = label.as_ref().map(|l| format!(" (the run with {l})")).unwrap_or_default();+            let mut v = fail("simulation_failed", format!("{e}{at}"), "Read the error: a part without a model, a node with no DC path to ground, or a bad analysis line. kicad_export_spice shows the netlist.");+            v["netlist"] = json!(run_lines);+            return Err(v);+        }+        runs.push((label.clone(), run));     }+    let _ = std::fs::remove_dir_all(&tmp);+    let run = runs[0].1.clone();     let mut v = spice::summarize(&run, &probes, points).map_err(|e| fail("no_such_probe", e, "Probe nets as {net: \"OUT\"} (or V(/OUT)) and source currents as {current: \"V1\"}; available lists this run's vectors."))?;     // the standard's signals (#23): each probe's own x and y, the first, last and extreme points kept-    let (signals, missing) = spice::signals(&run, &probes, points);+    let (mut signals, missing) = spice::signals(&run, &probes, points);+    if plan.len() > 1 || plan[0].0.is_some() {+        signals.clear();+        for (label, r) in &runs {+            for mut sg in spice::signals(r, &probes, points).0 {+                sg["step"] = json!(label);+                signals.push(sg);+            }+        }+        v["runs"] = json!(runs.len());+    }     v["signals"] = json!(signals);     v["missing"] = json!(missing);+    // a partly modelled sheet runs: these parts are not in the result (#23)+    v["notSimulated"] = json!(not_simulated);     let out_dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| sch.parent().unwrap_or(Path::new(".")).join("sim_output"));     let _ = std::fs::create_dir_all(&out_dir);     let stem = sch.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "sim".into());⋯ 9 unchanged lines ⋯     let kicad = state.kicad_info().primary().map(|p| p.cli_version.clone()).unwrap_or_default();     v["engine"] = json!(format!("ngspice {} (KiCad {})", ng.unwrap_or_else(|| "?".into()), if kicad.is_empty() { "?" } else { kicad.as_str() }));     v["output"] = json!(format!("{} vector(s) of {} over {} point(s)", v["probes"].as_array().map_or(0, |a| a.len()), v["plot"].as_str().unwrap_or(""), v["of"]));-    v["_hint"] = json!("probes[].values follow sweep.values (AC: db and phaseDeg). csvPath holds the same points; pull it with ab pull_file. missing lists probes this run did not have.");+    v["_hint"] = json!("probes[].values follow sweep.values (AC: db and phaseDeg). csvPath holds the same points; pull it with ab pull_file. missing lists probes this run did not have; notSimulated names parts left out for want of a model, so the result covers only the rest of the circuit.");     Ok(v) } +/// The runs a call asks for: one plain run, or one per sweep value and temperature (at most 50), each with its+/// label, its temperature and the ngspice commands that set its value.+fn sweep_plan(args: &Value, netlist: &str, cwd: &Path) -> Result<Vec<(Option<String>, Option<f64>, Vec<String>)>, Value> {+    const CAP: usize = 50;+    let bad = |e: String| fail("bad_args", e, "sweep: {parameter: \"R4\" (a part's value) or \"Q1.BF\" (a model parameter), start, stop, step, scale?: lin|dec|oct}; temperatures: {start, stop, step} in °C. At most 50 runs in all.");+    let range = |o: &Value, what: &str| -> Result<(f64, f64, f64), Value> {+        let g = |k: &str| spice::spice_num(&o[k]).ok_or_else(|| bad(format!("{what} needs {k} (a number, or SPICE text like 100p)")));+        Ok((g("start")?, g("stop")?, g("step")?))+    };+    let mut sweep: Vec<(String, String, f64)> = vec![]; // (label, command, value)+    if let Some(sw) = args.get("sweep").filter(|v| !v.is_null()) {+        let param = sw["parameter"].as_str().filter(|p| !p.trim().is_empty()).ok_or_else(|| bad("sweep needs parameter: \"R4\" or \"Q1.BF\"".into()))?.trim();+        let (a, b, st) = range(sw, "sweep")?;+        let values = spice::sweep_values(a, b, st, sw["scale"].as_str().unwrap_or("lin"), CAP).map_err(|e| bad(format!("sweep: {e}")))?;+        // the .model names in the netlist and the files it includes, for a model parameter+        let mut models = spice::model_names(netlist);+        for l in netlist.lines() {+            let t = l.trim();+            if t.to_ascii_lowercase().starts_with(".include") || t.to_ascii_lowercase().starts_with(".lib") {+                if let Some(path) = t.split_whitespace().nth(1).map(|p| p.trim_matches(['"', '\''])) {+                    let p = cwd.join(path);+                    models.extend(std::fs::read_to_string(&p).map(|x| spice::model_names(&x)).unwrap_or_default());+                }+            }+        }+        for x in values {+            let cmd = spice::sweep_command(netlist, param, x, &models).map_err(bad)?;+            sweep.push((format!("{param} = {}", spice::spice_label(x)), cmd, x));+        }+    }+    let mut temps: Vec<f64> = vec![];+    if let Some(t) = args.get("temperatures").filter(|v| !v.is_null()) {+        let (a, b, st) = range(t, "temperatures")?;+        temps = spice::sweep_values(a, b, st, "lin", CAP).map_err(|e| bad(format!("temperatures: {e}")))?;+    }+    if sweep.len().max(1) * temps.len().max(1) > CAP {+        return Err(bad(format!("{} runs ({} values x {} temperatures); at most {CAP}", sweep.len().max(1) * temps.len().max(1), sweep.len().max(1), temps.len().max(1))));+    }+    let mut plan = Vec::new();+    match (sweep.is_empty(), temps.is_empty()) {+        (true, true) => plan.push((None, None, vec![])),+        (false, true) => plan.extend(sweep.into_iter().map(|(l, c, _)| (Some(l), None, vec![c]))),+        (true, false) => plan.extend(temps.iter().map(|t| (Some(format!("T = {}", spice::spice_label(*t))), Some(*t), vec![]))),+        (false, false) => {+            for (l, c, _) in &sweep {+                for t in &temps {+                    plan.push((Some(format!("{l}, T = {}", spice::spice_label(*t))), Some(*t), vec![c.clone()]));+                }+            }+        }+    }+    Ok(plan)+}+ /// The job in a child of this binary, with a deadline: ngspice keeps global state and can exit its host. fn worker(job: &Value, timeout: Duration) -> Result<Value, Value> {     let exe = std::env::current_exe().map_err(|e| fail("worker_failed", format!("cannot find this bridge's binary: {e}"), ""))?;⋯ 33 unchanged lines ⋯     let last = out.lines().rev().find(|l| l.trim_start().starts_with('{')).unwrap_or("");     serde_json::from_str(last).map_err(|_| fail("simulation_failed", "the simulation worker ended without an answer (ngspice may have crashed on this circuit)", "kicad_export_spice shows the netlist; check every part has a SPICE model.")) }+--- a/rust/crates/kicad-core/src/land_pattern.rs+++ b/rust/crates/kicad-core/src/land_pattern.rs@@ -1,288 +1,122 @@ //! Footprints from a datasheet's land pattern, the EDA Bridge Standard's footprint_from_land_pattern.-//! The caller gives the numbers the datasheet prints (pitch, pad size, row span, body); `layout`-//! makes a complete footprint: pads numbered the standard way, an exposed pad, the courtyard,-//! silkscreen kept clear of the copper with a pin-1 dot, and a fab outline with a pin-1 chamfer.-//! `pads` and `layout` are the Altium bridge's (bridge-rs/src/footprint.rs, 0.2.77) unchanged, so the-//! same numbers give the same footprint in both tools: mm, Y up, origin at the centre, pin 1 top-left.-//! `fp_create_args` turns the layout into `fpedit::create`'s arguments in KiCad's frame (y down), and+//! The standard's engine (adom-eda-checks, footprint_from_land_pattern) lays the footprint out from the numbers the+//! datasheet prints: pads numbered the standard way, an exposed pad, the courtyard, silkscreen kept clear of the copper+//! with a pin-1 dot, and a fab outline with a pin-1 chamfer (mm, Y up, origin at the centre, pin 1 top-left), so the same+//! numbers give the same footprint in every tool. This module writes that layout in KiCad's terms: `from_engine` reads+//! the engine's reply, `fp_create_args` turns it into `fpedit::create`'s arguments in KiCad's frame (y down), and //! `vrml_box` is the extruded 3D body. use serde_json::{json, Value}; ⋯ 6 unchanged lines ⋯ fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 } fn num(a: &Value, k: &str) -> Option<f64> { a.get(k).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) } -/// BGA row letters: A..Z without I, O, Q, S, X, Z, then AA, AB, ...-pub fn bga_row(i: usize) -> String {-    const L: &[u8] = b"ABCDEFGHJKLMNPRTUVWY";-    if i < L.len() { (L[i] as char).to_string() } else { format!("{}{}", L[i / L.len() - 1] as char, L[i % L.len()] as char) }-}--/// The pads of a land pattern. Errors name the missing number in the words the caller used.-pub fn pads(a: &Value) -> Result<Vec<Pad>, String> {-    let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();-    let need = |k: &str| num(a, k).ok_or_else(|| format!("{family} needs {k} (mm)"));-    let shape = a.get("padShape").and_then(|s| s.as_str()).unwrap_or(if family == "bga" { "round" } else { "roundrect" }).to_string();-    let mut out = Vec::new();-    let mut push = |name: String, x: f64, y: f64, w: f64, h: f64| out.push(Pad { name, x: r3(x), y: r3(y), w: r3(w), h: r3(h), shape: shape.clone(), hole: 0.0 });-    match family.as_str() {-        "chip" | "two" | "2pad" => {-            let (span, pl, pw) = (need("span")?, need("padLength")?, need("padWidth")?);-            push("1".into(), -span / 2.0, 0.0, pl, pw);-            push("2".into(), span / 2.0, 0.0, pl, pw);-        }-        "dual" | "sot" => {-            let (span, pitch, pl, pw) = (need("span")?, need("pitch")?, need("padLength")?, need("padWidth")?);-            let (left, right) = if family == "sot" {-                (num(a, "leftPins").ok_or("sot needs leftPins and rightPins")? as usize, num(a, "rightPins").ok_or("sot needs leftPins and rightPins")? as usize)-            } else {-                let n = need("pins")? as usize;-                if n % 2 != 0 { return Err(format!("dual needs an even pin count, got {n}")); }-                (n / 2, n / 2)-            };-            // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left)-            for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); }-            for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * pitch, pl, pw); }-        }-        "quad" => {-            let (pitch, pl, pw) = (need("pitch")?, need("padLength")?, need("padWidth")?);-            let span_x = need("span")?;-            let span_y = num(a, "spanY").unwrap_or(span_x);-            let (ny, nx) = match (num(a, "pinsPerSideY"), num(a, "pinsPerSideX")) {-                (Some(y), Some(x)) => (y as usize, x as usize),-                _ => {-                    let n = need("pins")? as usize;-                    if n % 4 != 0 { return Err(format!("quad needs pins divisible by 4, or pinsPerSideX and pinsPerSideY; got {n}")); }-                    (n / 4, n / 4)-                }-            };-            let mut k = 1;-            // left top->bottom, bottom left->right, right bottom->top, top right->left-            for i in 0..ny { push(k.to_string(), -span_x / 2.0, ((ny as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); k += 1; }-            for i in 0..nx { push(k.to_string(), (i as f64 - (nx as f64 - 1.0) / 2.0) * pitch, -span_y / 2.0, pw, pl); k += 1; }-            for i in 0..ny { push(k.to_string(), span_x / 2.0, (i as f64 - (ny as f64 - 1.0) / 2.0) * pitch, pl, pw); k += 1; }-            for i in 0..nx { push(k.to_string(), ((nx as f64 - 1.0) / 2.0 - i as f64) * pitch, span_y / 2.0, pw, pl); k += 1; }-        }-        "bga" => {-            let (pitch, d) = (need("pitch")?, need("ballPad")?);-            let (rows, cols) = (need("rows")? as usize, need("cols")? as usize);-            let missing: Vec<String> = a.get("missing").and_then(|m| m.as_array()).cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(|s| s.to_uppercase())).collect();-            for r in 0..rows {-                for c in 0..cols {-                    let name = format!("{}{}", bga_row(r), c + 1);-                    if missing.contains(&name) { continue; }-                    push(name, (c as f64 - (cols as f64 - 1.0) / 2.0) * pitch, ((rows as f64 - 1.0) / 2.0 - r as f64) * pitch, d, d);-                }-            }-        }-        f => return Err(format!("family '{f}' is not one of chip, dual, sot, quad, bga")),-    }-    // the exposed (thermal) pad-    if let Some(ep) = a.get("exposedPad").filter(|e| e.is_object()) {-        let (w, h) = (num(ep, "w").ok_or("exposedPad needs w and h (mm)")?, num(ep, "h").ok_or("exposedPad needs w and h (mm)")?);-        let name = ep.get("number").map(|v| match v { Value::String(s) => s.clone(), o => o.to_string() }).unwrap_or_else(|| (out.len() + 1).to_string());-        out.push(Pad { name, x: r3(num(ep, "x").unwrap_or(0.0)), y: r3(num(ep, "y").unwrap_or(0.0)), w: r3(w), h: r3(h), shape: "rect".into(), hole: 0.0 });-    }-    Ok(out)-}--/// The parts of [a0, a1] that stay outside every blocked interval, kept when at least `min` long.-fn subtract(a0: f64, a1: f64, blocked: &mut [(f64, f64)], min: f64) -> Vec<(f64, f64)> {-    blocked.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());-    let mut out = Vec::new();-    let mut cur = a0;-    for &(b0, b1) in blocked.iter() {-        if b1 <= cur || b0 >= a1 { continue; }-        if b0 > cur && b0 - cur >= min { out.push((cur, b0.min(a1))); }-        cur = cur.max(b1);-        if cur >= a1 { break; }-    }-    if a1 - cur >= min { out.push((cur, a1)); }-    out-}--/// The whole footprint: pads, then silkscreen (the body outline minus `clear` mm around every pad, and a pin-1 dot),-/// the fab outline with a pin-1 chamfer, and the courtyard (pads and body grown by `margin`).-pub fn layout(a: &Value) -> Result<Fp, String> {-    let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();-    let pads = pads(a)?;+/// The engine's layout reply (pads, lines, discs, courtyardMm; layers overlay, fab, courtyard) as an `Fp`.+pub fn from_engine(v: &Value) -> Result<Fp, String> {+    let n = |x: &Value, k: &str| x[k].as_f64().unwrap_or(0.0);+    let layer = |l: &str| -> Result<&'static str, String> {+        match l { "overlay" => Ok("overlay"), "fab" => Ok("fab"), "courtyard" => Ok("courtyard"), o => Err(format!("the engine's layout has a layer KiCad's writer does not map: {o}")) }+    };+    let pads: Vec<Pad> = v["pads"].as_array().ok_or("the engine's layout has no pads")?.iter()+        .map(|p| Pad { name: p["name"].as_str().unwrap_or("").to_string(), x: n(p, "x"), y: n(p, "y"), w: n(p, "w"), h: n(p, "h"), shape: p["shape"].as_str().unwrap_or("roundrect").to_string(), hole: n(p, "hole") })+        .collect();     if pads.is_empty() { return Err("no pads".into()); }-    let body = a.get("body").cloned().unwrap_or(json!({}));-    let (bw, bh) = (num(&body, "w"), num(&body, "l").or_else(|| num(&body, "h")));-    let silk_w = num(a, "silkWidth").unwrap_or(0.15);-    let clear = num(a, "silkClearance").unwrap_or(0.2);-    let margin = num(a, "courtyard").unwrap_or(0.25);-    let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);-    let (mut x0, mut y0, mut x1, mut y1) = pads.iter().map(ext).fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, e| (a.0.min(e.0), a.1.min(e.1), a.2.max(e.2), a.3.max(e.3)));-    let mut fp = Fp { pads: pads.clone(), ..Default::default() };-    if let (Some(w), Some(h)) = (bw, bh) {-        let (bx0, by0, bx1, by1) = (-w / 2.0, -h / 2.0, w / 2.0, h / 2.0);-        x0 = x0.min(bx0); y0 = y0.min(by0); x1 = x1.max(bx1); y1 = y1.max(by1);-        // silkscreen: each body edge minus the stretches that come within `clear` of copper-        let keep = clear + silk_w / 2.0;-        let mut edge = |horizontal: bool, c: f64, a0: f64, a1: f64| {-            let mut blocked: Vec<(f64, f64)> = pads.iter().filter_map(|p| {-                let (px0, py0, px1, py1) = ext(p);-                if horizontal { (c > py0 - keep && c < py1 + keep).then_some((px0 - keep, px1 + keep)) } else { (c > px0 - keep && c < px1 + keep).then_some((py0 - keep, py1 + keep)) }-            }).collect();-            for (s0, s1) in subtract(a0, a1, &mut blocked, 0.2) {-                if horizontal { fp.lines.push(("overlay", r3(s0), r3(c), r3(s1), r3(c), silk_w)); } else { fp.lines.push(("overlay", r3(c), r3(s0), r3(c), r3(s1), silk_w)); }-            }-        };-        // the outline is drawn just outside the body so it is not hidden under it-        let o = silk_w / 2.0;-        edge(true, by1 + o, bx0 - o, bx1 + o);-        edge(true, by0 - o, bx0 - o, bx1 + o);-        edge(false, bx0 - o, by0 - o, by1 + o);-        edge(false, bx1 + o, by0 - o, by1 + o);-        // fab: the body with its pin-1 corner (top-left) cut-        let ch = (w.min(h) * 0.25).min(1.0);-        let fab = [(bx0 + ch, by1), (bx1, by1), (bx1, by0), (bx0, by0), (bx0, by1 - ch), (bx0 + ch, by1)];-        for k in 0..fab.len() - 1 { fp.lines.push(("fab", r3(fab[k].0), r3(fab[k].1), r3(fab[k + 1].0), r3(fab[k + 1].1), 0.1)); }-    }-    // pin-1 dot: beside pin 1 on the outside, clear of its pad; a two-pad part only when polarized-    let polar = a.get("polarized").and_then(|b| b.as_bool()).unwrap_or(false);-    if family != "chip" || polar {-        let p1 = &pads[0];-        let r = 0.15;-        let (dx, dy) = if family == "bga" { (x0 - clear - r, y1 + clear + r) } else { (p1.x - p1.w / 2.0 - clear - r - silk_w, p1.y) };-        fp.discs.push(("overlay", r3(dx), r3(dy), r));-        x0 = x0.min(dx - r);-        y1 = y1.max(dy + r);+    let mut lines = Vec::new();+    for l in v["lines"].as_array().into_iter().flatten() {+        lines.push((layer(l["layer"].as_str().unwrap_or(""))?, n(l, "x1"), n(l, "y1"), n(l, "x2"), n(l, "y2"), n(l, "width")));     }-    // courtyard: everything grown by the margin, on a 0.01 mm grid-    let g = |v: f64, up: bool| if up { (v * 100.0).ceil() / 100.0 } else { (v * 100.0).floor() / 100.0 };-    let c = (g(x0 - margin, false), g(y0 - margin, false), g(x1 + margin, true), g(y1 + margin, true));-    fp.courtyard = c;-    for (a0, b0, a1, b1) in [(c.0, c.1, c.2, c.1), (c.2, c.1, c.2, c.3), (c.2, c.3, c.0, c.3), (c.0, c.3, c.0, c.1)] {-        fp.lines.push(("courtyard", a0, b0, a1, b1, 0.05));+    let mut discs = Vec::new();+    for d in v["discs"].as_array().into_iter().flatten() {+        discs.push((layer(d["layer"].as_str().unwrap_or(""))?, n(d, "x"), n(d, "y"), n(d, "r")));     }-    Ok(fp)+    let c = &v["courtyardMm"];+    let cy = |i: usize| c[i].as_f64().unwrap_or(0.0);+    Ok(Fp { pads, lines, discs, courtyard: (cy(0), cy(1), cy(2), cy(3)) }) }  /// A plain description: "SOIC-8 dual, 1.27 mm pitch, 8 pads (from the datasheet land pattern)".⋯ 37 unchanged lines ⋯ mod tests {     use super::*; -    #[test]-    fn soic8_numbers_down_the_left_and_up_the_right() {-        let p = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6})).unwrap();-        assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("1", -2.7, 1.905));-        assert_eq!((p[3].name.as_str(), p[3].y), ("4", -1.905));-        assert_eq!((p[4].name.as_str(), p[4].x, p[4].y), ("5", 2.7, -1.905));-        assert_eq!((p[7].name.as_str(), p[7].y), ("8", 1.905));-        assert_eq!((p[0].w, p[0].h), (1.55, 0.6));-    }--    #[test]-    fn quad_goes_counter_clockwise_and_turns_the_top_and_bottom_pads() {-        let p = pads(&json!({"family": "quad", "pins": 16, "pitch": 0.5, "span": 3.0, "padLength": 0.8, "padWidth": 0.25, "exposedPad": {"w": 1.7, "h": 1.7}})).unwrap();-        assert_eq!(p.len(), 17);-        assert_eq!((p[0].x, p[0].y), (-1.5, 0.75));      // 1: left, top-        assert_eq!((p[4].x, p[4].y, p[4].w), (-0.75, -1.5, 0.25)); // 5: bottom, left end, turned-        assert_eq!((p[8].x, p[8].y), (1.5, -0.75));      // 9: right, bottom-        assert_eq!((p[12].x, p[12].y), (0.75, 1.5));     // 13: top, right end-        assert_eq!((p[16].name.as_str(), p[16].shape.as_str()), ("17", "rect"));-    }--    #[test]-    fn sot23_has_two_pins_left_and_one_right() {-        let p = pads(&json!({"family": "sot", "leftPins": 2, "rightPins": 1, "pitch": 1.9, "span": 2.2, "padLength": 1.0, "padWidth": 0.6})).unwrap();-        assert_eq!(p.iter().map(|q| (q.name.clone(), q.x, q.y)).collect::<Vec<_>>(), vec![("1".into(), -1.1, 0.95), ("2".into(), -1.1, -0.95), ("3".into(), 1.1, 0.0)]);-    }--    #[test]-    fn bga_rows_skip_confusable_letters() {-        assert_eq!((bga_row(0), bga_row(7), bga_row(8), bga_row(19), bga_row(20)), ("A".into(), "H".into(), "J".into(), "Y".into(), "AA".into()));-        let p = pads(&json!({"family": "bga", "rows": 3, "cols": 3, "pitch": 0.8, "ballPad": 0.4, "missing": ["b2"]})).unwrap();-        assert_eq!(p.len(), 8);-        assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("A1", -0.8, 0.8));-    }--    #[test]-    fn silkscreen_keeps_clear_of_copper_and_the_courtyard_holds_everything() {-        let a = json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9}});-        let fp = layout(&a).unwrap();-        let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);-        for &(l, x0, y0, x1, y1, w) in fp.lines.iter().filter(|l| l.0 == "overlay") {-            let _ = l;-            for p in &fp.pads {-                let (px0, py0, px1, py1) = ext(p);-                // distance from the segment (axis-aligned) to the pad box, minus half the stroke-                let dx = (px0 - x0.max(x1)).max(x0.min(x1) - px1).max(0.0);-                let dy = (py0 - y0.max(y1)).max(y0.min(y1) - py1).max(0.0);-                assert!((dx * dx + dy * dy).sqrt() - w / 2.0 >= 0.199, "silk {x0},{y0}-{x1},{y1} too close to pad {}", p.name);-            }-        }-        // a SOIC's body sides sit under its pads: only the top and bottom edges survive-        assert_eq!(fp.lines.iter().filter(|l| l.0 == "overlay").count(), 2);-        let c = fp.courtyard;-        assert!(fp.pads.iter().all(|p| { let (a0, b0, a1, b1) = ext(p); a0 >= c.0 && b0 >= c.1 && a1 <= c.2 && b1 <= c.3 }));-        assert_eq!(fp.discs.len(), 1);-        assert!(fp.discs[0].1 < -2.7 - 0.775);+    /// A SOIC-8 as the engine lays it out (trimmed: pins 1 and 8, one silk line, the fab chamfer, the pin-1 dot).+    fn soic() -> Value {+        json!({"pads": [{"name": "1", "x": -2.7, "y": 1.905, "w": 1.55, "h": 0.6, "shape": "roundrect", "hole": 0.0},+                        {"name": "8", "x": 2.7, "y": 1.905, "w": 1.55, "h": 0.6, "shape": "roundrect", "hole": 0.0}],+               "lines": [{"layer": "overlay", "x1": -1.95, "y1": 2.56, "x2": 1.95, "y2": 2.56, "width": 0.15},+                         {"layer": "fab", "x1": -0.95, "y1": 2.45, "x2": 1.95, "y2": 2.45, "width": 0.1},+                         {"layer": "courtyard", "x1": -3.73, "y1": -2.7, "x2": 3.73, "y2": 2.7, "width": 0.05}],+               "discs": [{"layer": "overlay", "x": -3.8, "y": 1.905, "r": 0.15}],+               "courtyardMm": [-3.73, -2.7, 3.73, 2.7]})     }      #[test]-    fn a_missing_number_is_named() {-        let e = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6})).unwrap_err();-        assert!(e.contains("padLength"));+    fn the_engines_layout_reads_back() {+        let fp = from_engine(&soic()).unwrap();+        assert_eq!((fp.pads.len(), fp.lines.len(), fp.discs.len()), (2, 3, 1));+        assert_eq!(fp.pads[0], Pad { name: "1".into(), x: -2.7, y: 1.905, w: 1.55, h: 0.6, shape: "roundrect".into(), hole: 0.0 });+        assert_eq!(fp.courtyard, (-3.73, -2.7, 3.73, 2.7));+        let mut odd = soic();+        odd["lines"][0]["layer"] = json!("paste");+        assert!(from_engine(&odd).unwrap_err().contains("paste"));+        assert!(from_engine(&json!({"pads": []})).is_err());     }      #[test]     fn the_kicad_footprint_is_y_down_with_the_layouts_own_graphics() {         let a = json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9, "height": 1.75}});-        let fp = layout(&a).unwrap();+        let fp = from_engine(&soic()).unwrap();         let k = fp_create_args(&a, &fp);         // pin 1 top-left: y up 1.905 is KiCad's y down -1.905         assert_eq!(k["pads"][0], json!({"number": "1", "type": "smd", "shape": "roundrect", "size": [1.55, 0.6], "at": [-2.7, -1.905]}));⋯ 1 unchanged line ⋯         assert!(c[1].as_f64().unwrap() < c[3].as_f64().unwrap());         assert!(k["graphics"].as_array().unwrap().iter().any(|g| g["layer"] == "fab") && k["graphics"].as_array().unwrap().iter().any(|g| g["r"].is_number()));         let (text, info) = crate::fpedit::create("SOIC-8_test", &k).unwrap();-        assert_eq!(info["pads"], 8);+        assert_eq!(info["pads"], 2);         assert!(text.contains("(fp_line") && text.contains("F.CrtYd") && text.contains("F.Fab") && !text.contains("fp_rect"), "{text}");         assert!(vrml_box(3.9, 4.9, 1.75, 0.1).contains("geometry Box { size 1.535433 1.929134 0.649606 }"));     } }+--- a/rust/crates/kicad-core/src/spice.rs+++ b/rust/crates/kicad-core/src/spice.rs@@ -1,374 +1,531 @@⋯ 23 unchanged lines ⋯     json!({"title": title, "analyses": analyses, "includes": includes, "elements": elements, "lines": lines.len()}) } +/// A number given as JSON or as SPICE text ("100p", "4k7" is not SPICE: "4.7k", "1meg").+pub fn spice_num(v: &Value) -> Option<f64> {+    if let Some(f) = v.as_f64() { return Some(f); }+    let t = v.as_str()?.trim().to_ascii_lowercase();+    let split = t.find(|c: char| c.is_ascii_alphabetic() && c != 'e').unwrap_or(t.len());+    // "1meg" / "1e-9": the mantissa stops at the first letter that is not an exponent+    let (num, suf) = match t[..split].parse::<f64>() {+        Ok(n) => (n, &t[split..]),+        Err(_) => return None,+    };+    let m = if suf.starts_with("meg") { 1e6 } else {+        match suf.chars().next() { None => 1.0, Some('t') => 1e12, Some('g') => 1e9, Some('k') => 1e3, Some('m') => 1e-3, Some('u') => 1e-6, Some('n') => 1e-9, Some('p') => 1e-12, Some('f') => 1e-15, _ => return None }+    };+    Some(num * m)+}++/// A value the way a person reads it: 300, 4.7k, 100p, 1meg.+pub fn spice_label(v: f64) -> String {+    let a = v.abs();+    let (d, s) = if a == 0.0 { (1.0, "") } else if a >= 1e12 { (1e12, "T") } else if a >= 1e9 { (1e9, "G") } else if a >= 1e6 { (1e6, "meg") } else if a >= 1e3 { (1e3, "k") } else if a >= 1.0 { (1.0, "") }+        else if a >= 1e-3 { (1e-3, "m") } else if a >= 1e-6 { (1e-6, "u") } else if a >= 1e-9 { (1e-9, "n") } else if a >= 1e-12 { (1e-12, "p") } else { (1e-15, "f") };+    let x = ((v / d) * 1e4).round() / 1e4;+    format!("{x}{s}")+}++/// The values of a sweep {start, stop, step, scale?}: lin steps by `step`; dec and oct take `step` points per+/// decade or octave. At most `cap` values.+pub fn sweep_values(start: f64, stop: f64, step: f64, scale: &str, cap: usize) -> Result<Vec<f64>, String> {+    let mut out = Vec::new();+    match scale {+        "lin" => {+            if step == 0.0 || (stop - start) * step < 0.0 { return Err("step must be non-zero and run from start toward stop".into()); }+            let n = ((stop - start) / step + 1e-9).floor() as i64;+            if n + 1 > cap as i64 { return Err(format!("{} runs; at most {cap}", n + 1)); }+            for k in 0..=n { out.push(start + k as f64 * step); }+        }+        "dec" | "oct" => {+            if start <= 0.0 || stop < start || step < 1.0 { return Err(format!("{scale} needs 0 < start <= stop and step >= 1 point per {}", if scale == "dec" { "decade" } else { "octave" })); }+            let base: f64 = if scale == "dec" { 10.0 } else { 2.0 };+            let n = ((stop / start).log(base) * step + 1e-9).floor() as i64;+            if n + 1 > cap as i64 { return Err(format!("{} runs; at most {cap}", n + 1)); }+            for k in 0..=n { out.push(start * base.powf(k as f64 / step)); }+        }+        o => return Err(format!("scale is lin, dec or oct, not {o}")),+    }+    Ok(out)+}++/// The element KiCad named for a designator: R4 itself, or with its SPICE letter in front (XU1).+fn element<'a>(netlist: &'a str, reference: &str) -> Option<&'a str> {+    let r = reference.to_ascii_uppercase();+    netlist.lines().map(str::trim).filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+'])).find(|t| {+        let n = t.split_whitespace().next().unwrap_or("").to_ascii_uppercase();+        n == r || (n.len() == r.len() + 1 && n.ends_with(r.as_str()))+    })+}++/// The ngspice command that sets one sweep value: a part's value ("R4") with alter, a model parameter ("Q1.BF")+/// with altermod on the model the part uses. `models` are the .model names the netlist and its includes define.+pub fn sweep_command(netlist: &str, parameter: &str, value: f64, models: &[String]) -> Result<String, String> {+    let (part, param) = match parameter.split_once('.') { Some((p, m)) => (p, Some(m)), None => (parameter, None) };+    let line = element(netlist, part).ok_or_else(|| format!("{part} is not in the netlist (a part without a model, or no such designator)"))?;+    let name = line.split_whitespace().next().unwrap_or(part);+    match param {+        None => Ok(format!("alter {name} = {value:e}")),+        Some(p) => {+            let model = line.split_whitespace().skip(1).find(|t| models.iter().any(|m| m.eq_ignore_ascii_case(t)))+                .ok_or_else(|| format!("{part}'s model is not one the netlist defines (.model), so {p} cannot be stepped"))?;+            Ok(format!("altermod {model} {p} = {value:e}"))+        }+    }+}++/// The .model names in netlist text.+pub fn model_names(text: &str) -> Vec<String> {+    text.lines().filter_map(|l| { let t: Vec<&str> = l.split_whitespace().collect(); (t.len() >= 2 && t[0].eq_ignore_ascii_case(".model")).then(|| t[1].to_string()) }).collect()+}++/// The netlist with `.temp t` before its .end.+pub fn with_temp(lines: &[String], t: f64) -> Vec<String> {+    let mut out: Vec<String> = lines.iter().filter(|l| !l.trim().to_ascii_lowercase().starts_with(".temp")).cloned().collect();+    let at = out.iter().rposition(|l| l.trim().eq_ignore_ascii_case(".end")).unwrap_or(out.len());+    out.insert(at, format!(".temp {t}"));+    out+}++/// KiCad writes a part it has no model for as a stub with no nodes (`J1 __J1`), which ngspice cannot parse: the+/// netlist without those lines, and the stubs' element names.+pub fn strip_unmodelled(netlist: &str) -> (String, Vec<String>) {+    let mut dropped = Vec::new();+    let kept: Vec<&str> = netlist.lines().filter(|l| {+        let t: Vec<&str> = l.split_whitespace().collect();+        let stub = t.len() == 2 && !t[0].starts_with(['.', '*', '+']) && t[1].starts_with("__");+        if stub { dropped.push(t[0].to_string()); }+        !stub+    }).collect();+    (kept.join("\n") + "\n", dropped)+}++/// The parts on the sheet that did not make it into KiCad's netlist (`refs` are the sheet's designators): KiCad+/// names each element by its designator, with the SPICE letter put in front when the designator does not start+/// with it (U1 as a subcircuit is XU1), so a designator no element ends with was left out for want of a model.+pub fn not_simulated(netlist: &str, refs: &[String]) -> Vec<String> {+    let names: Vec<String> = netlist.lines().map(str::trim_start)+        .filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+']))+        .filter_map(|t| t.split_whitespace().next()).map(str::to_ascii_uppercase).collect();+    refs.iter().filter(|r| {+        let r = r.to_ascii_uppercase();+        !names.iter().any(|n| *n == r || (n.len() == r.len() + 1 && n.ends_with(r.as_str())))+    }).cloned().collect()+}+ /// The circuit to run: the netlist without its `.end`, the schematic's own analyses commented out when /// `analyses` are given (theirs win), then those analyses and `.end`. pub fn prepare(netlist: &str, analyses: &[String]) -> Result<Vec<String>, String> {⋯ 341 unchanged lines ⋯         assert_eq!(a["probes"][0]["phaseDeg"], json!([0.0, -90.0]));         assert!(summarize(&run, &["v(/x)".into()], 3).is_err());     }++    #[test]+    fn kicads_stubs_for_parts_without_a_model_are_dropped() {+        // exported by kicad-cli 10.0.7 for an RC with a connector on its output+        let net = ".title KiCad schematic\nV1 /IN GND DC 0 SIN( 0 1 1k 0 0 0 ) AC 1\nJ1 __J1\nC1 /OUT GND 100n\nR1 /IN /OUT 1k\n.end\n";+        let (kept, dropped) = strip_unmodelled(net);+        assert_eq!(dropped, vec!["J1".to_string()]);+        assert!(!kept.contains("__J1") && kept.contains("R1 /IN /OUT 1k") && kept.ends_with(".end\n"));+        let refs: Vec<String> = ["V1", "J1", "C1", "R1"].iter().map(|s| s.to_string()).collect();+        assert_eq!(not_simulated(&kept, &refs), vec!["J1".to_string()]);+    }++    #[test]+    fn parts_kicad_left_out_of_the_netlist_are_named() {+        let net = ".title RC\n* U2 has no model\nR1 IN OUT 1k\nC1 OUT 0 100n\nXU1 OUT 0 VCC opamp\nVV1 IN 0 AC 1\n.end\n";+        let refs: Vec<String> = ["R1", "C1", "U1", "V1", "U2", "J1"].iter().map(|s| s.to_string()).collect();+        assert_eq!(not_simulated(net, &refs), vec!["U2".to_string(), "J1".to_string()]);+        assert_eq!(not_simulated(net, &refs[..4]), Vec::<String>::new());+    }++    #[test]+    fn sweeps_step_values_the_way_spice_does() {+        assert_eq!(sweep_values(300.0, 900.0, 300.0, "lin", 50).unwrap(), vec![300.0, 600.0, 900.0]);+        let d = sweep_values(100e-12, 100e-6, 1.0, "dec", 50).unwrap();+        assert_eq!(d.len(), 7);+        assert!((d[6] - 100e-6).abs() < 1e-15);+        assert_eq!(sweep_values(1.0, 8.0, 1.0, "oct", 50).unwrap().len(), 4);+        assert!(sweep_values(0.0, 100.0, 1.0, "lin", 50).unwrap_err().contains("at most 50"));+        assert!(sweep_values(1.0, 2.0, 1.0, "log", 50).is_err());+        assert_eq!((spice_num(&json!("100p")), spice_num(&json!("1meg")), spice_num(&json!("4.7k")), spice_num(&json!(2.5))), (Some(100e-12), Some(1e6), Some(4700.0), Some(2.5)));+        assert_eq!((spice_label(300.0), spice_label(4700.0), spice_label(1e-10), spice_label(-10.0)), ("300".to_string(), "4.7k".to_string(), "100p".to_string(), "-10".to_string()));+    }++    #[test]+    fn a_sweep_value_is_an_alter_on_the_element_kicad_named() {+        let net = ".title amp\n.model QNPN NPN(BF=100)\nR4 /A /B 1k\nQ1 /C /B /E QNPN\nXU1 /A /B /C opamp\n.end\n";+        let m = model_names(net);+        assert_eq!(sweep_command(net, "R4", 300.0, &m).unwrap(), "alter R4 = 3e2");+        assert_eq!(sweep_command(net, "Q1.BF", 50.0, &m).unwrap(), "altermod QNPN BF = 5e1");+        assert!(sweep_command(net, "R9", 1.0, &m).unwrap_err().contains("R9"));+        assert!(sweep_command(net, "U1.GAIN", 1.0, &m).is_err());+        let t = with_temp(&["R1 a 0 1k".to_string(), ".temp 27".to_string(), ".end".to_string()], 85.0);+        assert_eq!(t, vec!["R1 a 0 1k".to_string(), ".temp 85".to_string(), ".end".to_string()]);+    } }+--- a/rust/crates/kicad-platform/src/ngspice.rs+++ b/rust/crates/kicad-platform/src/ngspice.rs@@ -1,161 +1,176 @@⋯ 9 unchanged lines ⋯ /// `{plot, vectors: [{name, type, real: [..]} | {name, type, re: [..], im: [..]}], log: [..]}`. /// `codemodels` are the XSPICE code model libraries (`<kicad>/lib/ngspice/*.cm`) loaded first, as KiCad's own /// simulator does: KiCad ships no spinit to load them.-pub fn run(dll: &std::path::Path, lines: &[String], cwd: &std::path::Path, codemodels: &[std::path::PathBuf]) -> Result<Value, String> {-    imp::run(dll, lines, cwd, codemodels)+/// `commands` run after the circuit is loaded and before `run` (a sweep's `alter R4 = 300`); a command ngspice+/// reports an error for fails the run.+pub fn run(dll: &std::path::Path, lines: &[String], cwd: &std::path::Path, codemodels: &[std::path::PathBuf], commands: &[String]) -> Result<Value, String> {+    imp::run(dll, lines, cwd, codemodels, commands) }  #[cfg(windows)]⋯ 57 unchanged lines ⋯         Ok(unsafe { std::mem::transmute_copy(&p) })     } -    pub fn run(dll: &std::path::Path, lines: &[String], cwd: &std::path::Path, codemodels: &[std::path::PathBuf]) -> Result<Value, String> {+    pub fn run(dll: &std::path::Path, lines: &[String], cwd: &std::path::Path, codemodels: &[std::path::PathBuf], commands: &[String]) -> Result<Value, String> {         std::env::set_current_dir(cwd).map_err(|e| format!("cannot work in {}: {e}", cwd.display()))?;         let wide: Vec<u16> = dll.as_os_str().to_string_lossy().encode_utf16().chain(std::iter::once(0)).collect();         // the altered search path finds the library's own dependencies beside it, in KiCad's bin folder⋯ 16 unchanged lines ⋯         if unsafe { circ(ptrs.as_mut_ptr()) } != 0 {             return Err(format!("ngspice did not take the circuit: {}", errors()));         }+        for c in commands {+            let seen = LOG.lock().map(|l| l.len()).unwrap_or(0);+            let cs = CString::new(c.replace('\0', "")).unwrap();+            let rc = unsafe { command(cs.as_ptr()) };+            let new: Vec<String> = LOG.lock().map(|l| l.iter().skip(seen).cloned().collect()).unwrap_or_default();+            if let Some(e) = new.iter().find(|l| { let t = l.to_lowercase(); t.contains("error") || t.contains("no such") }) {+                return Err(format!("ngspice refused \"{c}\": {e}"));+            }+            if rc != 0 {+                return Err(format!("ngspice refused \"{c}\": {}", errors()));+            }+        }         let run = CString::new("run").unwrap();         if unsafe { command(run.as_ptr()) } != 0 {             return Err(format!("the analysis did not run: {}", errors()));⋯ 51 unchanged lines ⋯ #[cfg(not(windows))] mod imp {     use super::*;-    pub fn run(_dll: &std::path::Path, _lines: &[String], _cwd: &std::path::Path, _codemodels: &[std::path::PathBuf]) -> Result<Value, String> {+    pub fn run(_dll: &std::path::Path, _lines: &[String], _cwd: &std::path::Path, _codemodels: &[std::path::PathBuf], _commands: &[String]) -> Result<Value, String> {         Err("simulation runs through KiCad's ngspice.dll, on Windows for now".into())     } }+--- a/rust/vendor/adom-eda-checks/src/panel.rs+++ b/rust/vendor/adom-eda-checks/src/panel.rs@@ -0,0 +1,378 @@+//! Manufacturing panels: a step-and-repeat of a board with rails, tooling holes and fiducials, and how the boards come+//! apart: V-score lines or mouse-bite tabs drawn the way JLCPCB reads them, with JLCPCB's rules checked+//! (jlcpcb.com/blog/v-cut-panelization-standards, /blog/mouse-bite-panelization-guide, read 2026-10-23). From the Altium+//! bridge's panel.rs: the engine answers the plan in panel mm from the panel's lower-left corner, and each bridge builds+//! the panel in its own way (Altium an embedded board array, Fusion an EAGLE board with the copies placed).+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// A mouse-bite tab: five 0.6 mm holes 0.4 mm apart edge to edge (JLCPCB: 5 to 8 a set, 0.35 to 0.4 mm).+const BITE_HOLE: f64 = 0.6;+const BITE_PITCH: f64 = 1.0;+const BITES: i64 = 5;+const TAB: f64 = 5.0;++/// The panel in mm from its lower-left corner: its size, each board's outline corner, tooling holes and fiducials.+/// `edge` is the routed space between the rails and the boards (tabs); 0 for V-score.+pub fn panel_layout(w: f64, h: f64, rows: i64, cols: i64, gap: f64, rail: f64, edge: f64) -> Value {+    let m = rail + edge;+    let pw = 2.0 * m + cols as f64 * w + (cols - 1) as f64 * gap;+    let ph = 2.0 * m + rows as f64 * h + (rows - 1) as f64 * gap;+    let mut boards = Vec::new();+    for r in 0..rows { for c in 0..cols { boards.push(json!([r3(m + c as f64 * (w + gap)), r3(m + r as f64 * (h + gap))])); } }+    // rails 5 mm or wider carry 3 tooling holes (the fourth corner left bare so the panel only loads one way) and+    // 3 fiducials, each 5 mm in from its hole+    let (mut holes, mut fids) = (Vec::new(), Vec::new());+    if rail >= 5.0 {+        let k = rail / 2.0;+        for (x, y) in [(k, k), (pw - k, k), (k, ph - k)] { holes.push(json!([r3(x), r3(y)])); }+        for (x, y) in [(k + 5.0, k), (pw - k - 5.0, k), (k + 5.0, ph - k)] { fids.push(json!([r3(x), r3(y)])); }+    }+    json!({"size": [r3(pw), r3(ph)], "boards": boards, "toolingHoles": holes, "fiducials": fids})+}++/// V-score: the x of every vertical cut and the y of every horizontal one (boards edge to edge, gap 0). With rails the+/// panel's own edges are milled, not cut, but the rails' inner edges are cuts.+pub fn vcut_lines(w: f64, h: f64, rows: i64, cols: i64, rail: f64) -> (Vec<f64>, Vec<f64>) {+    let keep = |i: i64, n: i64| rail > 0.0 || (i != 0 && i != n);+    ((0..=cols).filter(|c| keep(*c, cols)).map(|c| r3(rail + c as f64 * w)).collect(),+     (0..=rows).filter(|r| keep(*r, rows)).map(|r| r3(rail + r as f64 * h)).collect())+}++/// Each cut as a line across the whole panel (JLCPCB: a V-cut runs edge to edge, no jumps), with "V-CUT" written at+/// both ends: in the rails (their inner half, clear of the tooling holes and fiducials at rail/2), or just outside the+/// panel without them. A label near the far edge goes on the other side of its line. Texts are (x, y, rotation).+pub fn vcut_marks(xs: &[f64], ys: &[f64], pw: f64, ph: f64, rail: f64) -> (Vec<[f64; 4]>, Vec<(f64, f64, i64)>) {+    const LEN: f64 = 5.0; // "V-CUT" at 1 mm high+    let (mut lines, mut texts) = (Vec::new(), Vec::new());+    let inside = rail >= 3.0;+    let beside = |p: f64, span: f64| if p + 0.4 + LEN > span - 0.5 { p - 0.4 - LEN } else { p + 0.4 };+    for &x in xs {+        lines.push([x, 0.0, x, ph]);+        let (lo, hi) = if inside { (rail - 1.3, ph - rail + 0.3) } else { (-2.0, ph + 1.0) };+        texts.push((r3(beside(x, pw)), r3(lo), 0));+        texts.push((r3(beside(x, pw)), r3(hi), 0));+    }+    for &y in ys {+        lines.push([0.0, y, pw, y]);+        // turned 90 degrees a text grows up from its anchor and its letters lie to the anchor's left+        let (lo, hi) = if inside { (rail - 0.3, pw - rail + 1.3) } else { (-1.0, pw + 2.0) };+        texts.push((r3(lo), r3(beside(y, ph)), 90));+        texts.push((r3(hi), r3(beside(y, ph)), 90));+    }+    (lines, texts)+}++fn copper(l: &Value) -> bool { l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false) }++/// The nearest copper to the outline's bounding box (mm, with where), and copper whose centre lies outside it (the+/// panel would repeat that into the rails and the neighbours).+pub fn edge_clearance(state: &Value, bb: (f64, f64, f64, f64)) -> (Option<(f64, Value)>, Vec<Value>) {+    let (x0, y0, x1, y1) = bb;+    let mut pts: Vec<(f64, f64, f64, Value)> = Vec::new();+    let arr = |k: &str| state[k].as_array().cloned().unwrap_or_default();+    for t in arr("tracks").iter().filter(|t| copper(&t["layer"])) {+        for p in [&t["start"], &t["end"]] { pts.push((f(&p["x"]), f(&p["y"]), f(&t["width"]) / 2.0, json!({"track": t["net"], "layer": t["layer"]}))); }+    }+    for a in arr("arcs").iter().filter(|a| copper(&a["layer"])) {+        let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+        let (s, mut e) = (f(&a["startAngle"]), f(&a["endAngle"]));+        if e <= s { e += 360.0; }+        for k in 0..=8 {+            let t = (s + (e - s) * k as f64 / 8.0).to_radians();+            pts.push((cx + r * t.cos(), cy + r * t.sin(), f(&a["width"]) / 2.0, json!({"arc": a["net"], "layer": a["layer"]})));+        }+    }+    for v in arr("vias") { pts.push((f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]) / 2.0, json!({"via": v["net"]}))); }+    for p in arr("pads") {+        let (rf, nm) = (p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+        pts.push((f(&p["x"]), f(&p["y"]), f(&p["sizeX"]).max(f(&p["sizeY"])) / 2.0, json!({"pad": if rf.is_empty() { nm.to_string() } else { format!("{rf}.{nm}") }})));+    }+    for g in arr("polygons").iter().filter(|g| copper(&g["layer"])) {+        for p in g["points"].as_array().cloned().unwrap_or_default() { pts.push((f(&p[0]), f(&p[1]), 0.0, json!({"pour": g["net"], "layer": g["layer"]}))); }+    }+    let mut near: Option<(f64, Value)> = None;+    let mut outside = Vec::new();+    for (x, y, half, what) in pts {+        let inside = x >= x0 && x <= x1 && y >= y0 && y <= y1;+        if !inside {+            if outside.len() < 10 { let mut o = what.clone(); o["at"] = json!([r3(x), r3(y)]); outside.push(o); }+            continue;+        }+        let d = (x - x0).min(x1 - x).min(y - y0).min(y1 - y) - half;+        if near.as_ref().map(|n| d < n.0).unwrap_or(true) { let mut o = what; o["at"] = json!([r3(x), r3(y)]); near = Some((r3(d), o)); }+    }+    (near, outside)+}++/// Does the board draw a line of its outline on a mechanical layer? Every copy in the array brings it along closed,+/// and a router follows it all the way round.+pub fn mech_outline(state: &Value, bb: (f64, f64, f64, f64)) -> bool {+    let (x0, y0, x1, y1) = bb;+    let on = |x: f64, y: f64| (x - x0).abs() < 0.05 || (x - x1).abs() < 0.05 || (y - y0).abs() < 0.05 || (y - y1).abs() < 0.05;+    let mech = |l: &Value| l.as_str().and_then(|s| s.strip_prefix('L')).and_then(|n| n.parse::<i64>().ok()).map(|n| (57..=72).contains(&n)).unwrap_or(false);+    state["tracks"].as_array().map(|ts| ts.iter().any(|t| mech(&t["layer"]) && on(f(&t["start"]["x"]), f(&t["start"]["y"])) && on(f(&t["end"]["x"]), f(&t["end"]["y"])))).unwrap_or(false)+}++fn rule(name: &str, ok: Option<bool>, detail: String) -> Value { json!({"rule": name, "ok": ok, "detail": detail}) }++/// JLCPCB's V-cut rules against this panel. `ok: null` is a note, not a pass or a fail.+#[allow(clippy::too_many_arguments)]+pub fn jlc_vscore(pw: f64, ph: f64, thick: Option<f64>, clear: Option<f64>, cuts: (usize, usize), rail: f64, w: f64, h: f64, rounded: bool) -> Vec<Value> {+    let mut out = Vec::new();+    let size_ok = pw >= 70.0 && ph >= 70.0 && pw <= 475.0 && ph <= 475.0;+    out.push(rule("panel 70 x 70 mm to 475 x 475 mm", Some(size_ok), if size_ok { format!("{} x {} mm", r3(pw), r3(ph)) } else {+        format!("{} x {} mm: add rows or columns, or widen the rails (railMm), to reach 70 mm each way", r3(pw), r3(ph)) }));+    out.push(match thick {+        Some(t) => rule("board 0.6 mm thick or more", Some(t >= 0.6), format!("{} mm (V-cut is not offered on 0.4 mm boards)", r3(t))),+        None => rule("board 0.6 mm thick or more", None, "the stack-up's thickness could not be read".into()),+    });+    out.push(match clear {+        Some(c) => rule("copper 0.4 mm or more from every cut (1.0 mm recommended, 2.0 mm by tall parts)", Some(c >= 0.4),+                        format!("nearest copper {c} mm from the board edge{}", if (0.4..1.0).contains(&c) { ": passes, under the 1.0 mm JLCPCB recommends" } else { "" })),+        None => rule("copper 0.4 mm or more from every cut", None, "no copper on the board".into()),+    });+    out.push(rule("at most 25 cuts each way", Some(cuts.0 <= 25 && cuts.1 <= 25), format!("{} vertical, {} horizontal", cuts.0, cuts.1)));+    let span = if rail > 0.0 { w.min(h).min(rail) } else { w.min(h) };+    out.push(rule("parallel cuts 2 mm apart or more", Some(span >= 2.0), format!("closest pair {} mm apart", r3(span))));+    let need = if thick.map(|t| t <= 0.8).unwrap_or(false) { 5.0 } else { 3.0 };+    out.push(if rail > 0.0 { rule("rails 3 mm wide or more (5 mm on 0.8 mm boards and thinner)", Some(rail >= need), format!("{rail} mm, {need} mm needed")) }+             else { rule("rails", None, "none: fine for bare boards, assembly needs rails (JLCPCB adds tooling holes and fiducials on them)".into()) });+    out.push(rule("straight board edges on the cuts (no arcs)", Some(!rounded), if rounded { "the outline has arcs (rounded corners): a V-cut is straight, so the corners come out square or need milling. Square the corners, or use tabs".into() } else { "rectangular".into() }));+    if w.min(h) < 15.0 { out.push(rule("pieces under 15 mm on a side cost extra", None, format!("{} x {} mm boards", r3(w), r3(h)))); }+    out.push(rule("V-cut is not offered with Economic Assembly", None, "order Standard Assembly for an assembled V-cut panel".into()));+    out+}++/// JLCPCB's mouse-bite rules against this panel.+pub fn jlc_tabs(gap: f64, pw: f64, ph: f64, clear: Option<f64>) -> Vec<Value> {+    let mut out = vec![+        rule("gap between boards 1.2 mm or more (1.6 or 2.0 typical)", Some(gap >= 1.2), format!("{gap} mm")),+        rule("0.6 mm holes, 5 to a set, 0.35 to 0.4 mm apart", Some(true), "five 0.6 mm holes at 1.0 mm pitch (0.4 mm between them), centred a third of a hole into the board".into()),+        rule("at least 2 sets an edge, one every 50 to 60 mm", Some(true), "2 tabs on edges of 15 mm and longer, one more each 55 mm; 1 on shorter edges".into()),+        rule("no mouse bites on the rail side", Some(true), "holes on the board side only".into()),+        rule("longest panel side under 250 mm (recommended)", Some(pw.max(ph) < 250.0), format!("{} mm", r3(pw.max(ph)))),+    ];+    if let Some(c) = clear { out.push(rule("copper clear of the bitten edge", Some(c >= 0.5), format!("nearest copper {c} mm from the board edge (mouse bites leave about 0.5 mm of rough edge)"))); }+    out+}++/// Where the tabs go along an edge of length `l`: their centres from the edge's start.+fn tab_centres(l: f64) -> Vec<f64> {+    if l < TAB + 1.0 { return vec![]; }+    let n = if l < 15.0 { 1 } else { ((l / 55.0).ceil() as usize).max(2) };+    (0..n).map(|k| l * (k as f64 + 0.5) / n as f64).collect()+}++/// The span [a, b] with the `cuts` spans taken out.+fn broken(a: f64, b: f64, cuts: &[(f64, f64)]) -> Vec<(f64, f64)> {+    let mut out = Vec::new();+    let mut s = a;+    let mut cs = cuts.to_vec();+    cs.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());+    for (c0, c1) in cs { if c0 > s { out.push((s, c0.min(b))); } s = s.max(c1); }+    if s < b { out.push((s, b)); }+    out+}++/// Mouse-bite tabs: the rout path on the outline layer (each board's edges broken at its tabs, the tab sides across+/// the gap, the rails' inner edge broken where tabs meet it) and the holes, all in panel mm. The rout path is drawn by+/// the bridge because the copies bring no outline of their own (the verb refuses a board that draws one).+pub fn tab_plan(w: f64, h: f64, rows: i64, cols: i64, gap: f64, rail: f64) -> (Vec<[f64; 4]>, Vec<[f64; 2]>, usize) {+    let lay = panel_layout(w, h, rows, cols, gap, rail, if rail > 0.0 { gap } else { 0.0 });+    let (pw, ph) = (f(&lay["size"][0]), f(&lay["size"][1]));+    let (mut lines, mut holes, mut tabs) = (Vec::new(), Vec::new(), 0usize);+    // the rails' inner edge, broken where outer tabs land: bottom, top, left, right+    let mut rail_cuts: [Vec<(f64, f64)>; 4] = Default::default();+    let off = BITE_HOLE / 2.0 - BITE_HOLE / 3.0;+    for r in 0..rows {+        for c in 0..cols {+            let b = &lay["boards"][(r * cols + c) as usize];+            let (bx, by) = (f(&b[0]), f(&b[1]));+            // (side, outer, along-x?, fixed coordinate, start, length, outward sign)+            for (side, outer, horiz, fixed, start, len, out) in [+                (0usize, r == 0, true, by, bx, w, -1.0), (1, r == rows - 1, true, by + h, bx, w, 1.0),+                (2, c == 0, false, bx, by, h, -1.0), (3, c == cols - 1, false, bx + w, by, h, 1.0)] {+                if outer && rail <= 0.0 { continue; } // the panel's own edge: the outline covers it+                let centres = tab_centres(len);+                let spans: Vec<(f64, f64)> = centres.iter().map(|t| (start + t - TAB / 2.0, start + t + TAB / 2.0)).collect();+                for (a, z) in broken(start, start + len, &spans) {+                    lines.push(if horiz { [a, fixed, z, fixed] } else { [fixed, a, fixed, z] });+                }+                let far = fixed + out * gap;+                for &(a, z) in &spans {+                    tabs += 1;+                    // the tab's two sides across the gap: drawn from the right and top edges (the neighbour's left and+                    // bottom edges are the same lines) and from every edge that faces a rail+                    if side == 1 || side == 3 || outer {+                        for p in [a, z] { lines.push(if horiz { [p, fixed, p, far] } else { [fixed, p, far, p] }); }+                    }+                    if outer { rail_cuts[side].push((a, z)); }+                    let mid = (a + z) / 2.0;+                    for k in 0..BITES {+                        let along = mid + (k - BITES / 2) as f64 * BITE_PITCH;+                        let across = fixed + out * off;+                        holes.push(if horiz { [r3(along), r3(across)] } else { [r3(across), r3(along)] });+                    }+                }+            }+        }+    }+    if rail > 0.0 {+        let (ix0, iy0, ix1, iy1) = (rail, rail, pw - rail, ph - rail);+        for (side, fixed, a, z, horiz) in [(0usize, iy0, ix0, ix1, true), (1, iy1, ix0, ix1, true), (2, ix0, iy0, iy1, false), (3, ix1, iy0, iy1, false)] {+            for (p, q) in broken(a, z, &rail_cuts[side]) { lines.push(if horiz { [p, fixed, q, fixed] } else { [fixed, p, fixed, q] }); }+        }+    }+    let lines = lines.into_iter().map(|l| [r3(l[0]), r3(l[1]), r3(l[2]), r3(l[3])]).collect();+    (lines, holes, tabs)+}++/// panelize: input {board, stack?, args: {rows, cols, separation?: none|vscore|tabs, gapMm?, railMm?, toolingHoles?}}. The+/// plan in panel mm from its lower-left corner: its size, where each copy's outline corner goes (and the shift that takes+/// the board's own coordinates there), the outline-layer lines (the V-CUT lines and their labels, or the tab rout path),+/// the mouse-bite and tooling holes, the fiducials, and JLCPCB's rules against it.+pub fn panelize(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let bad = |e: &str| crate::input::refuse("bad_args", e);+    let int = |k: &str, d: i64| args[k].as_i64().unwrap_or(d);+    let (rows, cols) = (int("rows", 1), int("cols", 2));+    if rows < 1 || cols < 1 || rows * cols > 400 { return bad("rows and cols are 1 or more, at most 400 boards"); }+    let sep = args["separation"].as_str().unwrap_or("none").to_ascii_lowercase();+    if !["none", "vscore", "tabs"].contains(&sep.as_str()) { return bad("separation is none, vscore (V-cut lines, boards edge to edge) or tabs (mouse bites)"); }+    let num = |k: &str| args[k].as_f64();+    let gap = match (sep.as_str(), num("gapMm")) {+        ("vscore", Some(g)) if g != 0.0 => return bad("V-score boards sit edge to edge: leave gapMm out or pass 0"),+        ("vscore", _) => 0.0,+        (_, Some(g)) => g,+        _ => 2.0,+    };+    let rail = num("railMm").unwrap_or(5.0);+    if gap < 0.0 || rail < 0.0 { return bad("gapMm and railMm are 0 or more"); }+    if sep == "tabs" && gap < 1.2 { return bad("mouse bites need a gap of 1.2 mm or more (JLCPCB: 1.6 or 2.0 typical)"); }+    let Some(bb) = crate::geom::outline_bbox(&st) else { return crate::input::refuse("no_outline", "the board has no outline to step and repeat") };+    let (x0, y0, x1, y1) = bb;+    let (w, h) = (x1 - x0, y1 - y0);+    if sep == "tabs" && mech_outline(&st, bb) {+        return json!({"success": false, "errorCode": "outline_on_mechanical_layer",+            "error": "the board draws its outline on a mechanical layer, and every copy in the panel brings that outline along closed: a router following it cuts each board free, so mouse-bite tabs cannot hold",+            "_hint": "Use separation: \"vscore\", or panelize without separation and order the fab's own panel with mouse bites, or move the outline to a layer the Gerbers leave out."});+    }+    let (clear, outside) = edge_clearance(&st, bb);+    let rounded = st["outlineEdges"].as_array().map(|es| es.iter().any(|e| e["kind"] == "arc")).unwrap_or(false);+    let thick = crate::input::stack(input).and_then(|s| s["totalUm"].as_f64()).map(|u| u / 1000.0);+    let edge = if sep == "tabs" && rail > 0.0 { gap } else { 0.0 };+    let lay = panel_layout(w, h, rows, cols, gap, rail, edge);+    let (pw, ph) = (f(&lay["size"][0]), f(&lay["size"][1]));+    let mut sep_out = json!({"kind": sep});+    let (mut lines, mut texts, mut holes, mut jlc): (Vec<[f64; 4]>, Vec<Value>, Vec<Value>, Vec<Value>) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+    match sep.as_str() {+        "vscore" => {+            let (xs, ys) = vcut_lines(w, h, rows, cols, rail);+            let (ls, ts) = vcut_marks(&xs, &ys, pw, ph, rail);+            lines = ls;+            texts = ts.iter().map(|(x, y, rot)| json!({"text": "V-CUT", "x": x, "y": y, "size": 1.0, "rotation": rot})).collect();+            jlc = jlc_vscore(pw, ph, thick, clear.as_ref().map(|c| c.0), (xs.len(), ys.len()), rail, w, h, rounded);+            sep_out["cutsX"] = json!(xs);+            sep_out["cutsY"] = json!(ys);+        }+        "tabs" => {+            let (ls, hs, tabs) = tab_plan(w, h, rows, cols, gap, rail);+            lines = ls;+            holes = hs.iter().map(|p| json!({"x": p[0], "y": p[1], "drill": BITE_HOLE, "plated": false})).collect();+            jlc = jlc_tabs(gap, pw, ph, clear.as_ref().map(|c| c.0));+            sep_out["tabs"] = json!(tabs);+        }+        _ => {}+    }+    let tooling = args["toolingHoles"].as_bool().unwrap_or(true);+    let at = |k: &str| -> Vec<Value> { lay[k].as_array().cloned().unwrap_or_default() };+    let shifts: Vec<Value> = at("boards").iter().map(|b| json!([r3(f(&b[0]) - x0), r3(f(&b[1]) - y0)])).collect();+    let mut warnings = Vec::new();+    if !outside.is_empty() { warnings.push(json!({"copperOutsideOutline": outside, "why": "every copy repeats it into the rails and the neighbouring boards: move it onto the board or delete it"})); }+    let fails = jlc.iter().filter(|r| r["ok"] == json!(false)).count();+    json!({"success": true, "rows": rows, "cols": cols, "boardMm": [r3(w), r3(h)], "panelMm": lay["size"], "gapMm": gap, "railMm": rail,+        "boardsAt": lay["boards"], "copyShift": shifts,+        "outlineLayer": {"lines": lines.iter().map(|l| json!({"x1": l[0], "y1": l[1], "x2": l[2], "y2": l[3], "width": 0.1})).collect::<Vec<_>>(), "texts": texts},+        "holes": holes, "toolingHoles": if tooling { json!(at("toolingHoles").iter().map(|p| json!({"x": p[0], "y": p[1], "drill": 2.0})).collect::<Vec<_>>()) } else { json!([]) },+        "fiducials": if tooling { json!(at("fiducials").iter().map(|p| json!({"x": p[0], "y": p[1]})).collect::<Vec<_>>()) } else { json!([]) },+        "separation": sep_out, "jlcpcb": if jlc.is_empty() { Value::Null } else { json!({"rules": jlc, "fits": fails == 0}) },+        "nearestCopperToEdge": clear.map(|(mm, mut what)| { what["mm"] = json!(mm); what }), "warnings": warnings,+        "_hint": "A plan in panel mm from its lower-left corner. Place each copy of the board shifted by copyShift (board coordinates plus the shift land it at boardsAt), draw the panel's outline (0, 0, panelMm) and outlineLayer's lines and texts on the outline layer, drill holes (non-plated mouse bites) and toolingHoles, and place the fiducials. jlcpcb.rules lists each rule with ok true, false or null (a note)."})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn panel_layout_places_boards_and_rails() {+        let l = panel_layout(40.0, 20.0, 2, 3, 2.0, 5.0, 0.0);+        assert_eq!(l["size"], json!([134.0, 52.0]));+        assert_eq!(l["boards"].as_array().unwrap().len(), 6);+        assert_eq!(l["boards"][4], json!([47.0, 27.0]));+        assert_eq!(l["toolingHoles"].as_array().unwrap().len(), 3);+        assert_eq!(l["fiducials"][1], json!([126.5, 2.5]));+        assert!(panel_layout(40.0, 20.0, 1, 2, 0.0, 3.0, 0.0)["toolingHoles"].as_array().unwrap().is_empty());+        // tabs: the boards stand a gap off the rails+        assert_eq!(panel_layout(40.0, 20.0, 1, 1, 2.0, 5.0, 2.0)["boards"][0], json!([7.0, 7.0]));+    }++    #[test]+    fn vcut_lines_run_across_the_panel_at_every_board_edge() {+        let (xs, ys) = vcut_lines(40.0, 20.0, 2, 3, 5.0);+        assert_eq!(xs, vec![5.0, 45.0, 85.0, 125.0]);+        assert_eq!(ys, vec![5.0, 25.0, 45.0]);+        // no rails: the panel's edges are milled, only the shared edges are cut+        let (xs, ys) = vcut_lines(40.0, 20.0, 2, 3, 0.0);+        assert_eq!((xs, ys), (vec![40.0, 80.0], vec![20.0]));+        let (lines, texts) = vcut_marks(&[5.0], &[5.0], 130.0, 50.0, 5.0);+        assert_eq!(lines, vec![[5.0, 0.0, 5.0, 50.0], [0.0, 5.0, 130.0, 5.0]]);+        assert_eq!(texts.len(), 4);+        // in the rail's inner half, clear of the fiducial at (7.5, 2.5) and its 2 mm opening+        assert_eq!(texts[0], (5.4, 3.7, 0));+        // near the right edge the label goes left of its line+        let (_, t2) = vcut_marks(&[125.0], &[], 130.0, 50.0, 5.0);+        assert_eq!(t2[0].0, 119.6);+    }++    #[test]+    fn jlc_vscore_rules_catch_a_small_thin_rounded_panel() {+        let r = jlc_vscore(130.0, 50.0, Some(0.39), Some(0.3), (4, 3), 5.0, 40.0, 20.0, true);+        let bad: Vec<&str> = r.iter().filter(|x| x["ok"] == json!(false)).map(|x| x["rule"].as_str().unwrap()).collect();+        assert_eq!(bad.len(), 4, "{bad:?}");+        assert!(bad.iter().any(|b| b.starts_with("panel 70")) && bad.iter().any(|b| b.starts_with("board 0.6")) && bad.iter().any(|b| b.starts_with("copper")) && bad.iter().any(|b| b.starts_with("straight")));+        let good = jlc_vscore(130.0, 90.0, Some(1.6), Some(1.2), (4, 5), 5.0, 40.0, 20.0, false);+        assert!(good.iter().all(|x| x["ok"] != json!(false)));+    }++    #[test]+    fn edge_clearance_finds_the_nearest_copper_and_strays() {+        let st = json!({"tracks": [{"layer": "F.Cu", "width": 0.2, "net": "A", "start": {"x": 0.5, "y": 10.0}, "end": {"x": 20.0, "y": 10.0}},+                                   {"layer": "L57", "width": 0.1, "net": "", "start": {"x": 0.0, "y": 0.0}, "end": {"x": 40.0, "y": 0.0}}],+                        "pads": [{"ref": "U3", "name": "1", "x": 60.0, "y": 5.0, "sizeX": 1.0, "sizeY": 1.0}]});+        let (near, out) = edge_clearance(&st, (0.0, 0.0, 40.0, 20.0));+        assert_eq!(near.unwrap().0, 0.4);+        assert_eq!(out.len(), 1);+        assert!(mech_outline(&st, (0.0, 0.0, 40.0, 20.0)));+    }++    #[test]+    fn tab_plan_follows_jlcpcb() {+        let (lines, holes, tabs) = tab_plan(40.0, 20.0, 1, 2, 2.0, 5.0);+        // two boards, 4 edges each with 2 tabs (40 mm edges: 2; 20 mm: 2)+        assert_eq!(tabs, 16);+        assert_eq!(holes.len(), 16 * 5);+        // holes sit a third of a hole into the board: 0.1 mm off the edge into the gap+        assert!(holes.contains(&[17.0, 6.9]));+        // a tab between the boards is bridged: no rout line crosses x = 47..49 at the tab's height+        assert!(!lines.iter().any(|l| l[0] == l[2] && (l[0] - 47.0).abs() < 1e-9 && l[1] <= 12.0 && l[3] >= 12.0));+        assert_eq!(broken(0.0, 10.0, &[(2.0, 4.0), (6.0, 8.0)]), vec![(0.0, 2.0), (4.0, 6.0), (8.0, 10.0)]);+        assert_eq!(tab_centres(40.0), vec![10.0, 30.0]);+        assert_eq!(tab_centres(120.0).len(), 3);+    }+}+--- a/rust/vendor/adom-eda-checks/src/landpattern.rs+++ b/rust/vendor/adom-eda-checks/src/landpattern.rs@@ -0,0 +1,277 @@+//! Footprints from a datasheet's land pattern: the AI gives the numbers the datasheet prints (pitch, pad size, row+//! span, body), the engine lays out a complete footprint: pads numbered the standard way, an exposed pad, the courtyard,+//! silkscreen kept clear of the copper with a pin-1 dot, and a fab outline with a pin-1 chamfer (mm, Y up, origin at the+//! centre). From the Altium bridge's footprint.rs; each bridge writes the layout in its own library format.+use serde_json::{json, Value};++#[derive(Debug, Clone, PartialEq)]+pub struct Pad { pub name: String, pub x: f64, pub y: f64, pub w: f64, pub h: f64, pub shape: String, pub hole: f64 }++#[derive(Debug, Default)]+pub struct Fp { pub pads: Vec<Pad>, pub lines: Vec<(&'static str, f64, f64, f64, f64, f64)>, pub discs: Vec<(&'static str, f64, f64, f64)>, pub courtyard: (f64, f64, f64, f64) }++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn num(a: &Value, k: &str) -> Option<f64> { a.get(k).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) }++/// BGA row letters: A..Z without I, O, Q, S, X, Z, then AA, AB, ...+pub fn bga_row(i: usize) -> String {+    const L: &[u8] = b"ABCDEFGHJKLMNPRTUVWY";+    if i < L.len() { (L[i] as char).to_string() } else { format!("{}{}", L[i / L.len() - 1] as char, L[i % L.len()] as char) }+}++/// The pads of a land pattern. Errors name the missing number in the words the caller used.+pub fn pads(a: &Value) -> Result<Vec<Pad>, String> {+    let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+    let need = |k: &str| num(a, k).ok_or_else(|| format!("{family} needs {k} (mm)"));+    let shape = a.get("padShape").and_then(|s| s.as_str()).unwrap_or(if family == "bga" { "round" } else { "roundrect" }).to_string();+    let mut out = Vec::new();+    let mut push = |name: String, x: f64, y: f64, w: f64, h: f64| out.push(Pad { name, x: r3(x), y: r3(y), w: r3(w), h: r3(h), shape: shape.clone(), hole: 0.0 });+    match family.as_str() {+        "chip" | "two" | "2pad" => {+            let (span, pl, pw) = (need("span")?, need("padLength")?, need("padWidth")?);+            push("1".into(), -span / 2.0, 0.0, pl, pw);+            push("2".into(), span / 2.0, 0.0, pl, pw);+        }+        "dual" | "sot" => {+            let (span, pitch, pl, pw) = (need("span")?, need("pitch")?, need("padLength")?, need("padWidth")?);+            let (left, right) = if family == "sot" {+                (num(a, "leftPins").ok_or("sot needs leftPins and rightPins")? as usize, num(a, "rightPins").ok_or("sot needs leftPins and rightPins")? as usize)+            } else {+                let n = need("pins")? as usize;+                if n % 2 != 0 { return Err(format!("dual needs an even pin count, got {n}")); }+                (n / 2, n / 2)+            };+            // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left)+            for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); }+            for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * pitch, pl, pw); }+        }+        "quad" => {+            let (pitch, pl, pw) = (need("pitch")?, need("padLength")?, need("padWidth")?);+            let span_x = need("span")?;+            let span_y = num(a, "spanY").unwrap_or(span_x);+            let (ny, nx) = match (num(a, "pinsPerSideY"), num(a, "pinsPerSideX")) {+                (Some(y), Some(x)) => (y as usize, x as usize),+                _ => {+                    let n = need("pins")? as usize;+                    if n % 4 != 0 { return Err(format!("quad needs pins divisible by 4, or pinsPerSideX and pinsPerSideY; got {n}")); }+                    (n / 4, n / 4)+                }+            };+            let mut k = 1;+            // left top->bottom, bottom left->right, right bottom->top, top right->left+            for i in 0..ny { push(k.to_string(), -span_x / 2.0, ((ny as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); k += 1; }+            for i in 0..nx { push(k.to_string(), (i as f64 - (nx as f64 - 1.0) / 2.0) * pitch, -span_y / 2.0, pw, pl); k += 1; }+            for i in 0..ny { push(k.to_string(), span_x / 2.0, (i as f64 - (ny as f64 - 1.0) / 2.0) * pitch, pl, pw); k += 1; }+            for i in 0..nx { push(k.to_string(), ((nx as f64 - 1.0) / 2.0 - i as f64) * pitch, span_y / 2.0, pw, pl); k += 1; }+        }+        "bga" => {+            let (pitch, d) = (need("pitch")?, need("ballPad")?);+            let (rows, cols) = (need("rows")? as usize, need("cols")? as usize);+            let missing: Vec<String> = a.get("missing").and_then(|m| m.as_array()).cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(|s| s.to_uppercase())).collect();+            for r in 0..rows {+                for c in 0..cols {+                    let name = format!("{}{}", bga_row(r), c + 1);+                    if missing.contains(&name) { continue; }+                    push(name, (c as f64 - (cols as f64 - 1.0) / 2.0) * pitch, ((rows as f64 - 1.0) / 2.0 - r as f64) * pitch, d, d);+                }+            }+        }+        f => return Err(format!("family '{f}' is not one of chip, dual, sot, quad, bga")),+    }+    // the exposed (thermal) pad+    if let Some(ep) = a.get("exposedPad").filter(|e| e.is_object()) {+        let (w, h) = (num(ep, "w").ok_or("exposedPad needs w and h (mm)")?, num(ep, "h").ok_or("exposedPad needs w and h (mm)")?);+        let name = ep.get("number").map(|v| match v { Value::String(s) => s.clone(), o => o.to_string() }).unwrap_or_else(|| (out.len() + 1).to_string());+        out.push(Pad { name, x: r3(num(ep, "x").unwrap_or(0.0)), y: r3(num(ep, "y").unwrap_or(0.0)), w: r3(w), h: r3(h), shape: "rect".into(), hole: 0.0 });+    }+    Ok(out)+}++/// The parts of [a0, a1] that stay outside every blocked interval, kept when at least `min` long.+fn subtract(a0: f64, a1: f64, blocked: &mut [(f64, f64)], min: f64) -> Vec<(f64, f64)> {+    blocked.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());+    let mut out = Vec::new();+    let mut cur = a0;+    for &(b0, b1) in blocked.iter() {+        if b1 <= cur || b0 >= a1 { continue; }+        if b0 > cur && b0 - cur >= min { out.push((cur, b0.min(a1))); }+        cur = cur.max(b1);+        if cur >= a1 { break; }+    }+    if a1 - cur >= min { out.push((cur, a1)); }+    out+}++/// The whole footprint: pads, then silkscreen (the body outline minus `clear` mm around every pad, and a pin-1 dot),+/// the fab outline with a pin-1 chamfer, and the courtyard (pads and body grown by `margin`).+pub fn layout(a: &Value) -> Result<Fp, String> {+    let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+    let pads = pads(a)?;+    if pads.is_empty() { return Err("no pads".into()); }+    let body = a.get("body").cloned().unwrap_or(json!({}));+    let (bw, bh) = (num(&body, "w"), num(&body, "l").or_else(|| num(&body, "h")));+    let silk_w = num(a, "silkWidth").unwrap_or(0.15);+    let clear = num(a, "silkClearance").unwrap_or(0.2);+    let margin = num(a, "courtyard").unwrap_or(0.25);+    let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+    let (mut x0, mut y0, mut x1, mut y1) = pads.iter().map(ext).fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, e| (a.0.min(e.0), a.1.min(e.1), a.2.max(e.2), a.3.max(e.3)));+    let mut fp = Fp { pads: pads.clone(), ..Default::default() };+    if let (Some(w), Some(h)) = (bw, bh) {+        let (bx0, by0, bx1, by1) = (-w / 2.0, -h / 2.0, w / 2.0, h / 2.0);+        x0 = x0.min(bx0); y0 = y0.min(by0); x1 = x1.max(bx1); y1 = y1.max(by1);+        // silkscreen: each body edge minus the stretches that come within `clear` of copper+        let keep = clear + silk_w / 2.0;+        let mut edge = |horizontal: bool, c: f64, a0: f64, a1: f64| {+            let mut blocked: Vec<(f64, f64)> = pads.iter().filter_map(|p| {+                let (px0, py0, px1, py1) = ext(p);+                if horizontal { (c > py0 - keep && c < py1 + keep).then_some((px0 - keep, px1 + keep)) } else { (c > px0 - keep && c < px1 + keep).then_some((py0 - keep, py1 + keep)) }+            }).collect();+            for (s0, s1) in subtract(a0, a1, &mut blocked, 0.2) {+                if horizontal { fp.lines.push(("overlay", r3(s0), r3(c), r3(s1), r3(c), silk_w)); } else { fp.lines.push(("overlay", r3(c), r3(s0), r3(c), r3(s1), silk_w)); }+            }+        };+        // the outline is drawn just outside the body so it is not hidden under it+        let o = silk_w / 2.0;+        edge(true, by1 + o, bx0 - o, bx1 + o);+        edge(true, by0 - o, bx0 - o, bx1 + o);+        edge(false, bx0 - o, by0 - o, by1 + o);+        edge(false, bx1 + o, by0 - o, by1 + o);+        // fab: the body with its pin-1 corner (top-left) cut+        let ch = (w.min(h) * 0.25).min(1.0);+        let fab = [(bx0 + ch, by1), (bx1, by1), (bx1, by0), (bx0, by0), (bx0, by1 - ch), (bx0 + ch, by1)];+        for k in 0..fab.len() - 1 { fp.lines.push(("fab", r3(fab[k].0), r3(fab[k].1), r3(fab[k + 1].0), r3(fab[k + 1].1), 0.1)); }+    }+    // pin-1 dot: beside pin 1 on the outside, clear of its pad; a two-pad part only when polarized+    let polar = a.get("polarized").and_then(|b| b.as_bool()).unwrap_or(false);+    if family != "chip" || polar {+        let p1 = &pads[0];+        let r = 0.15;+        let (dx, dy) = if family == "bga" { (x0 - clear - r, y1 + clear + r) } else { (p1.x - p1.w / 2.0 - clear - r - silk_w, p1.y) };+        fp.discs.push(("overlay", r3(dx), r3(dy), r));+        x0 = x0.min(dx - r);+        y1 = y1.max(dy + r);+    }+    // courtyard: everything grown by the margin, on a 0.01 mm grid+    let g = |v: f64, up: bool| if up { (v * 100.0).ceil() / 100.0 } else { (v * 100.0).floor() / 100.0 };+    let c = (g(x0 - margin, false), g(y0 - margin, false), g(x1 + margin, true), g(y1 + margin, true));+    fp.courtyard = c;+    for (a0, b0, a1, b1) in [(c.0, c.1, c.2, c.1), (c.2, c.1, c.2, c.3), (c.2, c.3, c.0, c.3), (c.0, c.3, c.0, c.1)] {+        fp.lines.push(("courtyard", a0, b0, a1, b1, 0.05));+    }+    Ok(fp)+}++/// A plain description: "SOIC-8, 1.27 mm pitch, 8 pads".+fn describe(a: &Value, fp: &Fp) -> String {+    let fam = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual");+    let pitch = num(a, "pitch").map(|p| format!(", {p} mm pitch")).unwrap_or_default();+    format!("{} {}{}, {} pads (from the datasheet land pattern)", a.get("name").and_then(|n| n.as_str()).unwrap_or(""), fam, pitch, fp.pads.len())+}++/// footprint_from_land_pattern: input {args: {name, family, ..., body?, exposedPad?, description?}}. The layout: pads,+/// lines and discs by layer (overlay, fab, courtyard), the courtyard box, a description and the pad table. A missing+/// number is refused in the caller's words.+pub fn footprint_from_land_pattern(input: &Value) -> Value {+    let args = &input["args"];+    let Some(name) = args["name"].as_str().filter(|n| !n.is_empty()) else { return crate::input::refuse("bad_args", "name is required") };+    let fp = match layout(args) {+        Ok(f) => f,+        Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e,+            "_hint": "Give the datasheet's RECOMMENDED LAND PATTERN numbers (mm): chip {span, padLength, padWidth}; dual/sot {pins | leftPins+rightPins, pitch, span (pad centre to pad centre across), padLength, padWidth}; quad {pins | pinsPerSideX+pinsPerSideY, pitch, span, spanY?, padLength, padWidth}; bga {rows, cols, pitch, ballPad, missing?}; plus exposedPad {w, h} and body {w, l, height?}."}),+    };+    let desc = args["description"].as_str().map(String::from).unwrap_or_else(|| describe(args, &fp));+    let c = fp.courtyard;+    json!({"success": true, "name": name, "description": desc, "units": "mm", "frame": "Y up, origin at the centre, pin 1 top-left",+        "pads": fp.pads.iter().map(|p| json!({"name": p.name, "x": p.x, "y": p.y, "w": p.w, "h": p.h, "shape": p.shape, "cornerRatio": if p.shape == "roundrect" { 0.25 } else { 0.0 }, "hole": p.hole})).collect::<Vec<_>>(),+        "lines": fp.lines.iter().map(|(l, x1, y1, x2, y2, w)| json!({"layer": l, "x1": x1, "y1": y1, "x2": x2, "y2": y2, "width": w})).collect::<Vec<_>>(),+        "discs": fp.discs.iter().map(|(l, x, y, r)| json!({"layer": l, "x": x, "y": y, "r": r})).collect::<Vec<_>>(),+        "courtyardMm": [c.0, c.1, c.2, c.3], "body": args["body"].clone(),+        "padTable": fp.pads.iter().map(|p| json!([p.name, p.x, p.y, p.w, p.h])).collect::<Vec<_>>(),+        "_hint": "A layout, not a library: write it in the tool's own footprint format (overlay is the silkscreen, fab the assembly outline, courtyard the keep-out), then check padTable against the datasheet. With body.height, generate_body makes the 3D body to attach."})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn soic8_numbers_down_the_left_and_up_the_right() {+        let p = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6})).unwrap();+        assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("1", -2.7, 1.905));+        assert_eq!((p[3].name.as_str(), p[3].y), ("4", -1.905));+        assert_eq!((p[4].name.as_str(), p[4].x, p[4].y), ("5", 2.7, -1.905));+        assert_eq!((p[7].name.as_str(), p[7].y), ("8", 1.905));+        assert_eq!((p[0].w, p[0].h), (1.55, 0.6));+    }++    #[test]+    fn quad_goes_counter_clockwise_and_turns_the_top_and_bottom_pads() {+        let p = pads(&json!({"family": "quad", "pins": 16, "pitch": 0.5, "span": 3.0, "padLength": 0.8, "padWidth": 0.25, "exposedPad": {"w": 1.7, "h": 1.7}})).unwrap();+        assert_eq!(p.len(), 17);+        assert_eq!((p[0].x, p[0].y), (-1.5, 0.75));      // 1: left, top+        assert_eq!((p[4].x, p[4].y, p[4].w), (-0.75, -1.5, 0.25)); // 5: bottom, left end, turned+        assert_eq!((p[8].x, p[8].y), (1.5, -0.75));      // 9: right, bottom+        assert_eq!((p[12].x, p[12].y), (0.75, 1.5));     // 13: top, right end+        assert_eq!((p[16].name.as_str(), p[16].shape.as_str()), ("17", "rect"));+    }++    #[test]+    fn sot23_has_two_pins_left_and_one_right() {+        let p = pads(&json!({"family": "sot", "leftPins": 2, "rightPins": 1, "pitch": 1.9, "span": 2.2, "padLength": 1.0, "padWidth": 0.6})).unwrap();+        assert_eq!(p.iter().map(|q| (q.name.clone(), q.x, q.y)).collect::<Vec<_>>(), vec![("1".into(), -1.1, 0.95), ("2".into(), -1.1, -0.95), ("3".into(), 1.1, 0.0)]);+    }++    #[test]+    fn bga_rows_skip_confusable_letters() {+        assert_eq!((bga_row(0), bga_row(7), bga_row(8), bga_row(19), bga_row(20)), ("A".into(), "H".into(), "J".into(), "Y".into(), "AA".into()));+        let p = pads(&json!({"family": "bga", "rows": 3, "cols": 3, "pitch": 0.8, "ballPad": 0.4, "missing": ["b2"]})).unwrap();+        assert_eq!(p.len(), 8);+        assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("A1", -0.8, 0.8));+    }++    #[test]+    fn silkscreen_keeps_clear_of_copper_and_the_courtyard_holds_everything() {+        let a = json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9}});+        let fp = layout(&a).unwrap();+        let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+        for &(l, x0, y0, x1, y1, w) in fp.lines.iter().filter(|l| l.0 == "overlay") {+            let _ = l;+            for p in &fp.pads {+                let (px0, py0, px1, py1) = ext(p);+                // distance from the segment (axis-aligned) to the pad box, minus half the stroke+                let dx = (px0 - x0.max(x1)).max(x0.min(x1) - px1).max(0.0);+                let dy = (py0 - y0.max(y1)).max(y0.min(y1) - py1).max(0.0);+                assert!((dx * dx + dy * dy).sqrt() - w / 2.0 >= 0.199, "silk {x0},{y0}-{x1},{y1} too close to pad {}", p.name);+            }+        }+        // a SOIC's body sides sit under its pads: only the top and bottom edges survive+        assert_eq!(fp.lines.iter().filter(|l| l.0 == "overlay").count(), 2);+        let c = fp.courtyard;+        assert!(fp.pads.iter().all(|p| { let (a0, b0, a1, b1) = ext(p); a0 >= c.0 && b0 >= c.1 && a1 <= c.2 && b1 <= c.3 }));+        assert_eq!(fp.discs.len(), 1);+        assert!(fp.discs[0].1 < -2.7 - 0.775);++    }++    #[test]+    fn a_missing_number_is_named() {+        let e = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6})).unwrap_err();+        assert!(e.contains("padLength"));+    }++    #[test]+    fn the_verb_answers_the_layout_in_the_standard_shape() {+        let r = footprint_from_land_pattern(&json!({"args": {"name": "SOIC-8", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9, "height": 1.75}}}));+        assert_eq!(r["success"], json!(true), "{r}");+        assert_eq!(r["pads"][0], json!({"name": "1", "x": -2.7, "y": 1.905, "w": 1.55, "h": 0.6, "shape": "roundrect", "cornerRatio": 0.25, "hole": 0.0}));+        assert!(r["lines"].as_array().unwrap().iter().any(|l| l["layer"] == "courtyard"));+        assert_eq!(r["discs"][0]["layer"], "overlay");+        assert_eq!(r["padTable"].as_array().unwrap().len(), 8);+        assert!(r["description"].as_str().unwrap().starts_with("SOIC-8 dual, 1.27 mm pitch, 8 pads"));+        let e = footprint_from_land_pattern(&json!({"args": {"name": "X", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6}}));+        assert_eq!(e["errorCode"], "bad_args");+        assert!(e["error"].as_str().unwrap().contains("padLength"));+        assert_eq!(footprint_from_land_pattern(&json!({"args": {"family": "chip"}}))["errorCode"], "bad_args");+    }+}+--- a/rust/vendor/adom-eda-checks/src/teardrops.rs+++ b/rust/vendor/adom-eda-checks/src/teardrops.rs@@ -0,0 +1,107 @@+//! Teardrops: where a track meets a via or a pad of its own net, a tapered fill from the pad out along the track, so a+//! drill wander or an etch nick does not cut the joint. Each is a polygon on the track's layer, planned clear of every+//! other net's copper; a joint with no room is skipped and named. The shape is the straight teardrop KiCad draws: as+//! wide as the pad at the pad's centre (`widthRatio`), narrowing to the track `lengthRatio` x the pad's size beyond its+//! edge.+use crate::route::{pad_shape, Shape};+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type P = (f64, f64);++/// The teardrop polygon from a joint at `c` (pad or via of size `s` across) along a track toward `toward`, `w` wide.+/// None when the track is too short or already as wide as the pad. Pure.+pub fn shape(c: P, toward: P, s: f64, w: f64, length_ratio: f64, width_ratio: f64) -> Option<Vec<P>> {+    let (dx, dy) = (toward.0 - c.0, toward.1 - c.1);+    let len = (dx * dx + dy * dy).sqrt();+    let half = (s / 2.0 * width_ratio).min(s / 2.0);+    if len < 1e-6 || w >= 2.0 * half * 0.9 { return None; }+    let (u, n) = ((dx / len, dy / len), (-dy / len, dx / len));+    // the taper ends lengthRatio x the pad's size past its edge, or before the track's far end+    let reach = (s / 2.0 + length_ratio * s).min(len * 0.8);+    if reach <= s / 2.0 { return None; }+    let at = |along: f64, side: f64| (c.0 + u.0 * along + n.0 * side, c.1 + u.1 * along + n.1 * side);+    Some(vec![at(0.0, half), at(reach, w / 2.0), at(reach, -w / 2.0), at(0.0, -half)])+}++/// teardrops: input {board, rules?, args: {targets?: ["via", "tht", "smd"] (default via and tht), lengthRatio? (0.5),+/// widthRatio? (1.0), clearanceMm?}}. The teardrops as polygons on their track's layer, and the joints skipped with why.+pub fn teardrops(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let targets: Vec<String> = args["targets"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_lowercase())).collect()).unwrap_or_else(|| vec!["via".into(), "tht".into()]);+    let (lr, wr) = (args["lengthRatio"].as_f64().unwrap_or(0.5), args["widthRatio"].as_f64().unwrap_or(1.0));+    let clearance = args["clearanceMm"].as_f64().unwrap_or(0.0).max(crate::fab::routing_clearance(&crate::input::rules(input)).unwrap_or(0.2));+    let net = |v: &Value| v["net"].as_str().unwrap_or("").to_string();+    let tracks: Vec<Value> = st["tracks"].as_array().cloned().unwrap_or_default().into_iter().filter(|t| !net(t).is_empty() && t["layer"].as_str().map(|l| l.ends_with(".Cu")).unwrap_or(false)).collect();+    let vias = st["vias"].as_array().cloned().unwrap_or_default();+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    // the joints: (name, net, centre, size across, the layers it is on: empty for every layer)+    let mut joints: Vec<(String, String, P, f64, Option<String>)> = Vec::new();+    if targets.iter().any(|t| t == "via") {+        for v in &vias { joints.push(("via".into(), net(v), (f(&v["position"]["x"]), f(&v["position"]["y"])), f(&v["size"]), None)); }+    }+    for p in &pads {+        let through = f(&p["drill"]) > 0.0 || p["layer"] == "*.Cu";+        if (through && !targets.iter().any(|t| t == "tht")) || (!through && !targets.iter().any(|t| t == "smd")) { continue; }+        let s = f(&p["sizeX"]).min(f(&p["sizeY"]));+        let name = format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+        joints.push((name, net(p), (f(&p["x"]), f(&p["y"])), s, (!through).then(|| p["layer"].as_str().unwrap_or("").to_string())));+    }+    // other nets' copper, by layer ("" every layer)+    let mut copper: Vec<(String, String, Shape)> = Vec::new();+    for t in &tracks { copper.push((net(t), t["layer"].as_str().unwrap_or("").into(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0))); }+    for v in &vias { let c = (f(&v["position"]["x"]), f(&v["position"]["y"])); copper.push((net(v), String::new(), Shape::Cap(c, c, f(&v["size"]) / 2.0))); }+    for p in &pads { let l = if f(&p["drill"]) > 0.0 || p["layer"] == "*.Cu" { String::new() } else { p["layer"].as_str().unwrap_or("").to_string() }; copper.push((net(p), l, pad_shape(p))); }+    let (mut out, mut skipped) = (Vec::new(), Vec::new());+    for t in &tracks {+        let layer = t["layer"].as_str().unwrap_or("").to_string();+        let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));+        for (end, other) in [(a, b), (b, a)] {+            let Some(j) = joints.iter().find(|j| j.1 == net(t) && j.4.as_ref().map(|l| *l == layer).unwrap_or(true) && ((j.2 .0 - end.0).powi(2) + (j.2 .1 - end.1).powi(2)).sqrt() < 0.01) else { continue };+            let Some(poly) = shape(j.2, other, j.3, f(&t["width"]), lr, wr) else { continue };+            let s = Shape::Poly(poly.clone());+            if let Some((on, _, _)) = copper.iter().find(|(on, l, c)| *on != j.1 && (l.is_empty() || *l == layer) && s.gap(c) < clearance - 1e-9) {+                skipped.push(json!({"at": j.0, "net": j.1, "layer": layer, "why": format!("no room: it would come within {clearance} mm of {on}")}));+                continue;+            }+            out.push(json!({"at": j.0, "net": j.1, "layer": layer, "points": poly.iter().map(|p| json!([r3(p.0), r3(p.1)])).collect::<Vec<_>>()}));+        }+    }+    json!({"success": true, "teardrops": out, "count": out.len(), "skipped": skipped, "clearanceMm": clearance, "targets": targets, "lengthRatio": lr, "widthRatio": wr, "revision": st["revision"],+        "_hint": "A plan: draw each teardrop as a filled copper polygon on its layer and net (the tool's own teardrop command where it has one), with expectedRevision: revision, then repour. Each keeps clearanceMm from other nets' copper; skipped names the joints with no room."})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn a_teardrop_tapers_from_the_pad_to_the_track() {+        let p = shape((0.0, 0.0), (5.0, 0.0), 0.6, 0.2, 0.5, 1.0).unwrap();+        // as wide as the via at its centre, the track's width 0.3 + 0.3 = 0.6 mm out+        assert_eq!(p, vec![(0.0, 0.3), (0.6, 0.1), (0.6, -0.1), (0.0, -0.3)]);+        // a track already as wide as the pad, or a stub too short to taper, takes none+        assert!(shape((0.0, 0.0), (5.0, 0.0), 0.6, 0.6, 0.5, 1.0).is_none());+        assert!(shape((0.0, 0.0), (0.3, 0.0), 0.6, 0.2, 0.5, 1.0).is_none());+    }++    #[test]+    fn teardrops_go_on_vias_and_skip_a_joint_with_no_room() {+        let b = json!({"revision": "r9",+            "tracks": [{"net": "A", "layer": "F.Cu", "width": 0.2, "start": {"x": 0.0, "y": 0.0}, "end": {"x": 5.0, "y": 0.0}},+                       {"net": "B", "layer": "F.Cu", "width": 0.2, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 15.0, "y": 0.0}},+                       {"net": "C", "layer": "F.Cu", "width": 0.2, "start": {"x": 10.4, "y": -3.0}, "end": {"x": 10.4, "y": -0.45}}],+            "vias": [{"net": "A", "size": 0.6, "drill": 0.3, "position": {"x": 0.0, "y": 0.0}}, {"net": "B", "size": 0.6, "drill": 0.3, "position": {"x": 10.0, "y": 0.0}}],+            "pads": []});+        let r = teardrops(&json!({"board": b, "args": {}}));+        assert_eq!(r["count"], json!(1), "{r}");+        assert_eq!(r["teardrops"][0]["at"], "via");+        assert_eq!(r["teardrops"][0]["net"], "A");+        // B's teardrop would come too near C's track+        assert_eq!(r["skipped"][0]["net"], "B");+        assert!(r["skipped"][0]["why"].as_str().unwrap().contains("C"));+    }+}+

Comments

No comments yet.

Log in to comment.