main
Colby Knox 1.1.0: every molecule gets large machine pins on the 32 mm grid; gallery rebuilt; corrected notes on kicad-to-molecule c794385 4d ago
//! `showcase`: one board in, a before/after bundle out.
//!
//! Converts the board in its own format, then builds both 3D models through the
//! real molecule pipeline: a preview .kicad_pcb with every model embedded, a STEP from
//! service-kicad, top/bottom artwork PNGs, and step2glb (molecule mode for the
//! molecule, so the anchoring gate runs). The external tools are the Adom CLIs
//! `service-kicad` and `step2glb`, plus `rsvg-convert` for the artwork.

use crate::model::Side;
use crate::plan::Plan;
use crate::{detect, kicad, read_board, svg, tokicad, Eda};
use anyhow::{bail, Context, Result};
use serde_json::{json, Map, Value};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;

fn run(cmd: &mut Command) -> Result<String> {
    let out = cmd.output().with_context(|| format!("could not run {:?}", cmd.get_program()))?;
    let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
    if !out.status.success() {
        bail!("{:?} failed:\n{}", cmd.get_program(), text.trim());
    }
    Ok(text)
}

/// Parse step2glb's "  key: value" stats lines.
fn stats(text: &str) -> Value {
    let mut m = Map::new();
    for l in text.lines() {
        let l = l.trim();
        if let Some((k, v)) = l.split_once(": ") {
            if k.chars().all(|c| c.is_ascii_lowercase() || c == '_') {
                let v = v.trim().trim_matches('"');
                let val = match v {
                    "true" => json!(true),
                    "false" => json!(false),
                    _ => v.parse::<f64>().map(|n| json!(n)).unwrap_or(json!(v)),
                };
                m.insert(k.into(), val);
            }
        }
        if l.starts_with("OK:") {
            m.insert("summary".into(), json!(l));
        }
        if l.starts_with("Hint:") || l.starts_with("WARN") {
            m.entry("hints").or_insert(json!([])).as_array_mut().unwrap().push(json!(l));
        }
    }
    Value::Object(m)
}

fn artwork_png(board: &crate::model::Board, side: Side, dir: &Path, name: &str) -> Result<Option<PathBuf>> {
    let bb = board.outline_bbox()?;
    let ppm = (4096.0 / bb.w().max(bb.h())).min(24.0);
    let svgp = dir.join(format!("{name}.svg"));
    let png = dir.join(format!("{name}.png"));
    std::fs::write(&svgp, svg::artwork(board, side, &bb, ppm))?;
    match run(Command::new("rsvg-convert").arg(&svgp).arg("-o").arg(&png)) {
        Ok(_) => Ok(Some(png)),
        Err(_) => Ok(None),
    }
}

/// STEP via service-kicad, then step2glb. Returns (glb path, stats).
fn build_glb(eda: Eda, board_path: &Path, dir: &Path, tag: &str, molecule: Option<&Plan>) -> Result<Value> {
    let t0 = Instant::now();
    let board = read_board(eda, board_path)?;
    let pcb = dir.join(format!("{tag}.render.kicad_pcb"));
    let text = if eda == Eda::Kicad { kicad::embed_pin_models(&std::fs::read_to_string(board_path)?)? } else { tokicad::write(&board)? };
    std::fs::write(&pcb, text)?;
    let step = dir.join(format!("{tag}.step"));
    run(Command::new("service-kicad").args(["pcb", "export", "step"]).arg(&pcb).arg("--out").arg(&step))
        .with_context(|| format!("STEP export of the {tag} board"))?;
    let t_step = t0.elapsed().as_secs_f64();
    let glb = dir.join(format!("{tag}.glb"));
    let mut cmd = Command::new("step2glb");
    cmd.arg("convert").arg(&step).arg("-o").arg(&glb);
    match molecule {
        Some(p) => {
            // step2glb parses .kicad_pcb and .brd natively; Altium goes through the preview board.
            let b = if eda == Eda::Altium { pcb.clone() } else { board_path.to_path_buf() };
            cmd.arg("--board").arg(b).arg("--pin").arg(p.spec().label);
        }
        // The original has no pins, so it cannot anchor; molecule mode still bakes the
        // same artwork overlay, which keeps the before/after pair comparable.
        None => {
            cmd.arg("--molecule");
        }
    }
    if let Some(png) = artwork_png(&board, Side::Top, dir, &format!("{tag}-top"))? {
        cmd.arg("--silk-top").arg(png);
    }
    if let Some(png) = artwork_png(&board, Side::Bottom, dir, &format!("{tag}-bottom"))? {
        cmd.arg("--silk-bottom").arg(png);
    }
    let mut st = stats(&run(&mut cmd).with_context(|| format!("step2glb for the {tag} board"))?);
    st["step_s"] = json!(t_step);
    st["total_s"] = json!(t0.elapsed().as_secs_f64());
    st["glb"] = json!(glb.file_name().unwrap().to_string_lossy());
    st["glb_bytes"] = json!(std::fs::metadata(&glb).map(|m| m.len()).unwrap_or(0));
    let _ = std::fs::remove_file(&step);
    Ok(st)
}

