app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Merge PR #348: kicad-lane: 1.0.87, simulate noise and Monte Carlo, turned bodies, engine 0.20.0
13 files changed
+396−61
rust/Cargo.lock+4−4rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/main.rs+29−1rust/crates/kicad-bridge/src/verbs_sim.rs+44−5rust/crates/kicad-core/src/bodies.rs+38−2rust/crates/kicad-core/src/spice.rs+128−3rust/crates/kicad-platform/src/ngspice.rs+45−25rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+2−2rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/bodies.rs+84−11rust/vendor/adom-eda-checks/src/placement.rs+2−1skills/kicad-bridge-test/run_verb_tests.py+15−2rust/Cargo.lock+4−4@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.19.1"+version = "0.20.0" dependencies = [ "roxmltree", "serde_json",@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.86"+version = "1.0.87" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.86"+version = "1.0.87" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.86"+version = "1.0.87" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.86"+version = "1.0.87" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/main.rs+29−1@@ -66,6 +66,18 @@ struct Command { 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}`, \@@ -223,7 +235,10 @@ fn handle(mut req: tiny_http::Request, started: Instant, request_count: u64, ver 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())); } }@@ -259,3 +274,16 @@ fn ngspice_worker() -> i32 { 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}");+ }+ }+}
rust/crates/kicad-bridge/src/verbs_sim.rs+44−5@@ -29,9 +29,9 @@ pub static VERBS: &[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, 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."], },@@ -110,14 +110,26 @@ fn simulate(state: &mut State, args: &Value) -> Result<Value, Value> { 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);@@ -171,6 +183,10 @@ fn simulate(state: &mut State, args: &Value) -> Result<Value, Value> { 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);@@ -223,6 +239,29 @@ fn sweep_plan(args: &Value, netlist: &str, cwd: &Path) -> Result<Vec<(Option<Str 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")?;
rust/crates/kicad-core/src/bodies.rs+38−2@@ -283,6 +283,17 @@ pub fn on_board(b: &Bounds, at: (f64, f64), angle: f64, bottom: bool) -> ([f64; ([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@@ -375,8 +386,15 @@ pub fn bodies(board_text: &str, y_up: bool, resolve: &mut dyn FnMut(&str) -> Res 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})), }@@ -445,4 +463,22 @@ mod tests { 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);+ } }
rust/crates/kicad-core/src/spice.rs+128−3@@ -110,6 +110,82 @@ pub fn with_temp(lines: &[String], t: f64) -> Vec<String> { 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>) {@@ -271,7 +347,18 @@ pub fn analysis_text(a: &Value) -> Result<String, String> { 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")), } } @@ -350,6 +437,8 @@ pub fn signals(run: &Value, wanted: &[String], points: usize) -> (Vec<Value>, Ve 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 {@@ -362,7 +451,7 @@ pub fn signals(run: &Value, wanted: &[String], points: usize) -> (Vec<Value>, Ve } 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);@@ -371,7 +460,8 @@ pub fn signals(run: &Value, wanted: &[String], points: usize) -> (Vec<Value>, Ve 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();@@ -527,4 +617,39 @@ mod tests { 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()));+ } }
rust/crates/kicad-platform/src/ngspice.rs+45−25@@ -123,35 +123,55 @@ mod imp { 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()));
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [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"
rust/vendor/adom-eda-checks/README.md+2−2@@ -75,7 +75,7 @@ Every check takes one JSON object: | `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` |@@ -115,7 +115,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 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
rust/vendor/adom-eda-checks/VENDOR.md+3−3@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.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`,
rust/vendor/adom-eda-checks/src/bodies.rs+84−11@@ -9,19 +9,74 @@ use std::collections::BTreeMap; 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")?;@@ -69,9 +124,7 @@ pub fn clashes(bs: &[BodyBox], gap: f64) -> (Vec<Value>, Vec<Value>) { 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)]);@@ -135,7 +188,7 @@ mod tests { 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]@@ -184,4 +237,24 @@ mod tests { 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));+ } }+
rust/vendor/adom-eda-checks/src/placement.rs+2−1@@ -111,7 +111,8 @@ pub fn is_stacked(pads: &[Value], bs: &[crate::bodies::BodyBox], a: &str, b: &st 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
skills/kicad-bridge-test/run_verb_tests.py+15−2@@ -1689,12 +1689,25 @@ def main(): 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)