app
adom-project-manager
Public Made by Adomby adom
DEPRECATED — renamed to Adom Nucleus (adom/adom-nucleus). Kept installable so existing installs and references keep working; depends on adom-nucleus. Install adom/adom-nucleus going forward.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
//! Canonical [`AdomLbrPart`] → KiCad `.kicad_sym` + `.kicad_mod` (the KiCad
//! WRITE side — inverse of `kicad.rs`). Symbol coords are stored in mils and
//! convert to mm here; footprint coords are already mm. Graphics arcs are
//! emitted in KiCad's 3-point (start/mid/end) form.
use crate::model::{Footprint, FpGraphic, FpLayer, Graphic, Symbol};
const MM_PER_MIL: f64 = 0.0254;
/// Selectable KiCad output format tier. Every KiCad reads all OLDER formats,
/// so only three tiers are meaningful (7 and 9 are redundant — their readers
/// accept the previous tier's files):
/// V6 — oldest stable s-expr grammar; for third-party tools stuck there
/// (Fusion 360's KiCad importer, kiutils-era parsers).
/// V8 — the "normalized bools" cleanup; the interop sweet spot, readable by
/// KiCad 8/9/10 and most modern third-party parsers. THE DEFAULT —
/// barrett's desktop runs KiCad 9.0.9, which REJECTS v10-format files
/// ("Unable to load library", verified 2026-07-06), and KiCad refuses
/// any file newer than itself.
/// V10 — current native format; opt in via --kicad-version 10.
/// Version values are what each KiCad release itself writes. Sources of truth
/// in the KiCad repo (branch `10.0`):
/// symbol lib → eeschema/sch_file_versions.h SEXPR_SYMBOL_LIB_FILE_VERSION
/// footprint → pcbnew/pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h SEXPR_BOARD_FILE_VERSION
/// ≥20250318 semantics: a bare "~" is a literal tilde, never "empty" — always
/// write real empty strings (valid in every tier).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum KicadVersion {
V6,
#[default]
V8,
V10,
}
impl KicadVersion {
/// Parse a user-facing selector ("6" | "8" | "10").
pub fn parse(s: &str) -> Option<Self> {
match s.trim() {
"6" | "v6" => Some(Self::V6),
"8" | "v8" => Some(Self::V8),
"10" | "v10" => Some(Self::V10),
_ => None,
}
}
pub fn sym_version(self) -> u32 {
match self {
Self::V6 => 20211014,
Self::V8 => 20231120,
Self::V10 => 20251024,
}
}
pub fn fp_version(self) -> u32 {
match self {
Self::V6 => 20211014,
Self::V8 => 20240108,
Self::V10 => 20260206,
}
}
/// `generator_version` token value; V6 predates the token entirely.
pub fn generator_version(self) -> Option<&'static str> {
match self {
Self::V6 => None,
Self::V8 => Some("8.0"),
Self::V10 => Some("10.0"),
}
}
/// V6 wrote bare boolean flags (`hide`); V8+ normalized to `(hide yes)`.
fn hide_flag(self) -> &'static str {
match self {
Self::V6 => " hide",
_ => " (hide yes)",
}
}
/// `exclude_from_sim` didn't exist in the V6 grammar — real KiCad 6
/// errors on unknown tokens, so omit it there.
fn writes_exclude_from_sim(self) -> bool {
!matches!(self, Self::V6)
}
/// Library header line(s) shared by the symbol emitters.
fn sym_header(self, generator: &str) -> String {
match self.generator_version() {
Some(gv) => format!(
"(kicad_symbol_lib\n\t(version {})\n\t(generator \"{generator}\")\n\t(generator_version \"{gv}\")\n",
self.sym_version()
),
None => format!("(kicad_symbol_lib\n\t(version {})\n\t(generator \"{generator}\")\n", self.sym_version()),
}
}
}
/// Convenience constants for callers that just want "current native".
pub const KICAD_SYM_VERSION: u32 = 20251024;
pub const KICAD_FP_VERSION: u32 = 20260206;
pub const KICAD_GENERATOR_VERSION: &str = "10.0";
/// Format a float compactly (KiCad tolerates plain decimals; trim trailing 0s).
fn n(v: f64) -> String {
let s = format!("{v:.6}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() }
}
// ---------------------------------------------------------------------------
// Symbol → .kicad_sym
// ---------------------------------------------------------------------------
/// Export a symbol as a complete `.kicad_sym` library (one symbol), in the
/// default format tier (V8 — loads in KiCad 8/9/10; see [`KicadVersion`]).
pub fn export_kicad_symbol(sym: &Symbol) -> String {
export_kicad_symbol_for(sym, KicadVersion::default())
}
/// Export a symbol targeting a specific KiCad format tier.
pub fn export_kicad_symbol_for(sym: &Symbol, ver: KicadVersion) -> String {
let m = |mil: f64| n(mil * MM_PER_MIL);
let name = &sym.name;
let reference = sym.designator_prefix.clone().unwrap_or_else(|| "U".into());
let footprint = sym.footprint.clone().unwrap_or_default();
let value = sym
.parameters
.iter()
.find(|p| p.name.eq_ignore_ascii_case("Value"))
.map(|p| p.value.clone())
.unwrap_or_else(|| name.clone());
// Bounding box (mm) over pins + graphics, so the Reference/Value labels sit
// just outside the symbol instead of overlapping it at the origin.
let (top, bottom, cx) = symbol_bbox_mm(sym);
let mut s = String::new();
s.push_str(&ver.sym_header("adom-lbr"));
s.push_str(&format!("\t(symbol \"{name}\"\n"));
// Preserve the source's pin-number/name visibility; when unknown (e.g. built
// from Altium or scratch), default passives (≤2 pins) to hidden like KiCad's
// own R/C/L symbols.
let passive = sym.pins.len() <= 2;
let hide_num = sym.pin_numbers_hidden.unwrap_or(passive);
let hide_name = sym.pin_names_hidden.unwrap_or(passive);
let hide = ver.hide_flag();
s.push_str(&if hide_num {
format!("\t\t(pin_numbers{hide})\n")
} else {
"\t\t(pin_numbers)\n".to_string()
});
s.push_str(&if hide_name {
format!("\t\t(pin_names (offset 0.254){hide})\n")
} else {
"\t\t(pin_names (offset 0.254))\n".to_string()
});
if ver.writes_exclude_from_sim() {
s.push_str("\t\t(exclude_from_sim no)\n");
}
s.push_str("\t\t(in_bom yes)\n\t\t(on_board yes)\n");
// properties (Reference above the symbol, Value below; metadata hidden at origin)
let prop = |k: &str, v: &str, x: f64, y: f64, hidden: bool| {
let h = if hidden { hide } else { "" };
format!(
"\t\t(property \"{k}\" \"{}\"\n\t\t\t(at {} {} 0)\n\t\t\t(effects (font (size 1.27 1.27)){h})\n\t\t)\n",
esc(v), n(x), n(y)
)
};
s.push_str(&prop("Reference", &reference, cx, top + 1.27, false));
s.push_str(&prop("Value", &value, cx, bottom - 1.27, false));
s.push_str(&prop("Footprint", &footprint, 0.0, 0.0, true));
// "" not "~": since format 20250318 a tilde is a literal tilde.
s.push_str(&prop("Datasheet", "", 0.0, 0.0, true));
// any extra parameters become hidden properties (at origin)
for p in &sym.parameters {
if !p.name.eq_ignore_ascii_case("Value") {
s.push_str(&prop(&p.name, &p.value, 0.0, 0.0, !p.visible));
}
}
// body graphics → the _0_1 (shared) sub-symbol
if !sym.graphics.is_empty() {
s.push_str(&format!("\t\t(symbol \"{name}_0_1\"\n"));
for g in &sym.graphics {
s.push_str(&sym_graphic(g, &m));
}
s.push_str("\t\t)\n");
}
// pins → the _1_1 (unit 1) sub-symbol
s.push_str(&format!("\t\t(symbol \"{name}_1_1\"\n"));
for pin in &sym.pins {
let len = pin.length.unwrap_or(200) as f64;
// legacy sources used "~" for an unnamed pin; in KiCad 10 that's a
// literal tilde, so write the real empty string.
let pname = if pin.name == "~" { "" } else { pin.name.as_str() };
s.push_str(&format!(
"\t\t\t(pin unspecified line\n\t\t\t\t(at {} {} {})\n\t\t\t\t(length {})\n\
\t\t\t\t(name \"{}\" (effects (font (size 1.27 1.27))))\n\
\t\t\t\t(number \"{}\" (effects (font (size 1.27 1.27))))\n\t\t\t)\n",
m(pin.x as f64), m(pin.y as f64), pin.rotation, m(len),
esc(pname), esc(&pin.number)
));
}
s.push_str("\t\t)\n\t)\n)\n");
s
}
/// One symbol body graphic → KiCad s-expr (mils→mm via `m`).
fn sym_graphic(g: &Graphic, m: &dyn Fn(f64) -> String) -> String {
let stroke_fill = |w: f64, filled: bool| {
let fill = if filled { "outline" } else { "none" };
format!(
"\t\t\t\t(stroke (width {}) (type default))\n\t\t\t\t(fill (type {fill}))\n",
n((w * MM_PER_MIL).max(0.0))
)
};
match g {
Graphic::Rect { x1, y1, x2, y2, width, filled } => format!(
"\t\t\t(rectangle\n\t\t\t\t(start {} {})\n\t\t\t\t(end {} {})\n{}\t\t\t)\n",
m(*x1), m(*y1), m(*x2), m(*y2), stroke_fill(*width, *filled)
),
Graphic::Circle { cx, cy, r, width } => format!(
"\t\t\t(circle\n\t\t\t\t(center {} {})\n\t\t\t\t(radius {})\n{}\t\t\t)\n",
m(*cx), m(*cy), m(*r), stroke_fill(*width, false)
),
Graphic::Arc { cx, cy, r, start, end, width } => {
let (s, mid, e) = arc_points(*cx, *cy, *r, *start, *end);
format!(
"\t\t\t(arc\n\t\t\t\t(start {} {})\n\t\t\t\t(mid {} {})\n\t\t\t\t(end {} {})\n{}\t\t\t)\n",
m(s.0), m(s.1), m(mid.0), m(mid.1), m(e.0), m(e.1), stroke_fill(*width, false)
)
}
Graphic::Polyline { points, width } => {
let mut pts = String::new();
for p in points {
pts.push_str(&format!("\t\t\t\t\t(xy {} {})\n", m(p[0]), m(p[1])));
}
format!(
"\t\t\t(polyline\n\t\t\t\t(pts\n{pts}\t\t\t\t)\n{}\t\t\t)\n",
stroke_fill(*width, false)
)
}
}
}
// ---------------------------------------------------------------------------
// Footprint → .kicad_mod
// ---------------------------------------------------------------------------
/// Export a footprint as a `.kicad_mod`, in the default format tier (V8 —
/// loads in KiCad 8/9/10; see [`KicadVersion`]).
pub fn export_kicad_footprint(fp: &Footprint) -> String {
export_kicad_footprint_for(fp, KicadVersion::default())
}
/// Export a footprint targeting a specific KiCad format tier.
pub fn export_kicad_footprint_for(fp: &Footprint, ver: KicadVersion) -> String {
let smd = fp.pads.iter().all(|p| p.drill_mm <= 0.0);
let mut s = String::new();
s.push_str(&format!("(footprint \"{}\"\n", esc(&fp.name)));
match ver.generator_version() {
Some(gv) => s.push_str(&format!(
"\t(version {})\n\t(generator \"adom-lbr\")\n\t(generator_version \"{gv}\")\n\t(layer \"F.Cu\")\n",
ver.fp_version()
)),
None => s.push_str(&format!(
"\t(version {})\n\t(generator \"adom-lbr\")\n\t(layer \"F.Cu\")\n",
ver.fp_version()
)),
}
s.push_str(if smd { "\t(attr smd)\n" } else { "\t(attr through_hole)\n" });
// silk / courtyard / fab graphics
for g in &fp.graphics {
s.push_str(&fp_graphic(g));
}
// pads
for pad in &fp.pads {
let thru = pad.drill_mm > 0.0;
let npth = thru && pad.plated == Some(false);
let (ptype, layers) = if npth {
// NPTH (mounting hole): no copper ring, mask relief only.
("np_thru_hole", "\"*.Mask\"")
} else if thru {
("thru_hole", "\"*.Cu\" \"*.Mask\"")
} else {
("smd", "\"F.Cu\" \"F.Paste\" \"F.Mask\"")
};
let shape = match pad.shape.as_str() {
"circle" => "circle",
"oval" => "oval",
"roundrect" => "roundrect",
_ => "rect",
};
let at = if pad.rotation != 0.0 {
format!("(at {} {} {})", n(pad.x_mm), n(pad.y_mm), n(pad.rotation))
} else {
format!("(at {} {})", n(pad.x_mm), n(pad.y_mm))
};
s.push_str(&format!(
"\t(pad \"{}\" {ptype} {shape} {at} (size {} {})",
esc(&pad.number), n(pad.w_mm), n(pad.h_mm)
));
if thru {
s.push_str(&format!(" (drill {})", n(pad.drill_mm)));
}
s.push_str(&format!(" (layers {layers})"));
if shape == "roundrect" {
s.push_str(" (roundrect_rratio 0.25)");
}
s.push_str(")\n");
}
s.push_str(")\n");
s
}
fn kicad_fp_layer(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "F.SilkS",
FpLayer::Courtyard => "F.CrtYd",
FpLayer::Fab => "F.Fab",
FpLayer::Copper => "F.Cu",
FpLayer::Other { .. } => "F.Fab",
}
}
fn fp_graphic(g: &FpGraphic) -> String {
match g {
FpGraphic::Line { layer, x1, y1, x2, y2, width } => format!(
"\t(fp_line (start {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (layer \"{}\"))\n",
n(*x1), n(*y1), n(*x2), n(*y2), n(*width), kicad_fp_layer(layer)
),
FpGraphic::Rect { layer, x1, y1, x2, y2, width } => format!(
"\t(fp_rect (start {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (fill none) (layer \"{}\"))\n",
n(*x1), n(*y1), n(*x2), n(*y2), n(*width), kicad_fp_layer(layer)
),
FpGraphic::Circle { layer, cx, cy, r, width } => format!(
"\t(fp_circle (center {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (fill none) (layer \"{}\"))\n",
n(*cx), n(*cy), n(cx + r), n(*cy), n(*width), kicad_fp_layer(layer)
),
FpGraphic::Arc { layer, cx, cy, r, start, end, width } => {
let (s, mid, e) = arc_points(*cx, *cy, *r, *start, *end);
format!(
"\t(fp_arc (start {} {}) (mid {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (layer \"{}\"))\n",
n(s.0), n(s.1), n(mid.0), n(mid.1), n(e.0), n(e.1), n(*width), kicad_fp_layer(layer)
)
}
}
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
/// center/radius/start-end angles (deg, CCW) → the (start, mid, end) points
/// KiCad uses. Same units in and out.
fn arc_points(cx: f64, cy: f64, r: f64, start: f64, end: f64) -> ((f64, f64), (f64, f64), (f64, f64)) {
// Angles can come straight out of a decoded (untrusted) binary — normalize
// arithmetically. A `while sweep <= 0 { += 360 }` loop hangs on huge
// negatives and never terminates on NaN.
let (start, end) = (
if start.is_finite() { start } else { 0.0 },
if end.is_finite() { end } else { 0.0 },
);
let mut sweep = (end - start).rem_euclid(360.0);
if sweep == 0.0 {
sweep = 360.0; // same start/end angle = full circle, as before
}
let mid_ang = start + sweep / 2.0;
let pt = |deg: f64| {
let a = deg.to_radians();
(cx + r * a.cos(), cy + r * a.sin())
};
(pt(start), pt(mid_ang), pt(end))
}
fn esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Symbol bounding box in mm over pins + graphics → `(top_y, bottom_y, center_x)`.
/// Used to place the Reference (above) and Value (below) labels clear of the art.
fn symbol_bbox_mm(sym: &Symbol) -> (f64, f64, f64) {
let (mut minx, mut maxx) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut miny, mut maxy) = (f64::INFINITY, f64::NEG_INFINITY);
let mut upd = |x: f64, y: f64| {
minx = minx.min(x);
maxx = maxx.max(x);
miny = miny.min(y);
maxy = maxy.max(y);
};
for p in &sym.pins {
upd(p.x as f64, p.y as f64);
}
for g in &sym.graphics {
match g {
Graphic::Rect { x1, y1, x2, y2, .. } => {
upd(*x1, *y1);
upd(*x2, *y2);
}
Graphic::Circle { cx, cy, r, .. } | Graphic::Arc { cx, cy, r, .. } => {
upd(cx - r, cy - r);
upd(cx + r, cy + r);
}
Graphic::Polyline { points, .. } => {
for p in points {
upd(p[0], p[1]);
}
}
}
}
if !minx.is_finite() {
return (2.54, -2.54, 0.0);
}
(
maxy * MM_PER_MIL,
miny * MM_PER_MIL,
((minx + maxx) / 2.0) * MM_PER_MIL,
)
}