pub fn showcase(input: &Path, dir: &Path, vcut: bool, skip_3d: bool) -> Result<Value> {
    std::fs::create_dir_all(dir)?;
    let eda = detect(input)?;
    let ext = input.extension().and_then(|e| e.to_str()).unwrap_or("bin");
    let src = dir.join(format!("source.{ext}"));
    std::fs::copy(input, &src)?;
    let mol = dir.join(format!("molecule.{ext}"));

    let t0 = Instant::now();
    let orig = read_board(eda, &src)?;
    let plan = crate::plan::make_plan(eda.name(), &input.file_name().unwrap().to_string_lossy(), orig.outline_bbox()?, vcut);
    let write = match eda {
        Eda::Kicad => kicad::write_molecule(&src, &plan, &mol)?,
        Eda::Eagle => crate::eagle::write_molecule(&src, &plan, &mol)?,
        Eda::Altium => crate::altium::write_molecule(&src, &plan, &mol)?,
    };
    let convert_ms = t0.elapsed().as_secs_f64() * 1000.0;
    let molb = read_board(eda, &mol)?;
    let back = molb.outline_bbox()?;
    anyhow::ensure!((back.w() - plan.new.w()).abs() < 1e-3 && (back.h() - plan.new.h()).abs() < 1e-3, "molecule outline read-back mismatch");
    std::fs::write(dir.join("plan.json"), serde_json::to_string_pretty(&plan.to_json())?)?;
    std::fs::write(dir.join("before.svg"), svg::render(&orig, None))?;
    std::fs::write(dir.join("after.svg"), svg::render(&molb, None))?;
    std::fs::write(dir.join("overlay.svg"), svg::render(&orig, Some(&plan)))?;
    let (landed, checked) = orig.track_end_check();

    let mut m = json!({
        "eda": eda.name(),
        "source": input.file_name().unwrap().to_string_lossy(),
        "files": {"source": src.file_name().unwrap().to_string_lossy(), "molecule": mol.file_name().unwrap().to_string_lossy()},
        "plan": plan.to_json(),
        "write": write,
        "convert_ms": convert_ms,
        "board": {
            "components": orig.components.len(), "pads": orig.pad_count(),
            "tracks": orig.tracks_top.len() + orig.tracks_bottom.len(), "vias": orig.vias.len(),
            "embedded_models": orig.models.len(), "track_ends_on_copper": [landed, checked],
        },
    });
    if !skip_3d {
        m["before"] = build_glb(eda, &src, dir, "before", None).unwrap_or_else(|e| json!({"error": format!("{e:#}")}));
        m["after"] = build_glb(eda, &mol, dir, "after", Some(&plan)).unwrap_or_else(|e| json!({"error": format!("{e:#}")}));
    }
    std::fs::write(dir.join("manifest.json"), serde_json::to_string_pretty(&m)?)?;
    Ok(m)
}