← Commit history
Cargo.lockadded+320
@@ -0,0 +1,320 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-sfconvert"+version = "0.1.0"+dependencies = [+ "altium-pcblib",+ "altium-schlib",+ "anyhow",+ "regex",+ "serde_json",+]++[[package]]+name = "aho-corasick"+version = "1.1.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"+dependencies = [+ "memchr",+]++[[package]]+name = "altium-pcblib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "altium-schlib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "anyhow"+version = "1.0.102"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"++[[package]]+name = "bumpalo"+version = "3.20.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"++[[package]]+name = "byteorder"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"++[[package]]+name = "cfb"+version = "0.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"+dependencies = [+ "byteorder",+ "fnv",+ "uuid",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "fnv"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"++[[package]]+name = "futures-core"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"++[[package]]+name = "futures-task"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"++[[package]]+name = "futures-util"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"+dependencies = [+ "futures-core",+ "futures-task",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "js-sys"+version = "0.3.102"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"+dependencies = [+ "cfg-if",+ "futures-util",+ "wasm-bindgen",+]++[[package]]+name = "memchr"+version = "2.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.46"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "regex"+version = "1.12.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-automata",+ "regex-syntax",+]++[[package]]+name = "regex-automata"+version = "0.4.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-syntax",+]++[[package]]+name = "regex-syntax"+version = "0.8.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"++[[package]]+name = "rustversion"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.150"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"++[[package]]+name = "syn"+version = "2.0.118"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "uuid"+version = "1.23.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"+dependencies = [+ "js-sys",+ "wasm-bindgen",+]++[[package]]+name = "wasm-bindgen"+version = "0.2.125"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"+dependencies = [+ "cfg-if",+ "once_cell",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.125"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.125"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.125"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+17
@@ -0,0 +1,17 @@+[package]+name = "adom-sfconvert"+version = "0.1.0"+edition = "2021"+description = "Convert foreign EDA libraries (EAGLE/Fusion .lbr, Altium .SchLib/.PcbLib) into standard KiCad symbol + footprint, so the rest of the Adom EDA family (adom-symbol/adom-footprint) can render + compare them."++[[bin]]+name = "adom-sfconvert"+path = "src/main.rs"++[dependencies]+anyhow = "1"+regex = "1"+serde_json = "1"+# Native Altium readers — pure Rust, no KiCad / no service round-trip.+altium-pcblib = { path = "../altium-pcblib" }+altium-schlib = { path = "../altium-schlib" }
README.md+57−2
@@ -1,3 +1,58 @@-# adom-sfconvert+## What this is -Native Rust converter: EAGLE/Fusion .lbr + Altium .SchLib/.PcbLib → KiCad, rendering each format's own geometry.+`adom-sfconvert` converts foreign EDA libraries — **EAGLE / Fusion 360 `.lbr`**+and **Altium `.SchLib` / `.PcbLib` / `.IntLib`** — into standard KiCad symbol ++footprint, and renders each format's **own** geometry as a native SVG. The name+echoes `adom-ds2sf`: **sf** = **s**ymbol + **f**ootprint.++![Same footprint from four sources, each rendered from its own geometry](screenshots/four-source-footprint.png)++*The same part's footprint from four sources — KiCad, Fusion, Altium (native), and+the datasheet — each rendered from its OWN geometry so you can see what disagrees.*++It is **fully native** — pure Rust, runs in your container, **no KiCad install+and no service round-trip**. Each format is read by the right native reader:++| Input | Read by | Native? |+|---|---|---|+| EAGLE / Fusion `.lbr` (XML) | built-in `eagle.rs` | ✅ |+| Altium `.PcbLib` (footprints) | [`altium-pcblib`](https://wiki.adom.inc/skill/altium-pcblib) | ✅ |+| Altium `.SchLib` (symbols) | [`altium-schlib`](https://wiki.adom.inc/skill/altium-schlib) | ✅ |++## Why native matters++The point of pulling a part's CAD from many sources is to **compare what's right+and what's wrong**. If every format is normalised through KiCad's importer first,+the sources converge — they all become "KiCad's interpretation," and the+disagreements you wanted to catch are erased. `adom-sfconvert` renders each+library from its own bytes, so EAGLE looks like EAGLE and Altium looks like+Altium. Conversion to `.kicad_sym` / `.kicad_mod` happens **only on export**.++## Usage++```bash+adom-sfconvert convert part.lbr     --out-dir ./out --name part   # EAGLE/Fusion+adom-sfconvert convert part.SchLib  --out-dir ./out --name part   # Altium symbol+adom-sfconvert convert part.PcbLib  --out-dir ./out --name part   # Altium footprint+adom-sfconvert version+```++Each `convert` emits, into `--out-dir`:++- `<name>.kicad_sym` / `<name>.kicad_mod` — the **export** files.+- `<name>-symbol.svg` / `<name>-footprint.svg` — the **native thumbnails**,+  drawn straight from the source geometry.++## Where it's used++`chip-fetcher` calls `adom-sfconvert` to turn every downloaded EAGLE/Fusion and+Altium library into a per-source thumbnail, shown side-by-side with the KiCad and+datasheet renders so you can pick what to export to your preferred EDA app. After+this tool went native, **nothing foreign touches the KiCad service** — only+KiCad's own format is rendered remotely.++## Companions++- `adom-ds2sf` — extract symbol + footprint from a datasheet PDF.+- `altium-pcblib` / `altium-schlib` — the native Altium readers this builds on.+- `chip-fetcher` — the consumer that shows every source for comparison.
SKILL.mdadded+59
@@ -0,0 +1,59 @@+---+name: adom-sfconvert+description: >+  Native Rust CLI that converts foreign EDA libraries — EAGLE/Fusion 360 .lbr and+  Altium .SchLib/.PcbLib/.IntLib — into KiCad symbol + footprint, and renders each+  format's OWN geometry as a native SVG thumbnail. Pure Rust, no KiCad install, no+  service round-trip: EAGLE/Fusion via built-in eagle.rs, Altium footprints via+  altium-pcblib, Altium symbols via altium-schlib. "sf" = symbol + footprint (like+  ds2sf). Conversion to .kicad_sym/.kicad_mod is export-only; rendering stays native+  so cross-source comparison shows each format's truth instead of one normalised+  KiCad interpretation. Used by chip-fetcher for per-source thumbnails.+---++# adom-sfconvert++Convert + natively render EAGLE/Fusion `.lbr` and Altium `.SchLib`/`.PcbLib`.++## When to reach for this++- You have a non-KiCad library (EAGLE/Fusion or Altium) and want a **KiCad+  symbol/footprint** out of it — without installing KiCad.+- You want a **native thumbnail** of a foreign library that shows its own+  geometry (for side-by-side source comparison), not a KiCad-normalised version.++## Command++```bash+adom-sfconvert convert <file> [--out-dir <dir>] [--name <stem>]+```++Recognised inputs: `.lbr` (EAGLE/Fusion XML), `.SchLib` / `.PcbLib` / `.IntLib`+(Altium OLE2 binary, auto-detected by the `D0CF11E0` magic).++Outputs per run: `<stem>.kicad_sym` / `<stem>.kicad_mod` (export) and+`<stem>-symbol.svg` / `<stem>-footprint.svg` (native thumbnails).++## Architecture (all native, all local)++```+.lbr     →  eagle.rs       →  KiCad sym/mod  +  native SVG+.PcbLib  →  altium-pcblib  →  KiCad mod      +  native SVG+.SchLib  →  altium-schlib  →  KiCad sym      +  native SVG+```++No `kicad-cli`, no service POST. The only thing in the wider pipeline that still+calls the shared KiCad service is rendering KiCad's *own* format — correct, since+that isn't a foreign conversion.++## Design rule++**Render native, convert on export.** Converting a foreign format to KiCad just+to draw it throws away exactly the differences a comparison tool exists to find.+Keep originals; emit `.kicad_*` only when the user picks KiCad as the target.++## Companions++- `adom-ds2sf` — datasheet PDF → symbol + footprint.+- `altium-pcblib` / `altium-schlib` — the native Altium readers.+- `chip-fetcher` — shows every source's render for compare-and-contrast.
install.shadded+7
@@ -0,0 +1,7 @@+#!/usr/bin/env bash+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"+DEST="$HOME/.claude/skills/adom-sfconvert"+mkdir -p "$DEST"+cp -f "$HERE/skills/adom-sfconvert/SKILL.md" "$DEST/SKILL.md"+echo "Installed adom-sfconvert skill → $DEST"
package.jsonadded+39
@@ -0,0 +1,39 @@+{+  "slug": "adom-sfconvert",+  "version": "1.0.0",+  "type": "skill",+  "description": "Native Rust converter: EAGLE/Fusion .lbr + Altium .SchLib/.PcbLib \u2192 KiCad symbol+footprint, rendering each format's OWN geometry. No KiCad install, no service round-trip.",+  "dependencies": {+    "altium-pcblib": "^1.0.0",+    "altium-schlib": "^1.0.0"+  },+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "tags": [+    "eagle",+    "fusion",+    "altium",+    "kicad",+    "rust",+    "eda",+    "converter",+    "footprint",+    "symbol"+  ],+  "hero": {+    "headline": "One converter, every format, native",+    "subhead": "EAGLE/Fusion + Altium \u2192 KiCad, each rendered from its OWN geometry. No KiCad install, no service round-trip.",+    "screenshot": "screenshots/four-source-footprint.png"+  },+  "discovery_triggers": [+    "convert eagle to kicad",+    "convert altium to kicad",+    "convert fusion lbr",+    "altium schlib pcblib",+    "native eda conversion",+    "sfconvert",+    "symbol footprint convert"+  ]+}\ No newline at end of file
screenshots/eagle-native.pngadded
⋯ 1 unchanged line ⋯
screenshots/four-source-footprint.pngadded
⋯ 1 unchanged line ⋯
skills/adom-sfconvert/SKILL.mdadded+59
@@ -0,0 +1,59 @@+---+name: adom-sfconvert+description: >+  Native Rust CLI that converts foreign EDA libraries — EAGLE/Fusion 360 .lbr and+  Altium .SchLib/.PcbLib/.IntLib — into KiCad symbol + footprint, and renders each+  format's OWN geometry as a native SVG thumbnail. Pure Rust, no KiCad install, no+  service round-trip: EAGLE/Fusion via built-in eagle.rs, Altium footprints via+  altium-pcblib, Altium symbols via altium-schlib. "sf" = symbol + footprint (like+  ds2sf). Conversion to .kicad_sym/.kicad_mod is export-only; rendering stays native+  so cross-source comparison shows each format's truth instead of one normalised+  KiCad interpretation. Used by chip-fetcher for per-source thumbnails.+---++# adom-sfconvert++Convert + natively render EAGLE/Fusion `.lbr` and Altium `.SchLib`/`.PcbLib`.++## When to reach for this++- You have a non-KiCad library (EAGLE/Fusion or Altium) and want a **KiCad+  symbol/footprint** out of it — without installing KiCad.+- You want a **native thumbnail** of a foreign library that shows its own+  geometry (for side-by-side source comparison), not a KiCad-normalised version.++## Command++```bash+adom-sfconvert convert <file> [--out-dir <dir>] [--name <stem>]+```++Recognised inputs: `.lbr` (EAGLE/Fusion XML), `.SchLib` / `.PcbLib` / `.IntLib`+(Altium OLE2 binary, auto-detected by the `D0CF11E0` magic).++Outputs per run: `<stem>.kicad_sym` / `<stem>.kicad_mod` (export) and+`<stem>-symbol.svg` / `<stem>-footprint.svg` (native thumbnails).++## Architecture (all native, all local)++```+.lbr     →  eagle.rs       →  KiCad sym/mod  +  native SVG+.PcbLib  →  altium-pcblib  →  KiCad mod      +  native SVG+.SchLib  →  altium-schlib  →  KiCad sym      +  native SVG+```++No `kicad-cli`, no service POST. The only thing in the wider pipeline that still+calls the shared KiCad service is rendering KiCad's *own* format — correct, since+that isn't a foreign conversion.++## Design rule++**Render native, convert on export.** Converting a foreign format to KiCad just+to draw it throws away exactly the differences a comparison tool exists to find.+Keep originals; emit `.kicad_*` only when the user picks KiCad as the target.++## Companions++- `adom-ds2sf` — datasheet PDF → symbol + footprint.+- `altium-pcblib` / `altium-schlib` — the native Altium readers.+- `chip-fetcher` — shows every source's render for compare-and-contrast.
src/altium.rsadded+71
@@ -0,0 +1,71 @@+//! Altium `.SchLib` / `.PcbLib` / `.IntLib` → KiCad, **fully native**.+//!+//! These are OLE2 compound-binary files. We read them with the pure-Rust+//! `altium-schlib` (symbols) and `altium-pcblib` (footprints) crates — no KiCad+//! install, no service round-trip. Each format renders its OWN geometry from its+//! OWN bytes (the native-fidelity rule), and we emit `.kicad_sym` / `.kicad_mod`+//! only as an EXPORT convenience.++use anyhow::{anyhow, Result};+use std::path::Path;++/// What a conversion produced, so callers can wire thumbnails + exports.+pub struct AltiumOut {+    pub files: Vec<String>,+}++/// Convert an Altium library. `ext` routes which half we read: `.SchLib` →+/// symbol, `.PcbLib` → footprint, `.IntLib` (or a bare OLE) → try both. Writes+/// `<stem>.kicad_sym` / `<stem>.kicad_mod` (export) and, when `svg` is true, the+/// native `<stem>-symbol.svg` / `<stem>-footprint.svg` thumbnails.+pub fn convert(file: &Path, out_dir: &Path, stem: &str, ext: &str, svg: bool) -> Result<AltiumOut> {+    std::fs::create_dir_all(out_dir).ok();+    let want_sym = matches!(ext, "schlib" | "intlib") || ext.is_empty();+    let want_fp = matches!(ext, "pcblib" | "intlib") || ext.is_empty();++    let mut files = Vec::new();+    let mut errs = Vec::new();++    if want_sym {+        match altium_schlib::parse_altium_schlib_path(file) {+            Ok(symbol) => {+                let ks = altium_schlib::to_kicad_sym(&symbol, stem);+                let p = out_dir.join(format!("{stem}.kicad_sym"));+                std::fs::write(&p, ks)?;+                files.push(p.display().to_string());+                if svg {+                    let s = altium_schlib::render_symbol_svg(&symbol);+                    let p = out_dir.join(format!("{stem}-symbol.svg"));+                    std::fs::write(&p, s)?;+                    files.push(p.display().to_string());+                }+            }+            Err(e) => errs.push(format!("sym: {e}")),+        }+    }+    if want_fp {+        match altium_pcblib::parse_altium_pcblib_path(file) {+            Ok(fp) => {+                let km = altium_pcblib::to_kicad_mod(&fp, stem);+                let p = out_dir.join(format!("{stem}.kicad_mod"));+                std::fs::write(&p, km)?;+                files.push(p.display().to_string());+                if svg {+                    let s = altium_pcblib::render_footprint_svg(&fp);+                    let p = out_dir.join(format!("{stem}-footprint.svg"));+                    std::fs::write(&p, s)?;+                    files.push(p.display().to_string());+                }+            }+            Err(e) => errs.push(format!("fp: {e}")),+        }+    }++    if files.is_empty() {+        return Err(anyhow!(+            "Altium import produced nothing: {}",+            if errs.is_empty() { "empty library".into() } else { errs.join("; ") }+        ));+    }+    Ok(AltiumOut { files })+}
src/eagle.rsadded+259
@@ -0,0 +1,259 @@+//! 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;") }++/// 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 {+    let mut out = format!(+        "(footprint \"{name}\" (version 20221018) (generator adom-sfconvert) (layer \"F.Cu\")\n  \+         (attr smd)\n  \+         (fp_text reference \"REF**\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n  \+         (fp_text value \"{name}\" (at 0 3) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n"+    );+    // 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 at = if r != 0.0 { format!("{x:.4} {:.4} {r}", -y) } else { format!("{x:.4} {:.4}", -y) };+        out += &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);+        out += &format!(+            "  (pad \"{n}\" thru_hole circle (at {x:.4} {:.4}) (size {dia:.4} {dia:.4}) (drill {drill:.4}) (layers \"*.Cu\" \"*.Mask\"))\n",+            -y+        );+    }+    // 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);+        out += &format!(+            "  (fp_line (start {x1:.4} {:.4}) (end {x2:.4} {:.4}) (stroke (width {w:.3}) (type solid)) (layer \"{kl}\"))\n",+            -y1, -y2+        );+    }+    out += ")\n";+    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('"', "'") }
src/main.rsadded+116
@@ -0,0 +1,116 @@+//! adom-sfconvert — convert foreign EDA libraries into standard KiCad symbol ++//! footprint, so the Adom EDA family (adom-symbol / adom-footprint) can render+//! and compare them. Named like ds2sf: "sf" = symbol + footprint.+//!+//! Supported today:+//!   • EAGLE / Fusion 360  .lbr   → KiCad   (native, faithful — renders own geometry)+//! Detected but not yet converted (honest):+//!   • Altium  .SchLib / .PcbLib  → needs KiCad's Altium importer (OLE binary)++mod altium;+mod eagle;++use anyhow::{anyhow, Context, Result};+use std::path::PathBuf;++fn main() {+    if let Err(e) = run() {+        eprintln!("ERROR: {e:#}");+        std::process::exit(1);+    }+}++fn usage() -> ! {+    eprintln!(+        "adom-sfconvert {}\n\n\+         USAGE:\n  \+         adom-sfconvert convert <library-file> [--out-dir <dir>] [--name <name>]\n  \+         adom-sfconvert version\n\n\+         Converts EAGLE/Fusion 360 .lbr (and, soon, Altium .SchLib/.PcbLib) into\n  \+         <name>.kicad_sym + <name>.kicad_mod — the standard format every Adom EDA\n  \+         app renders. Original files are never modified.",+        env!("CARGO_PKG_VERSION")+    );+    std::process::exit(2);+}++fn run() -> Result<()> {+    let args: Vec<String> = std::env::args().skip(1).collect();+    match args.first().map(String::as_str) {+        Some("version") | Some("--version") | Some("-V") => {+            println!("adom-sfconvert {}", env!("CARGO_PKG_VERSION"));+            Ok(())+        }+        Some("convert") => convert(&args[1..]),+        _ => usage(),+    }+}++fn convert(args: &[String]) -> Result<()> {+    let mut file: Option<PathBuf> = None;+    let mut out_dir: Option<PathBuf> = None;+    let mut name: Option<String> = None;+    let mut i = 0;+    while i < args.len() {+        match args[i].as_str() {+            "--out-dir" => { out_dir = args.get(i + 1).map(PathBuf::from); i += 2; }+            "--name" => { name = args.get(i + 1).cloned(); i += 2; }+            s if file.is_none() => { file = Some(PathBuf::from(s)); i += 1; }+            _ => i += 1,+        }+    }+    let file = file.ok_or_else(|| anyhow!("convert needs a library file"))?;+    let out_dir = out_dir.unwrap_or_else(|| file.parent().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")));+    let stem = name.unwrap_or_else(|| file.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "part".into()));++    let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();+    let bytes = std::fs::read(&file).with_context(|| format!("reading {}", file.display()))?;++    // Altium = OLE compound binary (magic d0 cf 11 e0). Read it NATIVELY with+    // altium-schlib / altium-pcblib — pure Rust, no KiCad, no service round-trip.+    // Emits the native SVG thumbnail + .kicad_sym/.kicad_mod (export).+    let is_ole = bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0]);+    if ext == "schlib" || ext == "pcblib" || ext == "intlib" || is_ole {+        let out = altium::convert(&file, &out_dir, &stem, &ext, true)?;+        println!("OK: {} (Altium → KiCad, native) → {}", file.display(), out.files.join(" + "));+        return Ok(());+    }++    let text = String::from_utf8_lossy(&bytes);+    if ext == "lbr" || text.contains("<eagle") {+        let (sym, modf) = eagle::convert(&text, &stem)?;+        std::fs::create_dir_all(&out_dir).ok();+        let mut wrote = Vec::new();+        if !sym.is_empty() {+            let p = out_dir.join(format!("{stem}.kicad_sym"));+            std::fs::write(&p, &sym).with_context(|| format!("writing {}", p.display()))?;+            wrote.push(p.display().to_string());+        }+        if !modf.is_empty() {+            let p = out_dir.join(format!("{stem}.kicad_mod"));+            std::fs::write(&p, &modf).with_context(|| format!("writing {}", p.display()))?;+            wrote.push(p.display().to_string());+        }+        // Native SVG thumbnails — drawn straight from the EAGLE geometry, no+        // KiCad round-trip (same as the Altium path).+        let sym_svg = eagle::render_symbol_svg(&text, &stem);+        if !sym_svg.is_empty() {+            let p = out_dir.join(format!("{stem}-symbol.svg"));+            std::fs::write(&p, &sym_svg).ok();+            wrote.push(p.display().to_string());+        }+        let fp_svg = eagle::render_footprint_svg(&text, &stem);+        if !fp_svg.is_empty() {+            let p = out_dir.join(format!("{stem}-footprint.svg"));+            std::fs::write(&p, &fp_svg).ok();+            wrote.push(p.display().to_string());+        }+        if wrote.is_empty() {+            return Err(anyhow!("EAGLE library had no <symbol> or <package> to convert"));+        }+        println!("OK: {} (EAGLE/Fusion, native) → {}", file.display(), wrote.join(" + "));+        return Ok(());+    }++    Err(anyhow!("unrecognized library format: {} (expected .lbr / .SchLib / .PcbLib)", file.display()))+}
uninstall.shadded+3
@@ -0,0 +1,3 @@+#!/usr/bin/env bash+rm -rf "$HOME/.claude/skills/adom-sfconvert"+echo "Removed adom-sfconvert skill"