skill
Adom SF Convert
Public Made by Adomby adom
Native Rust converter: EAGLE/Fusion .lbr + Altium .SchLib/.PcbLib → KiCad, rendering each format's own geometry.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
//! EAGLE / Fusion 360 `.lbr` (XML) → KiCad `.kicad_sym` + `.kicad_mod`.
//!
//! Fusion 360 inherited EAGLE's XML library format, so one parser covers both.
//! Coordinates in the EAGLE XML are millimetres (KiCad's unit too); EAGLE's Y
//! axis points UP while KiCad footprints point DOWN, so we negate Y.
//!
//! This is a faithful native conversion — no KiCad install, no service round-trip
//! — so each format renders its OWN geometry (export drift between formats shows).
use anyhow::{anyhow, Result};
use regex::Regex;
use std::collections::HashMap;
fn attr(tag: &str, name: &str) -> Option<String> {
let re = Regex::new(&format!(r#"{name}="([^"]*)""#)).ok()?;
re.captures(tag).map(|c| c[1].to_string())
}
fn fattr(tag: &str, name: &str) -> Option<f64> {
attr(tag, name).and_then(|v| v.trim().parse().ok())
}
/// EAGLE rotation string "R90" / "MR180" → degrees (mirror flag ignored here).
fn rot_deg(tag: &str) -> f64 {
attr(tag, "rot")
.and_then(|r| r.trim_start_matches(['R', 'M', 'S']).parse::<f64>().ok())
.unwrap_or(0.0)
}
/// Extract the first `<TAG ...>...</TAG>` (or the whole tag) block by name.
fn block<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("<{tag}");
let start = xml.find(&open)?;
let close = format!("</{tag}>");
let end = xml[start..].find(&close).map(|e| start + e + close.len())?;
Some(&xml[start..end])
}
/// pin-name → pad-number, from the deviceset `<connect .../>` rows.
fn connect_map(xml: &str) -> HashMap<String, String> {
let mut m = HashMap::new();
for c in Regex::new(r"<connect\b[^>]*/?>").unwrap().find_iter(xml) {
let t = c.as_str();
if let (Some(pin), Some(pad)) = (attr(t, "pin"), attr(t, "pad")) {
m.insert(pin, pad);
}
}
m
}
/// Convert a `.lbr` to (kicad_sym, kicad_mod). Either may be empty if the
/// library has no symbol / no package.
pub fn convert(lbr: &str, name: &str) -> Result<(String, String)> {
if !lbr.contains("<eagle") {
return Err(anyhow!("not an EAGLE/Fusion .lbr (no <eagle> root)"));
}
let kicad_mod = block(lbr, "package").map(|p| package_to_mod(p, name)).unwrap_or_default();
let kicad_sym = block(lbr, "symbol").map(|s| symbol_to_sym(s, name, &connect_map(lbr))).unwrap_or_default();
Ok((kicad_sym, kicad_mod))
}
// ------------------------------------------------------------------------
// Native SVG renderers — draw straight from the EAGLE geometry, the same way
// altium-pcblib/altium-schlib do, so the .lbr renders its OWN geometry with no
// KiCad round-trip. (EAGLE coords are mm, Y-up → negate Y to screen space.)
// ------------------------------------------------------------------------
fn xesc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">") }
/// Render the EAGLE `<package>` (footprint) to a standalone SVG: SMD + THT pads
/// plus silk/fab wires. Returns "" if the library has no package.
pub fn render_footprint_svg(lbr: &str, name: &str) -> String {
let Some(pkg) = block(lbr, "package") else { return String::new() };
// Collect pad rects (x,y,w,h) and silk lines, computing the bbox as we go.
let mut rects: Vec<(f64, f64, f64, f64, String)> = Vec::new();
let mut lines: Vec<(f64, f64, f64, f64, f64, String)> = Vec::new();
let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut bump = |x: f64, y: f64, hw: f64, hh: f64, mnx: &mut f64, mny: &mut f64, mxx: &mut f64, mxy: &mut f64| {
*mnx = mnx.min(x - hw); *mny = mny.min(y - hh); *mxx = mxx.max(x + hw); *mxy = mxy.max(y + hh);
};
for m in Regex::new(r"<smd\b[^>]*/?>").unwrap().find_iter(pkg) {
let t = m.as_str();
let (Some(x), Some(y), Some(dx), Some(dy)) = (fattr(t, "x"), fattr(t, "y"), fattr(t, "dx"), fattr(t, "dy")) else { continue };
let n = attr(t, "name").unwrap_or_default();
let (sy, hw, hh) = (-y, dx / 2.0, dy / 2.0);
bump(x, sy, hw, hh, &mut minx, &mut miny, &mut maxx, &mut maxy);
rects.push((x - hw, sy - hh, dx, dy, n));
}
for m in Regex::new(r"<pad\b[^>]*/?>").unwrap().find_iter(pkg) {
let t = m.as_str();
let (Some(x), Some(y)) = (fattr(t, "x"), fattr(t, "y")) else { continue };
let n = attr(t, "name").unwrap_or_default();
let d = fattr(t, "diameter").unwrap_or_else(|| fattr(t, "drill").map(|dr| dr + 0.5).unwrap_or(1.5));
let (sy, hw) = (-y, d / 2.0);
bump(x, sy, hw, hw, &mut minx, &mut miny, &mut maxx, &mut maxy);
rects.push((x - hw, sy - hw, d, d, n));
}
for m in Regex::new(r"<wire\b[^>]*/?>").unwrap().find_iter(pkg) {
let t = m.as_str();
let (Some(x1), Some(y1), Some(x2), Some(y2)) = (fattr(t, "x1"), fattr(t, "y1"), fattr(t, "x2"), fattr(t, "y2")) else { continue };
let layer = attr(t, "layer").unwrap_or_default();
let col = match layer.as_str() { "21" | "22" => "#cfd8dc", "51" | "52" => "#6b7785", _ => continue };
lines.push((x1, -y1, x2, -y2, fattr(t, "width").filter(|w| *w > 0.0).unwrap_or(0.12), col.into()));
}
if !minx.is_finite() { return String::new(); }
let pad = 1.0;
let (vx, vy, vw, vh) = (minx - pad, miny - pad, (maxx - minx) + 2.0 * pad, (maxy - miny) + 2.0 * pad);
let mut s = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vx:.3} {vy:.3} {vw:.3} {vh:.3}" width="{:.0}" height="{:.0}" font-family="monospace">"#,
vw * 50.0, vh * 50.0
);
s += &format!(r##"<rect x="{vx:.3}" y="{vy:.3}" width="{vw:.3}" height="{vh:.3}" fill="#0d1117"/>"##);
for (x1, y1, x2, y2, w, col) in &lines {
s += &format!(r#"<line x1="{x1:.3}" y1="{y1:.3}" x2="{x2:.3}" y2="{y2:.3}" stroke="{col}" stroke-width="{w:.3}"/>"#);
}
for (x, y, w, h, n) in &rects {
s += &format!(r##"<rect x="{x:.3}" y="{y:.3}" width="{w:.3}" height="{h:.3}" rx="0.05" fill="#ff9a4a" stroke="#7a3c10" stroke-width="0.03"/>"##);
s += &format!(r##"<text x="{:.3}" y="{:.3}" font-size="{:.2}" text-anchor="middle" dominant-baseline="middle" fill="#111">{}</text>"##,
x + w / 2.0, y + h / 2.0, (w.min(*h) * 0.55).clamp(0.2, 0.6), xesc(n));
}
s += &format!(r##"<text x="{:.3}" y="{:.3}" font-size="0.6" fill="#00e6dc">{} (EAGLE)</text>"##, vx + 0.3, vy + 0.9, xesc(name));
s += "</svg>\n";
s
}
/// Render the EAGLE `<symbol>` to a standalone SVG: pins (with names + pad
/// numbers from `<connect>`) plus the body wires. Returns "" if no symbol.
pub fn render_symbol_svg(lbr: &str, name: &str) -> String {
let Some(sym) = block(lbr, "symbol") else { return String::new() };
let connects = connect_map(lbr);
let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
struct P { x: f64, y: f64, len: f64, rot: f64, name: String, num: String }
let mut pins: Vec<P> = Vec::new();
for m in Regex::new(r"<pin\b[^>]*/?>").unwrap().find_iter(sym) {
let t = m.as_str();
let (Some(pname), Some(x), Some(y)) = (attr(t, "name"), fattr(t, "x"), fattr(t, "y")) else { continue };
let len = match attr(t, "length").as_deref() { Some("point") => 0.0, Some("short") => 2.54, Some("long") => 7.62, _ => 5.08 };
let num = connects.get(&pname).cloned().unwrap_or_else(|| pname.clone());
let (sx, sy) = (x, -y);
minx = minx.min(sx); maxx = maxx.max(sx); miny = miny.min(sy); maxy = maxy.max(sy);
pins.push(P { x: sx, y: sy, len, rot: rot_deg(t), name: pname, num });
}
let mut lines: Vec<(f64, f64, f64, f64, f64)> = Vec::new();
for m in Regex::new(r"<wire\b[^>]*/?>").unwrap().find_iter(sym) {
let t = m.as_str();
let (Some(x1), Some(y1), Some(x2), Some(y2)) = (fattr(t, "x1"), fattr(t, "y1"), fattr(t, "x2"), fattr(t, "y2")) else { continue };
for (vx, vy) in [(x1, -y1), (x2, -y2)] { minx = minx.min(vx); maxx = maxx.max(vx); miny = miny.min(vy); maxy = maxy.max(vy); }
lines.push((x1, -y1, x2, -y2, fattr(t, "width").filter(|w| *w > 0.0).unwrap_or(0.15)));
}
if pins.is_empty() && lines.is_empty() { return String::new(); }
let pad = 4.0;
let (vx, vy, vw, vh) = (minx - pad, miny - pad, (maxx - minx) + 2.0 * pad, (maxy - miny) + 2.0 * pad);
let mut s = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vx:.3} {vy:.3} {vw:.3} {vh:.3}" width="{:.0}" height="{:.0}" font-family="monospace">"#,
vw * 14.0, vh * 14.0
);
s += &format!(r##"<rect x="{vx:.3}" y="{vy:.3}" width="{vw:.3}" height="{vh:.3}" fill="#0d1117"/>"##);
for (x1, y1, x2, y2, w) in &lines {
s += &format!(r##"<line x1="{x1:.3}" y1="{y1:.3}" x2="{x2:.3}" y2="{y2:.3}" stroke="#00b8b1" stroke-width="{w:.3}"/>"##);
}
for p in &pins {
// pin extends from (x,y) outward along its rotation; R0 points right.
let (dx, dy) = match ((p.rot / 90.0).round() as i64).rem_euclid(4) {
0 => (p.len, 0.0), 1 => (0.0, -p.len), 2 => (-p.len, 0.0), _ => (0.0, p.len),
};
let (ex, ey) = (p.x + dx, p.y + dy);
s += &format!(r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="#7cb6ff" stroke-width="0.15"/>"##, p.x, p.y, ex, ey);
s += &format!(r##"<text x="{:.3}" y="{:.3}" font-size="1.2" fill="#e6edf3" text-anchor="middle">{}</text>"##, ex, ey - 0.6, xesc(&p.name));
s += &format!(r##"<text x="{:.3}" y="{:.3}" font-size="0.9" fill="#8b949e" text-anchor="middle">{}</text>"##, ex, ey + 1.4, xesc(&p.num));
}
s += &format!(r##"<text x="{:.3}" y="{:.3}" font-size="1.6" fill="#00e6dc">{} (EAGLE, {} pins)</text>"##, vx + 0.5, vy + 2.0, xesc(name), pins.len());
s += "</svg>\n";
s
}
/// EAGLE `<package>` → KiCad `.kicad_mod` (SMD + THT pads + silk lines).
fn package_to_mod(pkg: &str, name: &str) -> String {
// Collect pads + silk/fab wires FIRST, tracking the footprint's Y extent in
// KiCad space, so RefDes/Value land CLEAR of the whole footprint — matching
// adom-footprint's fp_gen rule (RefDes ~1 mm above the top, Value ~1 mm below
// the bottom). The old hardcoded `(at 0 ±3)` sat inside the pad ring of larger
// parts (e.g. an LQFP-64 whose pads reach ±5.9 mm), smearing text over copper.
let mut geom = String::new();
let (mut miny, mut maxy) = (f64::MAX, f64::MIN);
let mut track = |ya: f64, yb: f64| {
miny = miny.min(ya).min(yb);
maxy = maxy.max(ya).max(yb);
};
// SMD pads
for m in Regex::new(r"<smd\b[^>]*/?>").unwrap().find_iter(pkg) {
let t = m.as_str();
let (Some(n), Some(x), Some(y), Some(dx), Some(dy)) =
(attr(t, "name"), fattr(t, "x"), fattr(t, "y"), fattr(t, "dx"), fattr(t, "dy")) else { continue };
let r = rot_deg(t);
let round = fattr(t, "roundness").unwrap_or(0.0) / 100.0;
let (shape, extra) = if round > 0.0 {
("roundrect".to_string(), format!(" (roundrect_rratio {:.3})", (round / 2.0).min(0.5)))
} else { ("rect".to_string(), String::new()) };
let ky = -y;
// Rotation-agnostic conservative half-extent (a 90°-rotated pad swaps dx/dy).
let half = dx.max(dy) / 2.0;
track(ky - half, ky + half);
let at = if r != 0.0 { format!("{x:.4} {ky:.4} {r}") } else { format!("{x:.4} {ky:.4}") };
geom += &format!(
" (pad \"{n}\" smd {shape} (at {at}) (size {dx:.4} {dy:.4}) (layers \"F.Cu\" \"F.Paste\" \"F.Mask\"){extra})\n"
);
}
// THT pads
for m in Regex::new(r"<pad\b[^>]*/?>").unwrap().find_iter(pkg) {
let t = m.as_str();
let (Some(n), Some(x), Some(y)) = (attr(t, "name"), fattr(t, "x"), fattr(t, "y")) else { continue };
let drill = fattr(t, "drill").unwrap_or(0.6);
let dia = fattr(t, "diameter").unwrap_or(drill + 0.5);
let ky = -y;
track(ky - dia / 2.0, ky + dia / 2.0);
geom += &format!(
" (pad \"{n}\" thru_hole circle (at {x:.4} {ky:.4}) (size {dia:.4} {dia:.4}) (drill {drill:.4}) (layers \"*.Cu\" \"*.Mask\"))\n"
);
}
// Silk / fab wires (layer 21 = tPlace silk, 51 = tDocu fab).
for m in Regex::new(r"<wire\b[^>]*/?>").unwrap().find_iter(pkg) {
let t = m.as_str();
let (Some(x1), Some(y1), Some(x2), Some(y2)) =
(fattr(t, "x1"), fattr(t, "y1"), fattr(t, "x2"), fattr(t, "y2")) else { continue };
let layer = attr(t, "layer").unwrap_or_default();
let kl = match layer.as_str() { "21" | "22" => "F.SilkS", "51" | "52" => "F.Fab", _ => continue };
let w = fattr(t, "width").filter(|w| *w > 0.0).unwrap_or(0.12);
let (ky1, ky2) = (-y1, -y2);
track(ky1, ky2);
geom += &format!(
" (fp_line (start {x1:.4} {ky1:.4}) (end {x2:.4} {ky2:.4}) (stroke (width {w:.3}) (type solid)) (layer \"{kl}\"))\n"
);
}
// RefDes/Value clear of the full Y extent (+1 mm); fall back to ±3 if empty.
let r2 = |v: f64| (v * 100.0).round() / 100.0;
let (ref_y, val_y) = if miny <= maxy { (r2(miny - 1.0), r2(maxy + 1.0)) } else { (-3.0, 3.0) };
let mut out = format!(
"(footprint \"{name}\" (version 20221018) (generator adom-sfconvert) (layer \"F.Cu\")\n \
(attr smd)\n \
(fp_text reference \"REF**\" (at 0 {ref_y}) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n \
(fp_text value \"{name}\" (at 0 {val_y}) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n"
);
out += &geom;
out += ")\n";
out
}
/// Re-place a `.kicad_mod`'s RefDes/Value text CLEAR of the whole footprint —
/// applies the SAME one placement rule as adom-footprint's fp_gen (RefDes ~1 mm
/// above the top extent, Value ~1 mm below) to a `.kicad_mod` we didn't generate
/// ourselves, e.g. the `altium_pcblib` crate's output, which hardcodes `(at 0 ±3)`
/// (smeared over the pad ring of larger parts). Rewrites only the FIRST
/// `(fp_text reference …)` / `(fp_text value …)` `(at …)`; any other text is left
/// alone. No-op when the mod has no pad/line geometry to measure.
pub fn reposition_fp_text(km: &str) -> String {
let pad = Regex::new(r"\(pad\b[^()]*\(at -?[\d.]+ (-?[\d.]+)(?: -?[\d.]+)?\) \(size -?[\d.]+ (-?[\d.]+)\)").unwrap();
let line = Regex::new(r"\(start -?[\d.]+ (-?[\d.]+)\) \(end -?[\d.]+ (-?[\d.]+)\)").unwrap();
let (mut miny, mut maxy) = (f64::MAX, f64::MIN);
for c in pad.captures_iter(km) {
let y: f64 = c[1].parse().unwrap_or(0.0);
let dy: f64 = c[2].parse().unwrap_or(0.0);
miny = miny.min(y - dy / 2.0);
maxy = maxy.max(y + dy / 2.0);
}
for c in line.captures_iter(km) {
let y1: f64 = c[1].parse().unwrap_or(0.0);
let y2: f64 = c[2].parse().unwrap_or(0.0);
miny = miny.min(y1).min(y2);
maxy = maxy.max(y1).max(y2);
}
if miny > maxy {
return km.to_string();
}
let r2 = |v: f64| (v * 100.0).round() / 100.0;
let (ref_y, val_y) = (r2(miny - 1.0), r2(maxy + 1.0));
let mut out = km.to_string();
for (kind, y) in [("reference", ref_y), ("value", val_y)] {
let re = Regex::new(&format!(
r"(\(fp_text {kind}\b[^()]*\(at )-?[\d.]+ -?[\d.]+(?: -?[\d.]+)?(\))"
)).unwrap();
let rep = format!("${{1}}0 {y}${{2}}");
out = re.replace(&out, rep.as_str()).into_owned();
}
out
}
/// EAGLE `<symbol>` → KiCad `.kicad_sym` (pins + the body rectangle).
fn symbol_to_sym(sym: &str, name: &str, connects: &HashMap<String, String>) -> String {
let mut pins = String::new();
let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for m in Regex::new(r"<pin\b[^>]*/?>").unwrap().find_iter(sym) {
let t = m.as_str();
let (Some(pname), Some(x), Some(y)) = (attr(t, "name"), fattr(t, "x"), fattr(t, "y")) else { continue };
// EAGLE pin length keyword → mm; the pin's (x,y) is its CONNECTION end.
let len = match attr(t, "length").as_deref() {
Some("point") => 0.0, Some("short") => 2.54, Some("long") => 7.62, _ => 5.08,
};
let rot = rot_deg(t); // R0 pin body is to the LEFT, pin points right (angle 0 in KiCad means pin extends to the right from its body anchor)
let num = connects.get(&pname).cloned().unwrap_or_else(|| pname.clone());
minx = minx.min(x); maxx = maxx.max(x); miny = miny.min(y); maxy = maxy.max(y);
pins += &format!(
" (pin bidirectional line (at {x:.3} {y:.3} {rot}) (length {len:.2})\n \
(name \"{}\" (effects (font (size 1.27 1.27)))) (number \"{}\" (effects (font (size 1.27 1.27)))))\n",
esc(&pname), esc(&num)
);
}
if minx > maxx { return String::new(); }
let pad = 2.54;
format!(
"(kicad_symbol_lib (version 20211014) (generator adom-sfconvert)\n \
(symbol \"{name}\" (in_bom yes) (on_board yes)\n \
(property \"Reference\" \"U\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n \
(property \"Value\" \"{name}\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n \
(symbol \"{name}_1_1\"\n \
(rectangle (start {:.2} {:.2}) (end {:.2} {:.2}) (stroke (width 0.254) (type default)) (fill (type background)))\n{pins} )\n )\n)\n",
maxy + pad + 1.27, miny - pad - 1.27,
minx + pad, maxy + pad, maxx - pad, miny - pad,
)
}
fn esc(s: &str) -> String { s.replace('"', "'") }