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
//! 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);
    }
}