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 #336: kicad-lane: 1.0.78, SPICE simulation (#89), engine 0.10.4
18 files changed
+709−25
bridge.json+5−2rust/Cargo.lock+4−4rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/groups.rs+1rust/crates/kicad-bridge/src/kicad_only.txt+2rust/crates/kicad-bridge/src/main.rs+22rust/crates/kicad-bridge/src/verbs_sim.rs+181rust/crates/kicad-core/src/lib.rs+1rust/crates/kicad-core/src/spice.rs+210rust/crates/kicad-platform/Cargo.toml+1rust/crates/kicad-platform/src/lib.rs+3rust/crates/kicad-platform/src/ngspice.rs+161rust/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/route.rs+74−10skills/kicad-bridge-test/run_verb_tests.py+21−1skills/kicad-schematic-and-board-edits/SKILL.md+16−1bridge.json+5−2@@ -306,7 +306,9 @@ "kicad_library_views", "kicad_placement_check", "kicad_plan_route",- "kicad_generate_body"+ "kicad_generate_body",+ "kicad_export_spice",+ "kicad_simulate" ], "statusVerb": "kicad_status", "timeouts": {@@ -327,7 +329,8 @@ "freerouting": 900, "autoroute": 900, "prewarm": 120,- "library_views": 300+ "library_views": 300,+ "simulate": 300 } }, "dependencies": {
rust/Cargo.lock+4−4@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.10.3"+version = "0.10.4" dependencies = [ "roxmltree", "serde_json",@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.77"+version = "1.0.78" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.77"+version = "1.0.78" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.77"+version = "1.0.78" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.77"+version = "1.0.78" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/groups.rs+1@@ -45,6 +45,7 @@ pub static GROUPS: &[Group] = &[ Group { name: "order", verbs: || crate::verbs_order::VERBS, dispatch: crate::verbs_order::dispatch }, Group { name: "land_pattern", verbs: || crate::verbs_land_pattern::VERBS, dispatch: crate::verbs_land_pattern::dispatch }, Group { name: "library_views", verbs: || crate::verbs_library_views::VERBS, dispatch: crate::verbs_library_views::dispatch },+ Group { name: "sim", verbs: || crate::verbs_sim::VERBS, dispatch: crate::verbs_sim::dispatch }, Group { name: "build", verbs: || crate::verbs_build::VERBS, dispatch: crate::verbs_build::dispatch }, ];
rust/crates/kicad-bridge/src/kicad_only.txt+2@@ -108,3 +108,5 @@ kicad_upgrade kicad_validate_for_fab kicad_verb_times kicad_zone_state+kicad_export_spice+kicad_simulate
rust/crates/kicad-bridge/src/main.rs+22@@ -42,6 +42,7 @@ mod verbs_report; mod verbs_order; mod verbs_land_pattern; mod verbs_library_views;+mod verbs_sim; mod verbs_build; mod verbs_windows; @@ -120,6 +121,10 @@ fn json_response(body: Value, code: u16) -> Response<std::io::Cursor<Vec<u8>>> { const WORKERS: usize = 6; fn main() {+ // kicad_simulate runs ngspice in a child of this binary: one job from stdin, the result to stdout+ if std::env::args().any(|a| a == "--ngspice-worker") {+ std::process::exit(ngspice_worker());+ } let port = parse_port_arg().unwrap_or(0); let host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let started = Instant::now();@@ -235,3 +240,20 @@ fn handle(mut req: tiny_http::Request, started: Instant, request_count: u64, ver }; let _ = req.respond(json_response(body, code)); }++/// `--ngspice-worker`: `{dll, cwd, lines}` on stdin, kicad_platform::ngspice::run's answer (or `{error}`) on stdout.+fn ngspice_worker() -> i32 {+ use std::io::Read;+ let mut input = String::new();+ let _ = std::io::stdin().read_to_string(&mut input);+ let job: Value = serde_json::from_str(&input).unwrap_or(Value::Null);+ let lines: Vec<String> = job["lines"].as_array().into_iter().flatten().filter_map(|l| l.as_str().map(String::from)).collect();+ let cms: Vec<std::path::PathBuf> = job["codemodels"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(std::path::PathBuf::from)).collect();+ let out = kicad_platform::ngspice::run(std::path::Path::new(job["dll"].as_str().unwrap_or("")), &lines, std::path::Path::new(job["cwd"].as_str().unwrap_or(".")), &cms);+ let v = match out {+ Ok(v) => v,+ Err(e) => serde_json::json!({"error": e}),+ };+ println!("{v}");+ 0+}
rust/crates/kicad-bridge/src/verbs_sim.rsadded+181@@ -0,0 +1,181 @@+//! Verb group "sim": SPICE from a KiCad schematic (wiki #89), headless.+//! - `kicad_export_spice`: KiCad's own SPICE netlist of a sheet (kicad-cli), and what it holds.+//! - `kicad_simulate`: that netlist with the analysis asked for, run by the ngspice library KiCad installs+//! (kicad_platform::ngspice) in a child process of this binary (`--ngspice-worker`), so a circuit that+//! brings ngspice down never takes the bridge with it; the probes come back downsampled, and as CSV.++use std::io::Write;+use std::path::{Path, PathBuf};+use std::time::{Duration, Instant};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::{cli, spice};++pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_export_spice",+ summary: "Export a schematic's SPICE netlist with KiCad's own exporter (kicad-cli), headless, and say what it holds: its elements, the analyses the schematic carries, and the model files it includes.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 120,+ input: "{\"schPath\": \"C:/.../x.kicad_sch\", \"outputPath\"?: \"C:/.../x.cir\" (default beside the sheet), \"format\"?: \"spice\" | \"spicemodel\"}",+ example: "kicad_export_spice {\"schPath\":\"C:/Users/john/proj/filter.kicad_sch\"}",+ hint: "path is the .cir; elements counts the parts that made it into the netlist. A part without a SPICE model is left out or becomes a comment: give it Sim fields (Simulation_SPICE sources, or the Sim.* fields in its properties). spicemodel writes a subcircuit instead of a top-level circuit. kicad_simulate runs it.",+ related: &["kicad_simulate", "kicad_run_erc", "kicad_extract_netlist"],+ pitfalls: &["Ground must be the net 0: KiCad's GND symbol makes it so.", "Model files are referenced by .include with the path the part's Sim.Library field gives."],+ },+ Verb {+ name: "kicad_simulate",+ summary: "Simulate a schematic with SPICE, headless: KiCad's netlist of the sheet run by the ngspice library KiCad installs, with the analysis you give (transient, AC, DC sweep, operating point) or the one the schematic carries; each probe comes back downsampled with its first, last, min and max values (AC as dB and phase), and as a CSV file.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+ input: "{\"schPath\": \"C:/.../x.kicad_sch\", \"analysis\"?: \".tran 10u 5m\" | [\".op\", ...], \"probes\"?: [\"V(/OUT)\", \"I(V1)\"], \"points\"?: 200, \"outputDir\"?: \"C:/...\", \"timeoutSec\"?: 120}",+ example: "kicad_simulate {\"schPath\":\"C:/Users/john/proj/filter.kicad_sch\",\"analysis\":\".ac dec 20 10 1meg\",\"probes\":[\"V(/OUT)\"]}",+ hint: "probes are node voltages V(/NET) (or the vector names in available) and source currents I(V1); without probes every node and branch comes back (at most 24). sweep is time, frequency or the DC sweep. An AC probe has db and phaseDeg. csvPath holds the same points. Read log for ngspice's own messages when a run fails.",+ related: &["kicad_export_spice", "kicad_build_schematic", "kicad_sch_set_property"],+ pitfalls: &["Every part needs a SPICE model: R, C and L work from their values; sources come from the Simulation_SPICE library; ICs need a model file (Sim.Library).", "Runs on Windows through KiCad's ngspice.dll.", "A long transient with a tiny step returns many points: points downsamples the reply, not the run."],+ },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+ Some(match command {+ "kicad_export_spice" => export_spice(state, args).unwrap_or_else(|e| e),+ "kicad_simulate" => simulate(state, args).unwrap_or_else(|e| e),+ _ => return None,+ })+}++/// kicad-cli's SPICE netlist of `sch` written to `out`.+fn export(state: &mut State, sch: &Path, out: &Path, format: &str) -> Result<(String, PathBuf), Value> {+ let info = state.kicad_info();+ let Some(inst) = info.primary() else {+ let mut v = fail("not_installed", "KiCad not installed", "The netlist comes from kicad-cli.");+ v["alternatives"] = alternatives();+ return Err(v);+ };+ let cli_path = PathBuf::from(&inst.kicad_cli);+ let (out_s, sch_s) = (norm(out), norm(sch));+ let r = cli::run(&cli_path, &["sch", "export", "netlist", "--format", format, "--output", &out_s, &sch_s], Duration::from_secs(100));+ if r.exit_code != 0 || !out.is_file() {+ return Err(fail("export_failed", format!("kicad-cli could not export the netlist (rc {}): {}", r.exit_code, r.stderr.trim()), "kicad_run_erc shows what is wrong with the sheet."));+ }+ let text = std::fs::read_to_string(out).map_err(|e| fail("export_failed", format!("{}: {e}", norm(out)), ""))?;+ Ok((text, cli_path))+}++fn sch_arg(args: &Value) -> Result<PathBuf, Value> {+ let Some(p) = arg_any(args, &["schPath", "filePath"]).map(PathBuf::from) else {+ return Err(fail("missing_arg", "schPath is required", "The .kicad_sch to simulate (the root sheet of a project)."));+ };+ if !p.is_file() {+ return Err(fail("file_not_found", format!("{} does not exist", norm(&p)), "Use the desktop's absolute path."));+ }+ Ok(p)+}++fn export_spice(state: &mut State, args: &Value) -> Result<Value, Value> {+ let sch = sch_arg(args)?;+ let format = arg_str(args, "format").unwrap_or("spice");+ if !["spice", "spicemodel"].contains(&format) {+ return Err(fail("bad_args", "format is spice or spicemodel", ""));+ }+ let out = arg_str(args, "outputPath").map(PathBuf::from).unwrap_or_else(|| sch.with_extension("cir"));+ let (text, _) = export(state, &sch, &out, format)?;+ let mut v = spice::describe(&text);+ v["success"] = json!(true);+ v["path"] = json!(norm(&out));+ v["format"] = json!(format);+ v["output"] = json!(format!("SPICE netlist of {} written to {}", norm(&sch), norm(&out)));+ v["_hint"] = json!("kicad_simulate {schPath, analysis, probes} runs it through KiCad's ngspice.");+ Ok(v)+}++fn simulate(state: &mut State, args: &Value) -> Result<Value, Value> {+ let sch = sch_arg(args)?;+ let analyses: Vec<String> = match args.get("analysis") {+ Some(Value::String(s)) => vec![s.clone()],+ Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(String::from).collect(),+ _ => Vec::new(),+ };+ let probes: Vec<String> = args.get("probes").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).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")?;+ let lines = spice::prepare(&text, &analyses).map_err(|e| fail("bad_args", e, "analysis: \".tran 10u 5m\", \".ac dec 20 10 1meg\", \".dc V1 0 5 0.1\" or \".op\"."))?;+ let dll = cli_path.parent().unwrap_or(Path::new(".")).join("ngspice.dll");+ if !dll.is_file() {+ return Err(fail("ngspice_missing", format!("{} is not there", norm(&dll)), "KiCad's simulator library ships with KiCad 7 and later on Windows; reinstall KiCad (kicad_upgrade)."));+ }+ let cwd = sch.parent().unwrap_or(Path::new(".")).to_path_buf();+ // KiCad's XSPICE code models (<kicad>/lib/ngspice/*.cm), loaded the way KiCad's simulator loads them+ let cm_dir = cli_path.parent().and_then(Path::parent).map(|b| b.join("lib").join("ngspice")).unwrap_or_default();+ let mut codemodels: Vec<String> = std::fs::read_dir(&cm_dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "cm").unwrap_or(false)).map(|p| norm(&p)).collect();+ codemodels.sort();+ let job = json!({"dll": norm(&dll), "cwd": norm(&cwd), "lines": lines, "codemodels": codemodels});+ let timeout = Duration::from_secs(args.get("timeoutSec").and_then(Value::as_u64).unwrap_or(120).clamp(5, 280));+ let started = Instant::now();+ let run = worker(&job, timeout)?;+ let _ = std::fs::remove_dir_all(&tmp);+ if let Some(e) = run["error"].as_str() {+ let mut v = fail("simulation_failed", e.to_string(), "Read the error: a part without a model, a node with no DC path to ground, or a bad analysis line. kicad_export_spice shows the netlist.");+ v["netlist"] = json!(lines);+ return Err(v);+ }+ let mut v = spice::summarize(&run, &probes, points).map_err(|e| fail("no_such_probe", e, "Probe node voltages as V(/NET) and source currents as I(V1); available lists this run's vectors."))?;+ let out_dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| sch.parent().unwrap_or(Path::new(".")).join("sim_output"));+ let _ = std::fs::create_dir_all(&out_dir);+ let stem = sch.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "sim".into());+ let csv_path = out_dir.join(format!("{stem}-{}.csv", v["plot"].as_str().unwrap_or("run")));+ let _ = std::fs::write(&csv_path, spice::csv(&v));+ v["success"] = json!(true);+ v["csvPath"] = json!(norm(&csv_path));+ v["analysis"] = json!(lines.iter().filter(|l| l.starts_with('.') && !l.eq_ignore_ascii_case(".end") && !l.to_lowercase().starts_with(".title") && !l.to_lowercase().starts_with(".include")).collect::<Vec<_>>());+ v["log"] = json!(run["log"].as_array().map(|l| l.iter().rev().take(12).rev().cloned().collect::<Vec<_>>()).unwrap_or_default());+ v["seconds"] = json!((started.elapsed().as_secs_f64() * 100.0).round() / 100.0);+ v["engine"] = json!("ngspice (KiCad's ngspice.dll)");+ v["output"] = json!(format!("{} vector(s) of {} over {} point(s)", v["probes"].as_array().map_or(0, |a| a.len()), v["plot"].as_str().unwrap_or(""), v["of"]));+ v["_hint"] = json!("probes[].values follow sweep.values (AC: db and phaseDeg). csvPath holds the same points; pull it with ab pull_file. missing lists probes this run did not have.");+ Ok(v)+}++/// The job in a child of this binary, with a deadline: ngspice keeps global state and can exit its host.+fn worker(job: &Value, timeout: Duration) -> Result<Value, Value> {+ let exe = std::env::current_exe().map_err(|e| fail("worker_failed", format!("cannot find this bridge's binary: {e}"), ""))?;+ let mut child = std::process::Command::new(exe)+ .arg("--ngspice-worker")+ .stdin(std::process::Stdio::piped())+ .stdout(std::process::Stdio::piped())+ .stderr(std::process::Stdio::null())+ .spawn()+ .map_err(|e| fail("worker_failed", format!("cannot start the simulation worker: {e}"), ""))?;+ if let Some(mut si) = child.stdin.take() {+ let _ = si.write_all(job.to_string().as_bytes());+ }+ let stdout = child.stdout.take();+ let reader = std::thread::spawn(move || {+ let mut s = String::new();+ if let Some(mut o) = stdout {+ use std::io::Read;+ let _ = o.read_to_string(&mut s);+ }+ s+ });+ let start = Instant::now();+ loop {+ match child.try_wait() {+ Ok(Some(_)) => break,+ Ok(None) if start.elapsed() > timeout => {+ let _ = child.kill();+ let _ = child.wait();+ return Err(fail("simulation_timeout", format!("the simulation ran past {} s and was stopped", timeout.as_secs()), "Shorten the analysis (a smaller stop time or fewer points) or pass timeoutSec."));+ }+ Ok(None) => std::thread::sleep(Duration::from_millis(100)),+ Err(e) => return Err(fail("worker_failed", format!("{e}"), "")),+ }+ }+ let out = reader.join().unwrap_or_default();+ 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."))+}
rust/crates/kicad-core/src/lib.rs+1@@ -52,6 +52,7 @@ pub mod fpedit; pub mod symedit; pub mod land_pattern; pub mod library_views;+pub mod spice; pub mod libops; pub mod board_build; pub mod board_checks;
rust/crates/kicad-core/src/spice.rsadded+210@@ -0,0 +1,210 @@+//! SPICE for kicad_export_spice and kicad_simulate (wiki #89), the pure half: the netlist KiCad exports+//! (`kicad-cli sch export netlist --format spice`) made ready to run with the analysis asked for, and+//! ngspice's vectors summarised for an AI (each probe downsampled, with its first, last, min and max; AC as+//! magnitude in dB and phase in degrees). The run itself is kicad_platform::ngspice, in a child process.++use serde_json::{json, Value};++/// The dot-commands that start an analysis.+const ANALYSES: &[&str] = &[".tran", ".ac", ".dc", ".op", ".noise", ".tf", ".disto", ".pz", ".sp"];++fn is_analysis(line: &str) -> bool {+ let l = line.trim_start().to_lowercase();+ ANALYSES.iter().any(|a| l == *a || l.starts_with(&format!("{a} ")))+}++/// What the exported netlist holds: its title, the analyses the schematic carries, its .include/.lib lines+/// and how many element lines.+pub fn describe(netlist: &str) -> Value {+ let lines: Vec<&str> = netlist.lines().collect();+ let title = lines.iter().find_map(|l| l.trim().strip_prefix(".title").map(|t| t.trim().to_string()));+ let analyses: Vec<&str> = lines.iter().map(|l| l.trim()).filter(|l| is_analysis(l)).collect();+ let includes: Vec<&str> = lines.iter().map(|l| l.trim()).filter(|l| { let x = l.to_lowercase(); x.starts_with(".include") || x.starts_with(".lib") }).collect();+ let elements = lines.iter().filter(|l| { let t = l.trim_start(); !t.is_empty() && !t.starts_with('.') && !t.starts_with('*') && !t.starts_with('+') }).count();+ json!({"title": title, "analyses": analyses, "includes": includes, "elements": elements, "lines": lines.len()})+}++/// The circuit to run: the netlist without its `.end`, the schematic's own analyses commented out when+/// `analyses` are given (theirs win), then those analyses and `.end`.+pub fn prepare(netlist: &str, analyses: &[String]) -> Result<Vec<String>, String> {+ let mut out: Vec<String> = Vec::new();+ for l in netlist.lines() {+ let t = l.trim();+ if t.eq_ignore_ascii_case(".end") {+ continue;+ }+ if !analyses.is_empty() && is_analysis(t) {+ out.push(format!("* replaced by the call: {t}"));+ continue;+ }+ out.push(l.to_string());+ }+ for a in analyses {+ let a = a.trim();+ if !is_analysis(a) {+ return Err(format!("{a:?} is not an analysis: give one of {}", ANALYSES.join(", ")));+ }+ out.push(a.to_string());+ }+ if analyses.is_empty() && !out.iter().any(|l| is_analysis(l)) {+ return Err("the schematic carries no analysis and none was given: pass analysis, e.g. \".tran 10u 5m\" or \".ac dec 20 10 1meg\"".into());+ }+ out.push(".end".into());+ Ok(out)+}++/// A probe's name as ngspice names its vector: `V(/OUT)` and `/out` both mean the node vector `/out`;+/// `I(R1)` is `r1#branch`.+pub fn vector_name(probe: &str) -> String {+ let p = probe.trim().to_lowercase();+ if let Some(inner) = p.strip_prefix("v(").and_then(|s| s.strip_suffix(')')) {+ return inner.to_string();+ }+ if let Some(inner) = p.strip_prefix("i(").and_then(|s| s.strip_suffix(')')) {+ return format!("{inner}#branch");+ }+ p+}++fn r(v: f64) -> f64 {+ if v == 0.0 || !v.is_finite() {+ return v;+ }+ let mag = 10f64.powi(5 - v.abs().log10().ceil() as i32);+ (v * mag).round() / mag+}++fn pick(data: &[f64], idx: &[usize]) -> Vec<f64> {+ idx.iter().map(|&i| r(data[i])).collect()+}++/// The run's vectors as the reply gives them: the sweep (time, frequency or a DC sweep) and each probe+/// (every node and branch vector when none are named, at most 24), downsampled to `points`.+pub fn summarize(run: &Value, probes: &[String], points: usize) -> Result<Value, String> {+ let vecs = run["vectors"].as_array().cloned().unwrap_or_default();+ let name = |v: &Value| v["name"].as_str().unwrap_or("").to_lowercase();+ let sweep = vecs.iter().find(|v| ["time", "frequency", "v-sweep", "i-sweep", "temp-sweep"].contains(&name(v).as_str()) || name(v).ends_with("-sweep"));+ let len = vecs.iter().map(|v| v["real"].as_array().or(v["re"].as_array()).map(|a| a.len()).unwrap_or(0)).max().unwrap_or(0);+ let points = points.clamp(2, 5000);+ let idx: Vec<usize> = if len <= points { (0..len).collect() } else { (0..points).map(|k| k * (len - 1) / (points - 1)).collect() };+ 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 wanted: Vec<String> = probes.iter().map(|p| vector_name(p)).collect();+ let mut missing = Vec::new();+ let chosen: Vec<&Value> = if wanted.is_empty() {+ vecs.iter().filter(|v| Some(*v) != sweep).take(24).collect()+ } else {+ let mut c = Vec::new();+ for w in &wanted {+ match vecs.iter().find(|v| name(v) == *w || name(v).trim_start_matches('/') == w.trim_start_matches('/')) {+ Some(v) => c.push(v),+ None => missing.push(w.clone()),+ }+ }+ c+ };+ if chosen.is_empty() {+ return Err(format!("none of the probes is a vector of this run ({}); it has: {}", wanted.join(", "), vecs.iter().map(name).take(40).collect::<Vec<_>>().join(", ")));+ }+ let mut out = Vec::new();+ for v in chosen {+ let n = v["name"].as_str().unwrap_or("");+ if v["real"].is_array() {+ let d = nums(v, "real");+ if d.is_empty() {+ continue;+ }+ let (lo, hi) = d.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(*x), m.1.max(*x)));+ out.push(json!({"name": n, "first": r(d[0]), "last": r(*d.last().unwrap()), "min": r(lo), "max": r(hi), "values": pick(&d, &idx.iter().copied().filter(|&i| i < d.len()).collect::<Vec<_>>())}));+ } 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();+ let ph: Vec<f64> = re.iter().zip(&im).map(|(a, b)| b.atan2(*a).to_degrees()).collect();+ if db.is_empty() {+ continue;+ }+ let ii: Vec<usize> = idx.iter().copied().filter(|&i| i < db.len()).collect();+ let (lo, hi) = db.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(*x), m.1.max(*x)));+ out.push(json!({"name": n, "complex": true, "firstDb": r(db[0]), "lastDb": r(*db.last().unwrap()), "minDb": r(lo), "maxDb": r(hi), "db": pick(&db, &ii), "phaseDeg": pick(&ph, &ii)}));+ }+ }+ let x = sweep.map(|s| {+ let d = if s["real"].is_array() { nums(s, "real") } else { nums(s, "re") };+ json!({"name": s["name"], "first": d.first().map(|v| r(*v)), "last": d.last().map(|v| r(*v)), "values": pick(&d, &idx.iter().copied().filter(|&i| i < d.len()).collect::<Vec<_>>())})+ });+ Ok(json!({"plot": run["plot"], "points": idx.len(), "of": len, "sweep": x, "probes": out, "missing": missing,+ "available": vecs.iter().map(name).take(60).collect::<Vec<_>>()}))+}++/// The summary as CSV: the sweep, then a column per probe (dB and phase for a complex one).+pub fn csv(summary: &Value) -> String {+ let sweep: Vec<f64> = summary["sweep"]["values"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();+ let probes = summary["probes"].as_array().cloned().unwrap_or_default();+ let mut head = vec![summary["sweep"]["name"].as_str().unwrap_or("index").to_string()];+ let mut cols: Vec<Vec<f64>> = Vec::new();+ for p in &probes {+ let n = p["name"].as_str().unwrap_or("");+ if p["complex"] == json!(true) {+ head.push(format!("{n} dB"));+ head.push(format!("{n} deg"));+ cols.push(p["db"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default());+ cols.push(p["phaseDeg"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default());+ } else {+ head.push(n.to_string());+ cols.push(p["values"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default());+ }+ }+ let rows = cols.iter().map(Vec::len).chain(std::iter::once(sweep.len())).max().unwrap_or(0);+ let mut s = head.join(",") + "\n";+ for i in 0..rows {+ let mut row = vec![sweep.get(i).map(|v| v.to_string()).unwrap_or_else(|| i.to_string())];+ for c in &cols {+ row.push(c.get(i).map(|v| v.to_string()).unwrap_or_default());+ }+ s += &(row.join(",") + "\n");+ }+ s+}++#[cfg(test)]+mod tests {+ use super::*;++ const NET: &str = ".title KiCad schematic\nV1 /IN 0 DC 0 SIN( 0 1 1k ) AC 1\nR1 /IN /OUT 1k\nC1 /OUT 0 100n\n.tran 1u 2m\n.end\n";++ #[test]+ fn the_call_s_analysis_replaces_the_schematic_s() {+ let d = describe(NET);+ assert_eq!(d["elements"], 3);+ assert_eq!(d["analyses"], json!([".tran 1u 2m"]));+ let p = prepare(NET, &[".ac dec 10 10 100k".into()]).unwrap();+ assert!(p.contains(&"* replaced by the call: .tran 1u 2m".to_string()));+ assert_eq!(p[p.len() - 2], ".ac dec 10 10 100k");+ assert_eq!(p.last().unwrap(), ".end");+ assert_eq!(prepare(NET, &[]).unwrap().iter().filter(|l| *l == ".tran 1u 2m").count(), 1, "the schematic's own analysis runs");+ assert!(prepare("R1 a 0 1k\n.end\n", &[]).is_err(), "no analysis anywhere");+ assert!(prepare(NET, &["R2 a b 1".into()]).is_err());+ }++ #[test]+ fn probes_are_named_the_way_ngspice_names_vectors() {+ assert_eq!(vector_name("V(/OUT)"), "/out");+ assert_eq!(vector_name("I(V1)"), "v1#branch");+ let run = json!({"plot": "tran1", "vectors": [+ {"name": "time", "real": [0.0, 1.0, 2.0, 3.0, 4.0]},+ {"name": "/out", "real": [0.0, 0.5, 0.75, 0.875, 0.9375]},+ {"name": "v1#branch", "real": [0.0, -1.0, -0.5, -0.25, -0.125]}]});+ let s = summarize(&run, &["V(/OUT)".into(), "v(/nope)".into()], 3).unwrap();+ assert_eq!(s["probes"][0]["name"], "/out");+ assert_eq!(s["probes"][0]["values"], json!([0.0, 0.75, 0.9375]));+ assert_eq!(s["probes"][0]["max"], 0.9375);+ assert_eq!(s["sweep"]["values"], json!([0.0, 2.0, 4.0]));+ assert_eq!(s["missing"], json!(["/nope"]));+ assert!(csv(&s).starts_with("time,/out\n0,0\n2,0.75\n"));+ // AC: magnitude in dB and phase+ let ac = json!({"plot": "ac1", "vectors": [{"name": "frequency", "re": [10.0, 100.0], "im": [0.0, 0.0]}, {"name": "/out", "re": [1.0, 0.0], "im": [0.0, -0.1]}]});+ let a = summarize(&ac, &["/out".into()], 10).unwrap();+ assert_eq!(a["probes"][0]["db"], json!([0.0, -20.0]));+ assert_eq!(a["probes"][0]["phaseDeg"], json!([0.0, -90.0]));+ assert!(summarize(&run, &["v(/x)".into()], 3).is_err());+ }+}
rust/crates/kicad-platform/Cargo.toml+1@@ -35,6 +35,7 @@ windows = { version = "0.61", features = [ "Win32_Storage_Xps", # PrintWindow lives here in the windows crate (capture_window) "Win32_System_SystemInformation", # GetTickCount (seconds_since_input) "Win32_Media_Multimedia", # mciSendStringW: tour narration audio without a window (tour_input)+ "Win32_System_LibraryLoader", # LoadLibraryExW/GetProcAddress: KiCad's ngspice.dll for kicad_simulate (ngspice.rs) ] } uiautomation = "0.24"
rust/crates/kicad-platform/src/lib.rs+3@@ -343,6 +343,9 @@ pub fn major_minor(version: &str) -> String { version.split('.').take(2).collect::<Vec<_>>().join(".") } +/// SPICE through KiCad's ngspice library (kicad_simulate).+pub mod ngspice;+ #[cfg(windows)] mod windows; #[cfg(windows)]
rust/crates/kicad-platform/src/ngspice.rsadded+161@@ -0,0 +1,161 @@+//! A SPICE simulation through the ngspice shared library KiCad installs (`<kicad>/bin/ngspice.dll`), the+//! engine behind KiCad's own simulator (wiki #89). ngspice keeps global state and can bring its host down+//! on a bad circuit, so the bridge runs this in a child process of itself (`kicad-bridge --ngspice-worker`)+//! and only ever once per process. Windows only for now: the library names its API in sharedspice.h.++use serde_json::{json, Value};++/// Run `lines` (a whole circuit, analysis lines and `.end` included) in the ngspice library at `dll`, with+/// `cwd` as the working folder (so `.include` paths resolve), and return every vector of the last plot:+/// `{plot, vectors: [{name, type, real: [..]} | {name, type, re: [..], im: [..]}], log: [..]}`.+/// `codemodels` are the XSPICE code model libraries (`<kicad>/lib/ngspice/*.cm`) loaded first, as KiCad's own+/// simulator does: KiCad ships no spinit to load them.+pub fn run(dll: &std::path::Path, lines: &[String], cwd: &std::path::Path, codemodels: &[std::path::PathBuf]) -> Result<Value, String> {+ imp::run(dll, lines, cwd, codemodels)+}++#[cfg(windows)]+mod imp {+ use super::*;+ use std::ffi::{c_char, c_int, c_void, CStr, CString};+ use std::sync::Mutex;+ use windows::core::PCWSTR;+ use windows::Win32::Foundation::HMODULE;+ use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryExW, LOAD_WITH_ALTERED_SEARCH_PATH};++ #[repr(C)]+ struct Complex {+ re: f64,+ im: f64,+ }++ #[repr(C)]+ struct VectorInfo {+ name: *const c_char,+ v_type: c_int,+ flags: i16,+ real: *const f64,+ comp: *const Complex,+ length: c_int,+ }++ type SendChar = extern "C" fn(*const c_char, c_int, *mut c_void) -> c_int;+ type SendStat = extern "C" fn(*const c_char, c_int, *mut c_void) -> c_int;+ type Exit = extern "C" fn(c_int, bool, bool, c_int, *mut c_void) -> c_int;+ type Init = unsafe extern "C" fn(Option<SendChar>, Option<SendStat>, Option<Exit>, *mut c_void, *mut c_void, *mut c_void, *mut c_void) -> c_int;+ type Circ = unsafe extern "C" fn(*mut *mut c_char) -> c_int;+ type Command = unsafe extern "C" fn(*const c_char) -> c_int;+ type CurPlot = unsafe extern "C" fn() -> *const c_char;+ type AllVecs = unsafe extern "C" fn(*const c_char) -> *mut *mut c_char;+ type VecInfo = unsafe extern "C" fn(*const c_char) -> *const VectorInfo;++ static LOG: Mutex<Vec<String>> = Mutex::new(Vec::new());++ extern "C" fn on_print(s: *const c_char, _id: c_int, _u: *mut c_void) -> c_int {+ if !s.is_null() {+ let t = unsafe { CStr::from_ptr(s) }.to_string_lossy().into_owned();+ if let Ok(mut l) = LOG.lock() {+ if l.len() < 400 {+ l.push(t);+ }+ }+ }+ 0+ }+ extern "C" fn on_stat(_s: *const c_char, _id: c_int, _u: *mut c_void) -> c_int {+ 0+ }+ extern "C" fn on_exit(_status: c_int, _unload: bool, _quit: bool, _id: c_int, _u: *mut c_void) -> c_int {+ 0+ }++ fn sym<T: Copy>(lib: HMODULE, name: &str) -> Result<T, String> {+ let c = CString::new(name).unwrap();+ let p = unsafe { GetProcAddress(lib, windows::core::PCSTR(c.as_ptr() as *const u8)) }.ok_or(format!("ngspice has no {name}"))?;+ Ok(unsafe { std::mem::transmute_copy(&p) })+ }++ pub fn run(dll: &std::path::Path, lines: &[String], cwd: &std::path::Path, codemodels: &[std::path::PathBuf]) -> Result<Value, String> {+ std::env::set_current_dir(cwd).map_err(|e| format!("cannot work in {}: {e}", cwd.display()))?;+ let wide: Vec<u16> = dll.as_os_str().to_string_lossy().encode_utf16().chain(std::iter::once(0)).collect();+ // the altered search path finds the library's own dependencies beside it, in KiCad's bin folder+ let lib = unsafe { LoadLibraryExW(PCWSTR(wide.as_ptr()), None, LOAD_WITH_ALTERED_SEARCH_PATH) }.map_err(|e| format!("cannot load {}: {e}", dll.display()))?;+ let init: Init = sym(lib, "ngSpice_Init")?;+ let circ: Circ = sym(lib, "ngSpice_Circ")?;+ let command: Command = sym(lib, "ngSpice_Command")?;+ let cur_plot: CurPlot = sym(lib, "ngSpice_CurPlot")?;+ let all_vecs: AllVecs = sym(lib, "ngSpice_AllVecs")?;+ let vec_info: VecInfo = sym(lib, "ngGet_Vec_Info")?;+ let null = std::ptr::null_mut();+ unsafe { init(Some(on_print), Some(on_stat), Some(on_exit), null, null, null, null) };+ for cm in codemodels {+ let c = CString::new(format!("codemodel {}", cm.to_string_lossy().replace('\\', "/"))).unwrap();+ unsafe { command(c.as_ptr()) };+ }+ let owned: Vec<CString> = lines.iter().map(|l| CString::new(l.replace('\0', "")).unwrap()).collect();+ let mut ptrs: Vec<*mut c_char> = owned.iter().map(|c| c.as_ptr() as *mut c_char).collect();+ ptrs.push(std::ptr::null_mut());+ if unsafe { circ(ptrs.as_mut_ptr()) } != 0 {+ return Err(format!("ngspice did not take the circuit: {}", errors()));+ }+ let run = CString::new("run").unwrap();+ if unsafe { command(run.as_ptr()) } != 0 {+ return Err(format!("the analysis did not run: {}", errors()));+ }+ let plot_ptr = unsafe { cur_plot() };+ let plot = if plot_ptr.is_null() { String::new() } else { unsafe { CStr::from_ptr(plot_ptr) }.to_string_lossy().into_owned() };+ // an aborted analysis leaves the constants plot current: that is a failure, not a result+ let aborted = LOG.lock().map(|l| l.iter().any(|s| { let t = s.to_lowercase(); t.contains("aborted") || t.contains("fatal error") })).unwrap_or(false);+ 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;+ }+ 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 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<_>>()}));+ }+ }+ if vectors.is_empty() {+ return Err(format!("the analysis gave no vectors: {}", errors()));+ }+ Ok(json!({"plot": plot, "vectors": vectors, "log": LOG.lock().map(|l| l.clone()).unwrap_or_default()}))+ }++ fn errors() -> String {+ let l = LOG.lock().map(|l| l.clone()).unwrap_or_default();+ let e: Vec<&String> = l.iter().filter(|s| { let t = s.to_lowercase(); (t.contains("error") || t.contains("aborted")) && !t.contains("spinit") }).take(8).collect();+ if e.is_empty() { l.iter().rev().take(6).cloned().collect::<Vec<_>>().join(" | ") } else { e.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" | ") }+ }+}++#[cfg(not(windows))]+mod imp {+ use super::*;+ pub fn run(_dll: &std::path::Path, _lines: &[String], _cwd: &std::path::Path, _codemodels: &[std::path::PathBuf]) -> Result<Value, String> {+ Err("simulation runs through KiCad's ngspice.dll, on Windows for now".into())+ }+}
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.10.3"+version = "0.10.4" 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@@ -22,7 +22,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools | | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools |-| `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path |+| `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`). The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | @@ -95,7 +95,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.10.3"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.10.4"`), 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.13.3-- Standard commit: d991b05865a55a89da697159a8f626006281adbb-- Crate version: 0.10.3+- Standard version: 0.13.4+- Standard commit: bca24e18b1d8ef250edcb32683c7a4d20144ded6+- Crate version: 0.10.4 - Copied: 2026-10-28, 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/route.rs+74−10@@ -162,6 +162,15 @@ pub fn plan_route(input: &Value) -> Value { let ring: Vec<P> = p["points"].as_array()?.iter().map(|q| (f(&q[0]), f(&q[1]))).collect(); (ring.len() >= 3).then(|| (p["net"].as_str().unwrap_or("").to_string(), p["layer"].as_str().unwrap_or("").to_string(), Shape::Poly(ring))) }).collect();+ // a pour that holds the route's own end pad cannot be kept out of (fusion-lane #21: SW1.2 sits inside VBAT's F.Cu+ // pour, so avoidPours: ["VBAT"] found no path): it is exempt, and the reply names it+ let held: Vec<usize> = (0..pours.len()).filter(|&i| {+ let (_, l, s) = &pours[i];+ let Shape::Poly(ring) = s else { return false };+ ends.iter().any(|p| pad_layers(p, &copper).contains(l) && inside((f(&p["x"]), f(&p["y"])), ring))+ }).collect();+ let blocks = |i: usize| avoids(&pours[i].0) && !held.contains(&i);+ let exempt: Vec<Value> = held.iter().filter(|&&i| avoids(&pours[i].0)).map(|&i| json!({"net": pours[i].0, "layer": pours[i].1, "why": "it holds an end pad of the route"})).collect(); // copper of other nets, by layer; every pad for the via-in-pad rule. Net-less tracks and arcs are a hatched pour's // pieces (Altium lists 8777 of the Bluetooth Sentinel's 9378 tracks with no net) and pours re-pour round new copper, // so neither blocks a route: repour after placing it (the reply says so)@@ -230,7 +239,7 @@ pub fn plan_route(input: &Value) -> Value { edge_ok(a, a, r) && copper.iter().all(|l| clear_on(a, a, r, l)) && all_pads.iter().all(|s| s.dist(a, a) >= r + clearance - 1e-9) } else { edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[u.2])- && !pours.iter().any(|(n, l, s)| avoids(n) && *l == layers[u.2] && s.dist(a, b) < width / 2.0)+ && !(0..pours.len()).any(|i| blocks(i) && pours[i].1 == layers[u.2] && pours[i].2.dist(a, b) < width / 2.0) }; cache.insert(k, ok); ok@@ -276,14 +285,39 @@ pub fn plan_route(input: &Value) -> Value { let mut path = vec![goal_node]; while let Some(p) = parent.get(path.last().unwrap()) { path.push(*p); } path.reverse();+ // smooth the grid path: from each point, the farthest later point on the same layer that a straight or 45-degree+ // segment reaches with the same clearance, inside the outline and into no pour the grid path kept out of. Staircases+ // become diagonals (fusion-lane #21: Fusion's DRC flags every vertex of a staircase). smooth: false keeps the grid path.+ let at = |u: (usize, usize, usize)| (xs[u.0], ys[u.1]);+ let pours_hit = |a: P, b: P, l: usize| -> Vec<usize> { (0..pours.len()).filter(|&i| pours[i].1 == layers[l] && pours[i].2.dist(a, b) < width / 2.0).collect() };+ let octilinear = |a: P, b: P| { let (dx, dy) = ((b.0 - a.0).abs(), (b.1 - a.1).abs()); dx < 1e-9 || dy < 1e-9 || (dx - dy).abs() < 1e-9 };+ let smooth = args["smooth"].as_bool().unwrap_or(true);+ let mut pts = vec![path[0]];+ let mut i = 0;+ while i + 1 < path.len() {+ if path[i + 1].2 != path[i].2 || !smooth { pts.push(path[i + 1]); i += 1; continue; }+ let (mut best, mut j) = (i + 1, i + 2);+ let mut grid_pours: Vec<usize> = pours_hit(at(path[i]), at(path[i + 1]), path[i].2);+ while j < path.len() && path[j].2 == path[i].2 {+ for p in pours_hit(at(path[j - 1]), at(path[j]), path[i].2) { if !grid_pours.contains(&p) { grid_pours.push(p); } }+ let (a, b) = (at(path[i]), at(path[j]));+ if octilinear(a, b) && edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[path[i].2])+ && pours_hit(a, b, path[i].2).iter().all(|p| grid_pours.contains(p) && !blocks(*p)) { best = j; }+ j += 1;+ }+ pts.push(path[best]);+ i = best;+ } // keep the corners and the layer changes- let mut keep = vec![path[0]];- for w in path.windows(3) {+ let mut keep = vec![pts[0]];+ for w in pts.windows(3) { let (a, b, c) = (w[0], w[1], w[2]);- if a.2 == b.2 && b.2 == c.2 && ((a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1)) { continue; }+ let (pa, pb, pc) = (at(a), at(b), at(c));+ let straight = ((pb.0 - pa.0) * (pc.1 - pb.1) - (pb.1 - pa.1) * (pc.0 - pb.0)).abs() < 1e-9 && (pb.0 - pa.0) * (pc.0 - pb.0) + (pb.1 - pa.1) * (pc.1 - pb.1) >= 0.0;+ if a.2 == b.2 && b.2 == c.2 && straight { continue; } keep.push(b); }- if path.len() > 1 { keep.push(*path.last().unwrap()); }+ if pts.len() > 1 { keep.push(*pts.last().unwrap()); } let mut way = vec![json!({"pad": key(&ends[0])})]; let mut vias = Vec::new(); let mut length = 0.0;@@ -304,7 +338,8 @@ pub fn plan_route(input: &Value) -> Value { way.push(json!({"pad": key(&ends[1])})); json!({"success": true, "found": true, "lengthMm": r3(length), "vias": vias.len(), "viaAt": vias, "routeNet": {"net": net, "layer": layers[path[0].2], "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": [way]},- "planner": {"algorithm": "bounded orthogonal A*", "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "viasCheckedOn": copper, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},+ "planner": {"algorithm": if smooth { "bounded orthogonal A*, smoothed to straight and 45-degree segments" } else { "bounded orthogonal A*" }, "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "viasCheckedOn": copper, "clearanceMm": clearance, "edgeClearanceMm": edge,+ "avoidPours": args["avoidPours"].clone(), "avoidExempt": exempt, "pourCost": pour_cost, "smooth": smooth, "expandedNodes": seen.len()}, "crossesPours": crosses, "avoidPours": args["avoidPours"].clone(), "pourCost": pour_cost, "revision": revision, "_hint": if crosses.is_empty() { "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). A layer change is two waypoints at the same point, where route_net places a via. Pours (and a hatched pour's net-less pieces) are not obstacles: repour after placing the route. The path is orthogonal on the grid: a finer gridMm finds tighter paths and searches longer." } else { "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). The route runs through the pours in crossesPours: after the repour, check that none of those nets split in two (routing_state's splitNets), or plan again keeping out of them: avoidPours: true (or a list of the nets whose pours to keep out of), or pourCost: 2 to prefer going round them where a way exists. A layer change is two waypoints at the same point, where route_net places a via." }})@@ -419,15 +454,44 @@ mod tests { // by net: avoiding another net's pours only leaves this one crossable assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["VBAT"]}}))["vias"], json!(0)); assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["gnd"]}}))["vias"], json!(2));- // a pour over the whole board: a hard block finds no path, a cost still routes (across it)+ // a wall of pours across both layers: a hard block finds no path, a cost still routes (across it)+ let mut wall = board(json!([]));+ wall["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[8.0, 0.0], [12.0, 0.0], [12.0, 10.0], [8.0, 10.0]]}, {"net": "GND", "layer": "B.Cu", "points": [[8.0, 0.0], [12.0, 0.0], [12.0, 10.0], [8.0, 10.0]]}]);+ assert_eq!(plan_route(&json!({"board": wall, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}))["found"], json!(false));+ assert_eq!(plan_route(&json!({"board": wall, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 2.0}}))["found"], json!(true));+ // a pour that holds the route's own end pad cannot be kept out of: it is exempt and named (fusion-lane #21) let mut all = board(json!([]));- all["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}, {"net": "GND", "layer": "B.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}]);- assert_eq!(plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}))["found"], json!(false));- assert_eq!(plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 2.0}}))["found"], json!(true));+ all["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}]);+ let held = plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["GND"]}}));+ assert_eq!(held["found"], json!(true), "{held}");+ assert_eq!(held["planner"]["avoidExempt"][0]["net"], "GND");+ assert_eq!(held["planner"]["pourCost"], json!(0.0)); // and with the cost a short way round a narrow pour beats crossing it let mut strip = board(json!([])); strip["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[9.0, 2.0], [11.0, 2.0], [11.0, 10.0], [9.0, 10.0]]}]); let soft = plan_route(&json!({"board": strip, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 10.0, "allowVias": false}})); assert_eq!(soft["crossesPours"], json!([]), "{soft}"); }+ #[test]+ fn a_staircase_becomes_one_45_degree_segment() {+ // U1.1 at (2, 5) to a pad at (8, 9): the grid path is a staircase of corners; smoothed, the plan is straight and+ // 45-degree runs only, the diagonal clear of everything+ let mut b = board(json!([]));+ b["pads"][1]["x"] = json!(8.0); b["pads"][1]["y"] = json!(9.0);+ let r = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+ let way = r["routeNet"]["paths"][0].as_array().unwrap().clone();+ let mut pts = vec![(2.0, 5.0)];+ pts.extend(way[1..way.len() - 1].iter().map(|w| (f(&w["x"]), f(&w["y"]))));+ pts.push((8.0, 9.0));+ assert!(pts.len() <= 3, "at most one corner: {r}");+ for w in pts.windows(2) {+ let (dx, dy) = ((w[1].0 - w[0].0).abs(), (w[1].1 - w[0].1).abs());+ assert!(dx < 1e-9 || dy < 1e-9 || (dx - dy).abs() < 1e-9, "{w:?}");+ }+ assert!(pts.windows(2).any(|w| (w[1].0 - w[0].0).abs() > 1e-9 && (w[1].1 - w[0].1).abs() > 1e-9), "one diagonal: {r}");+ // shorter than the 10 mm staircase: 4 * sqrt(2) + 2+ assert!((f(&r["lengthMm"]) - (4.0 * 2f64.sqrt() + 2.0)).abs() < 1e-3, "{r}");+ let grid = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "smooth": false}}));+ assert!((f(&grid["lengthMm"]) - 10.0).abs() < 1e-9);+ } }
skills/kicad-bridge-test/run_verb_tests.py+21−1@@ -196,7 +196,7 @@ def matrix(w: dict) -> dict: "check_orientation", "find_space", "height_check", "placement_check", "pin_pad_check", "auto_classes", "thermal_vias", "via_fence", "board_report", "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators", "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic",- "library_views", "generate_body", "plan_route")},+ "library_views", "generate_body", "plan_route", "export_spice", "simulate")}, "panelize": dict(phase=8, scenario=True), # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files "add_footprint": dict(phase=8, scenario=True),@@ -1587,6 +1587,26 @@ def main(): g3 = next((c for c in gv.get("captured") or [] if c.get("view") == "3d"), {}) own("generate_body", {"body": gb, "view": gv}, ok(gb) and eng(gb) and abs(((gb.get("bboxMm") or {}).get("z") or 0) - 1.75) < 0.01 and (gb.get("attached") or {}).get("success") and g3.get("ok") and abs((g3.get("bodyHeightMm") or 0) - 1.75) < 0.1, "no SOIC-8 body written, attached and rendered at its 1.75 mm height")+ # 1.0.78: SPICE (wiki #89): an RC low-pass (1k, 100n) drawn from a netlist, exported, and swept: -3 dB at 1/(2 pi RC)+ rc = w["out"] + "/sim/rc.kicad_sch"+ C("build_schematic", {"schPath": rc, "overwrite": True, "title": "RC", "parts": [{"ref": "V1", "symbol": "Simulation_SPICE:VSIN"},+ {"ref": "R1", "symbol": "Device:R", "comment": "1k"}, {"ref": "C1", "symbol": "Device:C", "comment": "100n"}],+ "nets": {"IN": ["V1.1", "R1.1"], "OUT": ["R1.2", "C1.1"], "GND": ["V1.2", "C1.2"]}}, 400)+ ex = C("export_spice", {"schPath": rc}, 200)+ own("export_spice", ex, ok(ex) and ex.get("elements") == 3 and (ex.get("path") or "").endswith(".cir"), "no 3-element SPICE netlist of the RC")+ ac = C("simulate", {"schPath": rc, "analysis": ".ac dec 20 10 100k", "probes": ["V(/OUT)"], "points": 500}, 300)+ f = ((ac.get("sweep") or {}).get("values") or [])+ db = (((ac.get("probes") or [{}])[0]).get("db") or [])+ # the crossing interpolated in log frequency: at 20 points a decade the grid steps past it (1584.9 Hz is -2.99 dB)+ f3 = None+ for i in range(len(min(f, db, key=len)) - 1):+ if db[i] > -3.0 >= db[i + 1] and f[i] > 0 and f[i + 1] > 0:+ t = (-3.0 - db[i]) / (db[i + 1] - db[i])+ f3 = 10 ** (math.log10(f[i]) + t * (math.log10(f[i + 1]) - math.log10(f[i])))+ break+ bad = C("simulate", {"schPath": rc, "analysis": ".dc V9 0 1 0.1"}, 200)+ own("simulate", {"ac": ac, "bad": bad}, ok(ac) and f3 is not None and abs(f3 - 1591.5) / 1591.5 < 0.02 and ac.get("csvPath") and bad.get("errorCode") == "simulation_failed",+ f"the RC's -3 dB point was {f3} Hz, not about 1592, or a failing analysis did not fail") pr = C("plan_route", {"filePath": CK, "source": "file", "net": "/DRV_GHC", "from": "R12.1", "to": "U4.13"}, 200) rn = pr.get("routeNet") or {} own("plan_route", pr, ok(pr) and eng(pr) and pr.get("found") is True and (pr.get("planner") or {}).get("widthFrom") and isinstance(pr.get("crossesPours"), list) and rn.get("expectedRevision") == pr.get("revision") and rn.get("filePath") and rn.get("paths") and rn["paths"][0][0] == {"pad": "R12.1"},
skills/kicad-schematic-and-board-edits/SKILL.md+16−1@@ -1,7 +1,7 @@ --- name: kicad-schematic-and-board-edits description: >-- Build and change a KiCad design through the KiCad Bridge without touching the GUI: create a project, draw a schematic (place, rotate, move, wire pin to pin, labels and net stubs, junctions, buses and bus entries, hierarchical sheets and sheet pins, annotate, replace a symbol, reset fields, update from library, batches), read it back (components, pins, items, trace a point, check, design review, PNG render), make symbols and footprints, and edit the board (add, edit, flip, align, delete footprints, mounting holes, outline, layer count, graphics and SVG logos, 3D models, netclasses, board and custom rules, modify or copy tracks, diff pairs, refill, save, update the PCB from the schematic) plus fab outputs (DXF, GenCAD, IPC-2581, IPC-D-356, ODB++, one-zip fab package, fab pre-flight, cost). Trigger words: draw a schematic, wire these pins, add a net label, add a bus, hierarchical sheet, annotate, replace symbol, create a symbol, create a footprint, new kicad project, add a footprint to the board, flip a part, align parts, mounting hole, board outline, import a logo, netclass, design rules, update pcb from schematic, odb++, ipc-2581, fab package, pcb cost estimate, design review.+ Build and change a KiCad design through the KiCad Bridge without touching the GUI: create a project, draw a schematic (place, rotate, move, wire pin to pin, labels and net stubs, junctions, buses and bus entries, hierarchical sheets and sheet pins, annotate, replace a symbol, reset fields, update from library, batches), read it back (components, pins, items, trace a point, check, design review, PNG render), make symbols and footprints, and edit the board (add, edit, flip, align, delete footprints, mounting holes, outline, layer count, graphics and SVG logos, 3D models, netclasses, board and custom rules, modify or copy tracks, diff pairs, refill, save, update the PCB from the schematic) plus fab outputs (DXF, GenCAD, IPC-2581, IPC-D-356, ODB++, one-zip fab package, fab pre-flight, cost). Trigger words: simulate the circuit, spice simulation, ngspice, transient analysis, AC sweep, frequency response, bode plot, operating point, export spice netlist, draw a schematic, wire these pins, add a net label, add a bus, hierarchical sheet, annotate, replace symbol, create a symbol, create a footprint, new kicad project, add a footprint to the board, flip a part, align parts, mounting hole, board outline, import a logo, netclass, design rules, update pcb from schematic, odb++, ipc-2581, fab package, pcb cost estimate, design review. --- # Schematic and board edits@@ -25,6 +25,21 @@ Every verb here edits files or the live board the way KiCad would, answers what - Every write keeps a `.bak` and reports the ERC change. An open Schematic Editor does not see a file edit until it reloads the sheet: close it first, or reload after. +## Simulation (SPICE, headless)++`kicad_simulate {schPath, analysis, probes}` runs the sheet through ngspice, the simulator KiCad itself uses+(KiCad's own ngspice.dll on Windows), in a child process so a bad circuit cannot take the bridge down:+- `analysis`: `".tran 10u 5m"`, `".ac dec 20 10 1meg"`, `".dc V1 0 5 0.1"` or `".op"` (or a list); without+ it, the analysis the schematic carries runs.+- `probes`: node voltages `V(/OUT)` and source currents `I(V1)`; without them every node and branch comes+ back (24 at most). `available` lists this run's vectors.+- Each probe comes back downsampled to `points` (200) with its first, last, min and max; an AC probe as+ `db` and `phaseDeg`. `csvPath` holds the same points; `sweep` is time, frequency or the DC sweep.++Every part needs a SPICE model: R, C and L work from their values, sources come from the `Simulation_SPICE`+library (VSIN, VDC, VPULSE...), ICs need a model file (their Sim.Library field). `kicad_export_spice` writes+KiCad's netlist (`.cir`) and says what it holds. Checked: an RC of 1k and 100n is -3 dB at 1.58 kHz, -45 deg.+ ## Board (`kicad_*` on a .kicad_pcb) - **Parts**: `add_footprint` (from a library, a .kicad_mod or a copy of a placed part), `edit_footprint` (value,