main
Colby Knox board-to-molecule 1.0.0: Rust source, six before/after examples (KiCad, Fusion, Altium), Adom 3D viewer readme eb9ea91 4d ago
//! Board model -> preview .kicad_pcb (KiCad 9+ format).
//!
//! This is what lets one renderer serve all three EDAs: a Fusion or Altium board is
//! rebuilt as a KiCad board carrying its outline, holes, pads, tracks, vias, the
//! machine pins, and any embedded component models, all inside the one file, so
//! service-kicad can export a STEP that step2glb then anchors. It is a preview, not a
//! design translation: no nets, zones, or rules.

use crate::embed;
use crate::kicad::pin_step;
use crate::model::{rot, Board, Component, PadShape, Seg, Shape, Side};
use anyhow::Result;
use std::fmt::Write;

fn f(v: f64) -> String {
    let s = format!("{v:.5}");
    let s = s.trim_end_matches('0').trim_end_matches('.');
    if s == "-0" { "0".into() } else { s.into() }
}

fn u() -> String {
    format!("(uuid \"{}\")", uuid::Uuid::new_v4())
}

/// Machine-pin footprint name for an MP component, if it is one.
pub fn pin_kind(c: &Component) -> Option<&'static str> {
    let is_ref = matches!(c.r#ref.as_str(), "MP1" | "MP2" | "MP3" | "MP4");
    let p = c.package.to_ascii_uppercase();
    if !is_ref || !(p.contains("MACH_PIN") || p.contains("MACHINEPIN") || p.contains("PIN_LRG") || p.contains("PIN_MED")) {
        return None;
    }
    Some(if p.contains("LRG") || p.contains("LARGE") { "MachinePinLargeShort" } else { "MachinePinMediumShort" })
}

fn edge(out: &mut String, s: &Shape, layer: &str) {
    match s {
        Shape::Seg(Seg { x1, y1, x2, y2, mid: None, .. }) => {
            let _ = writeln!(out, "\t(gr_line (start {} {}) (end {} {}) (stroke (width 0.1) (type solid)) (layer \"{layer}\") {})", f(*x1), f(-y1), f(*x2), f(-y2), u());
        }
        Shape::Seg(Seg { x1, y1, x2, y2, mid: Some(m), .. }) => {
            let _ = writeln!(out, "\t(gr_arc (start {} {}) (mid {} {}) (end {} {}) (stroke (width 0.1) (type solid)) (layer \"{layer}\") {})", f(*x1), f(-y1), f(m.0), f(-m.1), f(*x2), f(-y2), u());
        }
        Shape::Circle(c) => {
            let _ = writeln!(out, "\t(gr_circle (center {} {}) (end {} {}) (stroke (width 0.1) (type solid)) (fill no) (layer \"{layer}\") {})", f(c.cx), f(-c.cy), f(c.cx + c.r), f(-c.cy), u());
        }
        Shape::Poly(p) => {
            let pts: String = p.iter().map(|(x, y)| format!("(xy {} {}) ", f(*x), f(-y))).collect();
            let _ = writeln!(out, "\t(gr_poly (pts {pts}) (stroke (width 0.1) (type solid)) (fill no) (layer \"{layer}\") {})", u());
        }
    }
}

