← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-lbr"-version = "0.1.4"+version = "1.1.0" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "0.1.4"+version = "1.1.0" edition = "2021" description = "EAGLE .lbr generator, linter, and validator for Fusion 360 Electronics. Converts KiCad symbols + footprints to complete EAGLE libraries." 
README.md+34−1
@@ -17,6 +17,34 @@ 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. +## 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)++Pass **`--fp part.kicad_mod`** and the package is built from the **real footprint+geometry** — every pad at its true position, size, and rotation. An LQFP-64 comes+out as a proper 4-sided ring (16/16/16/16), a BGA as a grid, with the silkscreen+outline derived from the footprint body. (Earlier versions emitted a two-column+placeholder; that now survives only as the fallback when `--fp` is omitted.)++```bash+adom-lbr generate --sym part.kicad_sym --fp part.kicad_mod -o part.lbr+```++**3D bodies for Fusion** are a handoff, by necessity: Fusion references a 3D model+by an Autodesk **cloud URN** (`urn:adsk.eagle:package:NNNN/V`) that only Fusion can+mint. So `--model part.step` emits the `.lbr` **plus** a `part.fusion3d.json`+manifest; the Fusion bridge uploads the model and mints the URN; then `bind-3d`+bakes it into the `.lbr` so the library is self-contained:++```bash+adom-lbr generate --sym part.kicad_sym --fp part.kicad_mod --model part.step -o part.lbr+adom-lbr bind-3d --lbr part.lbr --urn "urn:adsk.eagle:package:NNNN/1" --sidecar part.fusion3d.json+```++→ **Full walkthrough: [docs/fusion-export.md](docs/fusion-export.md)** — pad-mapping,+the cloud-URN problem, the 3-step handoff, and Altium/OrCAD notes.+ ## The 3-column library viewer — and an interactive embed  ![The Fusion-style 3-column viewer: symbol, footprint, 3D, and the pin→pad mapping table](docs/viewer3col.png)@@ -55,9 +83,11 @@ This is the whole point. Fusion will silently show "No Components" or refuse to  ## What it does -- **`generate`** — `.kicad_sym` (+ `.kicad_mod`) → native `.lbr`, lint-checked for Fusion compatibility.+- **`generate`** — `.kicad_sym` (+ real `.kicad_mod` via `--fp`) → native `.lbr`, lint-checked for Fusion compatibility. `--model` also emits a 3D handoff sidecar.+- **`bind-3d`** — bake a Fusion-minted cloud URN into a `.lbr` so the 3D body travels with the library (idempotent). - **`lint` / `validate`** — catch the things Fusion rejects: missing connects, incomplete devicesets, bad pad maps. - **`import`** — EAGLE `.lbr` → KiCad `.kicad_sym`.+- **`embed`** — a self-contained 3-column (symbol / footprint / 3D) viewer as one `.html`. - **Live app** — a viewer that lints the loaded `.lbr` and shows symbols / packages / devicesets / pins / pads at a glance.  ## Send to@@ -81,6 +111,9 @@ Used by **chip-fetcher** and the Send-to flow across **adom-symbol** and **adom-  ```bash 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 --urn "urn:adsk.eagle:package:NNNN/1" --sidecar part.fusion3d.json+adom-lbr import part.lbr -o part.kicad_sym   # reverse: EAGLE → KiCad adom-lbr serve --port 8784          # live linter app (AI-drivable) adom-lbr embed --dir <chip-dir>     # Fusion-style 3-column viewer as one .html ```
SKILL.md+82−63
@@ -1,103 +1,122 @@ --- name: adom-lbr description: >-  EAGLE .lbr generator, linter, validator, and bidirectional KiCad converter.-  Converts .kicad_sym to .lbr and back. 7-check linter catches empty connects,-  missing pins, duplicate names. Trigger words: lbr, eagle, library, kicad-  convert, fusion library, lint lbr, generate lbr, import lbr, check lbr.+  Turn a KiCad part (.kicad_sym + .kicad_mod) into one EAGLE .lbr that Fusion+  360, Altium, and OrCAD all import — with REAL footprint geometry (not a+  placeholder) — plus a round-trip back to KiCad .kicad_sym, a 3D-model handoff+  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. ---  # adom-lbr -EAGLE .lbr generator, linter, validator, and KiCad converter. Converts between KiCad .kicad_sym and EAGLE .lbr formats so both Fusion 360 and KiCad users can share component libraries.+Generate a single EAGLE `.lbr` from a KiCad part and place it in Fusion 360,+Altium, or OrCAD — or convert back to KiCad `.kicad_sym`. The `.lbr` is the+EAGLE-family interchange format the first three tools all read; KiCad gets a+native symbol via `import`. -## Why this exists+## When to use -Fusion 360 Electronics shows "No Components" when an .lbr file has an empty `<packages>` section or empty `<connects/>`. The old JS generator produced these broken files. adom-lbr generates a COMPLETE deviceset: symbol + placeholder SMD package + full pin-to-pad connect mappings.--KiCad users can import any .lbr file back to .kicad_sym with `adom-lbr import`, validated locally (Rust parser).+- "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"+  → regenerate **with `--fp`** so the real `.kicad_mod` geometry is used+- "Attach a 3D model to this Fusion library" → `--model` + `bind-3d`+- "Why does Fusion say No Components?" → `lint`  ## Commands  ```-adom-lbr generate --sym <path.kicad_sym> [-o output.lbr] [--fp <path.kicad_mod>] [--name <override>]-adom-lbr import <path.lbr> [-o output.kicad_sym]-adom-lbr lint <path.lbr>-adom-lbr validate <path.lbr>-adom-lbr check <path.kicad_sym>-adom-lbr health-adom-lbr install-adom-lbr completions <bash|zsh|fish>+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> --urn <urn:adsk.eagle:package:NNNN/V> [--package <name>] [--sidecar <part.fusion3d.json>] [-o out.lbr]+adom-lbr import   <part.lbr> [-o out.kicad_sym]+adom-lbr lint     <part.lbr>          # JSON error codes+adom-lbr validate <part.lbr>          # human-readable+adom-lbr check    <part.kicad_sym>    # local Rust validation+adom-lbr serve [--port 8784] [--file <lbr>]+adom-lbr health | install | completions <bash|zsh|fish> ``` -## generate+## generate — the core -Parses a KiCad .kicad_sym file and produces a valid EAGLE .lbr with:-- Symbol (body rectangle + pins with correct direction/rotation/length)-- Package (placeholder SMD pads for each pin number, courtyard outline)-- Deviceset with gate, device, and complete `<connect>` mappings+Parses the `.kicad_sym` (symbol) and, when given, the `.kicad_mod` (footprint),+and writes a complete EAGLE deviceset: symbol + package + `<connect>` mappings. -Auto-lints the output before writing. Exits 2 if lint fails.+**Always pass `--fp` for anything you'll fabricate.** With `--fp`, the package is+built from the real footprint: -## import+- one `<smd>` (or `<pad>` for thru-hole) per `.kicad_mod` pad, at its true+  position — a 4-sided LQFP stays 4-sided, BGA stays a grid, etc.+- KiCad is Y-down, EAGLE is Y-up → Y is negated; pad rotation maps to EAGLE+  `rot=R90/R180/R270`.+- silkscreen outline derived from the footprint body (`F.SilkS`, else `F.Fab`,+  else pad extents).++**Without `--fp`** it falls back to a synthesized two-column placeholder package+(symbol-only parts). If a user reports a wrong/2-column footprint, the fix is+almost always: regenerate with `--fp`.++Auto-lints before writing; exits 2 if lint fails. -Parses an EAGLE .lbr and converts to KiCad .kicad_sym format. Handles:-- Pin direction mapping (EAGLE pwr/in/out/io/pas/nc → KiCad power_in/input/output/bidirectional/passive/unconnected)-- Pin rotation, length, position-- Body rectangle from layer 94 wires-- Description, reference prefix-- Pin numbers from connect mappings-- Strips EAGLE @N dedup suffixes back to clean names+## The Fusion 3D / cloud-URN handoff -## lint+A Fusion `.lbr` references a 3D body by an Autodesk **cloud URN**+(`urn:adsk.eagle:package:NNNN/V`) that only Fusion can mint when it uploads the+model. It can't be made offline. So: -Checks an existing .lbr for Fusion 360 compatibility. Outputs JSON with `passed`, `errors[]`, `warnings[]`.+1. `generate --model part.step` emits the `.lbr` **plus** a `part.fusion3d.json`+   sidecar (schema `adom-lbr.fusion3d/1`): model path, format, units, pin-1/seat+   alignment note, `urn: null`, `urn_status: "pending-fusion-mint"`, and+   `bridge_steps` with the exact XML to write.+2. The Fusion bridge imports the `.lbr`, uploads `model.path`, Fusion mints the+   URN.+3. `bind-3d --lbr part.lbr --urn <minted> --sidecar part.fusion3d.json` patches+   `<packages3d>` + the device `<package3dinstance>` into the `.lbr` (idempotent)+   and flips the sidecar to `urn_status: "bound"`. -Checks:-- `empty-packages` — packages section empty (fatal: Fusion shows "No Components")-- `empty-connects` — device has no connect mappings (fatal)-- `device-no-package` — device element missing package attribute (fatal)-- `duplicate-pin-name` — EAGLE requires unique pin names; use @suffix for power/NC duplicates-- `connect-pad-missing` — connect references a pad that doesn't exist in the package-- `malformed-xml` — unbalanced angle brackets-- `no-symbol-layer` — nothing on layer 94 (symbol body invisible)+See [docs/fusion-export.md](docs/fusion-export.md) for the full flow. -## validate+## import -Same as lint but human-readable output. Exits 0 = valid, 2 = errors found.+EAGLE `.lbr` → KiCad `.kicad_sym`. Maps pin direction+(pwr/in/out/io/pas/nc → power_in/input/output/bidirectional/passive/unconnected),+rotation, length, position; body rectangle from layer-94 wires; pin numbers from+`<connect>`; strips `@N` dedup suffixes. -## check+## lint / validate / check -Validates a .kicad_sym file by local Rust validation — checks structure, pins, properties, duplicate names. Use after `import` to verify the output before sending to a desktop KiCad instance.+- `lint` — 7 checks, JSON (`passed`, `errors[]`, `warnings[]`): `empty-packages`,+  `empty-connects`, `device-no-package`, `duplicate-pin-name`,+  `connect-pad-missing`, `malformed-xml`, `no-symbol-layer`.+- `validate` — same checks, human-readable; exit 0 valid / 2 errors.+- `check` — validates a `.kicad_sym` locally (structure, pins, properties,+  duplicate names). Run after `import`.  ## Duplicate pin handling -EAGLE requires unique pin names. When KiCad has multiple pins with the same name (e.g., 6 IOVDD pins), the generator appends `@N` suffixes: `IOVDD`, `IOVDD@2`, `IOVDD@3`, etc. The `import` command strips these back.+EAGLE requires unique pin names. KiCad repeats (e.g. 6× `IOVDD`) get `@N`+suffixes on generate (`IOVDD`, `IOVDD@2`, …); `import` strips them. Pad numbers+in `<connects>` are unaffected.  ## Workflows -### Fusion 360 user (has .kicad_sym, needs .lbr)-```-adom-lbr generate --sym RP2040.kicad_sym -o RP2040.lbr-# Send to Fusion via adom-desktop send_files + fusion_open_lbr-```--### KiCad user (has .lbr, needs .kicad_sym)+### KiCad part → Fusion / Altium / OrCAD (real footprint + 3D) ```-adom-lbr import RP2040.lbr -o RP2040.kicad_sym-adom-lbr check RP2040.kicad_sym-# Send to KiCad via adom-desktop send_files + kicad_install_library+adom-lbr generate --sym part.kicad_sym --fp part.kicad_mod --model part.step -o part.lbr+# Fusion/Altium/OrCAD import part.lbr directly.+# Fusion 3D: bridge mints URN → adom-lbr bind-3d --lbr part.lbr --urn <urn> --sidecar part.fusion3d.json ``` -### Pipeline (adom-symbol → both targets)+### EAGLE .lbr → KiCad ```-adom-symbol create <mpn> → .kicad_sym-adom-lbr generate --sym <mpn>.kicad_sym → .lbr (for Fusion)-# .kicad_sym already native for KiCad+adom-lbr import part.lbr -o part.kicad_sym+adom-lbr check part.kicad_sym+# Send via adom-desktop: send_files + kicad_install_library ``` -### Lint any .lbr before use+### Lint a suspicious .lbr ```-adom-lbr lint suspicious.lbr-# JSON output with error codes for programmatic handling+adom-lbr lint suspicious.lbr   # JSON error codes for programmatic handling ```
VERSION+1−1
@@ -1 +1 @@-0.1.4+1.1.0\ No newline at end of file
bin/adom-lbr
⋯ 1 unchanged line ⋯
docs/fusion-export.mdadded+170
@@ -0,0 +1,170 @@+# Fusion 360 library export (and Altium / OrCAD)++> Sub-page of [adom-lbr](../README.md). adom-lbr exports to four ECAD tools from+> one source; this page is the deep dive on the **EAGLE `.lbr`** path — the one+> Fusion 360, Altium, and OrCAD all import — including the part most generators+> get wrong (the real footprint) and the part they skip entirely (the 3D model).++## Why one `.lbr` covers three tools++Fusion 360 Electronics is the modern face of EAGLE, and the EAGLE `.lbr`+library format is read natively by **Fusion 360, Altium Designer, and OrCAD**.+So adom-lbr generates the `.lbr` once and all three import it. KiCad is the+exception — it doesn't read `.lbr`, so adom-lbr converts to `.kicad_sym` via+`import`.++A complete `.lbr` deviceset has three linked parts:++```+<symbol>   schematic pins + body          (layer 94)+<package>  the real footprint: <smd>/<pad> + silkscreen outline+<deviceset><device package="…"><connects>  pin-name → pad-number map+```++Miss any one and Fusion shows **"No Components."** adom-lbr's linter exists to+catch exactly that.++## The footprint must be real++The single most important flag is **`--fp`**. It points at the KiCad+`.kicad_mod` so the package is built from true pad geometry instead of a+placeholder.++```bash+adom-lbr generate --sym ADS8588SIPM.kicad_sym --fp ADS8588SIPM.kicad_mod -o ADS8588SIPM.lbr+```++What the writer does with each `.kicad_mod` pad:++| KiCad (`.kicad_mod`, mm, Y-down) | EAGLE (`.lbr`, mm, Y-up) |+|---|---|+| `(pad "17" smd rect (at -3.75 5.738 90) (size 1.475 0.3))` | `<smd name="17" x="-3.75" y="-5.738" dx="1.475" dy="0.3" layer="1" rot="R270"/>` |+| thru-hole `(pad … (at x y) (size …) (drill d))` | `<pad … x drill diameter shape="long"/>` |++- **Y is negated** (KiCad Y-down → EAGLE Y-up).+- **Rotation** maps to `rot=R90/R180/R270`; because the Y flip reverses the+  rotation sense, the angle is negated (`R90 ↔ R270`). Rectangular pads are+  180°-symmetric so this is cosmetic for them, but it's correct for any shape.+- **Silkscreen outline** is derived from the footprint body — `F.SilkS` lines+  first, then `F.Fab`, then a fallback bounding box of the pads. Each `fp_line`+  is matched to its *own* layer (a balanced s-expression parse), so courtyard+  and body lines never bleed together.++### Worked example — ADS8588SIPM (LQFP-64)++| | Before (placeholder) | After (`--fp`) |+|---|---|---|+| Pads | 32 at x=−8, 32 at x=+8 (2 columns) | 16 left / 16 right / 16 top / 16 bottom |+| Extent | y only, ±7.5 | X **and** Y, ±5.74 mm |+| Shape in Fusion | a domino, not the chip | true 4-sided LQFP-64 |++![Generated LQFP-64 footprint, 4 sides, pin 1 top-left](screenshot-footprint-4sided.png)++Without `--fp` you still get a *valid* `.lbr` — just a two-column placeholder+package. That's intentional for symbol-only parts. For anything you'll fabricate,+pass `--fp`.++## The 3D model: a cloud-URN handoff++This is the part that can't be done in a flat file. In EAGLE/Fusion, a package's+3D body is **not embedded** — it's referenced by an Autodesk **cloud URN**:++```xml+<packages3d>+  <package3d name="ADS8588SIPM" urn="urn:adsk.eagle:package:99887/2" type="model">+    <packageinstances><packageinstance name="ADS8588SIPM"/></packageinstances>+  </package3d>+</packages3d>+…+<device name="" package="ADS8588SIPM">+  <connects>…</connects>+  <package3dinstances><package3dinstance package3d_urn="urn:adsk.eagle:package:99887/2"/></package3dinstances>+  <technologies><technology name=""/></technologies>+</device>+```++That URN is **minted by Fusion** when it uploads/generates the 3D package into+the managed cloud library. **It cannot be fabricated offline** — a made-up URN+won't resolve. So adom-lbr splits the job:++### Step 1 — emit the `.lbr` + a 3D sidecar++```bash+adom-lbr generate --sym part.kicad_sym --fp part.kicad_mod --model part.step -o part.lbr+# → part.lbr            (2D: symbol + real footprint)+# → part.fusion3d.json  (the handoff manifest)+```++The `.lbr` stays 2D. The sidecar (`schema: "adom-lbr.fusion3d/1"`) tells the+Fusion bridge what to do:++```json+{+  "schema": "adom-lbr.fusion3d/1",+  "package": "ADS8588SIPM",+  "library": "part.lbr",+  "model": { "path": "part.step", "format": "step", "units": "mm" },+  "placement": {+    "translation_mm": [0, 0, 0],+    "rotation_deg": [0, 0, 0],+    "note": "Footprint origin is the package centre; pin 1 per the .lbr <smd>. Run chipfit to confirm pin-1 alignment / seat Z before binding."+  },+  "urn": null,+  "urn_status": "pending-fusion-mint",+  "bridge_steps": [ "…import…", "…generate 3D package, capture URN…", "…bind…", "…save…", "…verify…" ]+}+```++### Step 2 — Fusion mints the URN++The Fusion bridge imports the `.lbr`, uploads `model.path`, and Fusion assigns a+URN like `urn:adsk.eagle:package:99887/2`. (Run [chipfit](../../adom-chipfit) here+to confirm pin-1 / seat-plane alignment before committing.)++### Step 3 — bake the URN back in++```bash+adom-lbr bind-3d --lbr part.lbr --urn "urn:adsk.eagle:package:99887/2" --sidecar part.fusion3d.json+```++`bind-3d` inserts the `<packages3d>` block after `</packages>` and the+`<package3dinstance>` into the device, then re-lints. It's **idempotent** —+re-running with a new URN cleanly replaces the old binding (one `<packages3d>`,+one instance). With `--sidecar` it flips the manifest to `urn_status: "bound"`+and records the URN.++Result: a self-contained `.lbr` that places in Fusion *with* its 3D body —+ready to redistribute.++## End-to-end++```bash+# 1. Generate the .lbr + sidecar from the KiCad part + STEP+adom-lbr generate --sym part.kicad_sym --fp part.kicad_mod --model part.step -o part.lbr++# 2. Hand part.lbr + part.fusion3d.json + the STEP to the Fusion bridge.+#    It imports, uploads the model, and returns the minted cloud URN.++# 3. Bake the URN in+adom-lbr bind-3d --lbr part.lbr --urn "<minted-urn>" --sidecar part.fusion3d.json++# 4. Verify+adom-lbr validate part.lbr        # complete deviceset, 0 errors+```++## Altium / OrCAD++Both import the same EAGLE `.lbr` directly — no separate generate step. A desktop+**bridge** for automated "Send to Altium/OrCAD" isn't built yet; until then, hand+the `.lbr` over and import it manually. Want that bridge? Build it and contribute+it on the wiki — adom-lbr's "Send to" already routes those targets.++## Troubleshooting++| Symptom | Cause | Fix |+|---|---|---|+| Footprint is two columns / a domino | generated without `--fp` | regenerate with `--fp part.kicad_mod` |+| Fusion: "No Components" | empty `<packages>` or `<connects>` | `adom-lbr lint` — fix the reported code |+| 3D body missing in Fusion | `.lbr` has no `<packages3d>` URN | run `bind-3d` after Fusion mints the URN |+| 3D body offset / wrong pin 1 | placement not aligned | run chipfit, set `placement` in the sidecar before binding |+| Outline looks lopsided | (fixed in 0.2) | upgrade — silk lines are now matched per-layer |
docs/screenshot-footprint-4sided.pngadded
⋯ 1 unchanged line ⋯
package.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "1.0.4",+  "version": "1.1.0",   "type": "app",   "description": "Symbol, footprint and 3D side by side with the pin to pad map and datasheet descriptions, exportable as one embeddable file.",   "dependencies": {},@@ -25,4 +25,4 @@   },   "needs_sudo": false,   "license": "MIT"-}\ No newline at end of file+}
page.json+8−4
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "1.0.4",+  "version": "1.1.0",   "type": "app",   "description": "Symbol, footprint and 3D side by side with the pin to pad map and datasheet descriptions, exportable as one embeddable file.",   "dependencies": {},@@ -13,8 +13,11 @@     "viewer"   ],   "hero": {-    "type": "image",-    "path": "adom-lbr-hero-v4.png"+    "type": "billboard",+    "headline": "See the part, three ways.",+    "subhead": "Symbol, footprint and 3D side by side with the pin to pad map and datasheet descriptions, exportable as one embeddable file.",+    "screenshot": "screenshots/shot.png",+    "accent": "#00b8b1"   },   "scripts": {     "install": "./install.sh",@@ -28,5 +31,6 @@   },   "title": "Adom Library",   "org": "adom",-  "source_visibility": "public"+  "source_visibility": "public",+  "license": "MIT" }\ No newline at end of file
src/main.rs+475−56
@@ -1,3 +1,4 @@+mod provenance; mod deliver; mod server; @@ -36,6 +37,36 @@ enum Commands {         /// Symbol name override         #[arg(long)]         name: Option<String>,+        /// Path to a 3D model (STEP/STP/GLB) to associate with the package.+        /// The .lbr stays 2D-only (a valid Fusion 3D ref needs an Autodesk+        /// cloud URN that can only be minted by Fusion). Instead, a sidecar+        /// `<out>.fusion3d.json` manifest is written telling the Fusion bridge+        /// which model to upload and how to align it, so it can mint the URN+        /// and patch it back into the .lbr.+        #[arg(long)]+        model: Option<PathBuf>,+    },+    /// Bind a Fusion-minted 3D-package cloud URN into an existing .lbr, making+    /// it self-contained: adds the `<packages3d>` entry and the device's+    /// `<package3dinstance>`. Run this after the Fusion bridge uploads the model+    /// and mints `urn:adsk.eagle:package:NNN/V`. Idempotent (re-running re-binds).+    #[command(name = "bind-3d")]+    Bind3d {+        /// Path to the .lbr to patch+        #[arg(long)]+        lbr: PathBuf,+        /// The Autodesk cloud URN Fusion minted (urn:adsk.eagle:package:NNN/V)+        #[arg(long)]+        urn: String,+        /// Package to attach to (default: the .lbr's first/device package)+        #[arg(long)]+        package: Option<String>,+        /// Output path (default: patch --lbr in place)+        #[arg(short, long)]+        output: Option<PathBuf>,+        /// Also patch this sidecar manifest's `urn`/`urn_status` to bound+        #[arg(long)]+        sidecar: Option<PathBuf>,     },     /// Lint an existing .lbr file for Fusion 360 compatibility     Lint {@@ -496,7 +527,250 @@ fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {     } } -fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String {+/// One SMD/thru-hole pad parsed from a `.kicad_mod`, in KiCad coordinates+/// (millimetres, Y-down). `drill` is `Some` only for thru-hole pads.+struct ModPad {+    name: String,+    smd: bool,+    x: f64,+    y: f64,+    dx: f64,+    dy: f64,+    angle: f64,+    drill: Option<f64>,+}++/// Split a `.kicad_mod` into the raw text of each `(<tag> …)` s-expression,+/// balancing parens so multi-line definitions are captured whole. `tag` is the+/// opening token without its paren, e.g. `"pad"` or `"fp_line"`.+fn sexpr_blocks<'a>(km: &'a str, tag: &str) -> Vec<&'a str> {+    let open = format!("({tag}");+    let mut blocks = Vec::new();+    let mut i = 0;+    while let Some(rel) = km[i..].find(&open) {+        let start = i + rel;+        // Require whitespace after the token so "(pads"/"(fp_lines" don't match.+        let after = &km[start + open.len()..];+        if !after.starts_with(|c: char| c.is_whitespace()) {+            i = start + open.len();+            continue;+        }+        let mut depth = 0i32;+        let mut end = start;+        for (k, c) in km[start..].char_indices() {+            match c {+                '(' => depth += 1,+                ')' => {+                    depth -= 1;+                    if depth == 0 {+                        end = start + k + 1;+                        break;+                    }+                }+                _ => {}+            }+        }+        if end > start {+            blocks.push(&km[start..end]);+            i = end;+        } else {+            break;+        }+    }+    blocks+}++/// Parse every real (named) pad out of a `.kicad_mod`, preserving the true+/// per-side positions, sizes, and rotations. Mechanical pads (empty name) and+/// pads missing position/size are skipped.+fn parse_mod_pads(km: &str) -> Vec<ModPad> {+    let name_re = regex_lite::Regex::new(r#"^\(pad\s+(?:"([^"]*)"|(\S+))\s+(\S+)"#).unwrap();+    let at_re = regex_lite::Regex::new(r#"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\s*\)"#).unwrap();+    let size_re = regex_lite::Regex::new(r#"\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\)"#).unwrap();+    let drill_re = regex_lite::Regex::new(r#"\(drill\s+(-?[\d.]+)"#).unwrap();++    let mut pads = Vec::new();+    for block in sexpr_blocks(km, "pad") {+        let Some(nc) = name_re.captures(block) else { continue };+        let name = nc.get(1).or_else(|| nc.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();+        if name.is_empty() {+            continue;+        }+        let smd = nc.get(3).map(|m| m.as_str()).unwrap_or("") == "smd";+        let Some(at) = at_re.captures(block) else { continue };+        let Some(size) = size_re.captures(block) else { continue };+        let x: f64 = at[1].parse().unwrap_or(0.0);+        let y: f64 = at[2].parse().unwrap_or(0.0);+        let angle: f64 = at.get(3).and_then(|m| m.as_str().parse().ok()).unwrap_or(0.0);+        let dx: f64 = size[1].parse().unwrap_or(0.0);+        let dy: f64 = size[2].parse().unwrap_or(0.0);+        let drill = drill_re.captures(block).and_then(|c| c[1].parse().ok());+        pads.push(ModPad { name, smd, x, y, dx, dy, angle, drill });+    }+    pads+}++/// Map a KiCad pad rotation (degrees, Y-down) to an EAGLE `rot=` attribute.+/// Y is flipped going KiCad→EAGLE, so the rotation sense is negated. Rect pads+/// are 180°-symmetric, so R90/R270 are visually identical, but we negate for+/// correctness on non-symmetric shapes.+fn smd_rotation(angle: f64) -> &'static str {+    let a = (((360.0 - angle).round() as i64) % 360 + 360) % 360;+    match a {+        90 => " rot=\"R90\"",+        180 => " rot=\"R180\"",+        270 => " rot=\"R270\"",+        _ => "",+    }+}++/// Bounding box (min_x, min_y, max_x, max_y) of `fp_line` endpoints on the+/// given layer, in KiCad coordinates. Each `fp_line` is extracted as a balanced+/// block and matched against its OWN `(layer …)`, so coordinates never leak+/// across layers. Returns `None` if the layer has no lines.+fn fp_line_bbox(km: &str, layer: &str) -> Option<(f64, f64, f64, f64)> {+    let pt_re = regex_lite::Regex::new(r#"\((?:start|end)\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();+    let layer_re = regex_lite::Regex::new(r#"\(layer\s+"([^"]+)"\)"#).unwrap();+    let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    let mut found = false;+    for block in sexpr_blocks(km, "fp_line") {+        if layer_re.captures(block).map(|c| c[1].to_string()).as_deref() != Some(layer) {+            continue;+        }+        for c in pt_re.captures_iter(block) {+            let x: f64 = c[1].parse().unwrap_or(0.0);+            let y: f64 = c[2].parse().unwrap_or(0.0);+            minx = minx.min(x); miny = miny.min(y);+            maxx = maxx.max(x); maxy = maxy.max(y);+            found = true;+        }+    }+    found.then_some((minx, miny, maxx, maxy))+}++/// Derive the package silkscreen outline bbox from the `.kicad_mod` body.+/// Prefers the silkscreen layer, falls back to fab, then to the pad extents.+fn package_outline(km: &str, pads: &[ModPad]) -> (f64, f64, f64, f64) {+    fp_line_bbox(km, "F.SilkS")+        .or_else(|| fp_line_bbox(km, "F.Fab"))+        .unwrap_or_else(|| {+            let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+            for p in pads {+                minx = minx.min(p.x - p.dx); miny = miny.min(p.y - p.dy);+                maxx = maxx.max(p.x + p.dx); maxy = maxy.max(p.y + p.dy);+            }+            (minx, miny, maxx, maxy)+        })+}++/// Write the `<out>.fusion3d.json` sidecar that hands the Fusion bridge the+/// 3D-model association the `.lbr` itself cannot carry. EAGLE/Fusion reference+/// a 3D body by an Autodesk cloud URN (`urn:adsk.eagle:package:NNN/V`) that is+/// only assigned when Fusion uploads/generates the 3D package — it can't be+/// minted offline. So we ship the model path + alignment intent and let the+/// bridge mint the URN and patch it back into the `.lbr`.+fn write_fusion3d_sidecar(+    side_path: &std::path::Path,+    package: &str,+    lbr_path: &std::path::Path,+    model_path: &std::path::Path,+) -> std::io::Result<()> {+    let fmt = model_path.extension().and_then(|e| e.to_str()).unwrap_or("")+        .to_ascii_lowercase();+    let fmt = match fmt.as_str() {+        "stp" | "step" => "step",+        "glb" => "glb",+        "wrl" | "vrml" => "vrml",+        other => other,+    };+    let manifest = serde_json::json!({+        "schema": "adom-lbr.fusion3d/1",+        "package": package,+        "library": lbr_path.to_string_lossy(),+        "model": {+            "path": model_path.to_string_lossy(),+            "format": fmt,+            "units": "mm"+        },+        "placement": {+            // .kicad_mod footprints are centred on the package origin (0,0),+            // seat plane at Z=0. Identity unless chipfit reports an offset.+            "translation_mm": [0.0, 0.0, 0.0],+            "rotation_deg": [0.0, 0.0, 0.0],+            "note": "Footprint origin is the package centre; pin 1 per the .lbr <smd>. Run chipfit to confirm pin-1 alignment / seat Z before binding."+        },+        "urn": serde_json::Value::Null,+        "urn_status": "pending-fusion-mint",+        "bridge_steps": [+            "Import the .lbr into the Fusion library editor — the 2D symbol + footprint are already valid.",+            format!("Generate a 3D package for package '{package}' by uploading model.path; Fusion mints an Autodesk cloud URN (urn:adsk.eagle:package:NNN/V)."),+            format!("Bind it: add <packages3d><package3d name=\"{package}\" urn=\"<URN>\" type=\"model\"><packageinstances><packageinstance name=\"{package}\"/></packageinstances></package3d>, and on the device add <package3dinstances><package3dinstance package3d_urn=\"<URN>\"/></package3dinstances>."),+            "Save the library and write the minted URN back into this manifest's `urn` field (urn_status -> bound).",+            "Verify the 3D body seats on the footprint with pin 1 aligned."+        ]+    });+    fs::write(side_path, serde_json::to_string_pretty(&manifest).unwrap() + "\n")+}++/// Patch a minted Fusion cloud URN into a `.lbr`, making the 3D reference+/// self-contained. Adds (or replaces) the `<packages3d>` section after+/// `</packages>` and inserts a `<package3dinstance>` into the device(s) using+/// `package`. `package` defaults to the device's `package=` attribute. The+/// operation is idempotent — any prior 3D binding is replaced.+fn bind_3d_urn(content: &str, urn: &str, package: Option<&str>) -> Result<String, String> {+    let urn = urn.trim();+    if urn.is_empty() {+        return Err("URN is empty".into());+    }++    // Resolve the target package: explicit, else the device's package= attr,+    // else the first <package name="…"> in <packages>.+    let dev_pkg_re = regex_lite::Regex::new(r#"<device\b[^>]*\bpackage="([^"]+)""#).unwrap();+    let pkg_re = regex_lite::Regex::new(r#"<package\s+name="([^"]+)""#).unwrap();+    let pkg = package+        .map(|s| s.to_string())+        .or_else(|| dev_pkg_re.captures(content).map(|c| c[1].to_string()))+        .or_else(|| pkg_re.captures(content).map(|c| c[1].to_string()))+        .ok_or_else(|| "No package found in .lbr to bind a 3D model to".to_string())?;++    let mut out = content.to_string();++    // 1) Build the <packages3d> block and place it right after </packages>.+    let block = format!(+        "<packages3d>\n<package3d name=\"{p}\" urn=\"{u}\" type=\"model\">\n  <packageinstances>\n    <packageinstance name=\"{p}\"/>\n  </packageinstances>\n</package3d>\n</packages3d>\n",+        p = esc_xml(&pkg), u = esc_xml(urn)+    );+    // Drop any existing <packages3d>…</packages3d> so re-binding is clean.+    if let (Some(s), Some(e)) = (out.find("<packages3d>"), out.find("</packages3d>")) {+        let end = e + "</packages3d>".len();+        // also swallow a trailing newline left behind+        let end = if out[end..].starts_with('\n') { end + 1 } else { end };+        out.replace_range(s..end, "");+    }+    let anchor = "</packages>\n";+    let pos = out.find(anchor).ok_or_else(|| "No </packages> in .lbr".to_string())?;+    let insert_at = pos + anchor.len();+    out.insert_str(insert_at, &block);++    // 2) Insert <package3dinstances> into each device using this package,+    //    between </connects> and <technologies> (EAGLE DTD order). Replace any+    //    existing <package3dinstances> in that device first.+    let inst = format!(+        "      <package3dinstances>\n        <package3dinstance package3d_urn=\"{}\"/>\n      </package3dinstances>\n",+        esc_xml(urn)+    );+    let dev_re = regex_lite::Regex::new(+        &format!(r#"(?s)(<device\b[^>]*\bpackage="{}".*?</connects>\n)(\s*<package3dinstances>.*?</package3dinstances>\n)?"#, regex_lite::escape(&pkg))+    ).unwrap();+    if !dev_re.is_match(&out) {+        return Err(format!("No <device> with package=\"{pkg}\" found to bind into"));+    }+    out = dev_re.replace_all(&out, |c: &regex_lite::Captures| format!("{}{}", &c[1], inst)).into_owned();++    Ok(out)+}++fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>) -> String {     let mut xml = String::with_capacity(8192);      xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -511,53 +785,93 @@ fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String {     xml.push_str("<library>\n");     xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description))); -    // Packages section — generate a placeholder SMD package from pin positions+    // Packages section. Prefer the real .kicad_mod geometry; only fall back to+    // the synthesized two-column placeholder when no footprint was supplied.     xml.push_str("<packages>\n");     xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(&sym.name)));-    xml.push_str(&format!("  <description>{} placeholder footprint</description>\n", esc_xml(&sym.name))); -    // Generate SMD pads from pin numbers-    let pad_size = 0.5;-    let pitch = 0.5;-    let pin_count = sym.pins.len();-    let half = (pin_count + 1) / 2;+    let mod_pads = fp_content.map(parse_mod_pads).unwrap_or_default(); -    for (i, pin) in sym.pins.iter().enumerate() {-        let (px, py) = if i < half {-            // Left column-            (-(pin_count as f64 * pitch / 4.0), (half as f64 / 2.0 - i as f64) * pitch)-        } else {-            // Right column-            (pin_count as f64 * pitch / 4.0, (half as f64 / 2.0 - (i - half) as f64) * pitch)-        };-        xml.push_str(&format!(-            "  <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",-            esc_xml(&pin.number), px, py, pad_size, pad_size-        ));-    }+    if !mod_pads.is_empty() {+        // ── Real footprint: emit one EAGLE pad per .kicad_mod pad, preserving+        // the true 4-sided positions. KiCad is Y-down, EAGLE Y-up → negate Y.+        let km = fp_content.unwrap();+        xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.name))); -    // Package outline-    let body_w = pin_count as f64 * pitch / 2.0 + 1.0;-    let body_h = half as f64 * pitch + 1.0;-    xml.push_str(&format!(-        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",-        -body_w / 2.0, body_h / 2.0, body_w / 2.0, body_h / 2.0-    ));-    xml.push_str(&format!(-        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",-        body_w / 2.0, body_h / 2.0, body_w / 2.0, -body_h / 2.0-    ));-    xml.push_str(&format!(-        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",-        body_w / 2.0, -body_h / 2.0, -body_w / 2.0, -body_h / 2.0-    ));-    xml.push_str(&format!(-        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",-        -body_w / 2.0, -body_h / 2.0, -body_w / 2.0, body_h / 2.0-    ));+        for p in &mod_pads {+            if p.smd {+                xml.push_str(&format!(+                    "  <smd name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" dx=\"{:.4}\" dy=\"{:.4}\" layer=\"1\"{}/>\n",+                    esc_xml(&p.name), p.x, -p.y, p.dx, p.dy, smd_rotation(p.angle)+                ));+            } else {+                // Thru-hole: EAGLE <pad> with a drill (diameter), oval shape.+                let drill = p.drill.unwrap_or_else(|| p.dx.min(p.dy) * 0.6);+                xml.push_str(&format!(+                    "  <pad name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" drill=\"{:.4}\" diameter=\"{:.4}\" shape=\"long\"{}/>\n",+                    esc_xml(&p.name), p.x, -p.y, drill, p.dx.max(p.dy), smd_rotation(p.angle)+                ));+            }+        } -    xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">&gt;NAME</text>\n", body_h / 2.0 + 0.5));-    xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">&gt;VALUE</text>\n", -body_h / 2.0 - 0.5));+        // Silkscreen outline derived from the .kicad_mod body (Y negated).+        let (minx, miny, maxx, maxy) = package_outline(km, &mod_pads);+        let (sx1, sy1, sx2, sy2) = (minx, -maxy, maxx, -miny);+        for (x1, y1, x2, y2) in [+            (sx1, sy1, sx2, sy1),+            (sx2, sy1, sx2, sy2),+            (sx2, sy2, sx1, sy2),+            (sx1, sy2, sx1, sy1),+        ] {+            xml.push_str(&format!(+                "  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.127\" layer=\"21\"/>\n",+                x1, y1, x2, y2+            ));+        }++        xml.push_str(&format!("  <text x=\"0\" y=\"{:.4}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">&gt;NAME</text>\n", sy2 + 0.5));+        xml.push_str(&format!("  <text x=\"0\" y=\"{:.4}\" size=\"1.27\" layer=\"27\" align=\"top-center\">&gt;VALUE</text>\n", sy1 - 0.5));+    } else {+        // ── Fallback (no --fp): synthesized two-column placeholder.+        xml.push_str(&format!("  <description>{} placeholder footprint</description>\n", esc_xml(&sym.name)));++        let pad_size = 0.5;+        let pitch = 0.5;+        let pin_count = sym.pins.len();+        let half = (pin_count + 1) / 2;++        for (i, pin) in sym.pins.iter().enumerate() {+            let (px, py) = if i < half {+                // Left column+                (-(pin_count as f64 * pitch / 4.0), (half as f64 / 2.0 - i as f64) * pitch)+            } else {+                // Right column+                (pin_count as f64 * pitch / 4.0, (half as f64 / 2.0 - (i - half) as f64) * pitch)+            };+            xml.push_str(&format!(+                "  <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",+                esc_xml(&pin.number), px, py, pad_size, pad_size+            ));+        }++        // Package outline+        let body_w = pin_count as f64 * pitch / 2.0 + 1.0;+        let body_h = half as f64 * pitch + 1.0;+        for (x1, y1, x2, y2) in [+            (-body_w / 2.0, body_h / 2.0, body_w / 2.0, body_h / 2.0),+            (body_w / 2.0, body_h / 2.0, body_w / 2.0, -body_h / 2.0),+            (body_w / 2.0, -body_h / 2.0, -body_w / 2.0, -body_h / 2.0),+            (-body_w / 2.0, -body_h / 2.0, -body_w / 2.0, body_h / 2.0),+        ] {+            xml.push_str(&format!(+                "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+                x1, y1, x2, y2+            ));+        }++        xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">&gt;NAME</text>\n", body_h / 2.0 + 0.5));+        xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">&gt;VALUE</text>\n", -body_h / 2.0 - 0.5));+    }      xml.push_str("</package>\n");     xml.push_str("</packages>\n");@@ -783,7 +1097,7 @@ fn main() {     let cli = Cli::parse();      match cli.command {-        Commands::Generate { sym, fp, output, name } => {+        Commands::Generate { sym, fp, output, name, model } => {             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);@@ -841,6 +1155,58 @@ fn main() {             println!("OK: Generated {} ({} pins, {} bytes)", out_path.display(), parsed.pins.len(), lbr.len());             println!("HINT: Lint {} with {} error(s), {} warning(s)", if lint.passed { "PASSED" } else { "FAILED" }, lint.errors.len(), lint.warnings.len()); +            // 3D model: emit a sidecar manifest for the Fusion bridge. A valid+            // Fusion 3D reference needs an Autodesk cloud URN, which only Fusion+            // can mint — so the .lbr stays 2D and the bridge does the upload ++            // URN write-back, guided by this manifest.+            if let Some(model_path) = &model {+                let side_path = out_path.with_extension("fusion3d.json");+                match write_fusion3d_sidecar(&side_path, &parsed.name, &out_path, model_path) {+                    Ok(()) => println!("OK: Wrote 3D sidecar {} (model: {}) — hand to the Fusion bridge to mint+bind the cloud URN", side_path.display(), model_path.display()),+                    Err(e) => eprintln!("WARN: Could not write 3D sidecar {}: {}", side_path.display(), e),+                }+            }++            if !lint.passed {+                process::exit(2);+            }+        }++        Commands::Bind3d { lbr, urn, package, output, sidecar } => {+            let content = fs::read_to_string(&lbr).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot read {}: {}", lbr.display(), e);+                process::exit(1);+            });+            let patched = bind_3d_urn(&content, &urn, package.as_deref()).unwrap_or_else(|e| {+                eprintln!("ERROR: {}", e);+                process::exit(1);+            });++            // Re-lint so a bad bind can't silently break the library.+            let lint = lint_lbr(&patched);+            for err in &lint.errors {+                eprintln!("LINT ERROR: [{}] {}", err.code, err.message);+            }++            let out_path = output.unwrap_or_else(|| lbr.clone());+            fs::write(&out_path, &patched).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);+                process::exit(1);+            });+            println!("OK: Bound 3D URN {} into {}", urn, out_path.display());++            if let Some(side) = &sidecar {+                match fs::read_to_string(side).ok().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()) {+                    Some(mut v) => {+                        v["urn"] = serde_json::Value::String(urn.clone());+                        v["urn_status"] = serde_json::Value::String("bound".into());+                        let _ = fs::write(side, serde_json::to_string_pretty(&v).unwrap() + "\n");+                        println!("OK: Updated sidecar {} (urn_status -> bound)", side.display());+                    }+                    None => eprintln!("WARN: Could not update sidecar {}", side.display()),+                }+            }+             if !lint.passed {                 process::exit(2);             }@@ -989,6 +1355,17 @@ fn main() {                         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) {+                        Ok((html, pins)) => {+                            eprintln!("[adom-lbr] 3-column viewer ready ({pins} pins) from {}", dir.display());+                            server::set_embed(html);+                        }+                        Err(e) => eprintln!("[adom-lbr] no 3-column viewer ({e}); serving lint card"),+                    }+                }             }             if let Err(e) = server::run(port) {                 eprintln!("ERROR: {e:#}");@@ -1062,34 +1439,59 @@ 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 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());+    Ok(())+}++/// Build the 3-column (symbol | footprint | 3D) interactive HTML for a part dir.+/// 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> {     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")])+    let sym_svg = read_first(dir, &[format!("{mpn}-symbol.svg"), "symbol.svg".into(), "assets/symbol.svg".into()])         .map(|s| s)         .unwrap_or_else(|| "<div class=\"ph\">no symbol render in this directory</div>".into());-    let fp_svg = read_first(dir, &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")])+    let fp_svg = read_first(dir, &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg"), "footprint.svg".into(), "assets/footprint.svg".into()])         .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into());     let threed = {-        let cands = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]-            .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists());+        let names = [+            format!("{mpn}-3d-iso-named.png"), format!("{mpn}-3d-iso.png"), format!("{mpn}-3d-iso-lg.png"),+            "3d-iso.png".into(), "assets/3d-iso.png".into(),+        ];+        let cands = names.iter().map(|s| dir.join(s)).find(|p| p.exists());         match cands.and_then(|p| fs::read(&p).ok()) {             Some(bytes) => format!("<img src=\"data:image/png;base64,{}\">", b64(&bytes)),             None => "<div class=\"ph\">no 3D render in this directory</div>".into(),         }     };-    // optional pin→pad connects from a .lbr+    // optional pin→pad connects from a .lbr (tolerate generic name)     let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();-    if let Some(lbr) = read_first(dir, &[format!("{mpn}.lbr")]) {+    let mut connect_order: Vec<(String, String)> = Vec::new();+    let lbr_text = read_first(dir, &[format!("{mpn}.lbr"), "library.lbr".into()])+        .or_else(|| {+            // any *.lbr in the dir+            fs::read_dir(dir).ok().and_then(|rd| rd.filter_map(|e| e.ok())+                .find(|e| e.path().extension().map(|x| x == "lbr").unwrap_or(false))+                .and_then(|e| fs::read_to_string(e.path()).ok()))+        });+    if let Some(lbr) = &lbr_text {         if let Ok(re) = regex_lite::Regex::new(r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#) {-            for cap in re.captures_iter(&lbr) {+            for cap in re.captures_iter(lbr) {                 pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());+                connect_order.push((cap[1].to_string(), cap[2].to_string()));             }         }     }     // pins + descriptions from the extracted symbol JSON     let mut pins_json = String::from("[]");     let mut pad_count = fp_svg.matches("adom-pad").count().max(0);-    if let Some(ext) = read_first(dir, &[format!("{mpn}-symbol.extracted.json")]) {+    if let Some(ext) = read_first(dir, &[format!("{mpn}-symbol.extracted.json"), "symbol.extracted.json".into(), "assets/symbol.extracted.json".into()]) {         if let Ok(v) = serde_json::from_str::<serde_json::Value>(&ext) {             if let Some(arr) = v.get("pins").and_then(|p| p.as_array()) {                 let rows: Vec<serde_json::Value> = arr.iter().map(|p| {@@ -1112,19 +1514,36 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String>             }         }     }+    // fallback: no extracted JSON — build the pin→pad rows straight from the .lbr connects+    if pins_json == "[]" && !connect_order.is_empty() {+        let rows: Vec<serde_json::Value> = connect_order.iter().map(|(pin, pad)| {+            serde_json::json!({ "number": pad, "name": pin, "pad": pad, "type": "Signal", "color": "#80cbc4", "desc": "" })+        }).collect();+        pad_count = pad_count.max(rows.len());+        pins_json = serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into());+    }     let pin_count = serde_json::from_str::<serde_json::Value>(&pins_json).ok()         .and_then(|v| v.as_array().map(|a| a.len())).unwrap_or(0); +    // Display name: prefer the .lbr's deviceset/symbol name over the dir name.+    let display_name = lbr_text.as_deref()+        .and_then(|l| ["deviceset", "symbol", "package"].iter().find_map(|t| {+            let n = format!("<{t} ");+            let i = l.find(&n)? + n.len();+            let r = &l[i..];+            let k = r.find("name=\"")? + 6;+            let j = r[k..].find('"')? + k;+            Some(r[k..j].to_string())+        }))+        .filter(|s| !s.is_empty())+        .unwrap_or_else(|| mpn.clone());     let html = include_str!("embed.html")-        .replace("__NAME__", &esc_html(&mpn))+        .replace("__NAME__", &esc_html(&display_name))         .replace("__SYM__", &sym_svg)         .replace("__FP__", &fp_svg)         .replace("__THREED__", &threed)         .replace("__PINS__", &pins_json)         .replace("__PADN__", &pad_count.to_string())         .replace("__PINN__", &pin_count.to_string());-    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());-    Ok(())+    Ok((html, pin_count)) }
src/server.rs+15−1
@@ -28,6 +28,8 @@ pub struct AppState { }  static STATE: Mutex<Option<AppState>> = Mutex::new(None);+/// Filled 3-column (symbol | footprint | 3D) HTML, served at `/` when present.+static EMBED_HTML: Mutex<Option<String>> = Mutex::new(None); static EVAL_PENDING: Mutex<VecDeque<(u64, String)>> = Mutex::new(VecDeque::new()); static EVAL_RESULTS: Mutex<Option<HashMap<u64, Value>>> = Mutex::new(None); static CONSOLE: Mutex<Option<Vec<String>>> = Mutex::new(None);@@ -40,6 +42,11 @@ pub fn set_state(s: AppState) {     *STATE.lock().unwrap() = Some(s); } +/// Install the 3-column viewer HTML so `GET /` shows symbol|footprint|3D live.+pub fn set_embed(html: String) {+    *EMBED_HTML.lock().unwrap() = 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);@@ -153,7 +160,14 @@ fn hd_post(body: &str) {  fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {     match (method, url) {-        (Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),+        (Method::Get, "/") | (Method::Get, "/index.html") => {+            // 3-column viewer when render assets are loaded; lint card otherwise.+            match &*EMBED_HTML.lock().unwrap() {+                Some(h) => html(h),+                None => html(APP_HTML),+            }+        }+        (Method::Get, "/lint") => html(APP_HTML),         (Method::Get, "/favicon.svg") => svg(FAVICON),         (Method::Get, "/state") => json_resp(&state_json()),         (Method::Post, "/load") => {