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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
//! board-to-molecule: turn any KiCad, Fusion/EAGLE, or Altium board into an Adom molecule.
mod altium;
mod eagle;
mod embed;
mod kicad;
mod model;
mod pipeline;
mod plan;
mod serve;
mod sexpr;
mod svg;
mod tokicad;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use serde_json::json;
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Parser)]
#[command(name = "board-to-molecule", version, about = "Turn any KiCad, Fusion/EAGLE, or Altium board into an Adom molecule: wings + four corner machine pins (MP1-MP4), written back in the board's own format.")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Write <board>.molecule.<ext> next to the source (or -o).
Convert {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
/// Add V-cut score lines along the original outline.
#[arg(long)]
vcut: bool,
/// Also write the plan JSON beside the output.
#[arg(long)]
plan_json: bool,
#[arg(long)]
json: bool,
},
/// Print the molecule plan (the EDA-neutral hub) as JSON, write nothing.
Plan {
input: PathBuf,
#[arg(long)]
vcut: bool,
},
/// Summarize what the reader sees in a board (outline, pads, tracks, components).
Inspect {
input: PathBuf,
},
/// Render a board to SVG (top view). With --molecule, overlay the plan's wings and pins.
Svg {
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
molecule: bool,
},
/// Transparent top or bottom artwork registered to the board outline bbox, for
/// step2glb's --silk-top/--silk-bottom overlay.
Artwork {
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long, default_value = "top")]
side: String,
#[arg(long, default_value_t = 24.0)]
px_per_mm: f64,
},
/// Before/after bundle for one board: native molecule, plan, SVGs, and both GLBs
/// built through service-kicad + step2glb (molecule mode, anchoring gate).
Showcase {
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
vcut: bool,
/// Skip the 3D builds (offline).
#[arg(long)]
no_3d: bool,
},
/// Serve the gallery viewer (showcase bundles under <gallery>).
Serve {
gallery: PathBuf,
#[arg(long, default_value_t = 8857)]
port: u16,
},
/// Install the board-to-molecule skill into ~/.claude/skills/.
Install,
/// Rebuild any board as a preview .kicad_pcb (copper, outline, silk, pin and
/// embedded models) for headless STEP export on service-kicad.
ToKicad {
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
},
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Eda {
Kicad,
Eagle,
Altium,
}
impl Eda {
fn name(self) -> &'static str {
match self {
Eda::Kicad => "kicad",
Eda::Eagle => "eagle",
Eda::Altium => "altium",
}
}
}
pub fn detect(path: &Path) -> Result<Eda> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let head = {
use std::io::Read;
let mut b = [0u8; 512];
let n = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?.read(&mut b)?;
b[..n].to_vec()
};
if head.starts_with(&[0xD0, 0xCF, 0x11, 0xE0]) || ext == "pcbdoc" {
return Ok(Eda::Altium);
}
let s = String::from_utf8_lossy(&head);
if s.contains("<eagle") || ext == "brd" {
if !s.contains('<') {
bail!("{} looks like a binary EAGLE board (pre-6.0); open and re-save it in EAGLE 6+ or Fusion first", path.display());
}
return Ok(Eda::Eagle);
}
if s.trim_start().starts_with("(kicad_pcb") || ext == "kicad_pcb" {
return Ok(Eda::Kicad);
}
bail!("cannot tell which EDA wrote {} (expected .kicad_pcb, .brd, or .PcbDoc)", path.display())
}
fn outline_bbox(eda: Eda, p: &Path) -> Result<plan::BBox> {
match eda {
Eda::Kicad => kicad::outline_bbox(p),
Eda::Eagle => eagle::outline_bbox(p),
Eda::Altium => altium::outline_bbox(p),
}
}
pub fn read_board(eda: Eda, p: &Path) -> Result<model::Board> {
match eda {
Eda::Kicad => kicad::read(p),
Eda::Eagle => eagle::read(p),
Eda::Altium => altium::read(p),
}
}
fn default_out(input: &Path) -> PathBuf {
let ext = input.extension().and_then(|e| e.to_str()).unwrap_or("");
let stem = input.file_stem().and_then(|s| s.to_str()).unwrap_or("board");
input.with_file_name(format!("{stem}.molecule.{ext}"))
}
fn make(input: &Path, vcut: bool) -> Result<(Eda, plan::Plan)> {
let eda = detect(input)?;
let bb = outline_bbox(eda, input)?;
Ok((eda, plan::make_plan(eda.name(), &input.display().to_string(), bb, vcut)))
}
fn run() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Cmd::Plan { input, vcut } => {
let (_, p) = make(&input, vcut)?;
println!("{}", serde_json::to_string_pretty(&p.to_json())?);
}
Cmd::Convert { input, output, vcut, plan_json, json } => {
let t0 = Instant::now();
let (eda, p) = make(&input, vcut)?;
let out = output.unwrap_or_else(|| default_out(&input));
let summary = match eda {
Eda::Kicad => kicad::write_molecule(&input, &p, &out)?,
Eda::Eagle => eagle::write_molecule(&input, &p, &out)?,
Eda::Altium => altium::write_molecule(&input, &p, &out)?,
};
// Read the result back: the new outline must be exactly the planned one.
let back = outline_bbox(eda, &out)?;
let ok = (back.w() - p.new.w()).abs() < 1e-3 && (back.h() - p.new.h()).abs() < 1e-3
&& (back.xmin - p.new.xmin).abs() < 1e-3 && (back.ymin - p.new.ymin).abs() < 1e-3;
if !ok {
bail!("read-back outline {:?} does not match the plan {:?}; output left at {} for inspection", back, p.new, out.display());
}
if plan_json {
std::fs::write(out.with_extension("plan.json"), serde_json::to_string_pretty(&p.to_json())?)?;
}
let ms = t0.elapsed().as_secs_f64() * 1000.0;
if json {
println!("{}", serde_json::to_string_pretty(&json!({"ok": true, "eda": eda.name(), "output": out, "plan": p.to_json(), "write": summary, "ms": ms}))?);
} else {
let s = p.spec();
println!("OK: {} -> {} ({}, {:.1}x{:.1} mm -> {:.1}x{:.1} mm, {} machine pins on the {} mm grid) in {:.0} ms",
input.display(), out.display(), eda.name(), p.orig.w(), p.orig.h(), p.new.w(), p.new.h(), s.label, s.grid_mm, ms);
for pin in &p.pins {
println!(" {} {:<11} ({:.3}, {:.3})", pin.r#ref, pin.role, pin.x, pin.y);
}
println!("Hint: verified by reading the output back; the outline matches the plan.");
if eda == Eda::Altium {
println!("Hint: open the .PcbDoc in Altium and run DRC once; Altium rebuilds its caches on first save.");
}
}
}
Cmd::Inspect { input } => {
let eda = detect(&input)?;
let t0 = Instant::now();
let b = read_board(eda, &input)?;
let bb = b.outline_bbox()?;
let (landed, checked) = b.track_end_check();
println!("{}", serde_json::to_string_pretty(&json!({
"eda": eda.name(),
"outline_mm": bb.json(),
"components": b.components.len(),
"pads": b.pad_count(),
"tracks": b.tracks_top.len() + b.tracks_bottom.len(),
"vias": b.vias.len(),
"pours": b.pours_top.len() + b.pours_bottom.len(),
"silk": b.silk_top.len() + b.silk_bottom.len(),
"embedded_models": b.models.len(),
"track_ends_on_copper": format!("{landed}/{checked}"),
"meta": b.meta,
"ms": t0.elapsed().as_secs_f64() * 1000.0,
}))?);
}
Cmd::Svg { input, output, molecule } => {
let eda = detect(&input)?;
let b = read_board(eda, &input)?;
let p = if molecule { Some(plan::make_plan(eda.name(), "", b.outline_bbox()?, false)) } else { None };
std::fs::write(&output, svg::render(&b, p.as_ref()))?;
println!("OK: wrote {}", output.display());
}
Cmd::Artwork { input, output, side, px_per_mm } => {
let eda = detect(&input)?;
let b = read_board(eda, &input)?;
let side = if side == "bottom" { model::Side::Bottom } else { model::Side::Top };
std::fs::write(&output, svg::artwork(&b, side, &b.outline_bbox()?, px_per_mm))?;
println!("OK: wrote {}", output.display());
}
Cmd::Showcase { input, output, vcut, no_3d } => {
let m = pipeline::showcase(&input, &output, vcut, no_3d)?;
let a = &m["after"];
println!("OK: {} -> {} ({} ms convert)", input.display(), output.display(), m["convert_ms"].as_f64().unwrap_or(0.0).round());
if !no_3d {
println!(" before.glb: {}", m["before"]["summary"].as_str().or(m["before"]["error"].as_str()).unwrap_or("?"));
println!(" after.glb: {} anchored={} pins={}", a["summary"].as_str().or(a["error"].as_str()).unwrap_or("?"), a["molecule_anchored"], a["footprint_pins"]);
if a["molecule_anchored"] != json!(true) {
println!("Hint: the molecule GLB is NOT anchored; do not publish it. See manifest.json for step2glb's hints.");
}
}
}
Cmd::Serve { gallery, port } => serve::serve(&gallery, port)?,
Cmd::Install => {
let home = std::env::var_os("HOME").context("HOME is not set")?;
let dir = PathBuf::from(home).join(".claude/skills/board-to-molecule");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("SKILL.md"), include_str!("../../skills/board-to-molecule/SKILL.md"))?;
println!("OK: installed the skill to {}", dir.join("SKILL.md").display());
}
Cmd::ToKicad { input, output } => {
let eda = detect(&input)?;
let b = read_board(eda, &input)?;
let text = if eda == Eda::Kicad { kicad::embed_pin_models(&std::fs::read_to_string(&input)?)? } else { tokicad::write(&b)? };
std::fs::write(&output, text)?;
println!("OK: wrote {} ({} components, {} embedded models)", output.display(), b.components.len(), b.models.len());
}
}
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("ERROR: {e:#}");
std::process::exit(1);
}
}