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.
master
Colby Knox
Merge PR #322: kicad-lane: 1.0.73, framed renders, canvas check, engine 0.4.1
9f5826b
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
//! `kicad-cli pcb render` (silkscreen PNGs) and the molecule export pack, ported from
//! handlers/export_molecule.py.
//!
//! The Adom molecule pipeline (container side) is: STEP, then step2glb (color-preserving
//! GLB), then `adom-molecule create --glb --pcb --sch --pro`. adom-molecule REQUIRES
//! four files for a KiCad source: .glb, .kicad_pcb, .kicad_sch and .kicad_pro. The GLB
//! comes from the STEP in the container; everything else lives on the desktop next to
//! the board. `export_molecule` does the whole desktop half in one call and returns a
//! manifest of host paths plus the exact container commands to finish.
//!
//! Every kicad-cli call goes through a `Runner` so the logic is unit-testable without
//! kicad-cli; the verb passes `cli_runner(path)`, which wraps `crate::cli::run`.
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::{json, Map, Value};
use crate::cli::{self, CliResult};
/// Runs kicad-cli with the given argv (no program name) and deadline.
pub type Runner<'a> = &'a dyn Fn(&[String], Duration) -> CliResult;
/// The real thing: `crate::cli::run` bound to one kicad-cli path.
pub fn cli_runner(cli: &Path) -> impl Fn(&[String], Duration) -> CliResult + '_ {
move |argv: &[String], timeout: Duration| {
let argv_ref: Vec<&str> = argv.iter().map(String::as_str).collect();
cli::run(cli, &argv_ref, timeout)
}
}
fn norm(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
fn stem_of(p: &Path) -> String {
p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into())
}
/// `kicad-cli pcb render --side <side> --background transparent --output <png> <pcb>`
/// (KiCad 9+). Ok(png) when the command succeeded AND the file exists; Err(reason)
/// otherwise, phrased like the Python's `missing` entries.
pub fn render_side(run: Runner, pcb: &Path, side: &str, png: &Path, timeout: Duration) -> Result<PathBuf, String> {
let argv: Vec<String> = vec![
"pcb".into(), "render".into(), "--side".into(), side.into(),
"--background".into(), "transparent".into(), "--output".into(), norm(png), norm(pcb),
];
let r = run(&argv, timeout);
if r.timed_out {
return Err(format!("silk-{side} (kicad-cli pcb render timed out after {}s)", timeout.as_secs()));
}
if r.exit_code == -1 {
return Err(format!("silk-{side} ({})", r.stderr.trim()));
}
if r.exit_code == 0 && png.is_file() {
Ok(png.to_path_buf())
} else {
Err(format!("silk-{side} (kicad-cli pcb render rc={})", r.exit_code))
}
}
/// `kicad-cli pcb export step --output <out_dir>/<stem>.step <pcb>`, the same
/// command and output naming as the kicad_export_step verb. Ok(step path) or
/// Err(kicad-cli stderr).
pub fn export_step(run: Runner, pcb: &Path, out_dir: &Path, timeout: Duration) -> Result<PathBuf, String> {
let out = out_dir.join(format!("{}.step", stem_of(pcb)));
let argv: Vec<String> = vec!["pcb".into(), "export".into(), "step".into(), "--output".into(), norm(&out), norm(pcb)];
let r = run(&argv, timeout);
if r.timed_out {
return Err(format!("STEP export timed out after {} seconds", timeout.as_secs()));
}
if r.exit_code != 0 {
return Err(r.stderr.trim().to_string());
}
if !out.is_file() {
return Err(format!("kicad-cli exited 0 but wrote no {}", out.display()));
}
Ok(out)
}
/// Where `kicad-cli pcb render` should look to frame some parts (wiki #105): its `--pivot` (cm, from the
/// board's centre, the Edge.Cuts box, y up; the same on either side, the renderer mirrors the bottom) and
/// the `--zoom` that fits the parts' pads, grown by `margin` mm, in the frame (zoom 1 fits the board).
/// `model` is the board model in KiCad's frame (y down). Measured on KiCad 10.0.7: J1 of the ESC board,
/// 21.5 mm left of centre, lands mid-frame at pivot -2.15,-0.2.
pub fn frame_parts(model: &Value, refs: &[String], margin: f64) -> Result<(f64, f64, f64), String> {
let f = |v: &Value| v.as_f64().unwrap_or(0.0);
let outline: Vec<(f64, f64)> = model["outline"].as_array().into_iter().flatten().map(|p| (f(&p[0]), f(&p[1]))).collect();
if outline.is_empty() {
return Err("the board has no outline on Edge.Cuts".into());
}
let bbox = |pts: &mut dyn Iterator<Item = (f64, f64, f64, f64)>| pts.fold(None, |b: Option<(f64, f64, f64, f64)>, (x0, y0, x1, y1)| Some(match b {
None => (x0, y0, x1, y1),
Some(o) => (o.0.min(x0), o.1.min(y0), o.2.max(x1), o.3.max(y1)),
}));
let (bx0, by0, bx1, by1) = bbox(&mut outline.iter().map(|&(x, y)| (x, y, x, y))).unwrap();
let want = |r: &str| refs.iter().any(|w| w.eq_ignore_ascii_case(r));
let pads: Vec<&Value> = model["pads"].as_array().into_iter().flatten().filter(|p| p["ref"].as_str().map_or(false, want)).collect();
let missing: Vec<&String> = refs.iter().filter(|r| !pads.iter().any(|p| p["ref"].as_str().map_or(false, |x| x.eq_ignore_ascii_case(r)))).collect();
if !missing.is_empty() {
return Err(format!("not on the board (or no pads): {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
}
let (px0, py0, px1, py1) = bbox(&mut pads.iter().map(|p| {
let r = f(&p["sizeX"]).max(f(&p["sizeY"])) / 2.0;
(f(&p["x"]) - r, f(&p["y"]) - r, f(&p["x"]) + r, f(&p["y"]) + r)
})).unwrap();
let (cx, cy) = ((bx0 + bx1) / 2.0, (by0 + by1) / 2.0);
let (ox, oy) = ((px0 + px1) / 2.0 - cx, (py0 + py1) / 2.0 - cy);
let (w, h) = (px1 - px0 + 2.0 * margin, py1 - py0 + 2.0 * margin);
let zoom = ((bx1 - bx0) / w).min((by1 - by0) / h).clamp(1.0, 50.0);
let r = |v: f64| (v * 1000.0).round() / 1000.0 + 0.0;
Ok((r(ox / 10.0), r(-oy / 10.0), (zoom * 100.0).round() / 100.0))
}
/// STEP export deadline (matches the kicad_export_step verb's budget).
pub const STEP_TIMEOUT: Duration = Duration::from_secs(280);
/// Per-side render deadline (Python: 180 s).
pub const RENDER_TIMEOUT: Duration = Duration::from_secs(180);
/// kicad_export_molecule {filePath, outputDir?, name?}: STEP + silk top/bottom PNGs +
/// sibling .kicad_sch/.kicad_pro discovery + the container commands. Same keys as the
/// Python handler: data.{step, silkscreen, pcb, sch, pro, nextSteps, missing?}, output,
/// _hint. `installed` false reproduces the Python's not-installed refusal.
pub fn export_molecule(installed: bool, args: &Value, run: Runner) -> Value {
if !installed {
return json!({"success": false, "errorCode": "not_installed", "error": "KiCad not installed",
"_hint": "Install KiCad first (kicad_upgrade), then retry."});
}
let file_path = args.get("filePath").and_then(Value::as_str).map(str::trim).unwrap_or("");
if file_path.is_empty() {
return json!({"success": false, "errorCode": "missing_arg", "error": "No filePath specified (the .kicad_pcb)",
"_hint": "Pass filePath: the absolute desktop path of the .kicad_pcb."});
}
let pcb = PathBuf::from(file_path);
if !pcb.exists() {
return json!({"success": false, "errorCode": "file_not_found", "error": format!("PCB file not found: {file_path}"),
"_hint": "Path must exist on the desktop host. send_files it first if it lives in the container."});
}
let out_dir = args
.get("outputDir")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| pcb.parent().unwrap_or(Path::new(".")).join("molecule_output"));
if let Err(e) = std::fs::create_dir_all(&out_dir) {
return json!({"success": false, "errorCode": "output_dir", "error": format!("cannot create {}: {e}", out_dir.display()),
"_hint": "Pass an outputDir the desktop user can write."});
}
let stem = stem_of(&pcb);
let mut missing: Vec<String> = Vec::new();
// 1) STEP: mandatory, the GLB comes from it.
let step = match export_step(run, &pcb, &out_dir, STEP_TIMEOUT) {
Ok(p) => p,
Err(e) => {
return json!({"success": false, "errorCode": "step_export_failed", "error": format!("STEP export failed: {e}"),
"_hint": "The STEP is mandatory for the molecule GLB: fix this first. kicad-cli's stderr names the reason (file version, missing 3D model)."});
}
};
// 2) Silkscreen renders: optional, need `kicad-cli pcb render` (KiCad 9+).
let mut silk = Map::new();
for side in ["top", "bottom"] {
let png = out_dir.join(format!("{stem}-silk-{side}.png"));
match render_side(run, &pcb, side, &png, RENDER_TIMEOUT) {
Ok(p) => {
silk.insert(side.into(), json!(norm(&p)));
}
Err(reason) => missing.push(reason),
}
}
let silk_sides: Vec<String> = silk.keys().cloned().collect();
// 3) Sibling project files by stem: adom-molecule needs sch + pro.
let sch = pcb.with_extension("kicad_sch");
let pro = pcb.with_extension("kicad_pro");
for (label, p) in [("sch", &sch), ("pro", &pro)] {
if !p.exists() {
missing.push(format!(
"{label} ({} not next to the board; adom-molecule requires it)",
p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
));
}
}
// 4) The exact container-side finish.
let name = args.get("name").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).unwrap_or(&stem);
let mut create = format!(
"adom-molecule create --source kicad --name \"{name}\" --owner <owner> --description \"<desc>\" --glb /tmp/{stem}.glb --pcb <pcb> --sch <sch> --pro <pro>"
);
if !silk.is_empty() {
create.push_str(" --silk-top <silk-top> --silk-bottom <silk-bottom>");
}
let next_steps = vec![
"pull_file the paths in data (step/pcb/sch/pro/silkscreen) to the container".to_string(),
format!("step2glb convert <step> --out /tmp/{stem}.glb"),
create,
];
let mut data = json!({
"step": norm(&step),
"silkscreen": Value::Object(silk),
"pcb": norm(&pcb),
"sch": if sch.exists() { json!(norm(&sch)) } else { Value::Null },
"pro": if pro.exists() { json!(norm(&pro)) } else { Value::Null },
"nextSteps": next_steps,
});
if !missing.is_empty() {
data["missing"] = json!(missing);
}
let mut output = format!("Molecule export pack in {}: STEP", norm(&out_dir));
if !silk_sides.is_empty() {
output.push_str(&format!(" + silk({})", silk_sides.join(",")));
}
if missing.is_empty() {
output.push_str(": complete");
} else {
output.push_str(&format!("; MISSING: {}", missing.join("; ")));
}
json!({
"success": true,
"data": data,
"output": output,
"_hint": "Desktop half of the molecule pipeline is done. pull_file everything in data, then run data.nextSteps in the container (step2glb, then adom-molecule create). adom-molecule REQUIRES glb+pcb+sch+pro; silk PNGs are optional polish for the molecule page.",
})
}
#[cfg(test)]
mod tests {
#[test]
fn parts_are_framed_from_the_board_centre() {
let t = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb")).unwrap();
let m = crate::board_model::build(&t, None, &crate::board_model::Opts { y_up: false }).unwrap();
let (x, y, z) = frame_parts(&m, &["J1".into()], 2.0).unwrap();
// J1's pads centre about 21.5 mm left of the board centre and a little below it: pivot in cm, y up
assert!((x + 2.15).abs() < 0.15 && y.abs() < 0.3, "{x} {y}");
assert!(z > 3.0 && z < 10.0, "{z}");
let (_, _, zall) = frame_parts(&m, &["J1".into(), "Q1".into()], 2.0).unwrap();
assert!(zall < z, "two parts far apart need a wider view");
assert!(frame_parts(&m, &["NOPE9".into()], 2.0).unwrap_err().contains("NOPE9"));
}
use super::*;
use std::cell::RefCell;
struct Tmp(PathBuf);
impl Drop for Tmp {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn tmp(name: &str) -> Tmp {
let p = std::env::temp_dir().join(format!("kicad-core-render-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
Tmp(p)
}
fn ok(stdout: &str) -> CliResult {
CliResult { exit_code: 0, stdout: stdout.into(), stderr: String::new(), elapsed_ms: 1, timed_out: false }
}
fn rc(code: i32, stderr: &str) -> CliResult {
CliResult { exit_code: code, stdout: String::new(), stderr: stderr.into(), elapsed_ms: 1, timed_out: false }
}
fn output_arg(argv: &[String]) -> PathBuf {
let i = argv.iter().position(|a| a == "--output").unwrap();
PathBuf::from(&argv[i + 1])
}
/// A fake kicad-cli that writes the requested outputs and records every call.
fn fake<'a>(calls: &'a RefCell<Vec<Vec<String>>>, render_works: bool) -> impl Fn(&[String], Duration) -> CliResult + 'a {
move |argv: &[String], _t: Duration| {
calls.borrow_mut().push(argv.to_vec());
match (argv[0].as_str(), argv[1].as_str()) {
("pcb", "export") => {
std::fs::write(output_arg(argv), "ISO-10303-21;").unwrap();
ok("")
}
("pcb", "render") if render_works => {
std::fs::write(output_arg(argv), b"\x89PNG").unwrap();
ok("")
}
("pcb", "render") => rc(2, "Invalid command: render"),
_ => rc(1, "unknown"),
}
}
}
#[test]
fn full_pack_with_silk_and_siblings() {
let t = tmp("full");
let pcb = t.0.join("demo.kicad_pcb");
std::fs::write(&pcb, "(kicad_pcb)").unwrap();
std::fs::write(t.0.join("demo.kicad_sch"), "(kicad_sch)").unwrap();
std::fs::write(t.0.join("demo.kicad_pro"), "{}").unwrap();
let calls = RefCell::new(Vec::new());
let run = fake(&calls, true);
let r = export_molecule(true, &json!({"filePath": norm(&pcb), "name": "Demo Board"}), &run);
assert_eq!(r["success"], json!(true), "{r}");
let out_dir = t.0.join("molecule_output");
assert_eq!(r["data"]["step"], json!(norm(&out_dir.join("demo.step"))));
assert_eq!(r["data"]["silkscreen"]["top"], json!(norm(&out_dir.join("demo-silk-top.png"))));
assert_eq!(r["data"]["silkscreen"]["bottom"], json!(norm(&out_dir.join("demo-silk-bottom.png"))));
assert_eq!(r["data"]["pcb"], json!(norm(&pcb)));
assert_eq!(r["data"]["sch"], json!(norm(&t.0.join("demo.kicad_sch"))));
assert_eq!(r["data"]["pro"], json!(norm(&t.0.join("demo.kicad_pro"))));
assert!(r["data"].get("missing").is_none());
let steps = r["data"]["nextSteps"].as_array().unwrap();
assert_eq!(steps.len(), 3);
assert!(steps[1].as_str().unwrap().contains("--out /tmp/demo.glb"));
assert!(steps[2].as_str().unwrap().contains("--name \"Demo Board\""));
assert!(steps[2].as_str().unwrap().contains("--silk-top <silk-top> --silk-bottom <silk-bottom>"));
assert_eq!(r["output"], json!(format!("Molecule export pack in {}: STEP + silk(bottom,top): complete", norm(&out_dir))));
// The exact commands the Python issues.
let c = calls.borrow();
assert_eq!(c.len(), 3);
assert_eq!(c[0][..4], ["pcb", "export", "step", "--output"].map(String::from));
assert_eq!(c[0][5], norm(&pcb));
assert_eq!(c[1], ["pcb", "render", "--side", "top", "--background", "transparent", "--output", &norm(&out_dir.join("demo-silk-top.png")), &norm(&pcb)].map(String::from));
assert_eq!(c[2][3], "bottom");
}
#[test]
fn old_kicad_without_render_and_no_siblings() {
let t = tmp("old");
let pcb = t.0.join("x.kicad_pcb");
std::fs::write(&pcb, "(kicad_pcb)").unwrap();
let out = t.0.join("custom");
let calls = RefCell::new(Vec::new());
let run = fake(&calls, false);
let r = export_molecule(true, &json!({"filePath": norm(&pcb), "outputDir": norm(&out)}), &run);
assert_eq!(r["success"], json!(true), "{r}");
assert!(out.join("x.step").is_file());
assert_eq!(r["data"]["silkscreen"], json!({}));
assert!(r["data"]["sch"].is_null());
assert!(r["data"]["pro"].is_null());
let missing: Vec<&str> = r["data"]["missing"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(missing, vec![
"silk-top (kicad-cli pcb render rc=2)",
"silk-bottom (kicad-cli pcb render rc=2)",
"sch (x.kicad_sch not next to the board; adom-molecule requires it)",
"pro (x.kicad_pro not next to the board; adom-molecule requires it)",
]);
assert!(!r["data"]["nextSteps"][2].as_str().unwrap().contains("--silk-top"));
assert!(r["output"].as_str().unwrap().starts_with(&format!("Molecule export pack in {}: STEP; MISSING: silk-top", norm(&out))));
}
#[test]
fn step_failure_is_fatal() {
let t = tmp("stepfail");
let pcb = t.0.join("b.kicad_pcb");
std::fs::write(&pcb, "(kicad_pcb)").unwrap();
let run = |_argv: &[String], _t: Duration| rc(1, "Error loading board: bad file");
let r = export_molecule(true, &json!({"filePath": norm(&pcb)}), &run);
assert_eq!(r["success"], json!(false));
assert_eq!(r["errorCode"], json!("step_export_failed"));
assert_eq!(r["error"], json!("STEP export failed: Error loading board: bad file"));
// No silk render attempted after a fatal STEP.
assert!(!t.0.join("molecule_output").join("b-silk-top.png").exists());
}
#[test]
fn arg_and_install_checks() {
let run = |_argv: &[String], _t: Duration| ok("");
let r = export_molecule(false, &json!({"filePath": "x"}), &run);
assert_eq!(r["error"], json!("KiCad not installed"));
let r = export_molecule(true, &json!({}), &run);
assert_eq!(r["error"], json!("No filePath specified (the .kicad_pcb)"));
let r = export_molecule(true, &json!({"filePath": "/definitely/not/here.kicad_pcb"}), &run);
assert_eq!(r["errorCode"], json!("file_not_found"));
assert!(r["error"].as_str().unwrap().starts_with("PCB file not found: "));
}
#[test]
fn render_side_reports_timeout_and_spawn_failure() {
let t = tmp("render");
let pcb = t.0.join("b.kicad_pcb");
let png = t.0.join("b-silk-top.png");
let timed = |_a: &[String], _t: Duration| CliResult { exit_code: -2, stdout: String::new(), stderr: "kicad-cli timed out".into(), elapsed_ms: 1, timed_out: true };
let e = render_side(&timed, &pcb, "top", &png, Duration::from_secs(180)).unwrap_err();
assert_eq!(e, "silk-top (kicad-cli pcb render timed out after 180s)");
let spawn = |_a: &[String], _t: Duration| rc(-1, "spawn failed: No such file");
let e = render_side(&spawn, &pcb, "bottom", &png, RENDER_TIMEOUT).unwrap_err();
assert_eq!(e, "silk-bottom (spawn failed: No such file)");
// rc 0 but no file is still a miss.
let silent = |_a: &[String], _t: Duration| ok("");
let e = render_side(&silent, &pcb, "top", &png, RENDER_TIMEOUT).unwrap_err();
assert_eq!(e, "silk-top (kicad-cli pcb render rc=0)");
}
}