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
//! The molecule plan: the EDA-neutral hub every reader feeds and every writer consumes.
//!
//! Same idea as the adom-lbr JSON: one canonical description in the middle, so a
//! board read from KiCad, Fusion/EAGLE, or Altium turns into the same plan, and the
//! same plan can be written back into any of them.
//!
//! Frame: millimeters, Y up. "Front" is the low-Y edge, so MP1 (front-left) is the
//! bottom-left pin seen from above. Readers for Y-down formats (KiCad) flip on the
//! way in; writers flip on the way out.

use serde_json::{json, Value};

pub const SCHEMA: &str = "adom.board-to-molecule.plan/1";

#[derive(Debug, Clone, Copy)]
pub struct Spec {
    pub label: &'static str,
    pub grid_mm: f64,
    pub edge_offset_mm: f64,
    pub pad_mm: f64,
    pub drill_mm: f64,
    /// Package name in Adom_Library_Master (Fusion/EAGLE); step2glb reads the pin size from it.
    pub eagle_package: &'static str,
    /// Footprint name used for KiCad and Altium.
    pub footprint: &'static str,
}

/// The one molecule rule: large machine pins, pin-to-pin span on the 32 mm grid,
/// pin centers 3 mm in from the board edge.
pub const PIN: Spec = Spec {
    label: "large",
    grid_mm: 32.0,
    edge_offset_mm: 3.0,
    pad_mm: 5.2,
    drill_mm: 3.45,
    eagle_package: "MACH_PIN_LRG_SHORT",
    footprint: "MachinePinLargeShort",
};

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BBox {
    pub xmin: f64,
    pub ymin: f64,
    pub xmax: f64,
    pub ymax: f64,
}

impl BBox {
    pub fn w(&self) -> f64 {
        self.xmax - self.xmin
    }
    pub fn h(&self) -> f64 {
        self.ymax - self.ymin
    }
    pub fn cx(&self) -> f64 {
        (self.xmin + self.xmax) / 2.0
    }
    pub fn cy(&self) -> f64 {
        (self.ymin + self.ymax) / 2.0
    }
    pub fn empty() -> BBox {
        BBox { xmin: f64::INFINITY, ymin: f64::INFINITY, xmax: f64::NEG_INFINITY, ymax: f64::NEG_INFINITY }
    }
    pub fn add(&mut self, x: f64, y: f64) {
        self.xmin = self.xmin.min(x);
        self.ymin = self.ymin.min(y);
        self.xmax = self.xmax.max(x);
        self.ymax = self.ymax.max(y);
    }
    pub fn is_empty(&self) -> bool {
        !self.xmin.is_finite()
    }
    pub fn json(&self) -> Value {
        json!({"x": r6(self.xmin), "y": r6(self.ymin), "w": r6(self.w()), "h": r6(self.h())})
    }
}

#[derive(Debug, Clone)]
pub struct Pin {
    pub r#ref: String,
    pub role: &'static str,
    pub x: f64,
    pub y: f64,
}

/// Adom convention: numbered counterclockwise seen from above, starting front-left.
pub const PIN_ROLES: [(&str, &str); 4] =
    [("MP1", "front-left"), ("MP2", "back-left"), ("MP3", "back-right"), ("MP4", "front-right")];

#[derive(Debug, Clone)]
pub struct Plan {
    pub eda: String,
    pub source: String,
    pub orig: BBox,
    pub new: BBox,
    pub pins: Vec<Pin>,
    pub vcut: bool,
    pub notes: Vec<String>,
}

