master
Colby Knox Merge PR #322: kicad-lane: 1.0.73, framed renders, canvas check, engine 0.4.1 9f5826b 13d ago
//! `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)");
    }
}