app
Board to Molecule
Public Made by Adomby adom
Turn any KiCad, Fusion/EAGLE, or Altium board into an Adom molecule: wings plus MP1-MP4 machine pins, written back in the board's own format.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
//! `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)
}