impl Plan {
    pub fn spec(&self) -> &'static Spec {
        &PIN
    }

    pub fn to_json(&self) -> Value {
        let s = self.spec();
        json!({
            "schema": SCHEMA,
            "source": {"eda": self.eda, "file": self.source},
            "units": "mm",
            "frame": "y-up",
            "orig_bbox": self.orig.json(),
            "new_bbox": self.new.json(),
            "size_label": s.label,
            "grid_mm": s.grid_mm,
            "edge_offset_mm": s.edge_offset_mm,
            "pin": {"pad_mm": s.pad_mm, "drill_mm": s.drill_mm,
                    "eagle_package": s.eagle_package, "footprint": s.footprint},
            "wing_x_mm": r6((self.new.w() - self.orig.w()) / 2.0),
            "wing_y_mm": r6((self.new.h() - self.orig.h()) / 2.0),
            "pins": self.pins.iter().map(|p| json!({"ref": p.r#ref, "role": p.role, "x": r6(p.x), "y": r6(p.y)})).collect::<Vec<_>>(),
            "vcut": self.vcut,
            "notes": self.notes,
        })
    }

    /// Score lines (Y-up) along the original outline: two full-length cuts on the long
    /// axis, two short ones between them, so the board snaps out of its wings.
    pub fn vcut_lines(&self) -> Vec<[f64; 4]> {
        let (o, n) = (&self.orig, &self.new);
        if o.h() >= o.w() {
            vec![
                [o.xmin, n.ymin, o.xmin, n.ymax],
                [o.xmax, n.ymin, o.xmax, n.ymax],
                [o.xmin, o.ymin, o.xmax, o.ymin],
                [o.xmin, o.ymax, o.xmax, o.ymax],
            ]
        } else {
            vec![
                [n.xmin, o.ymin, n.xmax, o.ymin],
                [n.xmin, o.ymax, n.xmax, o.ymax],
                [o.xmin, o.ymin, o.xmin, o.ymax],
                [o.xmax, o.ymin, o.xmax, o.ymax],
            ]
        }
    }
}

pub fn r6(v: f64) -> f64 {
    let r = (v * 1e6).round() / 1e6;
    if r == 0.0 { 0.0 } else { r }
}

/// Wings + four corner pins around `orig` (Y-up mm).
///
/// The corner-to-corner span snaps up to a whole number of grid steps, then the board
/// edge sits `edge_offset_mm` outside each pin. The new outline is centered on the
/// original, which never moves.
pub fn make_plan(eda: &str, source: &str, orig: BBox, vcut: bool) -> Plan {
    let s = &PIN;
    let snap = |v: f64| (s.grid_mm).max(((v / s.grid_mm * 1e9).round() / 1e9).ceil() * s.grid_mm);
    let new_w = snap(orig.w()) + 2.0 * s.edge_offset_mm;
    let new_h = snap(orig.h()) + 2.0 * s.edge_offset_mm;
    let new = BBox {
        xmin: orig.cx() - new_w / 2.0,
        ymin: orig.cy() - new_h / 2.0,
        xmax: orig.cx() + new_w / 2.0,
        ymax: orig.cy() + new_h / 2.0,
    };
    let (l, r) = (new.xmin + s.edge_offset_mm, new.xmax - s.edge_offset_mm);
    let (f, b) = (new.ymin + s.edge_offset_mm, new.ymax - s.edge_offset_mm);
    let pins = PIN_ROLES
        .iter()
        .map(|(rf, role)| {
            let (x, y) = match *role {
                "front-left" => (l, f),
                "back-left" => (l, b),
                "back-right" => (r, b),
                _ => (r, f),
            };
            Pin { r#ref: rf.to_string(), role, x, y }
        })
        .collect();
    Plan { eda: eda.into(), source: source.into(), orig, new, pins, vcut, notes: vec![] }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn bb(x: f64, y: f64, w: f64, h: f64) -> BBox {
        BBox { xmin: x, ymin: y, xmax: x + w, ymax: y + h }
    }

    #[test]
    fn large_pins_on_the_32mm_grid() {
        // Span rounds up to whole 32 mm steps (at least one), plus 3 mm a side.
        for (w, h, nw, nh) in [
            (10.4, 10.4, 38.0, 38.0),
            (25.4, 25.4, 38.0, 38.0),
            (32.0, 32.0, 38.0, 38.0),
            (32.1, 20.0, 70.0, 38.0),
            (35.0, 85.0, 70.0, 102.0),
            (136.2, 93.7, 166.0, 102.0),
        ] {
            let p = make_plan("kicad", "t", bb(0.0, 0.0, w, h), false);
            assert!((p.new.w() - nw).abs() < 1e-9 && (p.new.h() - nh).abs() < 1e-9, "{w}x{h} -> {}x{}", p.new.w(), p.new.h());
            let span = p.pins[3].x - p.pins[0].x;
            assert!((span / 32.0 - (span / 32.0).round()).abs() < 1e-9, "pin span {span} is not a 32 mm multiple");
            assert_eq!(p.spec().eagle_package, "MACH_PIN_LRG_SHORT");
        }
    }

    #[test]
    fn pins_ccw_from_front_left() {
        let p = make_plan("eagle", "t", bb(0.0, 0.0, 10.0, 10.0), false);
        assert!(p.pins[0].x < p.pins[3].x && p.pins[0].y < p.pins[1].y);
        assert_eq!(p.pins[1].x, p.pins[0].x);
        assert_eq!(p.pins[2].y, p.pins[1].y);
    }
}