app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footp
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
//! KiCad `.kicad_sym` / `.kicad_mod` **reader** (the import side of the KiCad
//! adapter). Parses the s-expression into the neutral [`AdomLbrPart`].
//!
//! Units: KiCad is millimetres. Symbol pins convert mm → mils (the model's
//! schematic unit, 1 mil = 0.0254 mm); footprint pads stay in mm.
//!
//! v0 scope: pins (number/name/position/rotation) and pads (number/type/shape/
//! position/size/drill). Silk/courtyard graphics import lands with the graphics
//! work — they have nowhere to go in the Altium *writer* yet.
use crate::model::{AdomLbrPart, ConnectEntry, Footprint, FpGraphic, FpLayer, Pad, Pin, Symbol};
use anyhow::{Context, Result};
const MM_PER_MIL: f64 = 0.0254;
// The s-expression parser lives in crate::sexpr (shared, KiCad-parser-inspired:
// line-tracked errors, strict balance checking, version gate). Re-exported here
// for backward compatibility with existing use sites.
pub use crate::sexpr::{parse_root, Sx};
// ---------------------------------------------------------------------------
// Symbol import
// ---------------------------------------------------------------------------
fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {
let root = parse_root(text)?;
// The file's declared format version drives version-scoped semantics
// (KiCad parser's m_requiredVersion gate).
let doc_version = root.doc_version();
// Top-level symbols only — a .kicad_sym is a LIBRARY and may hold many.
// Everything below must be scoped to the selected one, or a multi-symbol
// lib bleeds other symbols' properties/graphics into this part.
let syms: Vec<&Sx> = root
.items()
.iter()
.filter(|s| s.head() == Some("symbol"))
.collect();
let sym: &Sx = match syms.len() {
0 => anyhow::bail!("no (symbol ...) in kicad_sym"),
1 => syms[0],
_ => syms
.iter()
.find(|s| s.atom_at(1).is_some_and(|n| n.eq_ignore_ascii_case(wanted)))
.copied()
.with_context(|| {
let names: Vec<_> = syms.iter().filter_map(|s| s.atom_at(1)).collect();
format!(
"library holds {} symbols and none is named {wanted:?} — available: {}",
syms.len(),
names.join(", ")
)
})?,
};
let name = sym.atom_at(1).unwrap_or("SYM").to_string();
// designator prefix from the Reference property — of THIS symbol only
let mut prefix = "U".to_string();
let mut props = Vec::new();
sym.collect("property", &mut props);
for p in &props {
if p.atom_at(1) == Some("Reference") {
if let Some(r) = p.atom_at(2) {
prefix = r.to_string();
}
}
}
// all pins anywhere under the symbol
let mut pin_lists = Vec::new();
sym.collect("pin", &mut pin_lists);
let mut pins = Vec::new();
for p in pin_lists {
let at = p.child("at").map(|a| a.nums()).unwrap_or_default();
let (xmm, ymm, rot) = (
at.first().copied().unwrap_or(0.0),
at.get(1).copied().unwrap_or(0.0),
at.get(2).copied().unwrap_or(0.0),
);
let number = p
.child("number")
.and_then(|n| n.atom_at(1))
.unwrap_or("?")
.to_string();
// "~" means "unnamed" only under the legacy (< 20250318) format —
// gate on the file's declared version, exactly like KiCad's parser.
let pname = p
.child("name")
.and_then(|n| n.atom_at(1))
.map(|s| crate::sexpr::resolve_tilde(s, doc_version))
.unwrap_or_default();
// KiCad pin length is in mm → convert to mils (the model's unit).
let len_mil = p
.child("length")
.and_then(|l| l.nums().first().copied())
.map(|mm| (mm / MM_PER_MIL).round() as i32);
pins.push(Pin {
number,
name: pname,
x: (xmm / MM_PER_MIL).round() as i32,
y: (ymm / MM_PER_MIL).round() as i32,
length: len_mil,
rotation: ((rot as i32 % 360 + 360) % 360) as u16,
});
}
// Preserve the source's pin-number / pin-name visibility (KiCad
// `(pin_numbers (hide yes))`). Absent → shown (KiCad default).
let has_hide = |key: &str| -> bool {
match sym.child(key) {
None => false,
Some(node) => node
.child("hide")
.and_then(|h| h.atom_at(1))
.map(|v| v == "yes")
.unwrap_or_else(|| node.items().iter().any(|i| matches!(i, Sx::Atom(a) if a == "hide"))),
}
};
Ok(Symbol {
name,
pins,
body: None,
graphics: import_symbol_graphics(sym),
designator_prefix: Some(prefix),
footprint: None,
parameters: Vec::new(),
pin_numbers_hidden: Some(has_hide("pin_numbers")),
pin_names_hidden: Some(has_hide("pin_names")),
})
}
/// Import a KiCad symbol's body graphics (polyline / rectangle / circle / arc)
/// into neutral [`Graphic`]s, in mils. Generic — not part-specific.
fn import_symbol_graphics(root: &Sx) -> Vec<crate::model::Graphic> {
use crate::model::Graphic;
let to_mil = |mm: f64| mm / MM_PER_MIL;
let mut out = Vec::new();
let width_of = |g: &Sx| -> f64 {
g.child("stroke")
.and_then(|s| s.child("width"))
.and_then(|w| w.nums().first().copied())
.map(to_mil)
.unwrap_or(6.0)
};
let mut polys = Vec::new();
root.collect("polyline", &mut polys);
for g in polys {
if let Some(pts) = g.child("pts") {
let mut xy = Vec::new();
let mut pt_lists = Vec::new();
pts.collect("xy", &mut pt_lists);
for p in pt_lists {
let n = p.nums();
if n.len() >= 2 {
xy.push([to_mil(n[0]), to_mil(n[1])]);
}
}
if xy.len() >= 2 {
out.push(Graphic::Polyline { points: xy, width: width_of(g) });
}
}
}
// A shape is "filled" when its (fill (type ...)) is not `none` (KiCad fills
// with `outline` or `background`). Cap plates are filled rectangles.
let filled_of = |g: &Sx| -> bool {
g.child("fill")
.and_then(|f| f.child("type"))
.and_then(|t| t.atom_at(1))
.map(|t| t != "none")
.unwrap_or(false)
};
let mut rects = Vec::new();
root.collect("rectangle", &mut rects);
for g in rects {
let s = g.child("start").map(|c| c.nums()).unwrap_or_default();
let e = g.child("end").map(|c| c.nums()).unwrap_or_default();
if s.len() >= 2 && e.len() >= 2 {
out.push(Graphic::Rect {
x1: to_mil(s[0]), y1: to_mil(s[1]), x2: to_mil(e[0]), y2: to_mil(e[1]),
width: width_of(g), filled: filled_of(g),
});
}
}
let mut circs = Vec::new();
root.collect("circle", &mut circs);
for g in circs {
let c = g.child("center").map(|c| c.nums()).unwrap_or_default();
let r = g.child("radius").and_then(|r| r.nums().first().copied());
if c.len() >= 2 {
if let Some(r) = r {
out.push(Graphic::Circle { cx: to_mil(c[0]), cy: to_mil(c[1]), r: to_mil(r), width: width_of(g) });
}
}
}
// Arcs — KiCad gives 3 points (start / mid / end); convert to the neutral
// center + radius + start/end-angle form. Direction is normalized so the
// Altium CCW sweep from start→end passes through the mid point.
let mut arcs = Vec::new();
root.collect("arc", &mut arcs);
for g in arcs {
let s = g.child("start").map(|c| c.nums()).unwrap_or_default();
let m = g.child("mid").map(|c| c.nums()).unwrap_or_default();
let e = g.child("end").map(|c| c.nums()).unwrap_or_default();
if s.len() >= 2 && m.len() >= 2 && e.len() >= 2 {
if let Some((cx, cy, r, sa, ea)) =
arc_from_3pts(s[0], s[1], m[0], m[1], e[0], e[1])
{
out.push(Graphic::Arc {
cx: to_mil(cx),
cy: to_mil(cy),
r: to_mil(r),
start: sa,
end: ea,
width: width_of(g),
});
}
}
}
out
}
/// Given three points on an arc (start, mid, end), return
/// `(center_x, center_y, radius, start_angle, end_angle)` with angles in degrees
/// (0..360). The angle order is chosen so a counter-clockwise sweep from the
/// start angle to the end angle (Altium's arc convention) passes through `mid`.
/// Returns `None` if the points are collinear.
fn arc_from_3pts(
ax: f64, ay: f64, bx: f64, by: f64, cx: f64, cy: f64,
) -> Option<(f64, f64, f64, f64, f64)> {
let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if d.abs() < 1e-9 {
return None; // collinear → no unique circle
}
let a2 = ax * ax + ay * ay;
let b2 = bx * bx + by * by;
let c2 = cx * cx + cy * cy;
let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
let r = ((ax - ux).powi(2) + (ay - uy).powi(2)).sqrt();
let ang = |x: f64, y: f64| {
let mut a = (y - uy).atan2(x - ux).to_degrees();
if a < 0.0 {
a += 360.0;
}
a
};
let sa = ang(ax, ay);
let ma = ang(bx, by);
let ea = ang(cx, cy);
// Relative CCW position from the start angle; if the mid isn't inside the
// start→end CCW sweep, swap the endpoints so it is.
let rel = |x: f64| {
let mut v = (x - sa) % 360.0;
if v < 0.0 {
v += 360.0;
}
v
};
let (sa, ea) = if rel(ma) <= rel(ea) { (sa, ea) } else { (ea, sa) };
Some((ux, uy, r, sa, ea))
}
// ---------------------------------------------------------------------------
// Footprint import
// ---------------------------------------------------------------------------
fn import_footprint(text: &str) -> Result<Footprint> {
let root = parse_root(text)?;
// (footprint "NAME" ...) — name is the first atom after head
let name = root.atom_at(1).unwrap_or("FP").to_string();
let mut pad_lists = Vec::new();
root.collect("pad", &mut pad_lists);
let mut pads = Vec::new();
for p in pad_lists {
let number = p.atom_at(1).unwrap_or("?").to_string();
let ptype = p.atom_at(2).unwrap_or("smd");
let shape = p.atom_at(3).unwrap_or("rect").to_string();
let at = p.child("at").map(|a| a.nums()).unwrap_or_default();
let size = p.child("size").map(|a| a.nums()).unwrap_or_default();
let rot = at.get(2).copied().unwrap_or(0.0);
// (drill d) or (drill oval w h) — take the larger axis of a slot so
// the hole isn't undersized (full slot support is a future field).
let drill = p
.child("drill")
.map(|d| d.nums().into_iter().fold(0.0_f64, f64::max))
.unwrap_or(0.0);
let kshape = match shape.as_str() {
"circle" => "circle",
"oval" => "oval",
"roundrect" => "roundrect",
_ => "rect",
};
// thru_hole AND np_thru_hole both have a real hole; dropping the NPTH
// drill would silently turn a mounting hole into an SMD copper pad.
let is_th = ptype.ends_with("thru_hole");
pads.push(Pad {
number,
x_mm: at.first().copied().unwrap_or(0.0),
y_mm: at.get(1).copied().unwrap_or(0.0),
w_mm: size.first().copied().unwrap_or(0.5),
h_mm: size.get(1).copied().unwrap_or(0.5),
drill_mm: if is_th { drill } else { 0.0 },
rotation: rot,
shape: kshape.to_string(),
plated: if is_th { Some(ptype != "np_thru_hole") } else { None },
});
}
Ok(Footprint { name, pads, graphics: import_footprint_graphics(&root) })
}
/// Map a KiCad footprint layer name to a neutral [`FpLayer`] role. Returns
/// `None` for layers we don't carry (paste/mask/etc.).
fn kicad_fp_layer(layer: &str) -> Option<FpLayer> {
if layer.contains("SilkS") {
Some(FpLayer::Silk)
} else if layer.contains("CrtYd") {
Some(FpLayer::Courtyard)
} else if layer.contains("Fab") {
Some(FpLayer::Fab)
} else if layer.ends_with(".Cu") {
Some(FpLayer::Copper)
} else {
None
}
}
/// Import a KiCad footprint's silk/courtyard/fab graphics into neutral
/// [`FpGraphic`]s (mm, Y-down). Covers fp_line / fp_rect / fp_circle / fp_arc.
fn import_footprint_graphics(root: &Sx) -> Vec<FpGraphic> {
let mut out = Vec::new();
let layer_of = |g: &Sx| -> Option<FpLayer> {
g.child("layer")
.and_then(|l| l.atom_at(1))
.and_then(kicad_fp_layer)
};
let width_of = |g: &Sx| -> f64 {
g.child("stroke")
.and_then(|s| s.child("width"))
.and_then(|w| w.nums().first().copied())
.or_else(|| g.child("width").and_then(|w| w.nums().first().copied()))
.unwrap_or(0.12)
};
let mut lines = Vec::new();
root.collect("fp_line", &mut lines);
for g in lines {
if let Some(layer) = layer_of(g) {
let s = g.child("start").map(|c| c.nums()).unwrap_or_default();
let e = g.child("end").map(|c| c.nums()).unwrap_or_default();
if s.len() >= 2 && e.len() >= 2 {
out.push(FpGraphic::Line { layer, x1: s[0], y1: s[1], x2: e[0], y2: e[1], width: width_of(g) });
}
}
}
let mut rects = Vec::new();
root.collect("fp_rect", &mut rects);
for g in rects {
if let Some(layer) = layer_of(g) {
let s = g.child("start").map(|c| c.nums()).unwrap_or_default();
let e = g.child("end").map(|c| c.nums()).unwrap_or_default();
if s.len() >= 2 && e.len() >= 2 {
out.push(FpGraphic::Rect { layer, x1: s[0], y1: s[1], x2: e[0], y2: e[1], width: width_of(g) });
}
}
}
let mut circs = Vec::new();
root.collect("fp_circle", &mut circs);
for g in circs {
if let Some(layer) = layer_of(g) {
let c = g.child("center").map(|c| c.nums()).unwrap_or_default();
let e = g.child("end").map(|c| c.nums()).unwrap_or_default();
if c.len() >= 2 && e.len() >= 2 {
let r = ((e[0] - c[0]).powi(2) + (e[1] - c[1]).powi(2)).sqrt();
out.push(FpGraphic::Circle { layer, cx: c[0], cy: c[1], r, width: width_of(g) });
}
}
}
let mut arcs = Vec::new();
root.collect("fp_arc", &mut arcs);
for g in arcs {
if let Some(layer) = layer_of(g) {
let s = g.child("start").map(|c| c.nums()).unwrap_or_default();
let m = g.child("mid").map(|c| c.nums()).unwrap_or_default();
let e = g.child("end").map(|c| c.nums()).unwrap_or_default();
if s.len() >= 2 && m.len() >= 2 && e.len() >= 2 {
if let Some((cx, cy, r, sa, ea)) = arc_from_3pts(s[0], s[1], m[0], m[1], e[0], e[1]) {
out.push(FpGraphic::Arc { layer, cx, cy, r, start: sa, end: ea, width: width_of(g) });
}
}
}
}
out
}
// ---------------------------------------------------------------------------
// Public: KiCad symbol + footprint → AdomLbrPart
// ---------------------------------------------------------------------------
/// Import a KiCad symbol + footprint pair into the neutral part. The pin↔pad
/// link is identity-mapped (pin number == pad number) where they coincide, and
/// the symbol is given the footprint name so the Altium writer links them.
pub fn import_kicad(sym_text: &str, mod_text: &str, mpn: &str) -> Result<AdomLbrPart> {
let mut symbol = import_symbol(sym_text, mpn)?;
let footprint = import_footprint(mod_text)?;
symbol.footprint = Some(footprint.name.clone());
// Center the symbol on the origin (KiCad sources are often drawn off-origin).
symbol.center();
// identity connect map over shared designators
let pad_nums: std::collections::HashSet<&str> =
footprint.pads.iter().map(|p| p.number.as_str()).collect();
let connect = symbol
.pins
.iter()
.filter(|p| pad_nums.contains(p.number.as_str()))
.map(|p| ConnectEntry {
pin: p.number.clone(),
pad: p.number.clone(),
})
.collect();
Ok(AdomLbrPart {
schema_version: 2,
mpn: mpn.to_string(),
manufacturer: String::new(),
package: String::new(),
value: String::new(),
category: String::new(),
symbol,
footprint,
connect,
model_3d: None,
provenance: Some(serde_json::json!({"source":"kicad_import"})),
})
}