← Commit history
.gitignoreadded+4
@@ -0,0 +1,4 @@+/target+**/*.rs.bk+*.tmp+FUSION_BRIDGE_FEEDBACK.md
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-lbr"-version = "1.3.0"+version = "1.5.0" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "1.3.5"+version = "1.5.0" edition = "2021" description = "EAGLE .lbr generator, linter, and validator for Fusion 360 Electronics. Converts KiCad symbols + footprints to complete EAGLE libraries." 
README.md+26
@@ -19,6 +19,30 @@ adom-lbr turns a KiCad `.kicad_sym` (and optional `.kicad_mod`) into a true EAGL  The **Send to** bar in the live app pushes the loaded library to whichever of those tools you're running (via the Adom Desktop bridge); the Altium/OrCAD bridges are an open contribution slot. +## `export` — one part → all four tools in one command (new in 1.5)++```bash+adom-lbr export --sym part.kicad_sym --fp part.kicad_mod --model part.step --out part-library+```++One command, a **folder per EDA tool**, every folder built from the *same* source+so the symbol, the real footprint, and the chip outline match across all four:++```+part-library/+├── kicad/   part.kicad_sym  +  part.pretty/part.kicad_mod      ← native KiCad+├── fusion/  part.lbr        [+ part.fusion3d.json]             ← EAGLE + 3D handoff+├── orcad/   part.edn                                           ← EDIF 2.0.0 (via adom-orcad → native .olb)+├── altium/  part.lbr + sources + ALTIUM-README.md             ← direct import + native-bridge hand-off+└── EXPORT.md                                                   ← per-tool import steps+```++It stays a **pure exporter**: KiCad and Fusion are written directly, OrCAD is+produced by shelling the `adom-orcad` EDIF converter (a CLI, not an app), and the+Altium *native* `.SchLib/.PcbLib` step is left to the **Altium bridge** with a+staged hand-off — adom-lbr never drives an app. Use `--targets kicad,fusion` to+emit a subset. `generate`/`bind-3d` remain for single-format work.+ ## Real footprints & 3D models (new in 1.1)  ![ADS8588SIPM LQFP-64 — the package built from the real .kicad_mod: 16 pads per side, true 4-sided ring](docs/screenshot-footprint-4sided.png)@@ -93,6 +117,7 @@ This is the whole point. Fusion will silently show "No Components" or refuse to  ## What it does +- **`export`** — one part → a **complete bundle**, one folder per tool: native KiCad (`.kicad_sym` + `.pretty/.kicad_mod`), Fusion (`.lbr` + 3D sidecar), OrCAD (EDIF `.edn` via adom-orcad), Altium (`.lbr` + sources + bridge hand-off), with an `EXPORT.md` index. `--targets` for a subset. - **`generate`** — `.kicad_sym` (+ real `.kicad_mod` via `--fp`) → native `.lbr`, lint-checked for Fusion compatibility. `--model` also emits a 3D handoff sidecar. - **`bind-3d`** — pre-write a 3D binding (self-hosted `wip_urn`, or managed `urn`) into a `.lbr` so the body travels with the library; idempotent. The normal path is the Fusion bridge attach. - **`lint` / `validate`** — catch the things Fusion rejects: missing connects, incomplete devicesets, bad pad maps.@@ -121,6 +146,7 @@ Used by **chip-fetcher** and the Send-to flow across **adom-symbol** and **adom- ## Run it  ```bash+adom-lbr export --sym part.kicad_sym --fp part.kicad_mod --model part.step --out part-library  # all 4 tools, one folder each adom-lbr generate --sym part.kicad_sym --fp part.kicad_mod --name PART --output part.lbr adom-lbr generate --sym part.kicad_sym --fp part.kicad_mod --model part.step -o part.lbr  # + 3D sidecar adom-lbr bind-3d --lbr part.lbr --wip-urn "urn:adsk.wipprod:fs.file:vf.<id>?version=1" --sidecar part.fusion3d.json
SKILL.md+36−1
@@ -7,7 +7,9 @@ description: >   for Fusion's cloud library, and a 7-check Fusion linter. Trigger words: lbr,   eagle library, fusion library, altium library, orcad library, kicad convert,   generate lbr, import lbr, lint lbr, fusion 360 footprint, lbr footprint wrong,-  placeholder footprint, 3d model in lbr, bind 3d, fusion cloud urn, package3d.+  placeholder footprint, 3d model in lbr, bind 3d, fusion cloud urn, package3d,+  export library, export all tools, kicad fusion altium orcad, orcad edif, olb,+  schlib, pcblib, multi-tool library, library bundle. ---  # adom-lbr@@ -59,6 +61,8 @@ matched as closely as adom-symbol renders it. See [docs/ROADMAP.md](docs/ROADMAP  ## When to use +- "Export this part to **every** tool" / "give me KiCad + Fusion + OrCAD + Altium"+  → `export` (one bundle, one folder per tool, with an `EXPORT.md` index) - "Make a Fusion / Altium / OrCAD library from this KiCad part" - "Convert this `.kicad_sym` to `.lbr`" / "import this `.lbr` to KiCad" - "The generated footprint is a 2-column placeholder / doesn't match the chip"@@ -69,6 +73,7 @@ matched as closely as adom-symbol renders it. See [docs/ROADMAP.md](docs/ROADMAP ## Commands  ```+adom-lbr export   --sym <part.kicad_sym> [--fp <part.kicad_mod>] [--model <part.step>] [--symbol-art <svg>] [--out <dir>] [--targets kicad,fusion,orcad,altium] adom-lbr generate --sym <part.kicad_sym> [--fp <part.kicad_mod>] [--model <part.step>] [-o out.lbr] [--name <override>] adom-lbr bind-3d  (--lbr <part.lbr> | --record-only) (--wip-urn <…fs.file:vf…?version=N> | --urn <…eagle:package…>) [--lineage-urn <…dm.lineage…>] [--package <name>] [--sidecar <part.fusion3d.json>] [-o out.lbr] adom-lbr import   <part.lbr> [-o out.kicad_sym]@@ -79,6 +84,36 @@ adom-lbr serve [--port 8784] [--file <lbr>] adom-lbr health | install | completions <bash|zsh|fish> ``` +## export — all four tools in one shot++`export` is the one-command front door: from a single KiCad part it writes a+**folder per EDA tool**, each populated from the *same* source so the symbol,+the real footprint, and the chip outline are identical across them:++```+<name>-library/+├── kicad/   <name>.kicad_sym  +  <name>.pretty/<name>.kicad_mod   (native)+├── fusion/  <name>.lbr  [+ <name>.fusion3d.json]                  (EAGLE + 3D handoff)+├── orcad/   <name>.edn                                            (EDIF 2.0.0, via adom-orcad)+├── altium/  <name>.lbr + KiCad sources + ALTIUM-README.md         (direct import + bridge hand-off)+└── EXPORT.md                                                      (per-tool import steps)+```++- **KiCad** — native `.kicad_sym` (carried through 1:1 when you pass `--sym`,+  else synthesized) + the `.kicad_mod` in a `.pretty` footprint library.+- **Fusion 360** — the `.lbr`; with `--model`, also the `fusion3d.json` handoff+  (the bridge attaches the body — adom-lbr never drives Fusion).+- **OrCAD** — shells the **adom-orcad** converter to emit EDIF 2.0.0 (`.edn`),+  which Capture imports to a native `.olb`. If adom-orcad isn't installed it+  writes an `ORCAD-README.md` with the exact command instead of failing.+- **Altium** — the `.lbr` (Altium's Import Wizard reads EAGLE directly) **plus**+  the staged KiCad sources + `ALTIUM-README.md` for the **Altium bridge**, which+  drives a real Altium to native `.SchLib/.PcbLib` (Altium has no headless mode).++`--targets` limits the set (e.g. `--targets kicad,fusion`). This is still a pure+exporter: it writes files and hands off; the orchestrating AI does any+app-driving. `generate`/`bind-3d` remain for single-format work.+ ## generate — the core  Parses the `.kicad_sym` (symbol) and, when given, the `.kicad_mod` (footprint),
VERSION+1−1
@@ -1 +1 @@-1.3.5\ No newline at end of file+1.5.0\ No newline at end of file
bin/adom-lbr
⋯ 1 unchanged line ⋯
install.sh+27−2
@@ -1,4 +1,29 @@ #!/bin/bash+# install.sh for adom-lbr (app).+# Runs from the extracted module directory (~/project/adom_modules/adom-lbr/). set -euo pipefail-DIR="$(cd "$(dirname "$0")" && pwd)"; BIN="${HOME}/.local/bin"; mkdir -p "$BIN"-install -m 0755 "$DIR/bin/adom-lbr" "$BIN/adom-lbr"; echo "Installed adom-lbr"++DIR="$(cd "$(dirname "$0")" && pwd)"+cd "$DIR"++# App install: link the binary onto PATH.+BIN_DIR="${HOME}/.local/bin"+mkdir -p "$BIN_DIR"+install -m 0755 "$DIR/bin/adom-lbr" "$BIN_DIR/adom-lbr"+echo "Installed adom-lbr -> $BIN_DIR/adom-lbr"++# Bundle sub-skills: copy each skills/<name>/ to ~/.claude/skills/<name>/ so the+# focused fusion-export field guide installs alongside the app and surfaces on+# its own when exporting to Fusion.+SKILLS_SRC="$DIR/skills"+SKILLS_DST="${HOME}/.claude/skills"+if [ -d "$SKILLS_SRC" ]; then+  for d in "$SKILLS_SRC"/*/; do+    [ -d "$d" ] || continue+    name="$(basename "$d")"+    mkdir -p "$SKILLS_DST/$name"+    cp -R "$d." "$SKILLS_DST/$name/"+    echo "Installed sub-skill -> $SKILLS_DST/$name/"+  done+fi+
package.json+9−4
@@ -1,10 +1,14 @@ {   "slug": "adom-lbr",-  "version": "1.3.5",+  "version": "1.5.0",   "type": "app",-  "description": "KiCad \u2192 EAGLE .lbr generator for the whole EAGLE family (Fusion 360 / Altium / OrCAD), real-footprint accurate, with 3D-model handoff. Bidirectional KiCad converter + 7-check linter. Includes a focused fusion-export sub-skill.",+  "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": {},   "discovery_triggers": [+    "export library all tools",+    "kicad fusion altium orcad",+    "multi-tool library export",+    "orcad edif",     "kicad to eagle",     "generate lbr",     "eagle library",@@ -33,8 +37,9 @@   },   "needs_sudo": false,   "hero": {-    "type": "image",-    "path": "adom-lbr-hero-v4.png"+    "headline": "One part → every EDA tool",+    "subhead": "export delivers KiCad, Fusion 360, OrCAD & Altium from one source — real footprints, 3D handoff, and a live viewer.",+    "screenshot": "docs/viewer3col.png"   },   "tags": [     "eda",
page.json+10−4
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "1.3.5",+  "version": "1.5.0",   "type": "app",-  "description": "KiCad → EAGLE .lbr generator for the whole EAGLE family (Fusion 360 / Altium / OrCAD), real-footprint accurate, with 3D-model handoff. Bidirectional KiCad converter + 7-check linter. Includes a focused fusion-export sub-skill.",+  "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": {},   "tags": [     "eda",@@ -13,8 +13,10 @@     "viewer"   ],   "hero": {-    "type": "image",-    "path": "adom-lbr-hero-v4.png"+    "type": "billboard",+    "headline": "One part → every EDA tool",+    "subhead": "export delivers KiCad, Fusion 360, OrCAD & Altium from one source — real footprints, 3D handoff, and a live viewer.",+    "screenshot": "docs/viewer3col.png"   },   "scripts": {     "install": "./install.sh",@@ -31,6 +33,10 @@   "source_visibility": "public",   "license": "MIT",   "discovery_triggers": [+    "export library all tools",+    "kicad fusion altium orcad",+    "multi-tool library export",+    "orcad edif",     "kicad to eagle",     "generate lbr",     "eagle library",
src/embed.html+19−7
@@ -8,11 +8,13 @@    :root{--bg:#0d1117;--panel:#11161f;--panel2:#161c27;--border:#222b38;--text:#e6edf3;--muted:#8b949e;--accent:#5fe3c0}   *{box-sizing:border-box;margin:0;padding:0}html,body{height:100%}-  body{background:var(--bg);color:var(--text);font-family:'Satoshi','Segoe UI',sans-serif;display:flex;flex-direction:column;overflow:hidden}+  body{background:var(--bg);color:var(--text);font-family:'Satoshi','Segoe UI',sans-serif;display:flex;flex-direction:column;overflow:hidden}.below{flex:1;display:flex;min-height:0;min-width:0}.main{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}   header{display:flex;align-items:center;gap:9px;padding:9px 14px;border-bottom:1px solid var(--border);flex:0 0 auto}   header .dot{width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)}   header .b{font-weight:700;font-size:14px;font-family:'Familjen Grotesk',sans-serif}header .b span{color:var(--muted);font-weight:400}-  header .nm{margin-left:auto;font-weight:600;font-size:13px;color:#cfd8e2}+  header .nm{margin-left:10px;font-weight:600;font-size:13px;color:#cfd8e2}+  header .picker{margin-left:auto;background:var(--panel2);color:#cfd8e2;border:1px solid var(--border);border-radius:6px;padding:3px 9px;font-size:12px;font-family:inherit;cursor:pointer}+  header .picker:hover{border-color:var(--accent)}   .cols{flex:1 1 56%;display:flex;min-height:0;border-bottom:1px solid var(--border)}   .col{flex:1;display:flex;flex-direction:column;min-width:0;border-right:1px solid var(--border)}   .col:last-child{border-right:0}@@ -38,6 +40,9 @@ </style></head> <body>   <header><span class="dot"></span><span class="b">adom-lbr <span>· library viewer</span></span><span class="nm">__NAME__</span></header>+  <div class="below">+__PICKER__+  <div class="main" id="main">   <div class="cols">     <div class="col"><div class="ch">Symbol</div><div class="vp" data-vp>__SYM__</div></div>     <div class="col"><div class="ch">Footprint <span class="n">__PADN__ pads</span></div><div class="vp" data-vp>__FP__</div></div>@@ -47,12 +52,16 @@     <div class="mh">Pin → pad mapping <span style="color:#7e8a99">· __PINN__ pins, with datasheet descriptions</span></div>     <table><thead><tr><th>Pin</th><th>Name</th><th></th><th>Pad</th><th>Type</th><th>Description</th></tr></thead>     <tbody id="rows"></tbody></table>+    <script type="application/json" id="pinsdata">__PINS__</script>+  </div>+  </div>   </div> <script>-var PINS=__PINS__;-(function(){-  // each viewport: pan/zoom on its <svg> or <img>+// Wire the viewport pan/zoom + render the pin→pad table. A function so it can be+// re-run after the library manager swaps in a new chip's content (no reload).+function wireViewer(){   document.querySelectorAll('[data-vp]').forEach(function(vp){+    if(vp.dataset.wired)return; vp.dataset.wired='1';     var el=vp.querySelector('svg,img'); if(!el) return;     if(el.tagName.toLowerCase()==='svg'){ el.removeAttribute('width'); el.removeAttribute('height'); }     var s=1,tx=0,ty=0;@@ -71,7 +80,8 @@ var PINS=__PINS__;     ap();   });   function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }-  var rows=document.getElementById('rows');+  var rows=document.getElementById('rows'); rows.innerHTML='';+  var PINS=[]; try{PINS=JSON.parse((document.getElementById('pinsdata')||{}).textContent||'[]');}catch(e){}   PINS.forEach(function(p){     var col=p.color||'#8b949e', tr=document.createElement('tr');     tr.innerHTML='<td class="pin">'+esc(p.number)+'</td><td class="pin">'+esc(p.name)+'</td>'@@ -80,6 +90,8 @@ var PINS=__PINS__;       +'<td class="d">'+esc(p.desc||'')+'</td>';     rows.appendChild(tr);   });-})();+  if(!PINS.length){ rows.innerHTML='<tr><td colspan="6" style="padding:22px 14px;color:#8b949e;font-size:13px;line-height:1.75">No pin&nbsp;&rarr;&nbsp;pad data for this part yet. To populate it, add a <code style="background:#11161f;border:1px solid #222b38;padding:1px 5px;border-radius:4px;color:#79c0ff">&lt;MPN&gt;-symbol.extracted.json</code> (run the part through <b>ds2sf</b> / <b>adom-symbol</b>) <i>or</i> a <code style="background:#11161f;border:1px solid #222b38;padding:1px 5px;border-radius:4px;color:#79c0ff">.lbr</code> containing <code style="background:#11161f;border:1px solid #222b38;padding:1px 5px;border-radius:4px;color:#79c0ff">&lt;connects&gt;</code> into this chip\'s <code style="background:#11161f;border:1px solid #222b38;padding:1px 5px;border-radius:4px;color:#79c0ff">chip-fetcher/library/&lt;MPN&gt;/</code> folder, then reload.</td></tr>'; }+}+wireViewer(); </script> </body></html>
src/main.rs+573−29
@@ -37,13 +37,15 @@ enum Commands {         /// Symbol name override         #[arg(long)]         name: Option<String>,-        /// Path to an adom-symbol outline SVG (a vector line-drawing of the-        /// chip). When given, it's drawn as raw `<wire>` lines on the schematic-        /// symbol, centred in the symbol body.+        /// Chip outline to draw on the schematic symbol as `<wire>`s. PREFERRED:+        /// adom-symbol's OUTLINE CONTRACT `<mpn>-symbol-outline.mm.svg` (with its+        /// `.json` sidecar alongside) — already placed in symbol mm-space, so it's+        /// transcribed 1:1 with zero guessing. Otherwise a legacy flattened outline+        /// SVG (`<mpn>-3d-outline-*.svg`), which is centred + scaled by eye.         #[arg(long)]         symbol_art: Option<PathBuf>,-        /// Size of the symbol art as a fraction of the symbol body's smaller-        /// side (default 0.38, matching adom-symbol's placement). Higher = bigger.+        /// Size of LEGACY symbol art as a fraction of the body's smaller side+        /// (default 0.38). Ignored for the `.mm.svg` contract (placement is exact).         #[arg(long, default_value_t = 0.38)]         symbol_art_scale: f64,         /// Path to a 3D model (STEP/STP/GLB) to associate with the package.@@ -114,6 +116,40 @@ enum Commands {         /// Path to the .kicad_sym file         path: PathBuf,     },+    /// Export a COMPLETE multi-tool library bundle from one KiCad part, one+    /// folder per tool: native KiCad (.kicad_sym + .pretty/.kicad_mod), Fusion+    /// (.lbr + 3D handoff sidecar), OrCAD (EDIF .edn via adom-orcad), and an+    /// Altium hand-off (the .lbr for direct import + staged inputs + the exact+    /// bridge command for native .SchLib/.PcbLib). Writes an EXPORT.md index.+    /// adom-lbr stays a pure exporter — it produces files and hands off; it+    /// never drives Altium/OrCAD/Fusion (the orchestrating AI does that).+    Export {+        /// Path to the .kicad_sym (recommended — OrCAD's EDIF needs a symbol)+        #[arg(long)]+        sym: Option<PathBuf>,+        /// Path to the .kicad_mod footprint (real pad geometry for every target)+        #[arg(long)]+        fp: Option<PathBuf>,+        /// 3D model (STEP/STP/GLB) → Fusion 3D handoff sidecar+        #[arg(long)]+        model: Option<PathBuf>,+        /// Chip outline to draw on the schematic symbol (see `generate`)+        #[arg(long)]+        symbol_art: Option<PathBuf>,+        /// Legacy symbol-art scale (ignored for the .mm.svg contract)+        #[arg(long, default_value_t = 0.38)]+        symbol_art_scale: f64,+        /// Part name override (default: from the symbol/footprint)+        #[arg(long)]+        name: Option<String>,+        /// Output directory for the bundle (default: ./<name>-library)+        #[arg(short, long)]+        out: Option<PathBuf>,+        /// Comma-separated subset of targets to emit (default: all).+        /// Any of: kicad,fusion,orcad,altium+        #[arg(long)]+        targets: Option<String>,+    },     /// Export a Fusion-style 3-column INTERACTIVE HTML package — symbol | footprint     /// | 3D, each pan/zoomable, with a pin→pad mapping table carrying the datasheet     /// descriptions below. Self-contained for embedding in an iframe (e.g. a wiki@@ -134,6 +170,11 @@ enum Commands {         /// Optional .lbr to load into the view on startup         #[arg(long)]         file: Option<PathBuf>,+        /// chip-fetcher library root (folder of <MPN>/ dirs) for the on-the-fly+        /// chip switcher + `?chip=<MPN>` URLs. Auto-detected from --file's+        /// grandparent, else chip-fetcher/library.+        #[arg(long)]+        library: Option<PathBuf>,     },     /// Install skill + bash completions     Install,@@ -972,7 +1013,86 @@ fn symbol_art_wires(svg: &str, body: Option<&KicadBody>, scale_frac: f64) -> (St     (out, segs) } -fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&str>, symbol_art_scale: f64) -> String {+/// Resolved symbol-art input. The PREFERRED form is an adom-symbol OUTLINE+/// CONTRACT: a `<mpn>-symbol-outline.mm.svg` whose polylines are ALREADY placed+/// in the symbol's mm space (lite/decimated), plus a `<mpn>-symbol-outline.json`+/// sidecar describing the transform into EAGLE's Y-up frame. We transcribe those+/// 1:1 — no fitting, no re-decimation, so the art lands EXACTLY where adom-symbol+/// drew it. Anything else is a `Legacy` raw outline we centre+scale by eye.+enum SymbolArt {+    /// `mm_svg`: the contract overlay. `dx`: x offset (svg→eagle). `svg_cy`/`e_cy`:+    /// the two frame centres, used to flip+offset Y. Derived from the sidecar so+    /// nothing about adom-symbol's frame is hardcoded here.+    Contract { mm_svg: String, dx: f64, svg_cy: f64, e_cy: f64, variant: String },+    Legacy(String),+}++/// Resolve a `--symbol-art` path into a `SymbolArt`. If the file is a+/// `*.mm.svg` with a readable sidecar `*.json`, it's a contract; otherwise legacy.+fn resolve_symbol_art(path: &std::path::Path) -> Result<SymbolArt, String> {+    let content = fs::read_to_string(path).map_err(|e| format!("Cannot read symbol art {}: {}", path.display(), e))?;+    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");+    if name.ends_with(".mm.svg") {+        // Sibling sidecar: `<…>.mm.svg` → `<…>.json`.+        let json_path = path.with_file_name(name.replace(".mm.svg", ".json"));+        if let Ok(js) = fs::read_to_string(&json_path) {+            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&js) {+                let g = |a: &str, b: &str| v.get(a).and_then(|o| o.get(b)).and_then(|n| n.as_f64());+                if let (Some(svg_cx), Some(svg_cy), Some(e_cx), Some(e_cy)) = (+                    g("placement_svg_mm", "center_x"), g("placement_svg_mm", "center_y"),+                    g("placement_eagle_mm", "center_x"), g("placement_eagle_mm", "center_y"),+                ) {+                    let variant = v.get("default_variant").and_then(|s| s.as_str()).unwrap_or("?").to_string();+                    return Ok(SymbolArt::Contract { mm_svg: content, dx: svg_cx - e_cx, svg_cy, e_cy, variant });+                }+            }+        }+        eprintln!("WARN: {name} looks like an outline contract but its .json sidecar is missing/unreadable — falling back to centre+scale guessing.");+    }+    Ok(SymbolArt::Legacy(content))+}++/// Transcribe a contract `.mm.svg` (already in symbol mm-space, Y-down) to EAGLE+/// `<wire>`s (Y-up). 1:1 — every polyline segment is kept (the producer already+/// decimated to a lite outline). `eagle_x = svg_x - dx`, `eagle_y = e_cy - (svg_y - svg_cy)`.+fn contract_wires(mm_svg: &str, dx: f64, svg_cy: f64, e_cy: f64) -> (String, usize) {+    let poly_re = regex_lite::Regex::new(r#"<(polyline|polygon)\b[^>]*\bpoints="([^"]+)""#).unwrap();+    let mut out = String::new();+    let mut segs = 0usize;+    for c in poly_re.captures_iter(mm_svg) {+        let closed = &c[1] == "polygon";+        let mut pts: Vec<(f64, f64)> = c[2]+            .split_whitespace()+            .filter_map(|p| {+                let mut it = p.split(',');+                let x: f64 = it.next()?.trim().parse().ok()?;+                let y: f64 = it.next()?.trim().parse().ok()?;+                Some((x - dx, e_cy - (y - svg_cy))) // svg(Y-down) → eagle(Y-up)+            })+            .collect();+        if closed {+            if let Some(&first) = pts.first() { pts.push(first); }+        }+        for w in pts.windows(2) {+            out.push_str(&format!(+                "  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1016\" layer=\"94\"/>\n",+                w[0].0, w[0].1, w[1].0, w[1].1+            ));+            segs += 1;+        }+    }+    (out, segs)+}++/// Generate the wire block for a resolved symbol-art input.+fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64) -> (String, usize) {+    match art {+        SymbolArt::Contract { mm_svg, dx, svg_cy, e_cy, .. } => contract_wires(mm_svg, *dx, *svg_cy, *e_cy),+        SymbolArt::Legacy(svg) => symbol_art_wires(svg, body, scale_frac),+    }+}++fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64) -> String {     let mut xml = String::with_capacity(8192);      xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -1091,9 +1211,9 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option      // Optional: a vector drawing of the chip (adom-symbol's outline SVG) drawn     // as raw lines, centred inside the symbol body.-    if let Some(svg) = symbol_art {-        let (art, _) = symbol_art_wires(svg, sym.body.as_ref(), symbol_art_scale);-        xml.push_str(&art);+    if let Some(art) = symbol_art {+        let (wires, _) = symbol_art_block(art, sym.body.as_ref(), symbol_art_scale);+        xml.push_str(&wires);     }      let name_y = sym.body.as_ref().map(|b| b.top + 2.54).unwrap_or(10.0);@@ -1302,14 +1422,258 @@ const EAGLE_LAYERS: &str = r#"<layers>  // ── Main ── +// Locate the adom-orcad converter: PATH first, then the in-container build.+// Returns the invocable command, or None when it isn't installed.+fn find_adom_orcad() -> Option<String> {+    for c in ["adom-orcad", "/home/adom/project/adom-orcad/target/release/adom-orcad"] {+        let usable = if c.starts_with('/') {+            std::path::Path::new(c).exists()+        } else {+            std::process::Command::new(c).arg("--help").output().is_ok()+        };+        if usable {+            return Some(c.to_string());+        }+    }+    None+}++// `export` — one KiCad part → a complete bundle, one folder per EDA tool.+// adom-lbr stays a pure exporter: it WRITES files and hands off; it never+// drives Altium/OrCAD/Fusion. OrCAD is produced via the adom-orcad converter+// (a CLI, not an app); Altium native is left to its bridge with a staged+// hand-off, since "Altium has no headless mode".+fn run_export(+    sym: Option<PathBuf>,+    fp: Option<PathBuf>,+    model: Option<PathBuf>,+    symbol_art: Option<PathBuf>,+    symbol_art_scale: f64,+    name: Option<String>,+    out: Option<PathBuf>,+    targets: Option<String>,+) {+    use std::process::Command;++    // ── resolve inputs (same contract as `generate`) ──+    let symbol_art_resolved = symbol_art.as_ref().map(|p| {+        resolve_symbol_art(p).unwrap_or_else(|e| {+            eprintln!("ERROR: {e}");+            process::exit(1);+        })+    });+    let fp_content = fp.as_ref().map(|p| {+        fs::read_to_string(p).unwrap_or_else(|e| {+            eprintln!("ERROR: Cannot read footprint {}: {}", p.display(), e);+            process::exit(1);+        })+    });+    let sym_content = sym.as_ref().map(|p| {+        fs::read_to_string(p).unwrap_or_else(|e| {+            eprintln!("ERROR: Cannot read {}: {}", p.display(), e);+            process::exit(1);+        })+    });+    let parsed = match (&sym_content, &fp_content) {+        (Some(sc), _) => parse_kicad_sym(sc, name.as_deref()).unwrap_or_else(|e| {+            eprintln!("ERROR: {e}");+            process::exit(1);+        }),+        (None, Some(fc)) => {+            let syn = name.clone().or_else(|| first_footprint_name(fc)).unwrap_or_else(|| "FOOTPRINT".into());+            synth_symbol_from_mod(fc, &syn)+        }+        (None, None) => {+            eprintln!("ERROR: export needs at least --sym or --fp.");+            eprintln!("Hint: `adom-lbr export --sym part.kicad_sym --fp part.kicad_mod`.");+            process::exit(1);+        }+    };+    let part = parsed.name.clone();++    // ── target selection ──+    let targets_set: Vec<String> = match &targets {+        Some(t) => t.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect(),+        None => vec!["kicad".into(), "fusion".into(), "orcad".into(), "altium".into()],+    };+    let want = |t: &str| targets_set.iter().any(|w| w == t);++    // ── output dir ──+    let out_dir = out.unwrap_or_else(|| PathBuf::from(format!("{part}-library")));+    fs::create_dir_all(&out_dir).unwrap_or_else(|e| {+        eprintln!("ERROR: cannot create {}: {}", out_dir.display(), e);+        process::exit(1);+    });++    // (tool, state ∈ {ok,warn,note,skip}, detail)+    let mut status: Vec<(String, String, String)> = Vec::new();++    // 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);+    let lint = lint_lbr(&lbr);+    let kicad_sym_text = match &sym_content {+        Some(sc) => sc.clone(),            // already native KiCad — carry through 1:1+        None => generate_kicad_sym(&parsed),+    };++    // ── KiCad ──+    let kicad_dir = out_dir.join("kicad");+    let kicad_sym_path = kicad_dir.join(format!("{part}.kicad_sym"));+    if want("kicad") {+        let _ = fs::create_dir_all(&kicad_dir);+        let _ = fs::write(&kicad_sym_path, &kicad_sym_text);+        let mut detail = format!("{part}.kicad_sym");+        if let Some(fc) = &fp_content {+            let pretty = kicad_dir.join(format!("{part}.pretty"));+            let _ = fs::create_dir_all(&pretty);+            let _ = fs::write(pretty.join(format!("{part}.kicad_mod")), fc);+            detail.push_str(&format!(" + {part}.pretty/{part}.kicad_mod"));+        }+        status.push(("KiCad".into(), "ok".into(), detail));+    }+    // OrCAD reads a .kicad_sym on disk — make sure one exists even if the+    // 'kicad' target was excluded.+    if want("orcad") && !kicad_sym_path.exists() {+        let _ = fs::create_dir_all(&kicad_dir);+        let _ = fs::write(&kicad_sym_path, &kicad_sym_text);+    }++    // ── Fusion 360 ──+    if want("fusion") {+        let fdir = out_dir.join("fusion");+        let _ = fs::create_dir_all(&fdir);+        let lbr_path = fdir.join(format!("{part}.lbr"));+        let _ = fs::write(&lbr_path, &lbr);+        let mut detail = format!("{part}.lbr (lint {})", if lint.passed { "PASS" } else { "FAIL" });+        if let Some(mp) = &model {+            let side = fdir.join(format!("{part}.fusion3d.json"));+            match write_fusion3d_sidecar(&side, &part, &lbr_path, mp) {+                Ok(()) => detail.push_str(&format!(" + {part}.fusion3d.json (bridge attaches the body)")),+                Err(e) => detail.push_str(&format!(" + (sidecar FAILED: {e})")),+            }+        }+        let st = if lint.passed { "ok" } else { "warn" };+        status.push(("Fusion 360".into(), st.into(), detail));+    }++    // ── OrCAD (EDIF via adom-orcad) ──+    if want("orcad") {+        let odir = out_dir.join("orcad");+        let _ = fs::create_dir_all(&odir);+        let edn = odir.join(format!("{part}.edn"));+        match find_adom_orcad() {+            Some(bin) => {+                let res = Command::new(&bin)+                    .arg("edif").arg("--sym").arg(&kicad_sym_path)+                    .arg("--name").arg(&part)+                    .arg("--output").arg(&edn)+                    .output();+                match res {+                    Ok(o) if o.status.success() && edn.exists() => {+                        status.push(("OrCAD".into(), "ok".into(), format!("{part}.edn (EDIF 2.0.0 — Capture ▸ Import ▸ EDIF)")));+                    }+                    Ok(o) => {+                        let err = String::from_utf8_lossy(&o.stderr);+                        let _ = fs::write(odir.join("ORCAD-README.md"),+                            format!("# {part} → OrCAD\n\nadom-orcad could not produce EDIF.\n\nstderr:\n```\n{err}\n```\n\nRun manually:\n```\nadom-orcad edif --sym ../kicad/{part}.kicad_sym --name {part} --output {part}.edn\n```\n"));+                        status.push(("OrCAD".into(), "skip".into(), "adom-orcad failed — see ORCAD-README.md".into()));+                    }+                    Err(e) => status.push(("OrCAD".into(), "skip".into(), format!("could not run adom-orcad: {e}"))),+                }+            }+            None => {+                let _ = fs::write(odir.join("ORCAD-README.md"),+                    format!("# {part} → OrCAD\n\nThe OrCAD EDIF export needs **adom-orcad** (not found on PATH).\n\nInstall it, then:\n```\nadom-orcad edif --sym ../kicad/{part}.kicad_sym --name {part} --output {part}.edn\n```\n\nThen in OrCAD Capture: **Accessories/Import ▸ EDIF 2.0.0** ▸ select `{part}.edn` → native `.olb` symbol.\n"));+                status.push(("OrCAD".into(), "note".into(), "adom-orcad not installed — staged ORCAD-README.md".into()));+            }+        }+    }++    // ── Altium (hand-off) ──+    if want("altium") {+        let adir = out_dir.join("altium");+        let _ = fs::create_dir_all(&adir);+        // Altium imports an EAGLE .lbr directly via its Import Wizard. Also stage+        // the KiCad sources + model for the native bridge path.+        let _ = fs::write(adir.join(format!("{part}.lbr")), &lbr);+        let _ = fs::write(adir.join(format!("{part}.kicad_sym")), &kicad_sym_text);+        if let Some(fc) = &fp_content {+            let _ = fs::write(adir.join(format!("{part}.kicad_mod")), fc);+        }+        if let Some(mp) = &model {+            if let Ok(b) = fs::read(mp) {+                let ext = mp.extension().and_then(|e| e.to_str()).unwrap_or("step");+                let _ = fs::write(adir.join(format!("{part}.{ext}")), b);+            }+        }+        let _ = fs::write(adir.join("ALTIUM-README.md"), altium_readme(&part, fp_content.is_some(), model.is_some()));+        status.push(("Altium".into(), "ok".into(), format!("{part}.lbr (direct import) + sources + ALTIUM-README.md")));+    }++    // ── index + summary ──+    let _ = fs::write(out_dir.join("EXPORT.md"),+        export_index(&part, &status, fp_content.is_some(), model.is_some(), symbol_art_resolved.is_some()));++    println!("OK: exported '{part}' → {}", out_dir.display());+    for (tool, state, detail) in &status {+        let tag = match state.as_str() { "ok" => "✓", "warn" => "▲", "note" => "·", _ => "—" };+        println!("  {tag} {:<11} {}", tool, detail);+    }+    for w in &lint.warnings { println!("  LINT WARN [{}] {}", w.code, w.message); }+    for e in &lint.errors { println!("  LINT ERROR [{}] {}", e.code, e.message); }+    println!("NEXT: open {}/EXPORT.md for per-tool import steps.", out_dir.display());+    if model.is_some() {+        println!("NEXT (Fusion 3D): hand fusion/{part}.fusion3d.json to the Fusion bridge (Create3DPackage → FINISH → Save), then record the minted URN with `adom-lbr bind-3d --record-only`.");+    }+    if fp.is_none() {+        println!("HINT: no --fp → every target got a 2-COLUMN PLACEHOLDER footprint. Pass --fp <part.kicad_mod> for the real geometry.");+    }+}++fn altium_readme(part: &str, has_fp: bool, has_model: bool) -> String {+    let mut s = String::new();+    s.push_str(&format!("# {part} → Altium\n\nTwo paths — pick one.\n\n"));+    s.push_str("## 1. Direct (nothing else needed)\n\n");+    s.push_str(&format!("Altium imports EAGLE libraries: **File ▸ Import Wizard ▸ EAGLE Projects and Libraries** ▸ select `{part}.lbr`. You get a schematic symbol + PCB footprint in an integrated library.\n\n"));+    s.push_str("## 2. Native .SchLib / .PcbLib (Altium bridge)\n\n");+    s.push_str("adom-lbr is a pure exporter — it does **not** drive Altium. For true native\nAltium libraries, the orchestrating AI runs the **Altium bridge** (Adom Desktop\ndrives a real Altium via DelphiScript — Altium has no headless mode). The KiCad\nsources are staged here for it:\n\n");+    s.push_str(&format!("- `{part}.kicad_sym` — schematic symbol\n"));+    if has_fp { s.push_str(&format!("- `{part}.kicad_mod` — PCB footprint\n")); }+    if has_model { s.push_str("- the 3D model (`*.step` / `*.glb`) — 3D body\n"); }+    s.push_str("\nThen, on the machine running Altium + Adom Desktop, install the Altium library\nimporter and feed it these sources (search the wiki for the current package):\n\n");+    s.push_str("```\nadom-wiki search \"altium library importer\"\n# follow that package's README: it takes the KiCad sources above and drives\n# Altium (via Adom Desktop) to emit native .SchLib / .PcbLib.\n```\n");+    s+}++fn export_index(part: &str, status: &[(String, String, String)], has_fp: bool, has_model: bool, has_art: bool) -> String {+    let mut s = String::new();+    s.push_str(&format!("# {part} — multi-tool library bundle\n\n"));+    s.push_str("Generated by **`adom-lbr export`** from one KiCad part. One folder per EDA tool — all four read from the *same* source, so the symbol, the real footprint, and the chip outline are identical across them.\n\n");+    s.push_str(&format!("- Footprint geometry: **{}**\n", if has_fp { "real `.kicad_mod`" } else { "placeholder (no `--fp`)" }));+    s.push_str(&format!("- Chip outline on the schematic symbol: **{}**\n", if has_art { "embedded" } else { "none" }));+    s.push_str(&format!("- 3D-model hand-off: **{}**\n\n", if has_model { "yes (Fusion sidecar)" } else { "none" }));+    s.push_str("| Tool | Output | How to import |\n|---|---|---|\n");+    let kfp = if has_fp { format!(" + `kicad/{part}.pretty/`") } else { String::new() };+    s.push_str(&format!("| **KiCad** | `kicad/{part}.kicad_sym`{kfp} | Add the `.kicad_sym` as a symbol library; add the `.pretty` folder as a footprint library. |\n"));+    let fsc = if has_model { format!(" + `{part}.fusion3d.json`") } else { String::new() };+    let fnote = if has_model { " Then the bridge attaches the 3D body (Create3DPackage → FINISH → Save)." } else { "" };+    s.push_str(&format!("| **Fusion 360** | `fusion/{part}.lbr`{fsc} | Open the `.lbr` as an Electronics library.{fnote} |\n"));+    s.push_str(&format!("| **OrCAD** | `orcad/{part}.edn` | Capture ▸ Accessories/Import ▸ EDIF 2.0.0 ▸ select the `.edn` → native `.olb`. (Falls back to a README if adom-orcad isn't installed.) |\n"));+    s.push_str(&format!("| **Altium** | `altium/{part}.lbr` + sources | Direct: Import Wizard (EAGLE). Native `.SchLib/.PcbLib`: see `altium/ALTIUM-README.md`. |\n\n"));+    let _ = status;+    s.push_str("adom-lbr is a pure exporter: it writes these files and hands off. The orchestrating AI does any app-driving (Fusion bridge, Altium bridge).\n");+    s+}+ fn main() {     let cli = Cli::parse();      match cli.command {         Commands::Generate { sym, fp, output, name, model, symbol_art, symbol_art_scale } => {-            let symbol_art_content = symbol_art.as_ref().map(|p| {-                fs::read_to_string(p).unwrap_or_else(|e| {-                    eprintln!("ERROR: Cannot read symbol art {}: {}", p.display(), e);+            let symbol_art_resolved = symbol_art.as_ref().map(|p| {+                resolve_symbol_art(p).unwrap_or_else(|e| {+                    eprintln!("ERROR: {e}");                     process::exit(1);                 })             });@@ -1347,7 +1711,7 @@ fn main() {             };              let out_default = sym.as_ref().or(fp.as_ref()).cloned().unwrap_or_else(|| PathBuf::from(&parsed.name));-            let lbr = generate_lbr(&parsed, fp_content.as_deref(), symbol_art_content.as_deref(), symbol_art_scale);+            let lbr = generate_lbr(&parsed, fp_content.as_deref(), symbol_art_resolved.as_ref(), symbol_art_scale);              // Lint before writing             let lint = lint_lbr(&lbr);@@ -1386,20 +1750,23 @@ fn main() {                     println!("HINT: add --model <part.step> to also emit a Fusion 3D handoff sidecar (the bridge attaches the body).");                 }             }-            if let Some(svg) = &symbol_art_content {-                let (_, segs) = symbol_art_wires(svg, parsed.body.as_ref(), symbol_art_scale);+            if let Some(art) = &symbol_art_resolved {+                let (_, segs) = symbol_art_block(art, parsed.body.as_ref(), symbol_art_scale);                 let kb = lbr.len() / 1024;                 if segs == 0 {-                    println!("HINT: --symbol-art SVG had NO <polyline>/<polygon> points — nothing drawn. Give a flattened outline SVG (adom-symbol's `<mpn>-3d-outline-*.svg`), not a raster or a <path>-only file.");+                    println!("HINT: --symbol-art had NO <polyline>/<polygon> points — nothing drawn. Give an outline contract (`<mpn>-symbol-outline.mm.svg`) or a flattened outline SVG, not a raster or a <path>-only file.");+                } else if let SymbolArt::Contract { variant, .. } = art {+                    println!("HINT: drew the chip outline via the OUTLINE CONTRACT (`{variant}` variant) — {segs} segments on layer 94, transcribed 1:1 from adom-symbol's mm-space placement (zero guessing).");+                    if kb > 200 {+                        println!("WARN: ~{kb} KB .lbr — heavier than expected for a lite contract; check the producer's segment cap.");+                    }                 } else {-                    println!("HINT: drew the chip outline on the symbol — {segs} segments on layer 94, centred + scaled to the symbol body (matching adom-symbol's placement).");+                    println!("HINT: drew the chip outline (LEGACY centre+scale guess) — {segs} segments on layer 94. TIP: render `<mpn>-symbol.svg` with adom-symbol and pass its `<mpn>-symbol-outline.mm.svg` instead for an exact 1:1 placement.");                     if segs > 2500 || kb > 200 {                         println!("WARN: HEAVY symbol art — {segs} segments, ~{kb} KB .lbr. Detailed outlines (e.g. `-blueprint`) bloat the library and can lag Fusion's symbol editor.");-                        println!("      → Pass a SIMPLER outline instead (`<mpn>-3d-outline-bold.svg` or `-thin.svg` are ~1/3 the size), or drop the art for a 19 KB symbol.");+                        println!("      → Pass a SIMPLER outline instead (`<mpn>-3d-outline-bold.svg` or `-thin.svg` are ~1/3 the size), or the lite `-symbol-outline.mm.svg` contract.");                     } else if kb > 80 {-                        println!("HINT: moderate weight (~{kb} KB). Fine for one part; if you're batching a whole library, prefer the lighter `-bold`/`-thin` outlines.");-                    } else {-                        println!("HINT: light + clean (~{kb} KB). Want the full iso wireframe? a `-blueprint` outline has more detail (bigger file).");+                        println!("HINT: moderate weight (~{kb} KB). Fine for one part; if you're batching a whole library, prefer the lite `-symbol-outline.mm.svg` contract.");                     }                 }             }@@ -1422,6 +1789,10 @@ fn main() {             }         } +        Commands::Export { sym, fp, model, symbol_art, symbol_art_scale, name, out, targets } => {+            run_export(sym, fp, model, symbol_art, symbol_art_scale, name, out, targets);+        }+         Commands::Bind3d { lbr, wip_urn, urn, lineage_urn, record_only, package, output, sidecar } => {             // Exactly one of --wip-urn (self-hosted) or --urn (managed).             let (link, managed) = match (wip_urn.as_deref(), urn.as_deref()) {@@ -1625,19 +1996,71 @@ fn main() {                 process::exit(1);             }         }-        Commands::Serve { port, file } => {-            if let Some(f) = file {-                match fs::read_to_string(&f) {+        Commands::Serve { port, file, library } => {+            // Resolve the chip-fetcher library root (folder of <MPN>/ dirs) for+            // the chip switcher: --library, else --file's grandparent, else the+            // default chip-fetcher/library.+            let default_lib = PathBuf::from("/home/adom/project/chip-fetcher/library");+            let lib_root = library.clone()+                .or_else(|| file.as_ref().and_then(|f| f.parent()).and_then(|d| d.parent()).map(|p| p.to_path_buf()))+                .filter(|p| p.is_dir())+                .or_else(|| default_lib.is_dir().then(|| default_lib.clone()));+            let chips = lib_root.as_ref().map(|r| viewable_chips(r)).unwrap_or_default();+            // chip-fetcher's project groupings (filtered to viewable chips).+            let projects = load_chipfetcher_projects(&chips);++            // The current chip dir (from --file), and whether it has renders.+            let current_dir = file.as_ref().and_then(|f| f.parent()).map(|d| d.to_path_buf());+            let current_chip = current_dir.as_ref().and_then(|d| d.file_name()).and_then(|s| s.to_str()).map(String::from);++            // ── Reprimand: launched without a real chip-fetcher folder ──+            let renders_ok = current_dir.as_ref().map(|d| {+                let mpn = d.file_name().and_then(|s| s.to_str()).unwrap_or("");+                d.join(format!("{mpn}-symbol.svg")).exists() || d.join("symbol.svg").exists()+            }).unwrap_or(false);+            if file.is_none() && chips.is_empty() {+                eprintln!("⛔ adom-lbr serve: launched WITHOUT a chip-fetcher library folder, and no library root was found.");+                eprintln!("   The viewer renders a chip from its chip-fetcher dir (chip-fetcher/library/<MPN>/), which holds");+                eprintln!("   <MPN>-symbol.svg, -footprint.svg, the 3D render, and <MPN>-symbol.extracted.json.");+                eprintln!("   FIX: point --file at a <MPN>.lbr INSIDE such a folder, or pass --library <root>, e.g.:");+                eprintln!("     adom-lbr serve --file /home/adom/project/chip-fetcher/library/ADS8588SIPM/ADS8588SIPM.lbr");+            } else if file.is_some() && !renders_ok {+                eprintln!("⛔ adom-lbr serve: --file's folder ({}) is NOT a chip-fetcher chip dir —", current_dir.as_ref().map(|d| d.display().to_string()).unwrap_or_default());+                eprintln!("   no <MPN>-symbol.svg there, so the viewer can't show symbol/footprint/3D (you'll get empty panels).");+                eprintln!("   FIX: put the .lbr in chip-fetcher/library/<MPN>/ (next to its renders), e.g.:");+                eprintln!("     adom-lbr serve --file /home/adom/project/chip-fetcher/library/<MPN>/<MPN>.lbr");+            }++            // Register the on-the-fly chip switcher (builds any chip in the root).+            if let Some(root) = lib_root.clone() {+                if !chips.is_empty() {+                    let chips_for_builder = chips.clone();+                    let root_for_builder = root.clone();+                    let projects_for_builder = projects.clone();+                    server::set_chip_switcher(Box::new(move |mpn: &str| {+                        // Only serve chips that exist under the root (no path escape).+                        if !chips_for_builder.iter().any(|c| c == mpn) { return None; }+                        let dir = root_for_builder.join(mpn);+                        let picker = chip_picker_html(&chips_for_builder, mpn, &projects_for_builder);+                        build_embed_html(&dir, &picker).ok().map(|(h, _)| h)+                    }));+                    eprintln!("[adom-lbr] chip switcher: {} chips, {} chip-fetcher projects, under {} (sidebar + ?chip=<MPN>)", chips.len(), projects.len(), root.display());+                }+            }++            // Load + render the startup chip.+            if let Some(f) = &file {+                match fs::read_to_string(f) {                     Ok(content) => server::set_state(server::analyze_lbr(&content)),                     Err(e) => {                         eprintln!("ERROR: Cannot read {}: {}", f.display(), e);                         process::exit(1);                     }                 }-                // Build the 3-column (symbol|footprint|3D) viewer from the part dir-                // (the .lbr's parent), so GET / shows the full studio, not just lint.                 if let Some(dir) = f.parent() {-                    match build_embed_html(dir) {+                    let picker = current_chip.as_deref()+                        .map(|c| chip_picker_html(&chips, c, &projects)).unwrap_or_default();+                    match build_embed_html(dir, &picker) {                         Ok((html, pins)) => {                             eprintln!("[adom-lbr] 3-column viewer ready ({pins} pins) from {}", dir.display());                             server::set_embed(html);@@ -1645,6 +2068,15 @@ fn main() {                         Err(e) => eprintln!("[adom-lbr] no 3-column viewer ({e}); serving lint card"),                     }                 }+            } else if let Some(first) = chips.first() {+                // No --file but we have a library: open on the first chip.+                if let Some(root) = &lib_root {+                    let picker = chip_picker_html(&chips, first, &projects);+                    if let Ok((html, pins)) = build_embed_html(&root.join(first), &picker) {+                        eprintln!("[adom-lbr] opened on {first} ({pins} pins); switch via the dropdown or ?chip=<MPN>");+                        server::set_embed(html);+                    }+                }             }             if let Err(e) = server::run(port) {                 eprintln!("ERROR: {e:#}");@@ -1719,7 +2151,7 @@ fn read_first(dir: &std::path::Path, names: &[String]) -> Option<String> {  fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> {     let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string();-    let (html, pin_count) = build_embed_html(dir)?;+    let (html, pin_count) = build_embed_html(dir, "")?;     let out = out.unwrap_or_else(|| dir.join(format!("{mpn}-lbr-embed.html")));     fs::write(&out, &html).map_err(|e| format!("writing {}: {e}", out.display()))?;     println!("OK: 3-column interactive embed ({pin_count} pins) → {}", out.display());@@ -1730,7 +2162,118 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> /// Reads pre-rendered assets, tolerating both `<mpn>-` prefixed and generic /// (`symbol.svg`, `assets/footprint.svg`, …) names so the same renders that /// drive the wiki page also drive the live app and the `embed` export.-pub fn build_embed_html(dir: &std::path::Path) -> Result<(String, usize), String> {+/// List the chip-fetcher library folders under `root` that have a viewable+/// render (a `<MPN>-symbol.svg` / `symbol.svg`), sorted — the chip switcher's menu.+pub fn viewable_chips(root: &std::path::Path) -> Vec<String> {+    let mut chips: Vec<String> = fs::read_dir(root).into_iter().flatten().filter_map(|e| e.ok())+        .filter(|e| e.path().is_dir())+        .filter_map(|e| {+            let name = e.file_name().to_str()?.to_string();+            let has_sym = e.path().join(format!("{name}-symbol.svg")).exists()+                || e.path().join("symbol.svg").exists();+            has_sym.then_some(name)+        })+        .collect();+    chips.sort();+    chips+}++/// Read chip-fetcher's `projects.json` and return its groupings as+/// (project_name, [mpns]) — filtered to the chips that are actually viewable —+/// so the library manager's filter lists match chip-fetcher's projects exactly.+/// Empty projects (after filtering) are dropped. Alphabetical (BTreeMap order).+fn load_chipfetcher_projects(viewable: &[String]) -> Vec<(String, Vec<String>)> {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());+    let path = std::path::PathBuf::from(home).join(".config/adom-chip-fetcher/projects.json");+    let Ok(txt) = fs::read_to_string(&path) else { return Vec::new() };+    let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) else { return Vec::new() };+    let vset: std::collections::HashSet<&str> = viewable.iter().map(|s| s.as_str()).collect();+    let mut out = Vec::new();+    if let Some(projs) = v.get("projects").and_then(|p| p.as_object()) {+        for (name, p) in projs {+            let mpns: Vec<String> = p.get("mpns").and_then(|m| m.as_array())+                .map(|a| a.iter().filter_map(|x| x.as_str())+                    .filter(|m| vset.contains(m)).map(String::from).collect())+                .unwrap_or_default();+            if !mpns.is_empty() {+                out.push((name.clone(), mpns));+            }+        }+    }+    out+}++/// Build the collapsible LEFT chip-list sidebar for the viewer. Collapsed to a+/// thin rail by default; click to expand the scrollable list (the current chip+/// auto-highlighted + scrolled into view). When open, ↑/↓ flip to the prev/next+/// chip (reloads `?chip=<MPN>`) so you can fan through the library. A project+/// selector at the top mirrors chip-fetcher's groupings: "All chips" (global) ++/// each project; picking one filters the list (and the ↑/↓ range) to that+/// project, persisted across reloads. Empty when there's nothing to switch to.+fn chip_picker_html(chips: &[String], current: &str, projects: &[(String, Vec<String>)]) -> String {+    if chips.len() < 2 {+        return String::new();+    }+    let items: String = chips.iter().map(|c| {+        let cur = if c == current { " class=\"cur\"" } else { "" };+        format!("<a href=\"?chip={enc}\" data-mpn=\"{enc}\" onclick=\"return lmGo(this)\"{cur}>{name}</a>", enc = esc_html(c), name = esc_html(c))+    }).collect();+    let chips_json = serde_json::to_string(chips).unwrap_or_else(|_| "[]".into());+    let n = chips.len();+    // Project selector (chip-fetcher groupings): "All chips (N)" + each project.+    let proj_opts: String = projects.iter().map(|(name, m)| {+        format!("<option value=\"{}\">{} ({})</option>", esc_html(name), esc_html(name), m.len())+    }).collect();+    let proj_map: serde_json::Map<String, serde_json::Value> = projects.iter()+        .map(|(name, m)| (name.clone(), serde_json::to_value(m).unwrap_or(serde_json::Value::Null))).collect();+    let proj_json = serde_json::to_string(&proj_map).unwrap_or_else(|_| "{}".into());+    let proj_select = if projects.is_empty() { String::new() } else {+        format!("<div class=\"lmproj\"><select id=\"projsel\" onchange=\"filterProj(this.value)\"><option value=\"\">All chips · chip-fetcher ({n})</option>{proj_opts}</select></div>")+    };+    format!(+        "<style>\+        .lm{{flex:0 0 auto;width:30px;background:var(--panel);border-right:1px solid var(--border);transition:width .15s ease;overflow:hidden;display:flex;flex-direction:column;height:100%}}\+        .lm.open{{width:240px}}\+        .lmrail{{flex:1;display:flex;flex-direction:column;align-items:center;cursor:pointer;color:#cfd8e2}}\+        .lmrail:hover{{background:var(--panel2)}}.lm.open .lmrail{{display:none}}\+        .lmrail .tg{{margin-top:10px;font-size:15px}}\+        .lmrail .lbl{{writing-mode:vertical-rl;margin-top:10px;color:var(--muted);font-size:10px;letter-spacing:2px}}\+        .lmpanel{{display:none;flex-direction:column;width:240px;height:100%}}\+        .lm.open .lmpanel{{display:flex}}\+        .lmtop{{flex:0 0 auto;border-bottom:1px solid var(--border)}}\+        .lmh{{padding:9px 12px;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);display:flex;justify-content:space-between}}\+        .lmh .x{{cursor:pointer;color:#cfd8e2}}\+        .lmproj{{padding:0 10px 8px}}\+        .lmproj select{{width:100%;background:var(--panel2);color:#cfd8e2;border:1px solid var(--border);border-radius:5px;padding:4px 6px;font-size:12px;font-family:inherit;cursor:pointer}}\+        .lmlist{{flex:1;overflow-y:auto;scrollbar-width:thin;scrollbar-color:#323c4a transparent}}\+        .lmlist::-webkit-scrollbar{{width:9px}}\+        .lmlist::-webkit-scrollbar-track{{background:transparent}}\+        .lmlist::-webkit-scrollbar-thumb{{background:#323c4a;border-radius:6px;border:2px solid var(--panel)}}\+        .lmlist::-webkit-scrollbar-thumb:hover{{background:#46525f}}\+        .lmlist a{{display:block;padding:5px 12px;color:#cfd8e2;text-decoration:none;font-size:12.5px;border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}\+        .lmlist a:hover{{background:var(--panel2)}}\+        .lmlist a.cur{{background:#1f6feb22;border-left-color:var(--accent);color:#fff;font-weight:600}}\+        #main{{transition:opacity .1s ease}}\+        </style>\+        <aside class=\"lm\" id=\"lm\">\+        <div class=\"lmrail\" onclick=\"lmToggle()\" title=\"chip library — click to expand (↑/↓ to flip)\"><div class=\"tg\">☰</div><div class=\"lbl\">CHIPS {n}</div></div>\+        <div class=\"lmpanel\"><div class=\"lmtop\"><div class=\"lmh\"><span id=\"chcount\">{n} chips · ↑/↓ to flip</span><span class=\"x\" onclick=\"lmToggle()\">✕</span></div>{proj_select}</div><div class=\"lmlist\" id=\"lmlist\">{items}</div></div>\+        </aside>\+        <script>\+        var ALL={chips_json},CUR={cur_json},PROJECTS={proj_json},CHIPS=ALL.slice();\+        function lmToggle(){{var l=document.getElementById('lm');l.classList.toggle('open');if(l.classList.contains('open')){{var c=l.querySelector('a.cur');if(c)c.scrollIntoView({{block:'center'}});}}}}\+        function filterProj(p){{var set=p&&PROJECTS[p]?PROJECTS[p]:null;var links=[].slice.call(document.querySelectorAll('#lmlist a[data-mpn]'));links.forEach(function(a){{a.style.display=(!set||set.indexOf(a.getAttribute('data-mpn'))>=0)?'':'none';}});CHIPS=links.filter(function(a){{return a.style.display!=='none';}}).map(function(a){{return a.getAttribute('data-mpn');}});var s=document.getElementById('projsel');if(s)s.value=p||'';var cc=document.getElementById('chcount');if(cc)cc.textContent=CHIPS.length+(p?' in '+p:' chips')+' · ↑/↓ to flip';var c=document.querySelector('#lmlist a.cur');if(c&&c.style.display!=='none')c.scrollIntoView({{block:'center'}});}}\+        function setCur(mpn){{document.querySelectorAll('#lmlist a[data-mpn]').forEach(function(a){{a.classList.toggle('cur',a.getAttribute('data-mpn')===mpn);}});CUR=mpn;var c=document.querySelector('#lmlist a.cur');if(c)c.scrollIntoView({{block:'nearest'}});}}\+        var lmReq=0;\+        function loadChip(mpn){{if(!mpn||mpn===CUR)return;var my=++lmReq;setCur(mpn);var m=document.getElementById('main');if(!m)return;m.style.opacity='.45';fetch('?chip='+encodeURIComponent(mpn)).then(function(r){{return r.text();}}).then(function(html){{if(my!==lmReq)return;var d=new DOMParser().parseFromString(html,'text/html');var nm=d.getElementById('main');if(nm){{m.innerHTML=nm.innerHTML;if(window.wireViewer)wireViewer();}}var hn=d.querySelector('header .nm'),ch=document.querySelector('header .nm');if(hn&&ch)ch.textContent=hn.textContent;m.style.opacity='';if(d.title)document.title=d.title;try{{history.replaceState(null,'','?chip='+encodeURIComponent(mpn));}}catch(e){{}}}}).catch(function(){{if(my===lmReq)m.style.opacity='';}});}}\+        function lmGo(a){{loadChip(a.getAttribute('data-mpn'));return false;}}\+        addEventListener('keydown',function(e){{var l=document.getElementById('lm');if(!l||!l.classList.contains('open'))return;if(e.target&&/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;var i=CHIPS.indexOf(CUR);if(e.key==='ArrowDown'){{e.preventDefault();loadChip(CHIPS[Math.min((i<0?-1:i)+1,CHIPS.length-1)]);}}else if(e.key==='ArrowUp'){{e.preventDefault();loadChip(CHIPS[Math.max((i<0?0:i)-1,0)]);}}else if(e.key==='Escape'){{lmToggle();}}}});\+        </script>",+        cur_json = serde_json::to_string(current).unwrap_or_else(|_| "\"\"".into())+    )+}++pub fn build_embed_html(dir: &std::path::Path, picker: &str) -> Result<(String, usize), String> {     let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string();     // renders     let sym_svg = read_first(dir, &[format!("{mpn}-symbol.svg"), "symbol.svg".into(), "assets/symbol.svg".into()])@@ -1817,6 +2360,7 @@ pub fn build_embed_html(dir: &std::path::Path) -> Result<(String, usize), String         .filter(|s| !s.is_empty())         .unwrap_or_else(|| mpn.clone());     let html = include_str!("embed.html")+        .replace("__PICKER__", picker)         .replace("__NAME__", &esc_html(&display_name))         .replace("__SYM__", &sym_svg)         .replace("__FP__", &fp_svg)
src/server.rs+58−3
@@ -8,7 +8,7 @@ use anyhow::Result; use serde_json::{json, Value}; use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering};-use std::sync::Mutex;+use std::sync::{Mutex, OnceLock}; use tiny_http::{Header, Method, Response, Server};  #[derive(Default, Clone)]@@ -47,6 +47,27 @@ pub fn set_embed(html: String) {     *EMBED_HTML.lock().unwrap() = Some(html); } +/// Builds a chip's viewer HTML on demand, keyed by MPN — lets `GET /?chip=<MPN>`+/// switch chips against a chip-fetcher library root.+type ChipBuilder = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;+static CHIP_BUILDER: OnceLock<ChipBuilder> = OnceLock::new();+static CHIP_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);++/// Register the on-the-fly chip switcher (the library root's builder closure).+pub fn set_chip_switcher(builder: ChipBuilder) {+    let _ = CHIP_BUILDER.set(builder);+}++/// HTML for `GET /?chip=<MPN>`: build (and cache) that chip's viewer, else None.+fn chip_html(mpn: &str) -> Option<String> {+    if let Some(h) = CHIP_CACHE.lock().unwrap().as_ref().and_then(|m| m.get(mpn).cloned()) {+        return Some(h);+    }+    let html = (CHIP_BUILDER.get()?)(mpn)?;+    CHIP_CACHE.lock().unwrap().get_or_insert_with(HashMap::new).insert(mpn.to_string(), html.clone());+    Some(html)+}+ /// Parse + lint an `.lbr` (EAGLE XML) into the displayable app state. pub fn analyze_lbr(content: &str) -> AppState {     let lint = crate::lint_lbr(content);@@ -79,6 +100,29 @@ fn extract_attr(content: &str, tag: &str) -> Option<String> {     Some(rest[k..j].to_string()) } +/// Minimal percent-decoder for the `chip=` query value (handles `%XX` and `+`).+fn urldecode(s: &str) -> String {+    let b = s.as_bytes();+    let mut out = Vec::with_capacity(b.len());+    let mut i = 0;+    while i < b.len() {+        match b[i] {+            b'%' if i + 2 < b.len() => {+                if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {+                    out.push(v);+                    i += 3;+                    continue;+                }+                out.push(b'%');+                i += 1;+            }+            b'+' => { out.push(b' '); i += 1; }+            c => { out.push(c); i += 1; }+        }+    }+    String::from_utf8_lossy(&out).into_owned()+}+ fn pairs_json(v: &[(String, String)]) -> Value {     Value::Array(v.iter().map(|(c, m)| json!({ "code": c, "message": m })).collect()) }@@ -111,7 +155,9 @@ pub fn run(port: u16) -> Result<()> {     register_port(port, "adom-lbr", "EAGLE .lbr linter live app (AI-drivable)");      for mut req in server.incoming_requests() {-        let url = req.url().split('?').next().unwrap_or("/").to_string();+        // Full request target incl. query — route() splits path/query itself+        // (needed for `/?chip=<MPN>`).+        let url = req.url().to_string();         let method = req.method().clone();         let mut body = String::new();         if matches!(method, Method::Post) {@@ -159,8 +205,17 @@ fn hd_post(body: &str) { }  fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {-    match (method, url) {+    // Split path from query so `/?chip=ADS1115IDGSR` still routes to "/".+    let (path, query) = url.split_once('?').unwrap_or((url, ""));+    match (method, path) {         (Method::Get, "/") | (Method::Get, "/index.html") => {+            // `?chip=<MPN>` switches chips against the library root (on the fly).+            if let Some(chip) = query.split('&').find_map(|kv| kv.strip_prefix("chip=")) {+                let chip = urldecode(chip);+                if let Some(h) = chip_html(&chip) {+                    return html(&h);+                }+            }             // 3-column viewer when render assets are loaded; lint card otherwise.             match &*EMBED_HTML.lock().unwrap() {                 Some(h) => html(h),
uninstall.sh+16−1
@@ -1,3 +1,18 @@ #!/bin/bash+# uninstall.sh for adom-lbr (app). set -euo pipefail-rm -f "${HOME}/.local/bin/adom-lbr"; echo "Uninstalled adom-lbr"++rm -f "${HOME}/.local/bin/adom-lbr"+echo "Uninstalled adom-lbr"++# Remove bundled sub-skills.+DIR="$(cd "$(dirname "$0")" && pwd)"+if [ -d "$DIR/skills" ]; then+  for d in "$DIR/skills"/*/; do+    [ -d "$d" ] || continue+    name="$(basename "$d")"+    rm -rf "${HOME}/.claude/skills/$name"+    echo "Removed sub-skill -> ${HOME}/.claude/skills/$name"+  done+fi+