← All Pull Requests

kicad-lane: 1.0.87, simulate noise and Monte Carlo, turned bodies, engine 0.20.0 #49

Merged opened by Colby Knox 2026-10-29

1.0.87: refusals the caller can fix no longer say they may be a bridge bug.

  • Every verb: the "THIS MAY BE A KICAD BRIDGE BUG ... file an issue" line no longer appears on a refusal the caller fixes.
    • That covers a missing or bad argument, a name or file that isn't there, unsaved changes, a stale revision, a DRC refusal, no simulation model, a missing reason, and not supported.
    • Unexpected failures still carry it.
    • Before, kicad_current_capacity without its nets told the AI to file a bug for its own missing argument.
  • kicad_simulate: noise analysis (standard #23). analysis {type: "noise", source, output, reference?, sweep, points, start, stop} runs ngspice's .noise.
    • It answers the onoise and inoise spectra (V/rtHz) as signals, and totals {onoise, inoise} in V.
    • Checked: an RC filter's output noise is 4.0714 nV/rtHz, the resistor's sqrt(4kTR). The total is 0.2035 uV against sqrt(kT/C) = 0.2036 uV.
  • kicad_simulate: Monte Carlo (standard #23). monteCarlo {runs, distribution: uniform|gaussian, seed, tolerances: {resistor, capacitor, inductor, dcSource} %}.
    • Every part of a kind with a tolerance is drawn again for each run. Uniform draws stay within the tolerance; gaussian draws use sigma = tolerance/3, clipped to the tolerance.
    • Each run's step lists the values drawn, and the same seed draws the same values.
    • Transistor and digital tolerances are refused with not_supported.
    • Checked: on an inverting amplifier, every run's gain matches 20*log10(R2/R1) from its own drawn values, less the op-amp model's finite-gain error.
  • kicad_height_check and kicad_placement_check: turned parts (engine 0.20.0, standard #26). A part turned off the 90° grid now sends its body's real rectangle, so it no longer collides through its bigger bounding box.
    • Checked: StickHub, with a 45° LQFP and its decoupling caps, went from 70 false collisions to 1.
    • That 1 is a crystal tucked into the LQFP's lead-free corner, which a body box (leads included) can't tell apart. KiCad's courtyard DRC passes.
  • Engine: adom-eda-checks 0.20.0 (EDA Bridge Standard 0.23.0).

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.19.1"+version = "0.20.0" dependencies = [  "roxmltree",  "serde_json",⋯ 302 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.86"+version = "1.0.87" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.86"+version = "1.0.87" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.86"+version = "1.0.87" 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.86"+version = "1.0.87" 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,1823 +1,1837 @@⋯ 1688 unchanged lines ⋯         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)+        # 1.0.87 (#23): the RC's noise is its resistor's: sqrt(4kTR) = 4.07 nV/rtHz, sqrt(kT/C) = 0.2036 uV in all; Monte+        # Carlo draws R1 and C1 per run, the same seed the same values; a transistor tolerance is refused+        nz = C("simulate", {"schematic": rc, "analysis": {"type": "noise", "source": "V1", "output": "OUT", "sweep": "dec", "points": 20, "start": 1, "stop": 1e9}}, 300)+        on = next((sg for sg in nz.get("signals") or [] if sg.get("signal") == "onoise"), {})+        mcq = {"schematic": rc, "analysis": {"type": "ac", "sweep": "dec", "points": 1, "start": 1000, "stop": 1000}, "probes": [{"net": "OUT"}],+               "monteCarlo": {"runs": 3, "distribution": "gaussian", "seed": 42, "tolerances": {"resistor": 5, "capacitor": 10}}}+        mc1, mc2 = C("simulate", mcq, 300), C("simulate", mcq, 300)+        steps = [sg.get("step") for sg in mc1.get("signals") or []]+        mcbad = C("simulate", {"schematic": rc, "analysis": {"type": "op"}, "monteCarlo": {"runs": 2, "tolerances": {"transistor": 5}}}, 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)")+            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"+            and ok(nz) and on.get("yUnit") == "V/rtHz" and abs((on.get("y") or [0])[0] / 4.0714e-9 - 1) < 0.01+            and abs(((nz.get("totals") or {}).get("onoise") or 0) / 2.036e-7 - 1) < 0.02+            and ok(mc1) and len(steps) == 3 and all("R1 = " in (x or "") and "C1 = " in (x or "") for x in steps)+            and steps == [sg.get("step") for sg in mc2.get("signals") or []] and mcbad.get("errorCode") == "not_supported",+            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, noise or Monte Carlo went wrong (corners {cs}, diode {tv} over temperature, {nv} over N, noise totals {nz.get('totals')}, runs {steps})")         # 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/crates/kicad-bridge/src/main.rs+++ b/rust/crates/kicad-bridge/src/main.rs@@ -1,261 +1,290 @@⋯ 65 unchanged lines ⋯     args: Value, } +/// Error codes that mean the call itself needs changing, not the bridge.+pub fn caller_side(code: &str) -> bool {+    const EXACT: &[&str] = &["bad_arg", "bad_args", "file_not_found", "unsaved_changes", "stale_board", "revision_required", "drc_rejected",+        "no_model", "reason_required", "not_supported", "unsupported_platform", "unknown_verb", "unknown_layer", "unknown_rule", "unknown_key",+        "unknown_action", "wrong_file_type", "file_exists", "footprint_exists", "ambiguous", "ambiguous_board", "already_there", "already_flagged",+        "needs_closed_board", "live_required", "board_open", "kicad_running", "project_open", "too_close", "same_point", "zero_length", "plan_invalid",+        "invalid_path", "invalid_scope", "name_required", "engine_required", "foreground_required", "nothing_to_route", "nothing_in_box",+        "nothing_placed", "side_change_not_supported", "live_move_drops_units", "entry_off_bus", "bad_via_geometry"];+    EXACT.contains(&code) || code.starts_with("missing_") || code.starts_with("no_such_") || code.ends_with("_not_found")+        || code.ends_with("_not_open") || code.ends_with("_required")+}+ pub fn report_issues_line() -> String {     format!(         "THIS MAY BE A KICAD BRIDGE BUG. Check for a duplicate with `adom-wiki issue list {SLUG}`, \⋯ 151 unchanged lines ⋯                         o.insert("elapsedMs".into(), json!(t0.elapsed().as_millis() as u64));                         o.entry("bridgeVersion").or_insert(json!(VERSION));                         let failed = !o.get("success").and_then(Value::as_bool).unwrap_or(false);-                        if failed || nth == 1 {+                        // a refusal the caller fixes (an argument, a name, a file, a stale revision, a DRC finding) is not a+                        // bridge bug: the "file an issue" line stays off it+                        let callers = o.get("errorCode").and_then(Value::as_str).is_some_and(caller_side);+                        if (failed && !callers) || nth == 1 {                             o.insert("_reportIssues".into(), json!(report_issues_line()));                         }                     }⋯ 29 unchanged lines ⋯     println!("{v}");     0 }++#[cfg(test)]+mod tests {+    #[test]+    fn a_callers_own_mistake_is_not_reported_as_a_bridge_bug() {+        for c in ["bad_args", "missing_arg", "no_such_probe", "footprint_not_found", "pcb_editor_not_open", "reason_required", "drc_rejected", "no_model"] {+            assert!(super::caller_side(c), "{c}");+        }+        for c in ["internal", "handler_panicked", "write_failed", "simulation_failed", "worker_failed", "bad_layout", "ipc_unavailable"] {+            assert!(!super::caller_side(c), "{c}");+        }+    }+}+--- a/rust/crates/kicad-bridge/src/verbs_sim.rs+++ b/rust/crates/kicad-bridge/src/verbs_sim.rs@@ -1,288 +1,328 @@⋯ 28 unchanged lines ⋯         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, 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)\"], \"sweep\"?: {\"parameter\": \"R4\" | \"Q1.BF\", \"start\", \"stop\", \"step\", \"scale\"?: \"lin|dec|oct\"}, \"temperatures\"?: {\"start\", \"stop\", \"step\"}, \"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\": \"noise\", \"source\", \"output\", \"reference\"?, \"sweep\"?, \"points\"?, \"start\", \"stop\"} | {\"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\"}, \"monteCarlo\"?: {\"runs\", \"distribution\"?: \"uniform|gaussian\", \"seed\"?, \"tolerances\": {\"resistor\"?, \"capacitor\"?, \"inductor\"?, \"dcSource\"?} (%)}, \"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). 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.",+        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. monteCarlo draws every resistor, capacitor, inductor and DC source whose kind has a tolerance again for each run (uniform, or gaussian with sigma tol/3), each run's step listing the values drawn (the same seed draws the same). A noise analysis answers onoise and inoise (V/rtHz) and totals {onoise, inoise} in V. 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). 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."],     },⋯ 72 unchanged lines ⋯         Some(v) if !v.is_null() => vec![v.clone()],         _ => Vec::new(),     };-    let analyses: Vec<String> = given.iter().map(spice::analysis_text).collect::<Result<_, _>>().map_err(|e| fail("bad_args", e, "analysis: {type: \"ac\", sweep: \"dec\", points: 20, start: 10, stop: 1e6}, {type: \"tran\", step: 1e-5, stop: 5e-3}, {type: \"dc\", source: \"V1\", start, stop, step}, {type: \"op\"} or {type: \"raw\", text: \".noise ...\"}."))?;-    // probes: {net: "OUT"} | {current: "V1"} | "V(/OUT)", as ngspice's vector names-    let probes: Vec<String> = args.get("probes").and_then(Value::as_array).map(|a| a.iter().filter_map(spice::probe_vector).collect()).unwrap_or_default();+    let noise = given.iter().any(|g| g["type"] == "noise");+    // probes: {net: "OUT"} | {current: "V1"} | "V(/OUT)", as ngspice's vector names; a noise run answers its spectra+    let probes: Vec<String> = if noise { Vec::new() } else { args.get("probes").and_then(Value::as_array).map(|a| a.iter().filter_map(spice::probe_vector).collect()).unwrap_or_default() };     let points = args.get("points").and_then(Value::as_u64).unwrap_or(200) as usize;     let tmp = std::env::temp_dir().join(format!("adom-kicad-sim-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)));     let _ = std::fs::create_dir_all(&tmp);     let cir = tmp.join("circuit.cir");     let (text, cli_path) = export(state, &sch, &cir, "spice")?;+    // the analyses, a noise analysis's output and reference nets named as the netlist names their nodes+    let analyses: Vec<String> = given.iter().map(|g| {+        let mut g = g.clone();+        if g["type"] == "noise" {+            for k in ["output", "reference"] {+                if let Some(n) = g[k].as_str().map(|n| spice::resolve_node(&text, n)) {+                    g[k] = json!(n);+                }+            }+        }+        spice::analysis_text(&g)+    }).collect::<Result<_, _>>().map_err(|e| { let _ = std::fs::remove_dir_all(&tmp); fail("bad_args", e, "analysis: {type: \"ac\", sweep: \"dec\", points: 20, start: 10, stop: 1e6}, {type: \"tran\", step: 1e-5, stop: 5e-3}, {type: \"dc\", source: \"V1\", start, stop, step}, {type: \"noise\", source: \"V1\", output: \"OUT\", sweep: \"dec\", points: 10, start: 10, stop: 1e6}, {type: \"op\"} or {type: \"raw\", text: \".sens ...\"}.") })?;     let (not_simulated, parts) = unmodelled(&cli_path, &sch, &text);     if parts > 0 && not_simulated.len() == parts {         let _ = std::fs::remove_dir_all(&tmp);⋯ 47 unchanged lines ⋯         v["runs"] = json!(runs.len());     }     v["signals"] = json!(signals);+    // a noise run's integrated noise (#23): {onoise, inoise} in V+    if noise {+        v["totals"] = spice::noise_totals(&run);+    }     v["missing"] = json!(missing);     // a partly modelled sheet runs: these parts are not in the result (#23)     v["notSimulated"] = json!(not_simulated);⋯ 46 unchanged lines ⋯             sweep.push((format!("{param} = {}", spice::spice_label(x)), cmd, x));         }     }+    // Monte Carlo (#23): every resistor, capacitor, inductor and DC source whose kind has a tolerance is drawn again for+    // each run (alter), and the run's step lists the values drawn+    if let Some(mc) = args.get("monteCarlo").filter(|v| !v.is_null()) {+        if !sweep.is_empty() || args.get("temperatures").is_some_and(|t| !t.is_null()) {+            return Err(bad("monteCarlo runs on its own: leave out sweep and temperatures".into()));+        }+        let runs = mc["runs"].as_u64().filter(|n| (1..=CAP as u64).contains(n)).ok_or_else(|| bad(format!("monteCarlo needs runs, 1 to {CAP}")))? as usize;+        let gaussian = match mc["distribution"].as_str().unwrap_or("uniform") { "uniform" => false, "gaussian" | "gauss" => true, d => return Err(bad(format!("distribution is uniform or gaussian, not {d}"))) };+        let tol = mc["tolerances"].clone();+        let unsupported: Vec<&str> = ["transistor", "digital"].into_iter().filter(|k| tol[*k].as_f64().is_some_and(|t| t > 0.0)).collect();+        if !unsupported.is_empty() {+            return Err(fail("not_supported", format!("KiCad's simulation cannot draw {} tolerances", unsupported.join(" or ")), "Resistor, capacitor, inductor and dcSource tolerances are drawn (alter); a transistor's model parameters are shared by every part using the model, and KiCad simulates no digital parts here. Leave those out."));+        }+        let parts = spice::mc_parts(netlist);+        let draws = spice::mc_draws(&parts, &tol, runs, gaussian, mc["seed"].as_u64().unwrap_or(1));+        if draws.first().is_none_or(|d| d.is_empty()) {+            return Err(bad("monteCarlo drew nothing: give tolerances (%) for resistor, capacitor, inductor or dcSource, for parts the netlist has".into()));+        }+        return Ok(draws.into_iter().map(|d| {+            let label = d.iter().map(|(n, x)| format!("{n} = {}", spice::sig6(*x))).collect::<Vec<_>>().join(", ");+            (Some(label), None, d.iter().map(|(n, x)| format!("alter {n} = {x:e}")).collect())+        }).collect());+    }     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")?;⋯ 57 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/spice.rs+++ b/rust/crates/kicad-core/src/spice.rs@@ -1,530 +1,656 @@⋯ 109 unchanged lines ⋯     out } +/// A noise run's integrated totals {onoise, inoise} in V (null when the run has none).+pub fn noise_totals(run: &Value) -> Value {+    let get = |k: &str| run["vectors"].as_array().into_iter().flatten().find(|v| v["name"].as_str().map(|n| n.eq_ignore_ascii_case(k)).unwrap_or(false))+        .and_then(|v| v["real"].as_array().and_then(|a| a.first()).and_then(Value::as_f64));+    match (get("onoise_total"), get("inoise_total")) {+        (None, None) => Value::Null,+        (o, i) => json!({"onoise": o, "inoise": i}),+    }+}++/// A net as the netlist names its node: KiCad writes a labelled net on the root sheet as /OUT, ground as GND.+pub fn resolve_node(netlist: &str, net: &str) -> String {+    let nodes: std::collections::BTreeSet<&str> = netlist.lines().map(str::trim).filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+']))+        .flat_map(|t| t.split_whitespace().skip(1)).collect();+    let n = net.trim();+    if nodes.contains(n) { return n.to_string(); }+    let slash = format!("/{}", n.trim_start_matches('/'));+    if nodes.contains(slash.as_str()) { return slash; }+    nodes.iter().find(|x| x.eq_ignore_ascii_case(n) || x.eq_ignore_ascii_case(&slash)).map(|x| x.to_string()).unwrap_or_else(|| n.to_string())+}++/// The parts a Monte Carlo run can draw, from the netlist: resistors, capacitors and inductors by their value, DC+/// sources by their DC value. (element name, kind, nominal)+pub fn mc_parts(netlist: &str) -> Vec<(String, &'static str, f64)> {+    let mut out = Vec::new();+    for l in netlist.lines().map(str::trim).filter(|t| !t.is_empty() && !t.starts_with(['.', '*', '+'])) {+        let t: Vec<&str> = l.split_whitespace().collect();+        let Some(first) = t.first().and_then(|n| n.chars().next()) else { continue };+        let kind = match first.to_ascii_uppercase() { 'R' => "resistor", 'C' => "capacitor", 'L' => "inductor", 'V' => "dcSource", _ => continue };+        let value = if kind == "dcSource" {+            t.iter().position(|x| x.eq_ignore_ascii_case("dc")).and_then(|i| t.get(i + 1)).or_else(|| t.get(3)).and_then(|v| spice_num(&json!(v)))+        } else {+            t.get(3).and_then(|v| spice_num(&json!(v)))+        };+        if let Some(v) = value.filter(|v| *v != 0.0) {+            out.push((t[0].to_string(), kind, v));+        }+    }+    out+}++/// splitmix64: the same seed gives the same draws on every machine.+struct Rng(u64);+impl Rng {+    fn next(&mut self) -> f64 {+        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);+        let mut z = self.0;+        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);+        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);+        ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64+    }+}++/// A value to six significant digits, the way a run's step lists what was drawn.+pub fn sig6(v: f64) -> String {+    if v == 0.0 { return "0".into(); }+    let e = v.abs().log10().floor() as i32;+    if (-3..6).contains(&e) { let d = (5 - e).max(0) as usize; let s = format!("{v:.d$}"); if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } } else { format!("{v:.5e}") }+}++/// Each run's drawn values for the parts whose kind has a tolerance (%): uniform within +/-tol, or gaussian with+/// sigma tol/3 clipped to +/-tol.+pub fn mc_draws(parts: &[(String, &'static str, f64)], tolerances: &Value, runs: usize, gaussian: bool, seed: u64) -> Vec<Vec<(String, f64)>> {+    let mut rng = Rng(seed);+    (0..runs).map(|_| parts.iter().filter_map(|(name, kind, nominal)| {+        let tol = tolerances[*kind].as_f64().filter(|t| *t > 0.0)? / 100.0;+        let d = if gaussian {+            let (u1, u2) = (rng.next().max(1e-12), rng.next());+            ((-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos() * tol / 3.0).clamp(-tol, tol)+        } else {+            (rng.next() * 2.0 - 1.0) * tol+        };+        Some((name.clone(), nominal * (1.0 + d)))+    }).collect()).collect()+}+ /// 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>) {⋯ 155 unchanged lines ⋯             Some(st) => format!(".tran {} {} {}", g(num("step")?), g(num("stop")?), g(st)),             None => format!(".tran {} {}", g(num("step")?), g(num("stop")?)),         }),-        t => Err(format!("analysis type {t:?} is not op, dc, ac, tran or raw")),+        // output and reference are node names as the netlist writes them (resolve_node)+        "noise" => {+            let sweep = a["sweep"].as_str().unwrap_or("dec");+            if !["dec", "oct", "lin"].contains(&sweep) {+                return Err("a noise sweep is dec, oct or lin".into());+            }+            let out = a["output"].as_str().ok_or("a noise analysis names its output net")?;+            let node = match a["reference"].as_str().filter(|r| !r.is_empty() && *r != "0") { Some(r) => format!("v({out},{r})"), None => format!("v({out})") };+            let points = a.get("points").and_then(Value::as_f64).unwrap_or(10.0) as u64;+            Ok(format!(".noise {node} {} {sweep} {points} {} {}", a["source"].as_str().ok_or("a noise analysis names its input source")?, g(num("start")?), g(num("stop")?)))+        }+        t => Err(format!("analysis type {t:?} is not op, dc, ac, tran, noise or raw")),     } } ⋯ 72 unchanged lines ⋯     let nums = |v: &Value, k: &str| -> Vec<f64> { v[k].as_array().map(|a| a.iter().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default() };     let x_all: Vec<f64> = sweep.map(|s| if s["real"].is_array() { nums(s, "real") } else { nums(s, "re") }).unwrap_or_default();     let mut missing = Vec::new();+    // a noise run's integrated totals are single values, answered as totals (noise_totals), not as signals+    let vecs: Vec<Value> = vecs.into_iter().filter(|v| !v["name"].as_str().unwrap_or("").to_lowercase().ends_with("noise_total")).collect();     let chosen: Vec<&Value> = if wanted.is_empty() {         vecs.iter().filter(|v| !is_sweep(&name(v))).take(24).collect()     } else {⋯ 6 unchanged lines ⋯         }         c     };-    let label = |n: &str| if n.ends_with("#branch") { format!("I({})", n.trim_end_matches("#branch").to_uppercase()) } else { format!("V({})", n.trim_start_matches('/').to_uppercase()) };+    let label = |n: &str| if let Some(k) = n.strip_suffix("_spectrum") { k.to_string() } else if n.ends_with("#branch") { format!("I({})", n.trim_end_matches("#branch").to_uppercase()) } else { format!("V({})", n.trim_start_matches('/').to_uppercase()) };     let mut out = Vec::new();     for v in chosen {         let n = name(v);⋯ 2 unchanged lines ⋯             let y = nums(v, "real");             let idx = keep_extrema(&y, points);             let x: Vec<f64> = if x_all.len() == y.len() { idx.iter().map(|&i| r(x_all[i])).collect() } else { idx.iter().map(|&i| i as f64).collect() };-            out.push(json!({"signal": label(&n), "x": x, "y": idx.iter().map(|&i| r(y[i])).collect::<Vec<_>>(), "xUnit": x_unit, "yUnit": if current { "A" } else { "V" }}));+            let y_unit = if n.ends_with("noise_spectrum") { "V/rtHz" } else if current { "A" } else { "V" };+            out.push(json!({"signal": label(&n), "x": x, "y": idx.iter().map(|&i| r(y[i])).collect::<Vec<_>>(), "xUnit": x_unit, "yUnit": y_unit}));         } else {             let (re, im) = (nums(v, "re"), nums(v, "im"));             let db: Vec<f64> = re.iter().zip(&im).map(|(a, b)| 20.0 * a.hypot(*b).log10()).collect();⋯ 149 unchanged lines ⋯         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()]);     }++    #[test]+    fn noise_is_an_analysis_on_the_netlists_node_names() {+        let net = ".title amp\nV1 /IN GND DC 0 AC 1\nR1 /IN /OUT 1k\nC1 /OUT GND 100n\nV2 /VP GND DC 12\n.end\n";+        assert_eq!(resolve_node(net, "OUT"), "/OUT");+        assert_eq!(resolve_node(net, "GND"), "GND");+        let a = json!({"type": "noise", "source": "V1", "output": "/OUT", "sweep": "dec", "points": 10, "start": 10, "stop": 1e6});+        assert_eq!(analysis_text(&a).unwrap(), ".noise v(/OUT) V1 dec 10 10 1000000");+        let a = json!({"type": "noise", "source": "V1", "output": "/OUT", "reference": "/IN", "start": 10, "stop": 100});+        assert_eq!(analysis_text(&a).unwrap(), ".noise v(/OUT,/IN) V1 dec 10 10 100");+        let run = json!({"vectors": [{"name": "frequency", "real": [10.0, 100.0]}, {"name": "onoise_spectrum", "real": [4e-9, 4e-9]},+                                     {"name": "inoise_spectrum", "real": [4e-9, 4e-9]}, {"name": "onoise_total", "real": [1.2e-6]}, {"name": "inoise_total", "real": [1.3e-6]}]});+        let (sig, _) = signals(&run, &[], 200);+        assert_eq!(sig.iter().map(|s| (s["signal"].as_str().unwrap(), s["yUnit"].as_str().unwrap())).collect::<Vec<_>>(), vec![("onoise", "V/rtHz"), ("inoise", "V/rtHz")]);+        assert_eq!(noise_totals(&run), json!({"onoise": 1.2e-6, "inoise": 1.3e-6}));+    }++    #[test]+    fn monte_carlo_draws_within_tolerance_and_repeats_with_its_seed() {+        let net = ".title amp\nV1 /IN GND DC 0 SIN( 0 1 1k 0 0 0 ) AC 1\nV2 /VP GND DC 12\nR1 /IN /OUT 1k\nC1 /OUT GND 100n\nXU1 a b c opamp\n.end\n";+        let parts = mc_parts(net);+        assert_eq!(parts.iter().map(|p| (p.0.as_str(), p.1)).collect::<Vec<_>>(), vec![("V2", "dcSource"), ("R1", "resistor"), ("C1", "capacitor")]);+        let tol = json!({"resistor": 5, "capacitor": 10});+        let a = mc_draws(&parts, &tol, 50, false, 7);+        assert_eq!(a, mc_draws(&parts, &tol, 50, false, 7));+        assert_ne!(a, mc_draws(&parts, &tol, 50, false, 8));+        for run in &a {+            assert_eq!(run.len(), 2, "V2 has no tolerance");+            assert!((run[0].1 / 1000.0 - 1.0).abs() <= 0.05 + 1e-12 && (run[1].1 / 100e-9 - 1.0).abs() <= 0.10 + 1e-12);+        }+        for run in mc_draws(&parts, &tol, 50, true, 7) {+            assert!((run[0].1 / 1000.0 - 1.0).abs() <= 0.05 + 1e-12);+        }+        assert_eq!((sig6(9038.123), sig6(1.0004e-10), sig6(12.4331)), ("9038.12".to_string(), "1.00040e-10".to_string(), "12.4331".to_string()));+    } }+--- a/rust/crates/kicad-core/src/bodies.rs+++ b/rust/crates/kicad-core/src/bodies.rs@@ -1,448 +1,485 @@⋯ 282 unchanged lines ⋯     ([x.0, y.0, x.1, y.1], [b.min[2], b.max[2]]) } +/// The footprint rectangle of a footprint-frame box placed on the board (y up), as its four turned corners: what a+/// part not at a multiple of 90° really covers (its `on_board` extent is the bounding box of these).+pub fn on_board_corners(b: &Bounds, at: (f64, f64), angle: f64, bottom: bool) -> [[f64; 2]; 4] {+    let (x0, y0, x1, y1) = (b.min[0], b.min[1], b.max[0], b.max[1]);+    [[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(|[x, y]| {+        let q = if bottom { [x, -y, 0.0] } else { [x, y, 0.0] };+        let r = rot(q, 2, angle);+        [r[0] + at.0, r[1] + at.1]+    })+}+ // -- paths --------------------------------------------------------------------------------------  /// Expand `${VAR}` in a model path: the 3D model dirs of every KiCad version name the install's⋯ 86 unchanged lines ⋯                     let fy = |v: f64| if y_up { v } else { -v };                     let (y0, y1) = if y_up { (r[1], r[3]) } else { (fy(r[3]), fy(r[1])) };                     let r6 = |v: f64| (v * 1e4).round() / 1e4 + 0.0;-                    out.push(json!({"ref": reference, "side": side, "x0": r6(r[0]), "y0": r6(y0), "x1": r6(r[2]), "y1": r6(y1),-                        "z0": r6(z[0]), "z1": r6(z[1]), "heightMm": r6(z[1].max(0.0)), "model": m.path, "file": file}));+                    let mut row = json!({"ref": reference, "side": side, "x0": r6(r[0]), "y0": r6(y0), "x1": r6(r[2]), "y1": r6(y1),+                        "z0": r6(z[0]), "z1": r6(z[1]), "heightMm": r6(z[1].max(0.0)), "model": m.path, "file": file});+                    // a part turned off the 90° grid: its real rectangle, so the checks don't compare the bigger bounding+                    // box (standard #26)+                    if (angle / 90.0 - (angle / 90.0).round()).abs() > 1e-6 {+                        let cs = on_board_corners(&local, (x, y), angle, bottom);+                        row["corners"] = json!(cs.iter().map(|c| [r6(c[0]), r6(fy(c[1]))]).collect::<Vec<_>>());+                    }+                    out.push(row);                 }                 Err(why) => missing.push(json!({"ref": reference, "model": m.path, "why": why})),             }⋯ 62 unchanged lines ⋯         assert_eq!(find_model(&dir.join("R.wrl").to_string_lossy(), &dir), Some(dir.join("R.step")));         std::fs::remove_dir_all(&dir).ok();     }++    #[test]+    fn a_part_turned_45_degrees_covers_its_rectangle_not_the_bounding_box() {+        let mut b = Bounds::empty();+        b.add([-1.0, -0.5, 0.0]);+        b.add([1.0, 0.5, 0.6]);+        let cs = on_board_corners(&b, (10.0, 20.0), 45.0, false);+        let (r, _) = on_board(&b, (10.0, 20.0), 45.0, false);+        // the corners lie inside the bounding box and touch each of its sides+        for c in cs {+            assert!(c[0] >= r[0] - 1e-9 && c[0] <= r[2] + 1e-9 && c[1] >= r[1] - 1e-9 && c[1] <= r[3] + 1e-9);+        }+        // the rectangle keeps its 2 x 1 size: two neighbouring corners are 2 mm apart, the next pair 1 mm+        let d = |a: [f64; 2], b: [f64; 2]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt();+        assert!((d(cs[0], cs[1]) - 2.0).abs() < 1e-9 && (d(cs[1], cs[2]) - 1.0).abs() < 1e-9);+        // the bounding box is larger than 2 x 1 at 45°+        assert!(r[2] - r[0] > 2.0 && r[3] - r[1] > 1.0);+    } }+--- a/rust/crates/kicad-platform/src/ngspice.rs+++ b/rust/crates/kicad-platform/src/ngspice.rs@@ -1,175 +1,196 @@⋯ 122 unchanged lines ⋯         if aborted || plot.is_empty() || plot == "const" {             return Err(format!("the analysis did not run: {}", errors()));         }-        let mut names = Vec::new();-        let list = unsafe { all_vecs(plot_ptr) };-        if !list.is_null() {-            let mut i = 0;-            loop {-                let p = unsafe { *list.add(i) };-                if p.is_null() {-                    break;+        // every vector of one plot by name; a noise analysis leaves two plots (noiseN: the spectra over frequency,+        // noiseN+1: the integrated totals), so the other one is read too+        let read_plot = |name: &str| -> Vec<Value> {+            let Ok(cname) = CString::new(name) else { return Vec::new() };+            let mut names = Vec::new();+            let list = unsafe { all_vecs(cname.as_ptr()) };+            if !list.is_null() {+                let mut i = 0;+                loop {+                    let p = unsafe { *list.add(i) };+                    if p.is_null() {+                        break;+                    }+                    names.push(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned());+                    i += 1;                 }-                names.push(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned());-                i += 1;             }-        }-        let mut vectors = Vec::new();-        for n in &names {-            let full = CString::new(format!("{plot}.{n}")).unwrap();-            let info = unsafe { vec_info(full.as_ptr()) };-            if info.is_null() {-                continue;+            let mut vectors = Vec::new();+            for n in &names {+                let full = CString::new(format!("{name}.{n}")).unwrap();+                let info = unsafe { vec_info(full.as_ptr()) };+                if info.is_null() {+                    continue;+                }+                let v = unsafe { &*info };+                let len = v.length.max(0) as usize;+                if !v.real.is_null() {+                    let data = unsafe { std::slice::from_raw_parts(v.real, len) };+                    vectors.push(json!({"name": n, "type": v.v_type, "real": data}));+                } else if !v.comp.is_null() {+                    let data = unsafe { std::slice::from_raw_parts(v.comp, len) };+                    vectors.push(json!({"name": n, "type": v.v_type, "re": data.iter().map(|c| c.re).collect::<Vec<_>>(), "im": data.iter().map(|c| c.im).collect::<Vec<_>>()}));+                }             }-            let v = unsafe { &*info };-            let len = v.length.max(0) as usize;-            if !v.real.is_null() {-                let data = unsafe { std::slice::from_raw_parts(v.real, len) };-                vectors.push(json!({"name": n, "type": v.v_type, "real": data}));-            } else if !v.comp.is_null() {-                let data = unsafe { std::slice::from_raw_parts(v.comp, len) };-                vectors.push(json!({"name": n, "type": v.v_type, "re": data.iter().map(|c| c.re).collect::<Vec<_>>(), "im": data.iter().map(|c| c.im).collect::<Vec<_>>()}));+            vectors+        };+        let mut vectors = read_plot(&plot);+        let mut plot = plot;+        if let Some(n) = plot.strip_prefix("noise").and_then(|d| d.parse::<u32>().ok()) {+            let (spectra, totals) = if vectors.iter().any(|v| v["name"] == "frequency") { (n, n + 1) } else { (n.saturating_sub(1), n) };+            let other = if spectra == n { totals } else { spectra };+            let names: Vec<Value> = vectors.iter().map(|v| v["name"].clone()).collect();+            for v in read_plot(&format!("noise{other}")) {+                if !names.contains(&v["name"]) {+                    vectors.push(v);+                }             }+            // the spectra plot leads: its frequency is the sweep+            plot = format!("noise{spectra}");         }         if vectors.is_empty() {             return Err(format!("the analysis gave no vectors: {}", errors()));⋯ 15 unchanged lines ⋯         Err("simulation runs through KiCad's ngspice.dll, on Windows for now".into())     } }+--- 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.22.2-- Standard commit: 1bbf323d2e4e8bb452ca2ad194e2f34335d95367-- Crate version: 0.19.1+- Standard version: 0.23.0+- Standard commit: c3c265238d4353b7e3f7650f33c07ef699c28ead+- Crate version: 0.20.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,129 +1,130 @@⋯ 74 unchanged lines ⋯ | `board` | the board model, `<prefix>_routing_state` (`schema/board.schema.json`) | | `stack` | the stack-up companion, `<prefix>_layer_stack` | | `pours` | poured copper, `<prefix>_pour_state` (its `zones`) |-| `bodies` | the 3D bodies companion, `<prefix>_bodies`: `bodies[{ref, side, x0, y0, x1, y1, z0, z1}]` (one box per body or per part) and `noBody` |+| `bodies` | the 3D bodies companion, `<prefix>_bodies`: `bodies[{ref, side, x0, y0, x1, y1, z0, z1}]` (one box per body or per part) and `noBody`. A turned body may give its footprint as `{cx, cy, w, h, rotation}` or four `corners`; bodies are then compared by their rectangles (separating axes), not their boxes | | `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` (a `value` with its unit, `"0.1mm"`, works too). Only copper-to-copper clearance counts: a rule whose kind or name names the board edge, mask, silk, paste, holes, a keepout or restrict area, the courtyard or the same net is skipped | | `silk` | `<prefix>_silk_state` | | `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` |⋯ 33 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.19.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.20.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.19.1"+version = "0.20.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/bodies.rs+++ b/rust/vendor/adom-eda-checks/src/bodies.rs@@ -1,187 +1,261 @@⋯ 8 unchanged lines ⋯ fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 } fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) } -/// One body as a box in mm: its part, side (top/bottom), x and y extent, and z from the board surface it sits on.+/// One body as a box in mm: its part, side (top/bottom), x and y extent, and z from the board surface it sits on; a+/// turned body also has its footprint as a rectangle's four corners (`turned`), and x/y extent is their bounding box. #[derive(Debug, Clone)]-pub struct BodyBox { pub part: String, pub top: bool, pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64, pub z0: f64, pub z1: f64 }+pub struct BodyBox { pub part: String, pub top: bool, pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64, pub z0: f64, pub z1: f64, pub turned: Option<Vec<(f64, f64)>> } -/// The bodies companion (`bodies[{ref, side, x0, y0, x1, y1, z0, z1}]`, mm) as boxes. Pure.+/// A body row's turned rectangle: `{cx, cy, w, h, rotation}` (degrees counterclockwise) or `corners` (four [x, y]).+fn turned(r: &Value) -> Option<Vec<(f64, f64)>> {+    if let Some(cs) = r["corners"].as_array().filter(|a| a.len() == 4) { return Some(cs.iter().map(|c| (f(&c[0]), f(&c[1]))).collect()); }+    if r["cx"].is_number() && r["w"].is_number() && r["h"].is_number() {+        let (cx, cy, hw, hh, a) = (f(&r["cx"]), f(&r["cy"]), f(&r["w"]) / 2.0, f(&r["h"]) / 2.0, f(&r["rotation"]).to_radians());+        let (c, s) = (a.cos(), a.sin());+        return Some([(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].iter().map(|(u, v)| (cx + u * c - v * s, cy + u * s + v * c)).collect());+    }+    None+}++/// The bodies companion (`bodies[{ref, side, x0, y0, x1, y1, z0, z1}]`, mm, and for a turned body `{cx, cy, w, h,+/// rotation}` or `corners`) as boxes. Pure. pub fn boxes(rows: &[Value]) -> Vec<BodyBox> {-    rows.iter().filter(|r| r["ref"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|r| BodyBox {-        part: r["ref"].as_str().unwrap_or("").to_string(), top: r["side"].as_str() != Some("bottom"),-        x0: f(&r["x0"]).min(f(&r["x1"])), y0: f(&r["y0"]).min(f(&r["y1"])), x1: f(&r["x0"]).max(f(&r["x1"])), y1: f(&r["y0"]).max(f(&r["y1"])),-        z0: f(&r["z0"]), z1: f(&r["z1"]),+    rows.iter().filter(|r| r["ref"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|r| {+        let t = turned(r);+        let (x0, y0, x1, y1) = match &t {+            Some(cs) => cs.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1))),+            None => (f(&r["x0"]).min(f(&r["x1"])), f(&r["y0"]).min(f(&r["y1"])), f(&r["x0"]).max(f(&r["x1"])), f(&r["y0"]).max(f(&r["y1"]))),+        };+        BodyBox { part: r["ref"].as_str().unwrap_or("").to_string(), top: r["side"].as_str() != Some("bottom"), x0, y0, x1, y1, z0: f(&r["z0"]), z1: f(&r["z1"]), turned: t }     }).collect() } +/// How two bodies' footprints meet in x and y: (true, how deep) when they overlap, else (false, the gap between them).+/// Axis boxes compare as boxes; when either is turned, both are compared as rectangles by separating axes, so a part at+/// 45 degrees no longer counts its box's empty corners (kicad-lane #26: 70 false collisions round a turned QFN). Pure.+pub fn xy_meet(a: &BodyBox, b: &BodyBox) -> (bool, f64) {+    if a.turned.is_none() && b.turned.is_none() {+        let dx = (b.x0 - a.x1).max(a.x0 - b.x1);+        let dy = (b.y0 - a.y1).max(a.y0 - b.y1);+        return if dx < 0.0 && dy < 0.0 { (true, -(dx.max(dy))) } else { (false, (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt()) };+    }+    let poly = |x: &BodyBox| x.turned.clone().unwrap_or_else(|| vec![(x.x0, x.y0), (x.x1, x.y0), (x.x1, x.y1), (x.x0, x.y1)]);+    let (pa, pb) = (poly(a), poly(b));+    // separating axes: each edge's normal of either rectangle+    let mut sep = f64::MIN;+    for p in [&pa, &pb] {+        for i in 0..p.len() {+            let (e0, e1) = (p[i], p[(i + 1) % p.len()]);+            let (nx, ny) = (e0.1 - e1.1, e1.0 - e0.0);+            let l = (nx * nx + ny * ny).sqrt();+            if l < 1e-12 { continue; }+            let proj = |q: &Vec<(f64, f64)>| q.iter().map(|v| (v.0 * nx + v.1 * ny) / l).fold((f64::MAX, f64::MIN), |m, d| (m.0.min(d), m.1.max(d)));+            let ((a0, a1), (b0, b1)) = (proj(&pa), proj(&pb));+            sep = sep.max((b0 - a1).max(a0 - b1));+        }+    }+    if sep < 0.0 { return (true, -sep); }+    // apart: the shortest distance between their edges+    let seg = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| {+        let (dx, dy) = (b.0 - a.0, b.1 - a.1);+        let l2 = dx * dx + dy * dy;+        let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+        ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()+    };+    let mut d = f64::MAX;+    for (p, q) in [(&pa, &pb), (&pb, &pa)] {+        for v in p.iter() { for i in 0..q.len() { d = d.min(seg(*v, q[i], q[(i + 1) % q.len()])); } }+    }+    (false, d)+}+ /// The input's bodies: the companion's `bodies`, or the companion given as a bare list. pub fn input_boxes(input: &Value) -> Option<Vec<BodyBox>> {     let b = crate::input::part(input, "bodies")?;⋯ 41 unchanged lines ⋯             if a.part == b.part || a.top != b.top { continue; }             let dz = a.z1.min(b.z1) - a.z0.max(b.z0);             if dz <= 0.0 { continue; } // one sits above the other (a part under a connector's overhang)-            let dx = (b.x0 - a.x1).max(a.x0 - b.x1);-            let dy = (b.y0 - a.y1).max(a.y0 - b.y1);-            let (hit, d) = if dx < 0.0 && dy < 0.0 { (true, -(dx.max(dy))) } else { (false, (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt()) };+            let (hit, d) = xy_meet(a, b);             if !hit && d >= gap { continue; }             let key = if a.part < b.part { (a.part.clone(), b.part.clone()) } else { (b.part.clone(), a.part.clone()) };             let at = json!([r3((a.x0.max(b.x0) + a.x1.min(b.x1)) / 2.0), r3((a.y0.max(b.y0) + a.y1.min(b.y1)) / 2.0)]);⋯ 57 unchanged lines ⋯     use super::*;      fn bx(p: &str, top: bool, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> BodyBox {-        BodyBox { part: p.into(), top, x0, y0, x1, y1, z0, z1 }+        BodyBox { part: p.into(), top, x0, y0, x1, y1, z0, z1, turned: None }     }      #[test]⋯ 42 unchanged lines ⋯         assert_eq!(r["complete"], json!(false));         assert_eq!(height_check(&json!({"args": {}}))["errorCode"], "no_bodies");     }++    #[test]+    fn turned_bodies_meet_by_their_rectangles_not_their_boxes() {+        // a 4 mm QFN at 45 degrees and a cap in its box's empty corner: apart, not a collision (kicad-lane #26)+        let rows = vec![json!({"ref": "U1", "side": "top", "cx": 0.0, "cy": 0.0, "w": 4.0, "h": 4.0, "rotation": 45.0, "z0": 0.0, "z1": 0.9}),+                        json!({"ref": "C1", "side": "top", "x0": 2.0, "y0": 2.0, "x1": 2.6, "y1": 2.3, "z0": 0.0, "z1": 0.5})];+        let bs = boxes(&rows);+        assert!((bs[0].x1 - 2.0 * 2f64.sqrt()).abs() < 1e-9, "the box still covers the turned body");+        let (hit, d) = xy_meet(&bs[0], &bs[1]);+        assert!(!hit && d > 0.5, "{hit} {d}");+        let (col, _) = clashes(&bs, 0.2);+        assert!(col.is_empty());+        // the same cap moved onto the QFN's edge collides+        let near = boxes(&[rows[0].clone(), json!({"ref": "C1", "side": "top", "x0": 1.2, "y0": 1.2, "x1": 1.8, "y1": 1.5, "z0": 0.0, "z1": 0.5})]);+        assert!(xy_meet(&near[0], &near[1]).0);+        // corners work too+        let c = boxes(&[json!({"ref": "U2", "corners": [[0, 0], [2, 0], [2, 1], [0, 1]], "z0": 0, "z1": 1})]);+        assert_eq!((c[0].x1, c[0].y1), (2.0, 1.0));+    } }++--- a/rust/vendor/adom-eda-checks/src/placement.rs+++ b/rust/vendor/adom-eda-checks/src/placement.rs@@ -1,207 +1,210 @@⋯ 110 unchanged lines ⋯     if pa.is_empty() || pb.is_empty() || pa.iter().any(|x| pb.iter().any(|y| hit(grow(*x, margin), grow(*y, margin)))) { return false; }     let (ba, bb): (Vec<_>, Vec<_>) = (bs.iter().filter(|x| x.part == a).collect(), bs.iter().filter(|x| x.part == b).collect());     if ba.is_empty() || bb.is_empty() { return false; }-    !ba.iter().any(|p| bb.iter().any(|q| hit(grow((p.x0, p.y0, p.x1, p.y1), margin), grow((q.x0, q.y0, q.x1, q.y1), margin)) && p.z0.max(q.z0) < p.z1.min(q.z1)))+    // bodies meet when their footprints come within twice the margin (turned ones by their rectangles) and their heights overlap+    !ba.iter().any(|p| bb.iter().any(|q| { let (h, d) = crate::bodies::xy_meet(p, q); (h || d < 2.0 * margin) && p.z0.max(q.z0) < p.z1.min(q.z1) })) }  /// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads⋯ 87 unchanged lines ⋯         assert!(none.get("stacked").is_none());     } }++

Comments

No comments yet.

Log in to comment.