app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
← Commit history
Publish 1.5.0
14 files changed
+781−54
.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) @@ -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+1.5.0
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{'&':'&','<':'<','>':'>','"':'"'}[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 → 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"><MPN>-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"><connects></code> into this chip\'s <code style="background:#11161f;border:1px solid #222b38;padding:1px 5px;border-radius:4px;color:#79c0ff">chip-fetcher/library/<MPN>/</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+