pub fn write(b: &Board) -> Result<String> {
    let mut o = String::with_capacity(1 << 20);
    o.push_str("(kicad_pcb (version 20241229) (generator \"board-to-molecule\") (generator_version \"1.0\")\n");
    o.push_str("\t(general (thickness 1.6) (legacy_teardrops no))\n\t(paper \"A3\")\n");
    o.push_str("\t(layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal) (9 \"F.Adhes\" user \"F.Adhesive\") (11 \"B.Adhes\" user \"B.Adhesive\") (13 \"F.Paste\" user) (15 \"B.Paste\" user) (5 \"F.SilkS\" user \"F.Silkscreen\") (7 \"B.SilkS\" user \"B.Silkscreen\") (1 \"F.Mask\" user) (3 \"B.Mask\" user) (25 \"Edge.Cuts\" user) (31 \"F.CrtYd\" user \"F.Courtyard\") (29 \"B.CrtYd\" user \"B.Courtyard\") (35 \"F.Fab\" user) (33 \"B.Fab\" user))\n");
    o.push_str("\t(setup (pad_to_mask_clearance 0))\n\t(net 0 \"\")\n");
    for s in &b.outline {
        edge(&mut o, s, "Edge.Cuts");
    }
    let mut embedded: Vec<(String, Vec<u8>)> = vec![];
    let mut pins_used: Vec<&str> = vec![];
    for c in &b.components {
        let layer = if c.side == Side::Bottom { "B.Cu" } else { "F.Cu" };
        let _ = writeln!(o, "\t(footprint \"Preview:{}\" (layer \"{layer}\") {} (at {} {} {})", c.package.replace('"', "'"), u(), f(c.x), f(-c.y), f(c.rot));
        let _ = writeln!(o, "\t\t(property \"Reference\" \"{}\" (at 0 0 0) (layer \"F.Fab\") (hide yes) {} (effects (font (size 0.5 0.5) (thickness 0.08))))", c.r#ref.replace('"', "'"), u());
        let _ = writeln!(o, "\t\t(property \"Value\" \"{}\" (at 0 0 0) (layer \"F.Fab\") (hide yes) {} (effects (font (size 0.5 0.5) (thickness 0.08))))", c.value.replace('"', "'"), u());
        for (i, p) in c.pads.iter().enumerate() {
            // Pad position in the footprint frame (KiCad stores the pad angle absolute).
            let (lx, ly) = rot(p.x - c.x, p.y - c.y, -c.rot);
            let shape = match p.shape {
                PadShape::Rect => "rect",
                PadShape::Circle => "circle",
                PadShape::Oval => "oval",
                PadShape::RoundRect => "roundrect",
            };
            let (kind, layers, drill) = if p.drill > 0.0 {
                ("thru_hole", "\"*.Cu\" \"*.Mask\"".to_string(), format!(" (drill {})", f(p.drill.min(p.w.min(p.h) - 0.05).max(0.1))))
            } else if p.side == Side::Bottom {
                ("smd", "\"B.Cu\" \"B.Mask\"".to_string(), String::new())
            } else {
                ("smd", "\"F.Cu\" \"F.Mask\"".to_string(), String::new())
            };
            let rr = if p.shape == PadShape::RoundRect { " (roundrect_rratio 0.25)" } else { "" };
            let _ = writeln!(
                o,
                "\t\t(pad \"{}\" {kind} {shape} (at {} {} {}) (size {} {}){drill} (layers {layers}){rr} {})",
                if p.name.is_empty() { (i + 1).to_string() } else { p.name.replace('"', "'") },
                f(lx), f(-ly), f(p.rot), f(p.w.max(0.05)), f(p.h.max(0.05)), u()
            );
        }
        if let Some(pin) = pin_kind(c) {
            let _ = writeln!(o, "\t\t(model \"kicad-embed://{pin}.step\" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))");
            if !pins_used.contains(&pin) {
                pins_used.push(pin);
            }
        }
        for body in &c.bodies {
            let name = format!("{}.step", body.model);
            if let Some(data) = b.models.get(&body.model) {
                if !embedded.iter().any(|(n, _)| n == &name) {
                    embedded.push((name.clone(), data.clone()));
                }
                let _ = writeln!(
                    o,
                    "\t\t(model \"kicad-embed://{name}\" (offset (xyz {} {} {})) (scale (xyz 1 1 1)) (rotate (xyz {} {} {})))",
                    f(body.offset.0), f(body.offset.1), f(body.offset.2), f(body.rot.0), f(body.rot.1), f(body.rot.2)
                );
            }
        }
        o.push_str("\t)\n");
    }
    for (side, tracks) in [("F.Cu", &b.tracks_top), ("B.Cu", &b.tracks_bottom)] {
        for t in tracks.iter() {
            match t.mid {
                None => {
                    let _ = writeln!(o, "\t(segment (start {} {}) (end {} {}) (width {}) (layer \"{side}\") (net 0) {})", f(t.x1), f(-t.y1), f(t.x2), f(-t.y2), f(t.width.max(0.05)), u());
                }
                Some(m) => {
                    let _ = writeln!(o, "\t(arc (start {} {}) (mid {} {}) (end {} {}) (width {}) (layer \"{side}\") (net 0) {})", f(t.x1), f(-t.y1), f(m.0), f(-m.1), f(t.x2), f(-t.y2), f(t.width.max(0.05)), u());
                }
            }
        }
    }
    for v in &b.vias {
        let _ = writeln!(o, "\t(via (at {} {}) (size {}) (drill {}) (layers \"F.Cu\" \"B.Cu\") (net 0) {})", f(v.x), f(-v.y), f(v.dia.max(v.drill + 0.1)), f(v.drill), u());
    }
    if !pins_used.is_empty() || !embedded.is_empty() {
        o.push_str("\t(embedded_files\n");
        for p in &pins_used {
            o.push_str(&embed::file_entry(&format!("{p}.step"), pin_step(p), "\t\t"));
            o.push('\n');
        }
        for (n, d) in &embedded {
            o.push_str(&embed::file_entry(n, d, "\t\t"));
            o.push('\n');
        }
        o.push_str("\t)\n");
    }
    o.push_str(")\n");
    Ok(o)
}