app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
//! `.kicad_mod` generator — faithful Rust port of the Node `generateKicadMod`.
//! Pure string-building from explicit pad/body/courtyard data; output is
//! byte-identical to the Node generator (verified by the golden parity test).
//! No gallia, no Node.
pub struct Pad {
pub number: String,
pub x: f64,
pub y: f64,
pub dx: f64,
pub dy: f64,
pub shape: Option<String>,
pub angle: Option<f64>,
pub layers: Option<String>,
/// Mount type: `smd` (default) | `thru_hole` | `np_thru_hole`. Drives the pad
/// token and the default copper/mask layers.
pub mount: Option<String>,
/// Drill diameter (mm) for thru-hole pads. Emits `(drill D)` — without it a
/// thru-hole pad is meaningless and adom-lbr can only make an EAGLE `<smd>`.
pub drill: Option<f64>,
}
pub struct Rect {
pub x1: f64,
pub y1: f64,
pub x2: f64,
pub y2: f64,
}
pub struct FpInput {
pub name: String,
pub pads: Vec<Pad>,
pub body: Option<Rect>,
pub courtyard: Option<Rect>,
pub description: String,
pub tags: String,
/// Body/silk outline shape: `rect` (default) | `circle`. For round parts,
/// `circle` emits `fp_circle` on F.Fab + F.SilkS (real round silk) instead of
/// a bounding-box rectangle — adom-lbr transcribes it to an EAGLE `<circle>`.
pub body_shape: Option<String>,
}
/// Print an f64 the way JS `${x}` / Number.toString does (shortest round-trip,
/// integers without a trailing ".0"). Rust's `{}` already matches JS here,
/// including artifacts like `-0.5700000000000001`.
fn n(x: f64) -> String {
format!("{x}")
}
/// Round to 2 decimals — keeps derived text anchors clean (no `-1.4699999…`
/// float artifacts) and on a sensible KiCad grid.
fn round2(x: f64) -> f64 {
(x * 100.0).round() / 100.0
}
/// Render a rotation suffix for a `(at x y[ rot])` token (omitted when 0).
fn rot(r: f64) -> String {
if r != 0.0 { format!(" {}", n(r)) } else { String::new() }
}
/// One placed text item (RefDes or Value). Y is footprint-down (KiCad convention).
#[derive(serde::Serialize, Clone)]
pub struct TextItem {
pub x: f64,
pub y: f64,
pub size: f64,
pub rotation: f64,
pub anchor: String,
pub layer: String,
}
/// The keep-out the RefDes/Value text must stay OUTSIDE — the full footprint
/// extent (pads ∪ courtyard ∪ body). Named `courtyard` to match what every
/// downstream exporter validates against.
#[derive(serde::Serialize, Clone)]
pub struct KeepoutRect {
pub top: f64,
pub bottom: f64,
pub left: f64,
pub right: f64,
}
/// Explicit RefDes/Value placement INTENT, so exporters (adom-lbr → Fusion/.lbr,
/// Altium, OrCAD) validate against it instead of re-deriving from pads. This is
/// the SINGLE placement — exporters handle per-tool syntax (Y-flip, layer
/// numbers, anchor), there is NO per-tool switch here. Serializes to the
/// `textPlacement` block of `<mpn>-footprint.extracted.json`.
///
/// Invariant (Y-down): `reference.y <= courtyard.top` AND `value.y >= courtyard.bottom`.
#[derive(serde::Serialize, Clone)]
pub struct TextPlacement {
pub units: String,
#[serde(rename = "yAxis")]
pub y_axis: String,
#[serde(rename = "originRelative")]
pub origin_relative: String,
pub reference: TextItem,
pub value: TextItem,
pub courtyard: KeepoutRect,
}
/// Clearance (mm) the text sits beyond the full footprint extent.
const TEXT_CLEARANCE_MM: f64 = 1.0;
/// Text height (mm). 1 mm (KiCad default) for normal parts; scaled down so the
/// label is never larger than a tiny part. NOTE: adom-lbr's .lbr path currently
/// hardcodes 1.27 mm and drops rotation, so this size only reaches
/// KiCad/Altium/OrCAD — keep parts that must read well in Fusion at ~1 mm.
fn text_size(input: &FpInput) -> f64 {
let (xmin, ymin, xmax, ymax) = bbox(input);
let max_extent = (xmax - xmin).max(ymax - ymin);
let s = (max_extent / 2.5).clamp(0.5, 1.0);
(s * 10.0).round() / 10.0
}
/// The canonical RefDes/Value placement for a footprint — the one source of
/// truth used both to emit the `.kicad_mod` fp_text and the `textPlacement`
/// metadata. Centered on X; RefDes ~1 mm beyond the top extent, Value ~1 mm
/// beyond the bottom extent, off the full pads ∪ courtyard ∪ body bbox.
pub fn text_placement(input: &FpInput) -> TextPlacement {
let (xmin, ymin, xmax, ymax) = bbox(input);
let size = text_size(input);
TextPlacement {
units: "mm".into(),
y_axis: "down".into(),
origin_relative: "footprint-center".into(),
reference: TextItem {
x: 0.0,
y: round2(ymin - TEXT_CLEARANCE_MM),
size,
rotation: 0.0,
anchor: "center".into(),
layer: "F.SilkS".into(),
},
value: TextItem {
x: 0.0,
y: round2(ymax + TEXT_CLEARANCE_MM),
size,
rotation: 0.0,
anchor: "center".into(),
layer: "F.Fab".into(),
},
courtyard: KeepoutRect {
top: round2(ymin),
bottom: round2(ymax),
left: round2(xmin),
right: round2(xmax),
},
}
}
pub fn generate_kicad_mod(input: &FpInput) -> String {
let mut s = format!("(footprint \"{}\"\n", input.name);
s += " (version 20240108)\n (generator \"adom-footprint\")\n (layer \"F.Cu\")\n";
if !input.description.is_empty() {
s += &format!(" (descr \"{}\")\n", input.description);
}
if !input.tags.is_empty() {
s += &format!(" (tags \"{}\")\n", input.tags);
}
s += " (attr smd)\n";
// Courtyard
if let Some(c) = &input.courtyard {
s += &format!(" (fp_rect (start {} {}) (end {} {})\n", n(c.x1), n(c.y1), n(c.x2), n(c.y2));
s += " (stroke (width 0.05) (type default)) (fill none) (layer \"F.CrtYd\"))\n";
}
// For round parts, the body/fab/silk outline is a circle, not a rect — so a
// machine pin / round part carries real round silk (adom-lbr → EAGLE <circle>)
// instead of a bounding box. Centre + radius derive from the body rect.
let round = input.body_shape.as_deref() == Some("circle");
let circle_of = |b: &Rect, grow: f64| -> (f64, f64, f64) {
let (cx, cy) = ((b.x1 + b.x2) / 2.0, (b.y1 + b.y2) / 2.0);
let r = (b.x2 - b.x1).abs().max((b.y2 - b.y1).abs()) / 2.0 + grow;
(cx, cy, r)
};
// Fab outline
if let Some(b) = &input.body {
if round {
let (cx, cy, r) = circle_of(b, 0.0);
s += &format!(" (fp_circle (center {} {}) (end {} {})\n", n(cx), n(cy), n(cx + r), n(cy));
s += " (stroke (width 0.1) (type default)) (fill none) (layer \"F.Fab\"))\n";
} else {
s += &format!(" (fp_rect (start {} {}) (end {} {})\n", n(b.x1), n(b.y1), n(b.x2), n(b.y2));
s += " (stroke (width 0.1) (type default)) (fill none) (layer \"F.Fab\"))\n";
}
}
// Silkscreen outline (slightly outside fab)
if let Some(b) = &input.body {
if round {
let (cx, cy, r) = circle_of(b, 0.12);
s += &format!(" (fp_circle (center {} {}) (end {} {})\n", n(cx), n(cy), n(cx + r), n(cy));
s += " (stroke (width 0.12) (type default)) (fill none) (layer \"F.SilkS\"))\n";
} else {
let m = 0.12;
s += &format!(" (fp_rect (start {} {}) (end {} {})\n", n(b.x1 - m), n(b.y1 - m), n(b.x2 + m), n(b.y2 + m));
s += " (stroke (width 0.12) (type default)) (fill none) (layer \"F.SilkS\"))\n";
}
}
// Pin 1 marker — a corner chevron only makes sense for a rectangular body.
if !input.pads.is_empty() && !round {
if let Some(b) = &input.body {
s += &format!(" (fp_line (start {} {}) (end {} {})\n", n(b.x1 - 0.3), n(b.y1 - 0.3), n(b.x1 + 0.3), n(b.y1 - 0.3));
s += " (stroke (width 0.12) (type default)) (layer \"F.SilkS\"))\n";
}
}
// Reference and Value — placed CLEAR of the WHOLE footprint (pads ∪ courtyard
// ∪ body) via text_placement(), the single source of truth shared with the
// exported `textPlacement` metadata. adom-lbr carries this placement through to
// the Fusion .lbr (and KiCad/Altium/OrCAD) verbatim — it no longer invents its
// own — so RefDes/Value that sit on pad copper here show up smeared across the
// part in every tool. RefDes goes ~1 mm above the top extent, Value ~1 mm below
// the bottom (KiCad Y is down, so above = -Y); both stay user-visible/editable.
// (adom-lbr's `name-value-overlap` lint flags it if text ever lands on copper.)
let tp = text_placement(input);
let emit_text = |s: &mut String, kind: &str, label: &str, t: &TextItem| {
let th = round2(t.size * 0.15);
*s += &format!(
" (fp_text {} \"{}\" (at {} {}{})\n",
kind, label, n(t.x), n(t.y), rot(t.rotation)
);
*s += &format!(
" (layer \"{}\") (effects (font (size {} {}) (thickness {}))))\n",
t.layer, n(t.size), n(t.size), n(th)
);
};
emit_text(&mut s, "reference", "REF**", &tp.reference);
emit_text(&mut s, "value", &input.name, &tp.value);
// Pads
for pad in &input.pads {
let mount = pad.mount.clone().unwrap_or_else(|| "smd".to_string());
let is_th = mount == "thru_hole" || mount == "np_thru_hole";
let shape = pad.shape.clone().unwrap_or_else(|| "roundrect".to_string());
// THT pads plate through every copper layer + mask (no paste); SMD pads
// are front copper + paste + mask.
let layers = pad.layers.clone().unwrap_or_else(|| {
if is_th { "\"*.Cu\" \"*.Mask\"".to_string() } else { "\"F.Cu\" \"F.Paste\" \"F.Mask\"".to_string() }
});
let angle = match pad.angle {
Some(a) if a != 0.0 => format!(" {}", n(a)),
_ => String::new(),
};
// Emit the drill right after size, e.g. (drill 1.1) — required for a THT
// pad to be real; without it adom-lbr can only make an EAGLE <smd>.
let drill = match pad.drill {
Some(d) if d > 0.0 => format!(" (drill {})", n(d)),
_ => String::new(),
};
s += &format!(" (pad \"{}\" {} {} (at {} {}{}) (size {} {}){}\n", pad.number, mount, shape, n(pad.x), n(pad.y), angle, n(pad.dx), n(pad.dy), drill);
s += &format!(" (layers {})", layers);
if shape == "roundrect" {
s += " (roundrect_rratio 0.25)";
}
s += ")\n";
}
s += ")\n";
s
}
/// In-plane bounding box of the footprint (courtyard ∪ body ∪ pad extents),
/// in footprint-local mm — used to crop the rendered SVG.
pub fn bbox(input: &FpInput) -> (f64, f64, f64, f64) {
let mut xs = Vec::new();
let mut ys = Vec::new();
for r in [input.courtyard.as_ref(), input.body.as_ref()].into_iter().flatten() {
xs.push(r.x1);
xs.push(r.x2);
ys.push(r.y1);
ys.push(r.y2);
}
for p in &input.pads {
xs.push(p.x - p.dx / 2.0);
xs.push(p.x + p.dx / 2.0);
ys.push(p.y - p.dy / 2.0);
ys.push(p.y + p.dy / 2.0);
}
if xs.is_empty() {
return (-2.0, -2.0, 2.0, 2.0);
}
(
xs.iter().cloned().fold(f64::INFINITY, f64::min),
ys.iter().cloned().fold(f64::INFINITY, f64::min),
xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn r0402_matches_golden() {
let input = FpInput {
name: "R_0402_1005Metric".into(),
pads: vec![
Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },
Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },
],
body: Some(Rect { x1: -0.5, y1: -0.27, x2: 0.5, y2: 0.27 }),
courtyard: Some(Rect { x1: -0.93, y1: -0.47, x2: 0.93, y2: 0.47 }),
description: "0402 (1005 metric) resistor".into(),
tags: "resistor 0402".into(),
body_shape: None,
};
let got = generate_kicad_mod(&input);
let golden = include_str!("../tests/golden/R_0402.kicad_mod");
assert_eq!(got, golden, "must be byte-identical to the Node generator");
}
#[test]
fn text_clears_pads_outside_courtyard() {
// LQFP-style: pads reach well past a too-tight courtyard. RefDes/Value
// must follow the PAD extent (+1 mm), not the courtyard — otherwise they
// smear across the pad copper in Fusion/KiCad/Altium/OrCAD once adom-lbr
// carries the placement through verbatim.
let input = FpInput {
name: "LQFP_TEST".into(),
pads: vec![
// bottom-row pad: outer edge at y = 5.9 + 1.5/2 = 6.65
Pad { number: "1".into(), x: -3.5, y: 5.9, dx: 0.3, dy: 1.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },
// top-row pad: outer edge at y = -5.9 - 1.5/2 = -6.65
Pad { number: "2".into(), x: 3.5, y: -5.9, dx: 0.3, dy: 1.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },
],
// Deliberately too-tight: courtyard/body only ±5.0 while pads reach ±6.65.
body: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),
courtyard: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),
description: String::new(),
tags: String::new(),
body_shape: None,
};
let got = generate_kicad_mod(&input);
// pad outer extent ±6.65, +1 mm margin -> ±7.65
assert!(got.contains("(fp_text reference \"REF**\" (at 0 -7.65)"), "ref must clear pads, got:\n{got}");
assert!(got.contains("(fp_text value \"LQFP_TEST\" (at 0 7.65)"), "value must clear pads, got:\n{got}");
// And it must NOT fall back to the old courtyard-only placement (±5.8).
assert!(!got.contains("(at 0 5.8)") && !got.contains("(at 0 -5.8)"), "must not use courtyard-only placement");
}
#[test]
fn text_placement_holds_invariant() {
// reference.y <= courtyard.top AND value.y >= courtyard.bottom (Y-down),
// for both a large part and a tiny one, and the .kicad_mod fp_text must
// agree with the metadata (single source of truth).
let big = FpInput {
name: "LQFP".into(),
pads: vec![
Pad { number: "1".into(), x: -3.5, y: 5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None, mount: None, drill: None },
Pad { number: "2".into(), x: 3.5, y: -5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None, mount: None, drill: None },
],
body: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),
courtyard: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),
description: String::new(),
tags: String::new(),
body_shape: None,
};
let tp = text_placement(&big);
assert_eq!((tp.reference.x, tp.value.x), (0.0, 0.0), "centered on X");
assert!(tp.reference.y <= tp.courtyard.top, "ref clears top keep-out");
assert!(tp.value.y >= tp.courtyard.bottom, "value clears bottom keep-out");
assert_eq!(tp.reference.rotation, 0.0, "horizontal (rotation dropped in .lbr)");
assert_eq!(text_size(&big), 1.0, "normal part keeps 1 mm");
// .kicad_mod agrees with metadata
let km = generate_kicad_mod(&big);
assert!(km.contains(&format!("(at 0 {})", n(tp.reference.y))), "kicad_mod ref y matches metadata");
assert!(km.contains(&format!("(at 0 {})", n(tp.value.y))), "kicad_mod value y matches metadata");
// tiny part scales text down (but invariant still holds)
let tiny = FpInput {
name: "R_0402".into(),
pads: vec![
Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None, mount: None, drill: None },
Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None, mount: None, drill: None },
],
body: Some(Rect { x1: -0.5, y1: -0.27, x2: 0.5, y2: 0.27 }),
courtyard: Some(Rect { x1: -0.93, y1: -0.47, x2: 0.93, y2: 0.47 }),
description: String::new(),
tags: String::new(),
body_shape: None,
};
let tt = text_placement(&tiny);
assert!(tt.reference.size < 1.0, "tiny part scales text below 1 mm, got {}", tt.reference.size);
assert!(tt.reference.y <= tt.courtyard.top && tt.value.y >= tt.courtyard.bottom, "invariant holds for tiny part");
}
#[test]
fn thru_hole_pad_emits_drill_and_all_copper() {
// The machine-pin case from issue #102: a round Ø1.6 pad with a 1.1 mm
// drill must come out as a real thru_hole pad with (drill) + *.Cu *.Mask.
let input = FpInput {
name: "MachinePin".into(),
pads: vec![Pad {
number: "1".into(), x: 0.0, y: 0.0, dx: 1.6, dy: 1.6,
shape: Some("circle".into()), angle: None, layers: None,
mount: Some("thru_hole".into()), drill: Some(1.1),
}],
body: Some(Rect { x1: -1.0, y1: -1.0, x2: 1.0, y2: 1.0 }),
courtyard: Some(Rect { x1: -1.2, y1: -1.2, x2: 1.2, y2: 1.2 }),
description: String::new(), tags: String::new(),
body_shape: Some("circle".into()),
};
let km = generate_kicad_mod(&input);
assert!(km.contains("(pad \"1\" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.1)"),
"thru-hole pad must carry mount + drill, got:\n{km}");
assert!(km.contains("(layers \"*.Cu\" \"*.Mask\")"), "THT pad plates all copper, got:\n{km}");
// and NOT an smd pad
assert!(!km.contains("(pad \"1\" smd"), "must not fall back to smd, got:\n{km}");
}
#[test]
fn round_body_emits_fp_circle_silk_not_rect() {
// Round parts get real round silk (fp_circle) on F.SilkS + F.Fab, not a box.
let input = FpInput {
name: "RoundPart".into(),
pads: vec![Pad {
number: "1".into(), x: 0.0, y: 0.0, dx: 1.6, dy: 1.6,
shape: Some("circle".into()), angle: None, layers: None,
mount: Some("thru_hole".into()), drill: Some(1.1),
}],
body: Some(Rect { x1: -1.0, y1: -1.0, x2: 1.0, y2: 1.0 }),
courtyard: None,
description: String::new(), tags: String::new(),
body_shape: Some("circle".into()),
};
let km = generate_kicad_mod(&input);
assert!(km.contains("(fp_circle (center 0 0)") && km.contains("(layer \"F.SilkS\"))"),
"round part must emit fp_circle silk, got:\n{km}");
assert!(km.contains("(layer \"F.Fab\"))"), "round part must emit fp_circle fab, got:\n{km}");
// silk radius (1.0 + 0.12) sits just outside the fab radius (1.0)
assert!(km.contains("(center 0 0) (end 1.12 0)"), "silk circle grows 0.12 beyond body, got:\n{km}");
// no rectangular silk/fab box for a round part
assert!(!km.contains("fp_rect") || input.courtyard.is_some(), "no fab/silk rect for round part, got:\n{km}");
}
}