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.1.0
12 files changed
+789−130
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)++++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 @@ -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
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 |++++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"-}+}
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" }
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: ®ex_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\">>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\">>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\">>NAME</text>\n", sy2 + 0.5));+ xml.push_str(&format!(" <text x=\"0\" y=\"{:.4}\" size=\"1.27\" layer=\"27\" align=\"top-center\">>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\">>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\">>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") => {