← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-lbr"-version = "1.6.0"+version = "1.6.1" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "1.6.0"+version = "1.6.1" edition = "2021" description = "EAGLE .lbr generator, linter, and validator for Fusion 360 Electronics. Converts KiCad symbols + footprints to complete EAGLE libraries." 
SKILL.md+20−8
@@ -138,14 +138,26 @@ the part numbers, expect to be told to re-run with them.  ## >NAME / >VALUE placement — carried through, not owned -The footprint's RefDes (`>NAME`) and value (`>VALUE`) text positions are **carried-through from the `.kicad_mod`'s own `(fp_text reference …)` / `(fp_text value …)`**-(Y-negated for EAGLE) — so whatever **adom-footprint** places (and the user tweaks-in that app) is exactly what lands in the `.lbr`. adom-lbr does **not** own this.-It only auto-places (clear of the pads) as a fallback when the `.kicad_mod` has no-such text, and it **lints** the result: `name-value-overlap` warns when the text-sits on the pad copper (the "text all over the footprint" problem), telling the-caller to fix the placement upstream in adom-footprint.+The footprint's RefDes (`>NAME`) and value (`>VALUE`) text are **carried through+from the `.kicad_mod`'s own `(fp_text reference …)` / `(fp_text value …)`** —+**position** (Y-negated for EAGLE), **height** (from `(font (size W H))`), and+**rotation** (`(at X Y A)` → EAGLE `rot="R<A>"`). So whatever **adom-footprint**+places (and the user tweaks in that app) is exactly what lands in the `.lbr`.+adom-lbr does **not** own this — it only auto-places (clear of the pads) as a+fallback when the `.kicad_mod` carries no such text.++It also **lints** the result: `name-value-overlap` warns when the text sits in the+keep-out. The keep-out is the pad span by default, but if a sibling+`<mpn>-footprint.extracted.json` carries a `textPlacement.courtyard`+(`{top,bottom,left,right}`, KiCad mm), adom-lbr uses that explicit courtyard+instead — so it validates against the generator's intended zone, not a re-guess.++Per-tool reality: KiCad (native) and Fusion/EAGLE (`.lbr`) carry the placement+fully; **Altium** imports the `.lbr` but its EAGLE Import Wizard may re-place the+designator at its own default (no native Altium exporter exists); **OrCAD** is a+gap — adom-orcad's EDIF is schematic-symbol only, so footprint RefDes placement+isn't carried. One generic placement in the `.kicad_mod` is the right input for+all of them — never a per-tool switch.  ## generate — the core 
VERSION+1−1
@@ -1 +1 @@-1.6.0\ No newline at end of file+1.6.1\ No newline at end of file
bin/adom-lbr
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "1.6.0",+  "version": "1.6.1",   "type": "app",   "description": "One KiCad part \u2192 every major EDA tool. `adom-lbr export` writes a folder per tool: native KiCad, Fusion 360 (.lbr + 3D handoff), OrCAD (EDIF via adom-orcad), and an Altium bridge hand-off \u2014 real-footprint accurate. Bidirectional KiCad converter + 7-check linter + a live library viewer. Includes a focused fusion-export sub-skill.",   "dependencies": {},
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "1.6.0",+  "version": "1.6.1",   "type": "app",   "description": "One KiCad part → every major EDA tool. `adom-lbr export` writes a folder per tool: native KiCad, Fusion 360 (.lbr + 3D handoff), OrCAD (EDIF via adom-orcad), and an Altium bridge hand-off — real-footprint accurate. Bidirectional KiCad converter + 7-check linter + a live library viewer. Includes a focused fusion-export sub-skill.",   "dependencies": {},
src/main.rs+105−22
@@ -1195,14 +1195,61 @@ fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64)     } } -// Read a `(fp_text reference|value "…" (at X Y …))` position from a .kicad_mod,-// so adom-lbr can CARRY THROUGH the placement adom-footprint (and the user) set,-// rather than inventing its own. Returns KiCad-space (mm, Y-down) coords.-fn fp_text_pos(km: &str, kind: &str) -> Option<(f64, f64)> {-    let re = regex_lite::Regex::new(-        &format!(r#"\(fp_text\s+{}\s+"[^"]*"\s+\(at\s+([-\d.]+)\s+([-\d.]+)"#, kind)-    ).ok()?;-    re.captures(km).map(|c| (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0)))+// The carried-through RefDes/Value text from a .kicad_mod, in KiCad space+// (mm, Y-down): position, rotation (deg), and text height (mm).+struct FpText { x: f64, y: f64, angle: f64, size: f64 }++// Read a `(fp_text reference|value "…" (at X Y [A]) … (effects (font (size W H))))`+// from a .kicad_mod so adom-lbr can CARRY THROUGH the full placement+// adom-footprint (and the user) set — position, rotation, AND size — rather than+// inventing its own or flattening it to a default.+fn fp_text_info(km: &str, kind: &str) -> Option<FpText> {+    // Locate this kind's fp_text element, then window to the next fp_text (or a+    // bounded span) so `(at …)`/`(size …)` are read from THIS element only.+    let start = regex_lite::Regex::new(&format!(r#"\(fp_text\s+{}\s+""#, kind)).ok()?.find(km)?;+    let rest = &km[start.start()..];+    let end = rest.get(1..)+        .and_then(|r| r.find("(fp_text ").map(|i| i + 1))+        .unwrap_or(rest.len())+        .min(rest.len())+        .min(400);+    let win = &rest[..end];++    let at = regex_lite::Regex::new(r#"\(at\s+([-\d.]+)\s+([-\d.]+)(\s+[-\d.]+)?\)"#).ok()?;+    let c = at.captures(win)?;+    let x = c[1].parse().unwrap_or(0.0);+    let y = c[2].parse().unwrap_or(0.0);+    let angle = c.get(3).and_then(|g| g.as_str().trim().parse::<f64>().ok()).unwrap_or(0.0);+    // Text height = the 2nd value of (font (size W H)); fall back to EAGLE's 1.27.+    let size = regex_lite::Regex::new(r#"\(size\s+([-\d.]+)\s+([-\d.]+)\)"#).ok()+        .and_then(|re| re.captures(win).and_then(|c| c[2].parse::<f64>().ok()))+        .filter(|s| *s > 0.0)+        .unwrap_or(1.27);+    Some(FpText { x, y, angle, size })+}++// Emit a package `>NAME`/`>VALUE` text element. When the footprint carried one+// through (`info`), preserve its position (Y-negated for EAGLE), height, and+// rotation; otherwise auto-place clear of the pads as a fallback.+fn emit_pkg_text(xml: &mut String, info: &Option<FpText>, token: &str, layer: u32, auto_y: f64, auto_align: &str) {+    match info {+        Some(t) => {+            // KiCad Y-down → EAGLE Y-up is a faithful (non-mirroring) convention+            // change, so the rotation value carries straight through.+            let rot = if t.angle.abs() > 0.01 {+                format!(" rot=\"R{}\"", (t.angle.round() as i64).rem_euclid(360))+            } else { String::new() };+            let size = if t.size > 0.0 { t.size } else { 1.27 };+            xml.push_str(&format!(+                "  <text x=\"{:.4}\" y=\"{:.4}\" size=\"{:.4}\" layer=\"{}\" align=\"center\"{}>&gt;{}</text>\n",+                t.x, -t.y, size, layer, rot, token+            ));+        }+        None => xml.push_str(&format!(+            "  <text x=\"0\" y=\"{:.4}\" size=\"1.27\" layer=\"{}\" align=\"{}\">&gt;{}</text>\n",+            auto_y, layer, auto_align, token+        )),+    } }  fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, attrs: &PartAttrs) -> String {@@ -1280,14 +1327,8 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option             bot = bot.min(py - r);         }         let margin = 0.6;-        match fp_text_pos(km, "reference") {-            Some((x, y)) => xml.push_str(&format!("  <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"25\" align=\"center\">&gt;NAME</text>\n", x, -y)),-            None => xml.push_str(&format!("  <text x=\"0\" y=\"{:.4}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">&gt;NAME</text>\n", top + margin)),-        }-        match fp_text_pos(km, "value") {-            Some((x, y)) => xml.push_str(&format!("  <text x=\"{:.4}\" y=\"{:.4}\" size=\"1.27\" layer=\"27\" align=\"center\">&gt;VALUE</text>\n", x, -y)),-            None => xml.push_str(&format!("  <text x=\"0\" y=\"{:.4}\" size=\"1.27\" layer=\"27\" align=\"top-center\">&gt;VALUE</text>\n", bot - margin)),-        }+        emit_pkg_text(&mut xml, &fp_text_info(km, "reference"), "NAME", 25, top + margin, "bottom-center");+        emit_pkg_text(&mut xml, &fp_text_info(km, "value"), "VALUE", 27, bot - margin, "top-center");     } else {         // ── Fallback (no --fp): synthesized two-column placeholder.         xml.push_str(&format!("  <description>{} placeholder footprint</description>\n", esc_xml(&sym.name)));@@ -1448,6 +1489,14 @@ pub(crate) struct LintError { }  pub(crate) fn lint_lbr(content: &str) -> LintResult {+    lint_lbr_with(content, None)+}++// `keepout` = an explicit (pbot, ptop) vertical keep-out span in EAGLE Y-up mm+// (e.g. derived from a footprint's `textPlacement.courtyard`). When supplied it+// OVERRIDES the pad-derived span for the name-value-overlap check, so the+// generator's intended keep-out zone is honored instead of re-guessed from pads.+pub(crate) fn lint_lbr_with(content: &str, keepout: Option<(f64, f64)>) -> LintResult {     let mut errors = Vec::new();     let mut warnings = Vec::new(); @@ -1533,7 +1582,14 @@ pub(crate) fn lint_lbr(content: &str) -> LintResult {             let d: f64 = c[2].parse().unwrap_or(0.0);             ptop = ptop.max(y + d / 2.0); pbot = pbot.min(y - d / 2.0); npads += 1;         }-        if npads > 0 {+        // Prefer an explicit courtyard keep-out; else fall back to the pad span.+        let span = match keepout {+            Some((kb, kt)) => Some((kb, kt)),+            None if npads > 0 => Some((pbot, ptop)),+            None => None,+        };+        if let Some((pbot, ptop)) = span {+            let zone = if keepout.is_some() { "courtyard" } else { "pad span" };             let txt = regex_lite::Regex::new(r#"<text [^>]* y="([-\d.]+)"[^>]*layer="(25|27)"[^>]*>&gt;(NAME|VALUE)</text>"#).unwrap();             for c in txt.captures_iter(content) {                 let y: f64 = c[1].parse().unwrap_or(0.0);@@ -1541,8 +1597,8 @@ pub(crate) fn lint_lbr(content: &str) -> LintResult {                     warnings.push(LintError {                         code: "name-value-overlap".into(),                         message: format!(-                            ">{} text sits on the footprint (y={:.2} is within the pad span [{:.2}, {:.2}]) — it will overlap the pads in Fusion. Move it clear of the copper (adom-lbr auto-places it ~0.6mm beyond the outer pad edge).",-                            &c[3], y, pbot, ptop+                            ">{} text sits on the footprint (y={:.2} is within the {} [{:.2}, {:.2}]) — it will overlap the part in Fusion. Move it clear of the keep-out (adom-lbr auto-places it ~0.6mm beyond the outer edge).",+                            &c[3], y, zone, pbot, ptop                         ),                     });                 }@@ -1650,6 +1706,33 @@ fn sibling_manufacturer(sym_path: &std::path::Path) -> Option<String> {     None } +// Read an explicit text keep-out from a sibling `<mpn>-footprint.extracted.json`+// `textPlacement.courtyard` (KiCad mm, Y-down) and convert it to an EAGLE Y-up+// (pbot, ptop) span for the linter. None when the file/field is absent — the+// lint then falls back to the pad-derived span.+fn courtyard_keepout(fp_path: &std::path::Path) -> Option<(f64, f64)> {+    let dir = fp_path.parent()?;+    for entry in std::fs::read_dir(dir).ok()?.flatten() {+        let p = entry.path();+        let Some(name) = p.file_name().and_then(|n| n.to_str()) else { continue };+        if name.ends_with("-footprint.extracted.json") || name == "footprint.extracted.json" {+            let Ok(txt) = std::fs::read_to_string(&p) else { continue };+            let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) else { continue };+            let cy = match v.get("textPlacement").and_then(|t| t.get("courtyard")) {+                Some(c) => c, None => continue,+            };+            if let (Some(top), Some(bottom)) = (+                cy.get("top").and_then(|x| x.as_f64()),+                cy.get("bottom").and_then(|x| x.as_f64()),+            ) {+                // KiCad Y-down → EAGLE Y-up: ptop = -top, pbot = -bottom.+                return Some((-bottom, -top));+            }+        }+    }+    None+}+ // Resolve BOM attributes by priority: explicit flag → .kicad_sym property → // auto-fill (MF from the extracted.json, MPN from the part name). Returns the // attrs plus transparency notes about anything auto-derived.@@ -1785,7 +1868,7 @@ fn run_export(     // Shared EAGLE artifact (Fusion + Altium direct import) + native KiCad sym     // (also OrCAD's EDIF input). Build once, reuse.     let lbr = generate_lbr(&parsed, fp_content.as_deref(), symbol_art_resolved.as_ref(), symbol_art_scale, &attrs);-    let lint = lint_lbr(&lbr);+    let lint = lint_lbr_with(&lbr, fp.as_deref().and_then(courtyard_keepout));     let kicad_sym_text = match &sym_content {         Some(sc) => sc.clone(),            // already native KiCad — carry through 1:1         None => generate_kicad_sym(&parsed),@@ -1990,8 +2073,8 @@ fn main() {             let (attrs, attr_notes) = resolve_part_attrs(&parsed, sym.as_deref(), &mfr, &mpn, &mouser, &digikey, &lcsc);             let lbr = generate_lbr(&parsed, fp_content.as_deref(), symbol_art_resolved.as_ref(), symbol_art_scale, &attrs); -            // Lint before writing-            let lint = lint_lbr(&lbr);+            // Lint before writing (courtyard keep-out from sibling metadata if present)+            let lint = lint_lbr_with(&lbr, fp.as_deref().and_then(courtyard_keepout));             for err in &lint.errors {                 eprintln!("LINT ERROR: [{}] {}", err.code, err.message);             }