← Commit history

any-EDA→any-EDA framing; add adom-lbr Manager (list/viewer/layer-stackup/filter/highlight); export-kicad; KiCad import fill + pin-visibility fidelity

barrett-land ·a5990332b6 ·3mo ago ·parent de138a8
76 files changed +6156−1948
Cargo.toml+6−3README.md+17−3SKILL.md+39−7crates/altium-codec/Cargo.lock+326crates/altium-codec/Cargo.toml+21crates/altium-codec/src/altium_decode.rs+510crates/altium-codec/src/intlib.rs+144crates/altium-codec/src/kicad.rs+540crates/altium-codec/src/kicad_export.rs+289crates/altium-codec/src/lib.rs+87crates/altium-codec/src/main.rs+146crates/altium-codec/src/model.rs+276crates/altium-codec/src/pcblib.rs+532crates/altium-codec/src/schlib.rs+537crates/altium-codec/templates/arc_tpl.bincrates/altium-codec/templates/pcblib__FileHeader.bincrates/altium-codec/templates/pcblib__FileVersionInfo__Data.bincrates/altium-codec/templates/pcblib__FileVersionInfo__Header.bincrates/altium-codec/templates/pcblib__Library__ComponentParamsTOC__Data.bincrates/altium-codec/templates/pcblib__Library__ComponentParamsTOC__Header.bincrates/altium-codec/templates/pcblib__Library__Data.bincrates/altium-codec/templates/pcblib__Library__EmbeddedFonts.bincrates/altium-codec/templates/pcblib__Library__Header.bincrates/altium-codec/templates/pcblib__Library__LayerKindMapping__Data.bincrates/altium-codec/templates/pcblib__Library__LayerKindMapping__Header.bincrates/altium-codec/templates/pcblib__Library__ModelsNoEmbed__Data.bincrates/altium-codec/templates/pcblib__Library__ModelsNoEmbed__Header.bincrates/altium-codec/templates/pcblib__Library__Models__Data.bincrates/altium-codec/templates/pcblib__Library__Models__Header.bincrates/altium-codec/templates/pcblib__Library__PadViaLibrary__Data.bincrates/altium-codec/templates/pcblib__Library__PadViaLibrary__Header.bincrates/altium-codec/templates/pcblib__Library__Textures__Data.bincrates/altium-codec/templates/pcblib__Library__Textures__Header.bincrates/altium-codec/templates/pcblib__PCBComponent_1__Data.bincrates/altium-codec/templates/pcblib__PCBComponent_1__Header.bincrates/altium-codec/templates/pcblib__PCBComponent_1__Parameters.bincrates/altium-codec/templates/pcblib__PCBComponent_1__PrimitiveGuids__Data.bincrates/altium-codec/templates/pcblib__PCBComponent_1__PrimitiveGuids__Header.bincrates/altium-codec/templates/pcblib__PCBComponent_1__WideStrings.bincrates/altium-codec/templates/pcblib__REFFP__Data.bincrates/altium-codec/templates/pcblib__REFFP__Header.bincrates/altium-codec/templates/pcblib__REFFP__Parameters.bincrates/altium-codec/templates/pcblib__REFFP__PrimitiveGuids__Data.bincrates/altium-codec/templates/pcblib__REFFP__PrimitiveGuids__Header.bincrates/altium-codec/templates/pcblib__REFFP__UniqueIDPrimitiveInformation__Data.bincrates/altium-codec/templates/pcblib__REFFP__UniqueIDPrimitiveInformation__Header.bincrates/altium-codec/templates/pcblib__REFFP__WideStrings.bincrates/altium-codec/templates/pcblib_template.bincrates/altium-codec/templates/pin_record.bincrates/altium-codec/templates/track_tpl.bincrates/altium-pcblib/Cargo.toml+17crates/altium-pcblib/src/bin/svg.rs+78crates/altium-pcblib/src/lib.rs+737crates/altium-schlib/Cargo.lock+262crates/altium-schlib/Cargo.toml+25crates/altium-schlib/LICENSE+21crates/altium-schlib/README.md+89crates/altium-schlib/SKILL.md+66crates/altium-schlib/install.sh+7crates/altium-schlib/package.json+33crates/altium-schlib/screenshots/ads1263-symbol.pngcrates/altium-schlib/screenshots/hero-v2.pngcrates/altium-schlib/screenshots/hero.pngcrates/altium-schlib/screenshots/validation.pngcrates/altium-schlib/skills/altium-schlib/SKILL.md+66crates/altium-schlib/src/bin/json.rs+6crates/altium-schlib/src/bin/kicad.rs+10crates/altium-schlib/src/bin/svg.rs+7crates/altium-schlib/src/lib.rs+373crates/altium-schlib/uninstall.sh+3package.json+23−7src/embed.html+7−19src/main.rs+271−1836src/manage.html+160src/manage.rs+421src/server.rs+4−73
Cargo.toml+6−3
@@ -1,8 +1,8 @@ [package] name = "adom-lbr"-version = "1.8.3"+version = "0.3.0" edition = "2021"-description = "EAGLE .lbr generator, linter, and validator for Fusion 360 Electronics. Converts KiCad symbols + footprints to complete EAGLE libraries."+description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time."  [[bin]] name = "adom-lbr"@@ -17,7 +17,10 @@ serde_json = "1" quick-xml = { version = "0.36", features = ["serialize"] } tiny_http = "0.12" anyhow = "1"-cfb = "0.10"+altium-codec = { path = "crates/altium-codec" }++[workspace]+members = ["crates/altium-codec", "crates/altium-schlib", "crates/altium-pcblib"]  [profile.release] opt-level = "z"
README.md+17−3
@@ -1,8 +1,8 @@ # adom-lbr -**adom-lbr generator / EDA translator. One canonical part format → every major EDA tool — with native Altium output and no local EDA software.**+**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 part format, with native Altium output and no local EDA software.** -The **adom-lbr JSON** is Adom's own canonical part: one document carrying the symbol, footprint, 3D reference, and the pin↔pad map. adom-lbr is the translator around it — import a part from KiCad, then convert the adom-lbr JSON to **native Altium** (`.SchLib` / `.PcbLib` / `.IntLib`), an **EAGLE/Fusion** `.lbr`, or back to **KiCad**. The Altium libraries are written **natively** — the exact OLE2/CFBF binary — so no copy of Altium is needed at conversion time. Standalone Rust, no Node, no gallia.+The **adom-lbr JSON** is Adom's own canonical part: one document carrying the symbol, footprint, 3D reference, and the pin↔pad map. adom-lbr is the translator around it — **import a component from KiCad or Altium**, then convert the adom-lbr JSON to **any** other tool: **native Altium** (`.SchLib` / `.PcbLib` / `.IntLib`), a **KiCad** `.kicad_sym` + `.kicad_mod`, or an **EAGLE/Fusion** `.lbr`. **Any EDA tool in, any EDA tool out** — the canonical JSON is the hub, so every source pairs with every target. The Altium libraries are written **natively** — the exact OLE2/CFBF binary — so no copy of Altium is needed at conversion time. Standalone Rust, no Node, no gallia.  ("lbr" = **Adom Library**, the adom-lbr JSON — not EAGLE's `.lbr`, which is just one of the output formats.) @@ -15,7 +15,7 @@ The **adom-lbr JSON** is Adom's own canonical part: one document carrying the sy | **Import → hub** | **Altium `.SchLib` / `.PcbLib` / `.IntLib` → adom-lbr JSON** | bring an Altium part in — pins, pads, and silk/courtyard/fab **layers** | | **Export → Altium** | native `.SchLib` + `.PcbLib`, or one bundled `.IntLib` | Altium Designer — written natively, no Altium needed | | **Export → Fusion/EAGLE** | EAGLE `.lbr` (symbol + package + connects) | Autodesk Fusion 360 Electronics, EAGLE, OrCAD |-| **Export → KiCad** | native `.kicad_sym` | KiCad 6+ *(planned)* |+| **Export → KiCad** | native `.kicad_sym` + `.kicad_mod` (layers preserved) | KiCad 6+ | | **Merge** | add a part's symbol into an existing `.SchLib` | install a part into a user's Altium library |  Altium footprint graphics keep their **layers** across the round trip — silk /@@ -25,6 +25,20 @@ layers (outline → Mechanical 13 "Assembly Top", courtyard → Mechanical 15  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). +## adom-lbr Manager — browse the wiki, inspect any part++`adom-lbr manage` opens a web app that **lists every component on the wiki**; click+one and it renders the **schematic symbol**, **footprint**, and **3D model** on the+fly from the part's adom-lbr JSON (or, if the page has none, from its KiCad files).+It surfaces the **footprint layer stackup** — what layer each silk / courtyard / fab /+copper element becomes in **KiCad *and* Altium**, side by side — plus a per-layer+show/hide filter, drag-to-pan / scroll-to-zoom viewers, and **hover-to-highlight**+on pins & pads showing each one's number and name.++```bash+adom-lbr manage --port 8785        # → http://localhost:8785 (list ▸ click ▸ viewer)+```+ ## 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)
SKILL.md+39−7
@@ -1,13 +1,16 @@ --- name: adom-lbr description: >-  adom-lbr generator / EDA translator. The canonical adom-lbr JSON (symbol +-  footprint + pin↔pad map) is the hub; convert it to NATIVE Altium-  (.SchLib/.PcbLib/.IntLib — real OLE2 binary, no Altium needed), EAGLE/Fusion-  .lbr, or KiCad .kicad_sym. Import KiCad parts into the hub; merge a symbol into-  an existing .SchLib. Trigger words: adom-lbr, altium schlib pcblib intlib,-  convert to altium, kicad to altium, eda translator, lbr, eagle library, fusion-  library, adom-lbr json, kicad convert.+  adom-lbr — the EDA library translator. Bring a component in from ANY supported+  EDA tool and convert it to ANY other; the canonical adom-lbr JSON (symbol ++  footprint + 3D + pin↔pad map) is the hub. Import from KiCad OR Altium; export to+  NATIVE Altium (.SchLib/.PcbLib/.IntLib — real OLE2 binary, no Altium needed),+  KiCad (.kicad_sym + .kicad_mod), or EAGLE/Fusion .lbr. Merge a symbol into an+  existing .SchLib. `adom-lbr manage` opens the Manager: browse wiki components,+  view symbol/footprint/3D + the per-EDA (KiCad vs Altium) footprint layer stackup.+  Trigger words: adom-lbr, altium schlib pcblib intlib, convert to altium, kicad to+  altium, altium to kicad, any eda to any eda, eda translator, lbr, eagle library,+  fusion library, adom-lbr json, kicad convert, adom-lbr manager, layer stackup. ---  # adom-lbr@@ -31,6 +34,7 @@ adom-lbr import-altium part.IntLib --mpn MPN                                # or # OUT of the hub → native Altium (real OLE2/CFBF, no Altium needed) adom-lbr export-altium  part.adom-lbr.json --out-dir .                      # → MPN.SchLib + MPN.PcbLib adom-lbr export-intlib  part.adom-lbr.json                                  # → MPN.IntLib (bundled)+adom-lbr export-kicad   part.adom-lbr.json --out-dir .                      # → MPN.kicad_sym + MPN.kicad_mod adom-lbr add-to-schlib  existing.SchLib part.adom-lbr.json --out lib.SchLib # merge a symbol into a library ``` @@ -57,6 +61,33 @@ encoder patches both. Decode reads the layers back into the neutral roles. 3D STEP models attach to the footprint via Altium's model factory (bridge), not the native writer (the model checksum is a custom Altium algorithm). +### KiCad round-trip fidelity++`export-kicad` writes a KiCad-valid `.kicad_sym` + `.kicad_mod`. Import captures+what a naive converter drops: rectangle **fill** (`fill outline` vs `none` — a+filled cap plate stays filled), and per-symbol **pin-number / pin-name+visibility** (`(pin_numbers (hide yes))`), so a KiCad→JSON→KiCad round trip keeps+solid vs hollow art and shown vs hidden pin labels. Footprint silk/courtyard/fab+graphics keep their layers → `F.SilkS` / `F.CrtYd` / `F.Fab`.++## adom-lbr Manager (`adom-lbr manage`)++A web app (tiny_http → webview) that browses wiki components and inspects any+part. `adom-lbr manage --port 8785` → open `http://localhost:8785`.++- **List ▸ click ▸ viewer.** Lists every component on the wiki (wiki API); click a+  row to open its viewer.+- **On-the-fly render.** Fetches the part FRESH each click — prefers the page's+  canonical adom-lbr JSON, else generates it from the page's `.kicad_sym` ++  `.kicad_mod` — and renders **symbol + footprint + 3D** on the spot.+- **Layer stackup.** A table of the footprint's layers showing what each becomes+  in **KiCad and Altium** (e.g. Courtyard → `F.CrtYd` / Mechanical 15). Plus a+  per-layer show/hide **filter** on the footprint.+- **Interaction.** Drag-to-pan, scroll-to-zoom, double-click-reset on the symbol+  & footprint; **hover a pin or pad** to highlight it and read its number + name.+- Served relative-path so it works behind the code-server `/proxy/<port>/` proxy;+  open it as a webview via `adom-cli hydrogen webview open-or-refresh`.+ ## EAGLE / Fusion path  Fusion 360 Electronics shows "No Components" when an .lbr has an empty `<packages>` or `<connects/>`. adom-lbr's `generate` builds a COMPLETE deviceset: symbol + package + full pin-to-pad connects. KiCad users can round-trip any `.lbr` back to `.kicad_sym` with `adom-lbr import`.@@ -69,6 +100,7 @@ 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 manage [--port 8785]                 # the adom-lbr Manager web app adom-lbr health adom-lbr install adom-lbr completions <bash|zsh|fish>
crates/altium-codec/Cargo.lockadded+326
@@ -0,0 +1,326 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "altium-codec"+version = "0.1.0"+dependencies = [+ "altium-pcblib",+ "altium-schlib",+ "anyhow",+ "cfb",+ "flate2",+ "serde",+ "serde_json",+]++[[package]]+name = "altium-pcblib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "altium-schlib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "anyhow"+version = "1.0.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"++[[package]]+name = "bumpalo"+version = "3.20.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"++[[package]]+name = "byteorder"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"++[[package]]+name = "cfb"+version = "0.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"+dependencies = [+ "byteorder",+ "fnv",+ "uuid",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "fnv"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"++[[package]]+name = "futures-core"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"++[[package]]+name = "futures-task"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"++[[package]]+name = "futures-util"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"+dependencies = [+ "futures-core",+ "futures-task",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "js-sys"+version = "0.3.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"+dependencies = [+ "cfg-if",+ "futures-util",+ "wasm-bindgen",+]++[[package]]+name = "memchr"+version = "2.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.46"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "rustversion"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.150"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"++[[package]]+name = "syn"+version = "2.0.118"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "uuid"+version = "1.23.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"+dependencies = [+ "js-sys",+ "wasm-bindgen",+]++[[package]]+name = "wasm-bindgen"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"+dependencies = [+ "cfg-if",+ "once_cell",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
crates/altium-codec/Cargo.tomladded+21
@@ -0,0 +1,21 @@+[package]+name = "altium-codec"+version = "0.1.0"+edition = "2021"+description = "Encode + decode Altium .SchLib (symbols) and .PcbLib (footprints) from a neutral model — the JSON→Altium writer for the universal EDA interchange format. Decoders reuse the validated altium-schlib / altium-pcblib readers; encoders are their inverse."+license = "MIT"++[dependencies]+anyhow = "1"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+cfb = "0.10"+flate2 = "1"++# Decoders we invert. Path deps within the altium-writer workspace.+altium-schlib = { path = "../altium-schlib" }+altium-pcblib = { path = "../altium-pcblib" }++[[bin]]+name = "altium-codec"+path = "src/main.rs"
crates/altium-codec/src/altium_decode.rsadded+510
@@ -0,0 +1,510 @@+//! Altium `.SchLib` / `.PcbLib` / `.IntLib` → canonical [`AdomLbrPart`] (the+//! DECODE side — the inverse of `schlib`/`pcblib`/`intlib` encoders).+//!+//! Walks the OLE2/CFBF component streams directly so we control unit conversion:+//! symbol pins + graphics come back in mils, footprint pads + graphics in mm+//! (Y-down neutral), with silk/courtyard/fab layers preserved as [`FpLayer`]s.++use crate::model::{+    AdomLbrPart, ConnectEntry, Footprint, FpGraphic, FpLayer, Graphic, Pad, Pin, Symbol,+};+use anyhow::{Context, Result};+use std::collections::BTreeMap;+use std::io::{Cursor, Read};++/// Altium base unit = 1/10000 mil = 0.00000254 mm (PCB coordinates).+const ALTIUM_UNIT_MM: f64 = 0.00000254;+/// Schematic binary coordinate units per mil (pin X).+const UNITS_PER_MIL: f64 = 25.6;++// ---------------------------------------------------------------------------+// Public entry points+// ---------------------------------------------------------------------------++/// Decode a `.SchLib` + `.PcbLib` pair into the canonical part. Either may be+/// `None` (e.g. footprint-only), but at least one is required.+pub fn decode_altium(schlib: Option<&[u8]>, pcblib: Option<&[u8]>, mpn: &str) -> Result<AdomLbrPart> {+    let symbol = match schlib {+        Some(b) => decode_schlib_symbol(b)?,+        None => Symbol {+            name: mpn.to_string(),+            pins: vec![],+            body: None,+            graphics: vec![],+            designator_prefix: Some("U".into()),+            footprint: None,+            parameters: vec![],+            pin_numbers_hidden: None,+            pin_names_hidden: None,+        },+    };+    let footprint = match pcblib {+        Some(b) => decode_pcblib_footprint(b)?,+        None => Footprint { name: mpn.to_string(), pads: vec![], graphics: vec![] },+    };+    build_part(symbol, footprint, mpn)+}++/// Decode a `.IntLib` (bundles both) into the canonical part.+pub fn decode_intlib_part(bytes: &[u8], mpn: &str) -> Result<AdomLbrPart> {+    let (sch, pcb) = crate::intlib::extract_intlib_libs(bytes)?;+    decode_altium(Some(&sch), Some(&pcb), mpn)+}++fn build_part(mut symbol: Symbol, footprint: Footprint, mpn: &str) -> Result<AdomLbrPart> {+    if symbol.footprint.is_none() && !footprint.name.is_empty() {+        symbol.footprint = Some(footprint.name.clone());+    }+    // identity connect over shared designators+    let pad_nums: std::collections::HashSet<&str> =+        footprint.pads.iter().map(|p| p.number.as_str()).collect();+    let connect = symbol+        .pins+        .iter()+        .filter(|p| pad_nums.contains(p.number.as_str()))+        .map(|p| ConnectEntry { pin: p.number.clone(), pad: p.number.clone() })+        .collect();+    // pull value/manufacturer/etc. out of the symbol's decoded parameters+    let param = |name: &str| {+        symbol+            .parameters+            .iter()+            .find(|p| p.name.eq_ignore_ascii_case(name))+            .map(|p| p.value.clone())+            .unwrap_or_default()+    };+    Ok(AdomLbrPart {+        schema_version: 2,+        mpn: if mpn.is_empty() { symbol.name.clone() } else { mpn.to_string() },+        manufacturer: param("Manufacturer"),+        package: param("Package"),+        value: param("Value"),+        category: String::new(),+        symbol,+        footprint,+        connect,+        model_3d: None,+        provenance: Some(serde_json::json!({"source": "altium_decode"})),+    })+}++// ---------------------------------------------------------------------------+// CFBF helpers+// ---------------------------------------------------------------------------++fn read_streams(bytes: &[u8]) -> Result<BTreeMap<String, Vec<u8>>> {+    let mut comp = cfb::CompoundFile::open(Cursor::new(bytes.to_vec())).context("open CFBF")?;+    let paths: Vec<String> = comp+        .walk()+        .filter(|e| e.is_stream())+        .map(|e| e.path().to_string_lossy().to_string())+        .collect();+    let mut map = BTreeMap::new();+    for p in paths {+        let mut buf = Vec::new();+        comp.open_stream(&p)?.read_to_end(&mut buf)?;+        map.insert(p, buf);+    }+    Ok(map)+}++fn le_u16(b: &[u8], o: usize) -> u16 {+    u16::from_le_bytes([*b.get(o).unwrap_or(&0), *b.get(o + 1).unwrap_or(&0)])+}+fn le_u32(b: &[u8], o: usize) -> u32 {+    u32::from_le_bytes([+        *b.get(o).unwrap_or(&0),+        *b.get(o + 1).unwrap_or(&0),+        *b.get(o + 2).unwrap_or(&0),+        *b.get(o + 3).unwrap_or(&0),+    ])+}+fn le_i32(b: &[u8], o: usize) -> i32 {+    le_u32(b, o) as i32+}+fn le_i16(b: &[u8], o: usize) -> i16 {+    le_u16(b, o) as i16+}+fn le_i24(b: &[u8], o: usize) -> i32 {+    let v = (*b.get(o).unwrap_or(&0) as i32)+        | ((*b.get(o + 1).unwrap_or(&0) as i32) << 8)+        | ((*b.get(o + 2).unwrap_or(&0) as i32) << 16);+    (v << 8) >> 8 // sign-extend 24-bit+}+fn le_f64(b: &[u8], o: usize) -> f64 {+    let mut a = [0u8; 8];+    for i in 0..8 {+        a[i] = *b.get(o + i).unwrap_or(&0);+    }+    f64::from_le_bytes(a)+}++/// Read a `|KEY=VAL|...` field out of a text record.+fn field<'a>(txt: &'a str, key: &str) -> Option<&'a str> {+    let pat = format!("|{key}=");+    let i = txt.find(&pat)? + pat.len();+    let rest = &txt[i..];+    let end = rest.find('|').unwrap_or(rest.len());+    Some(&rest[..end])+}++// ---------------------------------------------------------------------------+// SchLib → Symbol+// ---------------------------------------------------------------------------++fn decode_schlib_symbol(bytes: &[u8]) -> Result<Symbol> {+    let streams = read_streams(bytes)?;+    // first component storage's Data (skip housekeeping streams)+    let (name, data) = streams+        .iter()+        .filter(|(p, _)| p.ends_with("/Data") && !p.contains("FileHeader") && !p.contains("Storage"))+        .map(|(p, d)| {+            let comp = p.trim_start_matches('/').split('/').next().unwrap_or("SYM").to_string();+            (comp, d.clone())+        })+        .next()+        .context("SchLib has no component Data stream")?;++    let mut pins = Vec::new();+    let mut graphics = Vec::new();+    let mut parameters = Vec::new();+    let mut designator_prefix = Some("U".to_string());+    let mut footprint = None;+    let mut lib_name = name.clone();++    let mut cur = 0usize;+    while cur + 4 <= data.len() {+        let len = le_u16(&data, cur) as usize;+        let flags = le_u16(&data, cur + 2);+        let payload = &data[cur + 4..(cur + 4 + len).min(data.len())];+        cur += 4 + len;+        if flags == 0x0100 {+            // binary pin record+            if let Some(p) = decode_pin(payload) {+                pins.push(p);+            }+            continue;+        }+        // text record+        if payload.first() != Some(&b'|') {+            continue;+        }+        let txt = String::from_utf8_lossy(payload);+        let txt = txt.split('\0').next().unwrap_or("");+        let rec = field(txt, "RECORD").unwrap_or("");+        match rec {+            "1" => {+                if let Some(n) = field(txt, "LibReference") {+                    lib_name = n.to_string();+                }+            }+            "6" | "11" | "12" | "14" => {+                if let Some(g) = decode_sym_graphic(txt, rec) {+                    graphics.push(g);+                }+            }+            "34" => {+                if let Some(t) = field(txt, "Text") {+                    designator_prefix = Some(t.trim_end_matches('?').to_string());+                }+            }+            "41" => {+                if let (Some(name), Some(text)) = (field(txt, "Name"), field(txt, "Text")) {+                    if name != "Comment" && !text.is_empty() && text != "*" {+                        parameters.push(crate::model::Parameter {+                            name: name.to_string(),+                            value: text.to_string(),+                            visible: !txt.contains("|IsHidden=T"),+                        });+                    }+                }+            }+            "45" => {+                if let Some(m) = field(txt, "ModelName") {+                    footprint = Some(m.to_string());+                }+            }+            _ => {}+        }+    }++    Ok(Symbol {+        name: lib_name,+        pins,+        body: None,+        graphics,+        designator_prefix,+        footprint,+        parameters,+        pin_numbers_hidden: None,+        pin_names_hidden: None,+    })+}++/// Decode a binary pin record → neutral [`Pin`] (connection-point mils).+fn decode_pin(p: &[u8]) -> Option<Pin> {+    if p.len() < 26 {+        return None;+    }+    // orientation packed in low 2 bits of the length field [15:17]+    let field15 = le_u16(p, 15);+    let aorient = (field15 & 0x3) as u32;+    let length_units = (field15 & 0xFFFC) as f64;+    let len_mil = (length_units / UNITS_PER_MIL).round() as i32;+    // body-end location: X = i24 @[17:20] (÷25.6/mil), Y = i16 @[20:22] (×10 mil)+    let body_x = (le_i24(p, 17) as f64 / UNITS_PER_MIL).round() as i32;+    let body_y = (le_i16(p, 20) as i32) * 10;+    // kicad angle q from aorient = (q + 2) % 4+    let q = ((aorient + 2) % 4) as i32;+    let (dx, dy) = match q % 4 {+        0 => (1, 0),+        1 => (0, 1),+        2 => (-1, 0),+        _ => (0, -1),+    };+    // connection = body_end - length * dir+    let x = body_x - len_mil * dx;+    let y = body_y - len_mil * dy;+    // strings: name pstr then designator pstr @ 26+    let mut j = 26usize;+    let name = read_pstr(p, &mut j);+    let number = read_pstr(p, &mut j);+    Some(Pin {+        number: if number.is_empty() { "?".into() } else { number },+        name,+        x,+        y,+        length: Some(len_mil),+        rotation: (q * 90) as u16,+    })+}++fn read_pstr(p: &[u8], j: &mut usize) -> String {+    if *j >= p.len() {+        return String::new();+    }+    let n = p[*j] as usize;+    let start = *j + 1;+    let end = (start + n).min(p.len());+    *j = end;+    String::from_utf8_lossy(&p[start..end]).to_string()+}++/// Decode a symbol graphic text record (RECORD=6/11/12/14) → [`Graphic`] (mils).+fn decode_sym_graphic(txt: &str, rec: &str) -> Option<Graphic> {+    // coordinate = base*10 + frac/10000 (mils), from `KEY` + optional `KEY_Frac`+    let coord = |key: &str| -> f64 {+        let base: f64 = field(txt, key).and_then(|v| v.parse().ok()).unwrap_or(0.0);+        let frac: f64 = field(txt, &format!("{key}_Frac"))+            .and_then(|v| v.parse().ok())+            .unwrap_or(0.0);+        base * 10.0 + frac / 10000.0+    };+    let width = field(txt, "LineWidth").and_then(|v| v.parse::<f64>().ok()).unwrap_or(1.0) * 10.0;+    match rec {+        "14" => Some(Graphic::Rect {+            x1: coord("Location.X"),+            y1: coord("Location.Y"),+            x2: coord("Corner.X"),+            y2: coord("Corner.Y"),+            width,+            filled: txt.contains("|IsSolid=T"),+        }),+        "11" => Some(Graphic::Circle {+            cx: coord("Location.X"),+            cy: coord("Location.Y"),+            r: coord("Radius"),+            width,+        }),+        "12" => Some(Graphic::Arc {+            cx: coord("Location.X"),+            cy: coord("Location.Y"),+            r: coord("Radius"),+            start: field(txt, "StartAngle").and_then(|v| v.parse().ok()).unwrap_or(0.0),+            end: field(txt, "EndAngle").and_then(|v| v.parse().ok()).unwrap_or(360.0),+            width,+        }),+        "6" => {+            let n: usize = field(txt, "LocationCount").and_then(|v| v.parse().ok()).unwrap_or(0);+            let mut points = Vec::new();+            for i in 1..=n {+                points.push([coord(&format!("X{i}")), coord(&format!("Y{i}"))]);+            }+            if points.len() >= 2 {+                Some(Graphic::Polyline { points, width })+            } else {+                None+            }+        }+        _ => None,+    }+}++// ---------------------------------------------------------------------------+// PcbLib → Footprint (pads + silk/courtyard/fab graphics)+// ---------------------------------------------------------------------------++fn decode_pcblib_footprint(bytes: &[u8]) -> Result<Footprint> {+    let streams = read_streams(bytes)?;+    let (name, data) = streams+        .iter()+        .filter(|(p, _)| {+            p.ends_with("/Data")+                && !p.contains("/Library")+                && !p.contains("FileVersionInfo")+                && !p.contains("FileHeader")+        })+        .map(|(p, d)| {+            let comp = p.trim_start_matches('/').split('/').next().unwrap_or("FP").to_string();+            (comp, d.clone())+        })+        .next()+        .context("PcbLib has no component Data stream")?;++    // component name block: <u32 len><name>+    let name_len = le_u32(&data, 0) as usize;+    let mut cur = 4 + name_len;+    let fp_name = {+        // the block holds a pstring: <u8 len><name>+        let blk = &data[4..(4 + name_len).min(data.len())];+        if !blk.is_empty() {+            let n = blk[0] as usize;+            String::from_utf8_lossy(&blk[1..(1 + n).min(blk.len())]).to_string()+        } else {+            name+        }+    };++    let mut pads = Vec::new();+    let mut graphics = Vec::new();++    while cur < data.len() {+        let rt = data[cur];+        cur += 1;+        let nblocks = match rt {+            0x02 => 6,             // Pad+            0x05 => 2,             // Text+            0x01 | 0x03 | 0x04 | 0x06 | 0x09 | 0x0B | 0x0C => 1,+            _ => 1,+        };+        let mut blocks: Vec<&[u8]> = Vec::new();+        let mut ok = true;+        for _ in 0..nblocks {+            if cur + 4 > data.len() {+                ok = false;+                break;+            }+            let blen = le_u32(&data, cur) as usize;+            cur += 4;+            if cur + blen > data.len() {+                ok = false;+                break;+            }+            blocks.push(&data[cur..cur + blen]);+            cur += blen;+        }+        if !ok {+            break;+        }+        match rt {+            0x02 => {+                if let Some(pad) = decode_pad(&blocks) {+                    pads.push(pad);+                }+            }+            0x04 => {+                if let Some(g) = decode_track(blocks[0]) {+                    graphics.push(g);+                }+            }+            0x01 => {+                if let Some(g) = decode_arc(blocks[0]) {+                    graphics.push(g);+                }+            }+            _ => {}+        }+    }++    Ok(Footprint { name: fp_name, pads, graphics })+}++fn altium_layer(byte: u8) -> FpLayer {+    match byte {+        33 => FpLayer::Silk,+        69 => FpLayer::Fab,+        71 => FpLayer::Courtyard,+        1 | 32 => FpLayer::Copper,+        b => FpLayer::Other { altium: b },+    }+}++fn decode_pad(blocks: &[&[u8]]) -> Option<Pad> {+    let designator = blocks.first()?;+    let n = *designator.first()? as usize;+    let number = String::from_utf8_lossy(&designator[1..(1 + n).min(designator.len())]).to_string();+    let pd = blocks.get(4)?; // pad data block+    if pd.len() < 60 {+        return None;+    }+    let x = le_i32(pd, 13) as f64 * ALTIUM_UNIT_MM;+    let y = -(le_i32(pd, 17) as f64) * ALTIUM_UNIT_MM; // Altium Y-up → neutral Y-down+    let w = le_u32(pd, 21) as f64 * ALTIUM_UNIT_MM;+    let h = le_u32(pd, 25) as f64 * ALTIUM_UNIT_MM;+    let hole = le_u32(pd, 45) as f64 * ALTIUM_UNIT_MM;+    let shape = match pd[49] {+        1 => "circle",+        9 => "roundrect",+        _ => "rect",+    };+    let rotation = if pd.len() >= 60 { le_f64(pd, 52) } else { 0.0 };+    Some(Pad {+        number,+        x_mm: x,+        y_mm: y,+        w_mm: w,+        h_mm: h,+        drill_mm: hole,+        rotation,+        shape: shape.to_string(),+    })+}++/// Track (0x04): [0]layer, [13:17]x1, [17:21]y1, [21:25]x2, [25:29]y2, [29:33]width.+fn decode_track(p: &[u8]) -> Option<FpGraphic> {+    if p.len() < 33 {+        return None;+    }+    let layer = altium_layer(p[0]);+    let c = |o: usize| le_i32(p, o) as f64 * ALTIUM_UNIT_MM;+    Some(FpGraphic::Line {+        layer,+        x1: c(13),+        y1: -c(17),+        x2: c(21),+        y2: -c(25),+        width: le_u32(p, 29) as f64 * ALTIUM_UNIT_MM,+    })+}++/// Arc (0x01): [0]layer, [13:17]cx, [17:21]cy, [21:25]r, [25:33]f64 start,+/// [33:41]f64 end, [41:45]width. A full 0..360 arc becomes a Circle.+fn decode_arc(p: &[u8]) -> Option<FpGraphic> {+    if p.len() < 45 {+        return None;+    }+    let layer = altium_layer(p[0]);+    let c = |o: usize| le_i32(p, o) as f64 * ALTIUM_UNIT_MM;+    let cx = c(13);+    let cy = -c(17);+    let r = c(21);+    let start = le_f64(p, 25);+    let end = le_f64(p, 33);+    let width = le_u32(p, 41) as f64 * ALTIUM_UNIT_MM;+    if (end - start).abs() >= 359.9 || (start == 0.0 && end == 360.0) {+        Some(FpGraphic::Circle { layer, cx, cy, r, width })+    } else {+        // Y-flip mirrors angles: negate + swap so the visible sweep matches.+        Some(FpGraphic::Arc { layer, cx, cy, r, start: -end, end: -start, width })+    }+}
crates/altium-codec/src/intlib.rsadded+144
@@ -0,0 +1,144 @@+//! Altium `.IntLib` (Integrated Library) **encoder** + decoder.+//!+//! An IntLib is a CFBF container that *bundles* a symbol library and a footprint+//! library so the symbol's footprint model always resolves (no external library+//! search path needed). Structure:+//! ```text+//!   /SchLib/0.schlib        0x02 marker + zlib(a complete standard .SchLib)+//!   /PCBLib/0.pcblib        0x02 marker + zlib(a complete standard .PcbLib)+//!   /LibCrossRef.Txt        symbol↔footprint cross-reference (framed strings)+//!   /Parameters   .bin      |KEY=VAL parameters (note: 3 spaces in the name)+//!   /Version.Txt            00 02 00 00 00+//! ```+//! Because we already encode the inner `.SchLib`/`.PcbLib`, the IntLib writer is+//! just: encode both, zlib-wrap, and assemble the container + crossref.++use crate::model::{Footprint, Symbol};+use crate::{encode_pcblib, encode_schlib};+use anyhow::{Context, Result};+use flate2::write::ZlibEncoder;+use flate2::Compression;+use std::io::{Cursor, Read, Write};++/// `0x02` marker byte + zlib-DEFLATE of the library bytes (Altium's scheme).+fn wrap(bytes: &[u8]) -> Result<Vec<u8>> {+    let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());+    enc.write_all(bytes)?;+    let z = enc.finish()?;+    let mut out = vec![0x02u8];+    out.extend_from_slice(&z);+    Ok(out)+}++/// `[u32 len+1][u8 len][bytes]` — a LibCrossRef string block.+fn sblock(s: &str) -> Vec<u8> {+    let b = s.as_bytes();+    let mut out = ((b.len() + 1) as u32).to_le_bytes().to_vec();+    out.push(b.len() as u8);+    out.extend_from_slice(b);+    out+}++fn lib_cross_ref(comp: &str, descr: &str, fp: &str) -> Vec<u8> {+    let one = 1u32.to_le_bytes();+    let mut o = vec![0u8];+    o.extend_from_slice(&one);+    o.extend(sblock(comp));+    o.extend(sblock(r":\SchLib\0.schlib"));+    o.extend_from_slice(&one);+    o.extend(sblock(descr));+    o.extend(sblock(&format!(r"C:\adom\{comp}.SchLib")));+    o.extend_from_slice(&one);+    o.extend(sblock(fp));+    o.extend(sblock("PCBLIB"));+    o.extend_from_slice(&one);+    o.extend(sblock(r":\PCBLib\0.pcblib"));+    o.extend(sblock(&format!(r"C:\adom\{fp}.PcbLib")));+    o+}++/// Encode a symbol + footprint into a self-contained `.IntLib`. The symbol must+/// carry `footprint = Some(footprint.name)` so its PCBLIB model resolves against+/// the bundled footprint.+pub fn encode_intlib(symbol: &Symbol, footprint: &Footprint, descr: &str) -> Result<Vec<u8>> {+    let schlib = encode_schlib(std::slice::from_ref(symbol))?;+    let pcblib = encode_pcblib(footprint)?;++    let cursor = Cursor::new(Vec::<u8>::new());+    let mut comp = cfb::CompoundFile::create(cursor).context("create IntLib CFBF")?;+    comp.create_storage("/SchLib")?;+    comp.create_storage("/PCBLib")?;++    write_stream(&mut comp, "/SchLib/0.schlib", &wrap(&schlib)?)?;+    write_stream(&mut comp, "/PCBLib/0.pcblib", &wrap(&pcblib)?)?;+    write_stream(+        &mut comp,+        "/LibCrossRef.Txt",+        &lib_cross_ref(&symbol.name, descr, &footprint.name),+    )?;+    write_stream(&mut comp, "/Version.Txt", &[0x00, 0x02, 0x00, 0x00, 0x00])?;++    // Parameters   .bin — [u8 0x00] then [u32 len][text\0] records:+    // one for the symbol's parameters, then one for the footprint's. (No leading+    // pipe in the text, unlike the |RECORD records.)+    let mut params = vec![0x00u8];+    let push_rec = |buf: &mut Vec<u8>, text: &str| {+        let mut p = text.as_bytes().to_vec();+        p.push(0);+        buf.extend_from_slice(&(p.len() as u32).to_le_bytes());+        buf.extend_from_slice(&p);+    };+    push_rec(+        &mut params,+        &format!(+            "Comment=*|Component Kind=Standard|Description={descr}|Footprint={}",+            footprint.name+        ),+    );+    push_rec(+        &mut params,+        &format!("Height=0mil|Pad Count={}", footprint.pads.len()),+    );+    write_stream(&mut comp, "/Parameters   .bin", &params)?;++    comp.flush()?;+    Ok(comp.into_inner().into_inner())+}++fn write_stream<F: Read + Write + std::io::Seek>(+    comp: &mut cfb::CompoundFile<F>,+    path: &str,+    bytes: &[u8],+) -> Result<()> {+    let mut s = comp+        .create_stream(path)+        .with_context(|| format!("create {path}"))?;+    s.write_all(bytes)?;+    s.flush()?;+    Ok(())+}++/// Decode an `.IntLib`: inflate the embedded libs and parse each with the+/// existing readers. Returns `(symbol_json, footprint_json)`.+pub fn decode_intlib(bytes: &[u8]) -> Result<(serde_json::Value, serde_json::Value)> {+    let (sch, pcb) = extract_intlib_libs(bytes)?;+    Ok((crate::decode_schlib_first(&sch)?, crate::decode_pcblib(&pcb)?))+}++/// Extract the raw (inflated) embedded `.SchLib` + `.PcbLib` byte images from an+/// IntLib — for callers that want to re-decode them into the neutral model.+pub fn extract_intlib_libs(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {+    use flate2::read::ZlibDecoder;+    let cursor = Cursor::new(bytes.to_vec());+    let mut comp = cfb::CompoundFile::open(cursor).context("open IntLib")?;+    let inflate = |comp: &mut cfb::CompoundFile<Cursor<Vec<u8>>>, path: &str| -> Result<Vec<u8>> {+        let mut raw = Vec::new();+        comp.open_stream(path)?.read_to_end(&mut raw)?;+        let mut out = Vec::new();+        ZlibDecoder::new(&raw[1..]).read_to_end(&mut out)?; // skip 0x02 marker+        Ok(out)+    };+    let sch = inflate(&mut comp, "/SchLib/0.schlib")?;+    let pcb = inflate(&mut comp, "/PCBLib/0.pcblib")?;+    Ok((sch, pcb))+}
crates/altium-codec/src/kicad.rsadded+540
@@ -0,0 +1,540 @@+//! KiCad `.kicad_sym` / `.kicad_mod` **reader** (the import side of the KiCad+//! adapter). Parses the s-expression into the neutral [`AdomLbrPart`].+//!+//! Units: KiCad is millimetres. Symbol pins convert mm → mils (the model's+//! schematic unit, 1 mil = 0.0254 mm); footprint pads stay in mm.+//!+//! v0 scope: pins (number/name/position/rotation) and pads (number/type/shape/+//! position/size/drill). Silk/courtyard graphics import lands with the graphics+//! work — they have nowhere to go in the Altium *writer* yet.++use crate::model::{AdomLbrPart, ConnectEntry, Footprint, FpGraphic, FpLayer, Pad, Pin, Symbol};+use anyhow::{Context, Result};++const MM_PER_MIL: f64 = 0.0254;++// ---------------------------------------------------------------------------+// Minimal s-expression parser+// ---------------------------------------------------------------------------++#[derive(Debug)]+enum Sx {+    Atom(String),+    List(Vec<Sx>),+}++impl Sx {+    fn head(&self) -> Option<&str> {+        match self {+            Sx::List(v) => match v.first() {+                Some(Sx::Atom(a)) => Some(a.as_str()),+                _ => None,+            },+            _ => None,+        }+    }+    fn items(&self) -> &[Sx] {+        match self {+            Sx::List(v) => v,+            _ => &[],+        }+    }+    /// First child list whose head == name.+    fn child(&self, name: &str) -> Option<&Sx> {+        self.items().iter().find(|s| s.head() == Some(name))+    }+    /// Atom string at position i (after the head), if it's an atom.+    fn atom_at(&self, i: usize) -> Option<&str> {+        match self.items().get(i) {+            Some(Sx::Atom(a)) => Some(a.as_str()),+            _ => None,+        }+    }+    /// Numeric atoms in this list (skipping the head atom).+    fn nums(&self) -> Vec<f64> {+        self.items()+            .iter()+            .skip(1)+            .filter_map(|s| match s {+                Sx::Atom(a) => a.parse::<f64>().ok(),+                _ => None,+            })+            .collect()+    }+    /// Walk the whole tree, collecting every list whose head == name.+    fn collect<'a>(&'a self, name: &str, out: &mut Vec<&'a Sx>) {+        if self.head() == Some(name) {+            out.push(self);+        }+        for s in self.items() {+            if let Sx::List(_) = s {+                s.collect(name, out);+            }+        }+    }+}++fn tokenize(s: &str) -> Vec<String> {+    let mut toks = Vec::new();+    let mut it = s.chars().peekable();+    while let Some(&c) = it.peek() {+        if c.is_whitespace() {+            it.next();+        } else if c == '(' || c == ')' {+            toks.push(c.to_string());+            it.next();+        } else if c == '"' {+            it.next();+            let mut buf = String::from("\u{0}"); // sentinel marks "was a string"+            while let Some(&c) = it.peek() {+                if c == '"' {+                    it.next();+                    break;+                } else if c == '\\' {+                    it.next();+                    if let Some(&e) = it.peek() {+                        buf.push(e);+                        it.next();+                    }+                } else {+                    buf.push(c);+                    it.next();+                }+            }+            toks.push(buf);+        } else {+            let mut buf = String::new();+            while let Some(&c) = it.peek() {+                if c.is_whitespace() || c == '(' || c == ')' {+                    break;+                }+                buf.push(c);+                it.next();+            }+            toks.push(buf);+        }+    }+    toks+}++fn parse(toks: &[String], pos: &mut usize) -> Sx {+    // expects toks[*pos] == "("+    *pos += 1; // consume "("+    let mut list = Vec::new();+    while *pos < toks.len() {+        let t = &toks[*pos];+        if t == "(" {+            list.push(parse(toks, pos));+        } else if t == ")" {+            *pos += 1;+            break;+        } else {+            // strip the string sentinel if present+            let a = t.strip_prefix('\u{0}').unwrap_or(t).to_string();+            list.push(Sx::Atom(a));+            *pos += 1;+        }+    }+    Sx::List(list)+}++fn parse_root(text: &str) -> Result<Sx> {+    let toks = tokenize(text);+    let start = toks.iter().position(|t| t == "(").context("no s-expr")?;+    let mut pos = start;+    Ok(parse(&toks, &mut pos))+}++// ---------------------------------------------------------------------------+// Symbol import+// ---------------------------------------------------------------------------++fn import_symbol(text: &str) -> Result<Symbol> {+    let root = parse_root(text)?;+    let sym = root+        .child("symbol")+        .context("no (symbol ...) in kicad_sym")?;+    let name = sym.atom_at(1).unwrap_or("SYM").to_string();++    // designator prefix from the Reference property+    let mut prefix = "U".to_string();+    let mut props = Vec::new();+    root.collect("property", &mut props);+    for p in &props {+        if p.atom_at(1) == Some("Reference") {+            if let Some(r) = p.atom_at(2) {+                prefix = r.to_string();+            }+        }+    }++    // all pins anywhere under the symbol+    let mut pin_lists = Vec::new();+    sym.collect("pin", &mut pin_lists);+    let mut pins = Vec::new();+    for p in pin_lists {+        let at = p.child("at").map(|a| a.nums()).unwrap_or_default();+        let (xmm, ymm, rot) = (+            at.first().copied().unwrap_or(0.0),+            at.get(1).copied().unwrap_or(0.0),+            at.get(2).copied().unwrap_or(0.0),+        );+        let number = p+            .child("number")+            .and_then(|n| n.atom_at(1))+            .unwrap_or("?")+            .to_string();+        let pname = p+            .child("name")+            .and_then(|n| n.atom_at(1))+            .unwrap_or("~")+            .to_string();+        // KiCad pin length is in mm → convert to mils (the model's unit).+        let len_mil = p+            .child("length")+            .and_then(|l| l.nums().first().copied())+            .map(|mm| (mm / MM_PER_MIL).round() as i32);+        pins.push(Pin {+            number,+            name: pname,+            x: (xmm / MM_PER_MIL).round() as i32,+            y: (ymm / MM_PER_MIL).round() as i32,+            length: len_mil,+            rotation: ((rot as i32 % 360 + 360) % 360) as u16,+        });+    }++    // Preserve the source's pin-number / pin-name visibility (KiCad+    // `(pin_numbers (hide yes))`). Absent → shown (KiCad default).+    let has_hide = |key: &str| -> bool {+        match sym.child(key) {+            None => false,+            Some(node) => node+                .child("hide")+                .and_then(|h| h.atom_at(1))+                .map(|v| v == "yes")+                .unwrap_or_else(|| node.items().iter().any(|i| matches!(i, Sx::Atom(a) if a == "hide"))),+        }+    };++    Ok(Symbol {+        name,+        pins,+        body: None,+        graphics: import_symbol_graphics(&root),+        designator_prefix: Some(prefix),+        footprint: None,+        parameters: Vec::new(),+        pin_numbers_hidden: Some(has_hide("pin_numbers")),+        pin_names_hidden: Some(has_hide("pin_names")),+    })+}++/// Import a KiCad symbol's body graphics (polyline / rectangle / circle / arc)+/// into neutral [`Graphic`]s, in mils. Generic — not part-specific.+fn import_symbol_graphics(root: &Sx) -> Vec<crate::model::Graphic> {+    use crate::model::Graphic;+    let to_mil = |mm: f64| mm / MM_PER_MIL;+    let mut out = Vec::new();+    let width_of = |g: &Sx| -> f64 {+        g.child("stroke")+            .and_then(|s| s.child("width"))+            .and_then(|w| w.nums().first().copied())+            .map(to_mil)+            .unwrap_or(6.0)+    };++    let mut polys = Vec::new();+    root.collect("polyline", &mut polys);+    for g in polys {+        if let Some(pts) = g.child("pts") {+            let mut xy = Vec::new();+            let mut pt_lists = Vec::new();+            pts.collect("xy", &mut pt_lists);+            for p in pt_lists {+                let n = p.nums();+                if n.len() >= 2 {+                    xy.push([to_mil(n[0]), to_mil(n[1])]);+                }+            }+            if xy.len() >= 2 {+                out.push(Graphic::Polyline { points: xy, width: width_of(g) });+            }+        }+    }++    // A shape is "filled" when its (fill (type ...)) is not `none` (KiCad fills+    // with `outline` or `background`). Cap plates are filled rectangles.+    let filled_of = |g: &Sx| -> bool {+        g.child("fill")+            .and_then(|f| f.child("type"))+            .and_then(|t| t.atom_at(1))+            .map(|t| t != "none")+            .unwrap_or(false)+    };++    let mut rects = Vec::new();+    root.collect("rectangle", &mut rects);+    for g in rects {+        let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+        let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+        if s.len() >= 2 && e.len() >= 2 {+            out.push(Graphic::Rect {+                x1: to_mil(s[0]), y1: to_mil(s[1]), x2: to_mil(e[0]), y2: to_mil(e[1]),+                width: width_of(g), filled: filled_of(g),+            });+        }+    }++    let mut circs = Vec::new();+    root.collect("circle", &mut circs);+    for g in circs {+        let c = g.child("center").map(|c| c.nums()).unwrap_or_default();+        let r = g.child("radius").and_then(|r| r.nums().first().copied());+        if c.len() >= 2 {+            if let Some(r) = r {+                out.push(Graphic::Circle { cx: to_mil(c[0]), cy: to_mil(c[1]), r: to_mil(r), width: width_of(g) });+            }+        }+    }++    // Arcs — KiCad gives 3 points (start / mid / end); convert to the neutral+    // center + radius + start/end-angle form. Direction is normalized so the+    // Altium CCW sweep from start→end passes through the mid point.+    let mut arcs = Vec::new();+    root.collect("arc", &mut arcs);+    for g in arcs {+        let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+        let m = g.child("mid").map(|c| c.nums()).unwrap_or_default();+        let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+        if s.len() >= 2 && m.len() >= 2 && e.len() >= 2 {+            if let Some((cx, cy, r, sa, ea)) =+                arc_from_3pts(s[0], s[1], m[0], m[1], e[0], e[1])+            {+                out.push(Graphic::Arc {+                    cx: to_mil(cx),+                    cy: to_mil(cy),+                    r: to_mil(r),+                    start: sa,+                    end: ea,+                    width: width_of(g),+                });+            }+        }+    }+    out+}++/// Given three points on an arc (start, mid, end), return+/// `(center_x, center_y, radius, start_angle, end_angle)` with angles in degrees+/// (0..360). The angle order is chosen so a counter-clockwise sweep from the+/// start angle to the end angle (Altium's arc convention) passes through `mid`.+/// Returns `None` if the points are collinear.+fn arc_from_3pts(+    ax: f64, ay: f64, bx: f64, by: f64, cx: f64, cy: f64,+) -> Option<(f64, f64, f64, f64, f64)> {+    let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));+    if d.abs() < 1e-9 {+        return None; // collinear → no unique circle+    }+    let a2 = ax * ax + ay * ay;+    let b2 = bx * bx + by * by;+    let c2 = cx * cx + cy * cy;+    let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;+    let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;+    let r = ((ax - ux).powi(2) + (ay - uy).powi(2)).sqrt();+    let ang = |x: f64, y: f64| {+        let mut a = (y - uy).atan2(x - ux).to_degrees();+        if a < 0.0 {+            a += 360.0;+        }+        a+    };+    let sa = ang(ax, ay);+    let ma = ang(bx, by);+    let ea = ang(cx, cy);+    // Relative CCW position from the start angle; if the mid isn't inside the+    // start→end CCW sweep, swap the endpoints so it is.+    let rel = |x: f64| {+        let mut v = (x - sa) % 360.0;+        if v < 0.0 {+            v += 360.0;+        }+        v+    };+    let (sa, ea) = if rel(ma) <= rel(ea) { (sa, ea) } else { (ea, sa) };+    Some((ux, uy, r, sa, ea))+}++// ---------------------------------------------------------------------------+// Footprint import+// ---------------------------------------------------------------------------++fn import_footprint(text: &str) -> Result<Footprint> {+    let root = parse_root(text)?;+    // (footprint "NAME" ...)  — name is the first atom after head+    let name = root.atom_at(1).unwrap_or("FP").to_string();++    let mut pad_lists = Vec::new();+    root.collect("pad", &mut pad_lists);+    let mut pads = Vec::new();+    for p in pad_lists {+        let number = p.atom_at(1).unwrap_or("?").to_string();+        let ptype = p.atom_at(2).unwrap_or("smd");+        let shape = p.atom_at(3).unwrap_or("rect").to_string();+        let at = p.child("at").map(|a| a.nums()).unwrap_or_default();+        let size = p.child("size").map(|a| a.nums()).unwrap_or_default();+        let rot = at.get(2).copied().unwrap_or(0.0);+        let drill = p+            .child("drill")+            .and_then(|d| d.nums().first().copied())+            .unwrap_or(0.0);+        let kshape = match shape.as_str() {+            "circle" => "circle",+            "oval" => "oval",+            "roundrect" => "roundrect",+            _ => "rect",+        };+        pads.push(Pad {+            number,+            x_mm: at.first().copied().unwrap_or(0.0),+            y_mm: at.get(1).copied().unwrap_or(0.0),+            w_mm: size.first().copied().unwrap_or(0.5),+            h_mm: size.get(1).copied().unwrap_or(0.5),+            drill_mm: if ptype == "thru_hole" { drill } else { 0.0 },+            rotation: rot,+            shape: kshape.to_string(),+        });+    }++    Ok(Footprint { name, pads, graphics: import_footprint_graphics(&root) })+}++/// Map a KiCad footprint layer name to a neutral [`FpLayer`] role. Returns+/// `None` for layers we don't carry (paste/mask/etc.).+fn kicad_fp_layer(layer: &str) -> Option<FpLayer> {+    if layer.contains("SilkS") {+        Some(FpLayer::Silk)+    } else if layer.contains("CrtYd") {+        Some(FpLayer::Courtyard)+    } else if layer.contains("Fab") {+        Some(FpLayer::Fab)+    } else if layer.ends_with(".Cu") {+        Some(FpLayer::Copper)+    } else {+        None+    }+}++/// Import a KiCad footprint's silk/courtyard/fab graphics into neutral+/// [`FpGraphic`]s (mm, Y-down). Covers fp_line / fp_rect / fp_circle / fp_arc.+fn import_footprint_graphics(root: &Sx) -> Vec<FpGraphic> {+    let mut out = Vec::new();+    let layer_of = |g: &Sx| -> Option<FpLayer> {+        g.child("layer")+            .and_then(|l| l.atom_at(1))+            .and_then(kicad_fp_layer)+    };+    let width_of = |g: &Sx| -> f64 {+        g.child("stroke")+            .and_then(|s| s.child("width"))+            .and_then(|w| w.nums().first().copied())+            .or_else(|| g.child("width").and_then(|w| w.nums().first().copied()))+            .unwrap_or(0.12)+    };++    let mut lines = Vec::new();+    root.collect("fp_line", &mut lines);+    for g in lines {+        if let Some(layer) = layer_of(g) {+            let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+            let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+            if s.len() >= 2 && e.len() >= 2 {+                out.push(FpGraphic::Line { layer, x1: s[0], y1: s[1], x2: e[0], y2: e[1], width: width_of(g) });+            }+        }+    }++    let mut rects = Vec::new();+    root.collect("fp_rect", &mut rects);+    for g in rects {+        if let Some(layer) = layer_of(g) {+            let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+            let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+            if s.len() >= 2 && e.len() >= 2 {+                out.push(FpGraphic::Rect { layer, x1: s[0], y1: s[1], x2: e[0], y2: e[1], width: width_of(g) });+            }+        }+    }++    let mut circs = Vec::new();+    root.collect("fp_circle", &mut circs);+    for g in circs {+        if let Some(layer) = layer_of(g) {+            let c = g.child("center").map(|c| c.nums()).unwrap_or_default();+            let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+            if c.len() >= 2 && e.len() >= 2 {+                let r = ((e[0] - c[0]).powi(2) + (e[1] - c[1]).powi(2)).sqrt();+                out.push(FpGraphic::Circle { layer, cx: c[0], cy: c[1], r, width: width_of(g) });+            }+        }+    }++    let mut arcs = Vec::new();+    root.collect("fp_arc", &mut arcs);+    for g in arcs {+        if let Some(layer) = layer_of(g) {+            let s = g.child("start").map(|c| c.nums()).unwrap_or_default();+            let m = g.child("mid").map(|c| c.nums()).unwrap_or_default();+            let e = g.child("end").map(|c| c.nums()).unwrap_or_default();+            if s.len() >= 2 && m.len() >= 2 && e.len() >= 2 {+                if let Some((cx, cy, r, sa, ea)) = arc_from_3pts(s[0], s[1], m[0], m[1], e[0], e[1]) {+                    out.push(FpGraphic::Arc { layer, cx, cy, r, start: sa, end: ea, width: width_of(g) });+                }+            }+        }+    }+    out+}++// ---------------------------------------------------------------------------+// Public: KiCad symbol + footprint → AdomLbrPart+// ---------------------------------------------------------------------------++/// Import a KiCad symbol + footprint pair into the neutral part. The pin↔pad+/// link is identity-mapped (pin number == pad number) where they coincide, and+/// the symbol is given the footprint name so the Altium writer links them.+pub fn import_kicad(sym_text: &str, mod_text: &str, mpn: &str) -> Result<AdomLbrPart> {+    let mut symbol = import_symbol(sym_text)?;+    let footprint = import_footprint(mod_text)?;+    symbol.footprint = Some(footprint.name.clone());+    // Center the symbol on the origin (KiCad sources are often drawn off-origin).+    symbol.center();++    // identity connect map over shared designators+    let pad_nums: std::collections::HashSet<&str> =+        footprint.pads.iter().map(|p| p.number.as_str()).collect();+    let connect = symbol+        .pins+        .iter()+        .filter(|p| pad_nums.contains(p.number.as_str()))+        .map(|p| ConnectEntry {+            pin: p.number.clone(),+            pad: p.number.clone(),+        })+        .collect();++    Ok(AdomLbrPart {+        schema_version: 2,+        mpn: mpn.to_string(),+        manufacturer: String::new(),+        package: String::new(),+        value: String::new(),+        category: String::new(),+        symbol,+        footprint,+        connect,+        model_3d: None,+        provenance: Some(serde_json::json!({"source":"kicad_import"})),+    })+}
crates/altium-codec/src/kicad_export.rsadded+289
@@ -0,0 +1,289 @@+//! Canonical [`AdomLbrPart`] → KiCad `.kicad_sym` + `.kicad_mod` (the KiCad+//! WRITE side — inverse of `kicad.rs`). Symbol coords are stored in mils and+//! convert to mm here; footprint coords are already mm. Graphics arcs are+//! emitted in KiCad's 3-point (start/mid/end) form.++use crate::model::{Footprint, FpGraphic, FpLayer, Graphic, Symbol};++const MM_PER_MIL: f64 = 0.0254;++/// Format a float compactly (KiCad tolerates plain decimals; trim trailing 0s).+fn n(v: f64) -> String {+    let s = format!("{v:.6}");+    let s = s.trim_end_matches('0').trim_end_matches('.');+    if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() }+}++// ---------------------------------------------------------------------------+// Symbol → .kicad_sym+// ---------------------------------------------------------------------------++/// Export a symbol as a complete `.kicad_sym` library (one symbol).+pub fn export_kicad_symbol(sym: &Symbol) -> String {+    let m = |mil: f64| n(mil * MM_PER_MIL);+    let name = &sym.name;+    let reference = sym.designator_prefix.clone().unwrap_or_else(|| "U".into());+    let footprint = sym.footprint.clone().unwrap_or_default();+    let value = sym+        .parameters+        .iter()+        .find(|p| p.name.eq_ignore_ascii_case("Value"))+        .map(|p| p.value.clone())+        .unwrap_or_else(|| name.clone());++    // Bounding box (mm) over pins + graphics, so the Reference/Value labels sit+    // just outside the symbol instead of overlapping it at the origin.+    let (top, bottom, cx) = symbol_bbox_mm(sym);++    let mut s = String::new();+    s.push_str("(kicad_symbol_lib\n\t(version 20231120)\n\t(generator \"adom-lbr\")\n");+    s.push_str(&format!("\t(symbol \"{name}\"\n"));+    // Preserve the source's pin-number/name visibility; when unknown (e.g. built+    // from Altium or scratch), default passives (≤2 pins) to hidden like KiCad's+    // own R/C/L symbols.+    let passive = sym.pins.len() <= 2;+    let hide_num = sym.pin_numbers_hidden.unwrap_or(passive);+    let hide_name = sym.pin_names_hidden.unwrap_or(passive);+    s.push_str(if hide_num {+        "\t\t(pin_numbers (hide yes))\n"+    } else {+        "\t\t(pin_numbers)\n"+    });+    s.push_str(if hide_name {+        "\t\t(pin_names (offset 0.254) (hide yes))\n"+    } else {+        "\t\t(pin_names (offset 0.254))\n"+    });+    s.push_str("\t\t(exclude_from_sim no)\n\t\t(in_bom yes)\n\t\t(on_board yes)\n");+    // properties (Reference above the symbol, Value below; metadata hidden at origin)+    let prop = |k: &str, v: &str, x: f64, y: f64, hidden: bool| {+        let h = if hidden { " (hide yes)" } else { "" };+        format!(+            "\t\t(property \"{k}\" \"{}\"\n\t\t\t(at {} {} 0)\n\t\t\t(effects (font (size 1.27 1.27)){h})\n\t\t)\n",+            esc(v), n(x), n(y)+        )+    };+    s.push_str(&prop("Reference", &reference, cx, top + 1.27, false));+    s.push_str(&prop("Value", &value, cx, bottom - 1.27, false));+    s.push_str(&prop("Footprint", &footprint, 0.0, 0.0, true));+    s.push_str(&prop("Datasheet", "~", 0.0, 0.0, true));+    // any extra parameters become hidden properties (at origin)+    for p in &sym.parameters {+        if !p.name.eq_ignore_ascii_case("Value") {+            s.push_str(&prop(&p.name, &p.value, 0.0, 0.0, !p.visible));+        }+    }++    // body graphics → the _0_1 (shared) sub-symbol+    if !sym.graphics.is_empty() {+        s.push_str(&format!("\t\t(symbol \"{name}_0_1\"\n"));+        for g in &sym.graphics {+            s.push_str(&sym_graphic(g, &m));+        }+        s.push_str("\t\t)\n");+    }++    // pins → the _1_1 (unit 1) sub-symbol+    s.push_str(&format!("\t\t(symbol \"{name}_1_1\"\n"));+    for pin in &sym.pins {+        let len = pin.length.unwrap_or(200) as f64;+        s.push_str(&format!(+            "\t\t\t(pin unspecified line\n\t\t\t\t(at {} {} {})\n\t\t\t\t(length {})\n\+             \t\t\t\t(name \"{}\" (effects (font (size 1.27 1.27))))\n\+             \t\t\t\t(number \"{}\" (effects (font (size 1.27 1.27))))\n\t\t\t)\n",+            m(pin.x as f64), m(pin.y as f64), pin.rotation, m(len),+            esc(&pin.name), esc(&pin.number)+        ));+    }+    s.push_str("\t\t)\n\t)\n)\n");+    s+}++/// One symbol body graphic → KiCad s-expr (mils→mm via `m`).+fn sym_graphic(g: &Graphic, m: &dyn Fn(f64) -> String) -> String {+    let stroke_fill = |w: f64, filled: bool| {+        let fill = if filled { "outline" } else { "none" };+        format!(+            "\t\t\t\t(stroke (width {}) (type default))\n\t\t\t\t(fill (type {fill}))\n",+            n((w * MM_PER_MIL).max(0.0))+        )+    };+    match g {+        Graphic::Rect { x1, y1, x2, y2, width, filled } => format!(+            "\t\t\t(rectangle\n\t\t\t\t(start {} {})\n\t\t\t\t(end {} {})\n{}\t\t\t)\n",+            m(*x1), m(*y1), m(*x2), m(*y2), stroke_fill(*width, *filled)+        ),+        Graphic::Circle { cx, cy, r, width } => format!(+            "\t\t\t(circle\n\t\t\t\t(center {} {})\n\t\t\t\t(radius {})\n{}\t\t\t)\n",+            m(*cx), m(*cy), m(*r), stroke_fill(*width, false)+        ),+        Graphic::Arc { cx, cy, r, start, end, width } => {+            let (s, mid, e) = arc_points(*cx, *cy, *r, *start, *end);+            format!(+                "\t\t\t(arc\n\t\t\t\t(start {} {})\n\t\t\t\t(mid {} {})\n\t\t\t\t(end {} {})\n{}\t\t\t)\n",+                m(s.0), m(s.1), m(mid.0), m(mid.1), m(e.0), m(e.1), stroke_fill(*width, false)+            )+        }+        Graphic::Polyline { points, width } => {+            let mut pts = String::new();+            for p in points {+                pts.push_str(&format!("\t\t\t\t\t(xy {} {})\n", m(p[0]), m(p[1])));+            }+            format!(+                "\t\t\t(polyline\n\t\t\t\t(pts\n{pts}\t\t\t\t)\n{}\t\t\t)\n",+                stroke_fill(*width, false)+            )+        }+    }+}++// ---------------------------------------------------------------------------+// Footprint → .kicad_mod+// ---------------------------------------------------------------------------++/// Export a footprint as a `.kicad_mod`.+pub fn export_kicad_footprint(fp: &Footprint) -> String {+    let smd = fp.pads.iter().all(|p| p.drill_mm <= 0.0);+    let mut s = String::new();+    s.push_str(&format!("(footprint \"{}\"\n", esc(&fp.name)));+    s.push_str("\t(version 20240108)\n\t(generator \"adom-lbr\")\n\t(layer \"F.Cu\")\n");+    s.push_str(if smd { "\t(attr smd)\n" } else { "\t(attr through_hole)\n" });++    // silk / courtyard / fab graphics+    for g in &fp.graphics {+        s.push_str(&fp_graphic(g));+    }++    // pads+    for pad in &fp.pads {+        let thru = pad.drill_mm > 0.0;+        let (ptype, layers) = if thru {+            ("thru_hole", "\"*.Cu\" \"*.Mask\"")+        } else {+            ("smd", "\"F.Cu\" \"F.Paste\" \"F.Mask\"")+        };+        let shape = match pad.shape.as_str() {+            "circle" => "circle",+            "oval" => "oval",+            "roundrect" => "roundrect",+            _ => "rect",+        };+        let at = if pad.rotation != 0.0 {+            format!("(at {} {} {})", n(pad.x_mm), n(pad.y_mm), n(pad.rotation))+        } else {+            format!("(at {} {})", n(pad.x_mm), n(pad.y_mm))+        };+        s.push_str(&format!(+            "\t(pad \"{}\" {ptype} {shape} {at} (size {} {})",+            esc(&pad.number), n(pad.w_mm), n(pad.h_mm)+        ));+        if thru {+            s.push_str(&format!(" (drill {})", n(pad.drill_mm)));+        }+        s.push_str(&format!(" (layers {layers})"));+        if shape == "roundrect" {+            s.push_str(" (roundrect_rratio 0.25)");+        }+        s.push_str(")\n");+    }+    s.push_str(")\n");+    s+}++fn kicad_fp_layer(l: &FpLayer) -> &'static str {+    match l {+        FpLayer::Silk => "F.SilkS",+        FpLayer::Courtyard => "F.CrtYd",+        FpLayer::Fab => "F.Fab",+        FpLayer::Copper => "F.Cu",+        FpLayer::Other { .. } => "F.Fab",+    }+}++fn fp_graphic(g: &FpGraphic) -> String {+    match g {+        FpGraphic::Line { layer, x1, y1, x2, y2, width } => format!(+            "\t(fp_line (start {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (layer \"{}\"))\n",+            n(*x1), n(*y1), n(*x2), n(*y2), n(*width), kicad_fp_layer(layer)+        ),+        FpGraphic::Rect { layer, x1, y1, x2, y2, width } => format!(+            "\t(fp_rect (start {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (fill none) (layer \"{}\"))\n",+            n(*x1), n(*y1), n(*x2), n(*y2), n(*width), kicad_fp_layer(layer)+        ),+        FpGraphic::Circle { layer, cx, cy, r, width } => format!(+            "\t(fp_circle (center {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (fill none) (layer \"{}\"))\n",+            n(*cx), n(*cy), n(cx + r), n(*cy), n(*width), kicad_fp_layer(layer)+        ),+        FpGraphic::Arc { layer, cx, cy, r, start, end, width } => {+            let (s, mid, e) = arc_points(*cx, *cy, *r, *start, *end);+            format!(+                "\t(fp_arc (start {} {}) (mid {} {}) (end {} {})\n\t\t(stroke (width {}) (type default)) (layer \"{}\"))\n",+                n(s.0), n(s.1), n(mid.0), n(mid.1), n(e.0), n(e.1), n(*width), kicad_fp_layer(layer)+            )+        }+    }+}++// ---------------------------------------------------------------------------+// helpers+// ---------------------------------------------------------------------------++/// center/radius/start-end angles (deg, CCW) → the (start, mid, end) points+/// KiCad uses. Same units in and out.+fn arc_points(cx: f64, cy: f64, r: f64, start: f64, end: f64) -> ((f64, f64), (f64, f64), (f64, f64)) {+    let mut sweep = end - start;+    while sweep <= 0.0 {+        sweep += 360.0;+    }+    let mid_ang = start + sweep / 2.0;+    let pt = |deg: f64| {+        let a = deg.to_radians();+        (cx + r * a.cos(), cy + r * a.sin())+    };+    (pt(start), pt(mid_ang), pt(end))+}++fn esc(s: &str) -> String {+    s.replace('\\', "\\\\").replace('"', "\\\"")+}++/// Symbol bounding box in mm over pins + graphics → `(top_y, bottom_y, center_x)`.+/// Used to place the Reference (above) and Value (below) labels clear of the art.+fn symbol_bbox_mm(sym: &Symbol) -> (f64, f64, f64) {+    let (mut minx, mut maxx) = (f64::INFINITY, f64::NEG_INFINITY);+    let (mut miny, mut maxy) = (f64::INFINITY, f64::NEG_INFINITY);+    let mut upd = |x: f64, y: f64| {+        minx = minx.min(x);+        maxx = maxx.max(x);+        miny = miny.min(y);+        maxy = maxy.max(y);+    };+    for p in &sym.pins {+        upd(p.x as f64, p.y as f64);+    }+    for g in &sym.graphics {+        match g {+            Graphic::Rect { x1, y1, x2, y2, .. } => {+                upd(*x1, *y1);+                upd(*x2, *y2);+            }+            Graphic::Circle { cx, cy, r, .. } | Graphic::Arc { cx, cy, r, .. } => {+                upd(cx - r, cy - r);+                upd(cx + r, cy + r);+            }+            Graphic::Polyline { points, .. } => {+                for p in points {+                    upd(p[0], p[1]);+                }+            }+        }+    }+    if !minx.is_finite() {+        return (2.54, -2.54, 0.0);+    }+    (+        maxy * MM_PER_MIL,+        miny * MM_PER_MIL,+        ((minx + maxx) / 2.0) * MM_PER_MIL,+    )+}
crates/altium-codec/src/lib.rsadded+87
@@ -0,0 +1,87 @@+//! altium-codec — encode + decode Altium `.SchLib` (symbols) and `.PcbLib`+//! (footprints) from a neutral part model.+//!+//! This is the Altium half of the universal EDA interchange writer: the+//! canonical JSON maps onto [`model`], the encoders emit native Altium binary+//! with **no Altium at runtime**, and the decoders (thin wrappers over the+//! validated `altium-schlib` / `altium-pcblib` readers) let every encode be+//! round-trip checked.++pub mod altium_decode;+pub mod intlib;+pub mod kicad;+pub mod kicad_export;+pub mod model;+pub mod pcblib;+pub mod schlib;++pub use altium_decode::{decode_altium, decode_intlib_part};+pub use intlib::{decode_intlib, encode_intlib};+pub use kicad::import_kicad;+pub use kicad_export::{export_kicad_footprint, export_kicad_symbol};+pub use model::{AdomLbrPart, BodyRect, ConnectEntry, Footprint, FpGraphic, FpLayer, Graphic, Pad, Parameter, Pin, Symbol};+pub use pcblib::{decode_pcblib, encode_pcblib};+pub use schlib::{add_symbols_to_schlib, decode_schlib_first, encode_schlib};++#[cfg(test)]+mod tests {+    use super::*;++    fn two_pin_symbol() -> Symbol {+        Symbol {+            name: "REF2".into(),+            designator_prefix: Some("U".into()),+            footprint: None,+            body: None,+            graphics: vec![],+            parameters: vec![],+            // pins in mils (connection point); length 100, angle 0 → Altium+            // stores the body end = -100 + 100 = 0.+            pins: vec![+                Pin { number: "1".into(), name: "A".into(), x: -100, y: 100, length: Some(100), rotation: 0 },+                Pin { number: "2".into(), name: "B".into(), x: -100, y: -100, length: Some(100), rotation: 0 },+            ],+        }+    }++    #[test]+    fn schlib_roundtrip_pins() {+        let sym = two_pin_symbol();+        let bytes = encode_schlib(&[sym.clone()]).expect("encode");+        let decoded = decode_schlib_first(&bytes).expect("decode our own output");++        assert_eq!(decoded["module_name"], "REF2");+        assert_eq!(decoded["pin_count"], 2);+        let pins = decoded["pins"].as_array().unwrap();+        assert_eq!(pins[0]["name"], "A");+        assert_eq!(pins[0]["number"], "1");+        assert_eq!(pins[1]["name"], "B");+        assert_eq!(pins[1]["number"], "2");+        // body-end X = (-100 + 100) mil * 25.6 = 0; Y = 100 mil / 10 = 10+        assert_eq!(pins[0]["x"], 0);+        assert_eq!(pins[0]["y"], 10);+    }++    #[test]+    fn pcblib_roundtrip_pads() {+        let fp = Footprint {+            name: "REFFP".into(),+            pads: vec![+                Pad { number: "1".into(), x_mm: -0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into() },+                Pad { number: "2".into(), x_mm: 0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into() },+            ],+            graphics: vec![],+        };+        let bytes = encode_pcblib(&fp).expect("encode");+        let decoded = decode_pcblib(&bytes).expect("decode our own output");++        assert_eq!(decoded["pad_count"], 2);+        let pads = decoded["pads"].as_array().unwrap();+        assert_eq!(pads[0]["number"], "1");+        // -0.762 mm in, -0.762 mm back (within rounding)+        let x = pads[0]["at"][0].as_f64().unwrap();+        assert!((x - (-0.762)).abs() < 1e-3, "x was {x}");+        let w = pads[0]["size"][0].as_f64().unwrap();+        assert!((w - 1.016).abs() < 1e-3, "w was {w}");+    }+}
crates/altium-codec/src/main.rsadded+146
@@ -0,0 +1,146 @@+//! altium-codec CLI — encode neutral-model JSON to Altium libraries, or decode.+//!+//! Usage:+//!   altium-codec encode-sym <symbols.json> <out.SchLib>+//!   altium-codec encode-fp  <footprint.json> <out.PcbLib>+//!   altium-codec decode     <in.SchLib|in.PcbLib>+//!+//! symbols.json: a JSON array of Symbol, or a single Symbol object.+//! footprint.json: a single Footprint object.++use altium_codec::{+    decode_pcblib, decode_schlib_first, encode_pcblib, encode_schlib, import_kicad, Footprint,+    Symbol,+};+use anyhow::{bail, Context, Result};+use std::fs;+use std::path::Path;++/// Seed a symbol's Altium property fields from the part identity, unless the+/// adom-lbr json already carries a same-named parameter. `Value` is drawn on the+/// symbol; identity metadata (Manufacturer, MPN, Package) is added hidden.+fn add_part_parameters(symbol: &mut altium_codec::Symbol, part: &altium_codec::AdomLbrPart) {+    use altium_codec::Parameter;+    let mut push = |name: &str, value: &str, visible: bool| {+        if value.is_empty() {+            return;+        }+        if symbol.parameters.iter().any(|p| p.name.eq_ignore_ascii_case(name)) {+            return;+        }+        symbol.parameters.push(Parameter { name: name.into(), value: value.into(), visible });+    };+    push("Value", &part.value, true);+    push("Manufacturer", &part.manufacturer, false);+    push("Manufacturer Part Number", &part.mpn, false);+    push("Package", &part.package, false);+}++fn main() -> Result<()> {+    let args: Vec<String> = std::env::args().collect();+    if args.len() < 3 {+        eprintln!("usage: altium-codec <encode-sym|encode-fp|decode> <in> [out]");+        std::process::exit(2);+    }+    match args[1].as_str() {+        "encode-sym" => {+            let json = fs::read_to_string(&args[2]).context("read symbols json")?;+            let syms: Vec<Symbol> = match serde_json::from_str::<Vec<Symbol>>(&json) {+                Ok(v) => v,+                Err(_) => vec![serde_json::from_str::<Symbol>(&json).context("parse Symbol")?],+            };+            let bytes = encode_schlib(&syms)?;+            let out = args.get(3).map(|s| s.as_str()).unwrap_or("out.SchLib");+            fs::write(out, &bytes)?;+            println!("wrote {} ({} bytes, {} symbols)", out, bytes.len(), syms.len());+        }+        "encode-fp" => {+            let json = fs::read_to_string(&args[2]).context("read footprint json")?;+            let fp: Footprint = serde_json::from_str(&json).context("parse Footprint")?;+            let bytes = encode_pcblib(&fp)?;+            let out = args.get(3).map(|s| s.as_str()).unwrap_or("out.PcbLib");+            fs::write(out, &bytes)?;+            println!("wrote {} ({} bytes, {} pads)", out, bytes.len(), fp.pads.len());+        }+        "decode" => {+            let p = Path::new(&args[2]);+            let bytes = fs::read(p).context("read input")?;+            let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();+            let v = if ext.contains("pcblib") {+                decode_pcblib(&bytes)?+            } else {+                decode_schlib_first(&bytes)?+            };+            println!("{}", serde_json::to_string_pretty(&v)?);+        }+        // kicad sym+mod  →  universal part.json  (the import / hub step)+        "import-kicad" => {+            let sym = fs::read_to_string(&args[2]).context("read kicad_sym")?;+            let md = fs::read_to_string(&args[3]).context("read kicad_mod")?;+            let mpn = args.get(4).cloned().unwrap_or_else(|| "PART".into());+            let part = import_kicad(&sym, &md, &mpn)?;+            let json = serde_json::to_string_pretty(&part)?;+            println!("{json}");+        }+        // universal part.json  →  Altium .SchLib + .PcbLib  (the export step)+        "export-altium" => {+            let json = fs::read_to_string(&args[2]).context("read part.json")?;+            let part: altium_codec::AdomLbrPart =+                serde_json::from_str(&json).context("parse AdomLbrPart")?;+            let outdir = args.get(3).map(|s| s.as_str()).unwrap_or(".");+            let mut symbol = part.symbol.clone();+            add_part_parameters(&mut symbol, &part);+            let sym_bytes = encode_schlib(&[symbol])?;+            let fp_bytes = encode_pcblib(&part.footprint)?;+            let sp = format!("{outdir}/{}.SchLib", part.mpn);+            let pp = format!("{outdir}/{}.PcbLib", part.mpn);+            fs::write(&sp, &sym_bytes)?;+            fs::write(&pp, &fp_bytes)?;+            println!(+                "wrote {sp} ({} pins) + {pp} ({} pads)",+                part.symbol.pins.len(),+                part.footprint.pads.len()+            );+        }+        // universal part.json  →  one self-contained Altium .IntLib+        "export-intlib" => {+            let json = fs::read_to_string(&args[2]).context("read part.json")?;+            let part: altium_codec::AdomLbrPart =+                serde_json::from_str(&json).context("parse AdomLbrPart")?;+            let descr = if part.value.is_empty() { part.mpn.clone() } else { format!("{} {}", part.value, part.package) };+            let mut symbol = part.symbol.clone();+            add_part_parameters(&mut symbol, &part);+            let bytes = altium_codec::encode_intlib(&symbol, &part.footprint, &descr)?;+            let out = args.get(3).cloned().unwrap_or_else(|| format!("{}.IntLib", part.mpn));+            fs::write(&out, &bytes)?;+            println!("wrote {out} ({} bytes) — symbol {} + footprint {} bundled",+                bytes.len(), part.symbol.name, part.footprint.name);+        }+        // Add a part's symbol into an EXISTING .SchLib (non-destructive merge):+        //   altium-codec add-to-schlib <existing.SchLib> <part.json> <out.SchLib>+        "add-to-schlib" => {+            let existing = fs::read(&args[2]).context("read existing SchLib")?;+            let json = fs::read_to_string(&args[3]).context("read part.json")?;+            // Accept an AdomLbrPart, a bare Symbol, or an array of Symbols.+            let syms: Vec<Symbol> = if let Ok(p) =+                serde_json::from_str::<altium_codec::AdomLbrPart>(&json)+            {+                vec![p.symbol]+            } else if let Ok(v) = serde_json::from_str::<Vec<Symbol>>(&json) {+                v+            } else {+                vec![serde_json::from_str::<Symbol>(&json).context("parse Symbol/AdomLbrPart")?]+            };+            let merged = altium_codec::add_symbols_to_schlib(&existing, &syms)?;+            let out = args.get(4).map(|s| s.as_str()).unwrap_or("merged.SchLib");+            fs::write(out, &merged)?;+            println!(+                "wrote {out} ({} bytes) — added {} symbol(s) into existing library",+                merged.len(),+                syms.len()+            );+        }+        other => bail!("unknown command: {other}"),+    }+    Ok(())+}
crates/altium-codec/src/model.rsadded+276
@@ -0,0 +1,276 @@+//! The **adom-lbr** part model — the in-memory form of the adom-lbr json, the+//! canonical Adom library format. Every EDA adapter (Altium, EAGLE/Fusion,+//! OrCAD) converts to and from this one shape. It is Adom's own format; the+//! per-tool files are just generated views of it.+//!+//! Units: symbols use Altium schematic units (1 = 1/100 inch grid step, i.e.+//! "mils/10" as Altium stores Location.X/Y as i16 in 1/100-inch? — see schlib.rs+//! for the exact scale). Footprints use millimetres at the model layer; the+//! PcbLib encoder converts mm → Altium's 1/10000-mil integer units.++use serde::{Deserialize, Serialize};++/// A schematic symbol: a body rectangle plus pins. One SchLib holds many.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Symbol {+    /// LibReference — the component's library name.+    pub name: String,+    /// Pins, in declaration order.+    pub pins: Vec<Pin>,+    /// Body rectangle in mils (lo corner, hi corner). Defaults to a sane box.+    #[serde(default)]+    pub body: Option<BodyRect>,+    /// Generic body graphics (polylines/rects/circles/arcs). When present, these+    /// are drawn instead of the default body box. Format-neutral.+    #[serde(default)]+    pub graphics: Vec<Graphic>,+    /// Designator prefix without the '?' (e.g. "U", "R"). Default "U".+    #[serde(default)]+    pub designator_prefix: Option<String>,+    /// Optional footprint name to link (PCBLIB implementation).+    #[serde(default)]+    pub footprint: Option<String>,+    /// Component property fields (Altium RECORD=41 parameters) — e.g. Value,+    /// Manufacturer, MPN. Shown in the component's Properties panel.+    #[serde(default)]+    pub parameters: Vec<Parameter>,+    /// Whether pin numbers are hidden on the symbol (KiCad `(pin_numbers+    /// (hide yes))`). `None` → the exporter picks a passive-friendly default.+    #[serde(default)]+    pub pin_numbers_hidden: Option<bool>,+    /// Whether pin names are hidden on the symbol. `None` → default.+    #[serde(default)]+    pub pin_names_hidden: Option<bool>,+}++/// A component parameter / property field (Altium RECORD=41). Appears in the+/// component's Properties. `visible=false` → the field exists but isn't drawn+/// on the symbol (Altium `IsHidden=T`), which is the norm for metadata.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Parameter {+    /// Property name (e.g. "Value", "Manufacturer").+    pub name: String,+    /// Property value/text.+    pub value: String,+    /// Whether the field is drawn on the symbol. Metadata defaults to hidden.+    #[serde(default)]+    pub visible: bool,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Pin {+    /// Pin number / designator (e.g. "1", "A4").+    pub number: String,+    /// Pin name / label (e.g. "VDD", "GND").+    pub name: String,+    /// Location.X in mils.+    pub x: i32,+    /// Location.Y in mils.+    pub y: i32,+    /// Pin length in mils. Default 200.+    #[serde(default)]+    pub length: Option<i32>,+    /// Orientation in degrees: 0 / 90 / 180 / 270.+    #[serde(default)]+    pub rotation: u16,+}++/// A generic symbol graphic primitive (the symbol body / decorations). Units are+/// mils. This is format-agnostic: each EDA adapter maps it to its own primitive+/// (KiCad polyline/rectangle/circle/arc ↔ Altium RECORD=6/14/11/12). Works for+/// ANY component — nothing is cap-specific.+#[derive(Debug, Clone, Serialize, Deserialize)]+#[serde(tag = "kind", rename_all = "lowercase")]+pub enum Graphic {+    /// Connected line segments through `points` (mils).+    Polyline { points: Vec<[f64; 2]>, #[serde(default)] width: f64 },+    /// Axis-aligned rectangle (mils).+    Rect { x1: f64, y1: f64, x2: f64, y2: f64, #[serde(default)] width: f64, #[serde(default)] filled: bool },+    /// Circle (mils).+    Circle { cx: f64, cy: f64, r: f64, #[serde(default)] width: f64 },+    /// Arc, angles in degrees (mils).+    Arc { cx: f64, cy: f64, r: f64, start: f64, end: f64, #[serde(default)] width: f64 },+}++impl Symbol {+    /// Shift the symbol so its bounding box (pins + graphics) is centered on the+    /// origin, snapped to the 50-mil grid so pins stay grid-aligned.+    pub fn center(&mut self) {+        let (mut minx, mut maxx) = (f64::INFINITY, f64::NEG_INFINITY);+        let (mut miny, mut maxy) = (f64::INFINITY, f64::NEG_INFINITY);+        let mut upd = |x: f64, y: f64| {+            minx = minx.min(x);+            maxx = maxx.max(x);+            miny = miny.min(y);+            maxy = maxy.max(y);+        };+        for p in &self.pins {+            upd(p.x as f64, p.y as f64);+        }+        for g in &self.graphics {+            match g {+                Graphic::Polyline { points, .. } => {+                    for pt in points {+                        upd(pt[0], pt[1]);+                    }+                }+                Graphic::Rect { x1, y1, x2, y2, .. } => {+                    upd(*x1, *y1);+                    upd(*x2, *y2);+                }+                Graphic::Circle { cx, cy, r, .. } | Graphic::Arc { cx, cy, r, .. } => {+                    upd(cx - r, cy - r);+                    upd(cx + r, cy + r);+                }+            }+        }+        if !minx.is_finite() {+            return;+        }+        let cx = (((minx + maxx) / 2.0) / 50.0).round() * 50.0;+        let cy = (((miny + maxy) / 2.0) / 50.0).round() * 50.0;+        for p in &mut self.pins {+            p.x -= cx as i32;+            p.y -= cy as i32;+        }+        for g in &mut self.graphics {+            match g {+                Graphic::Polyline { points, .. } => {+                    for pt in points.iter_mut() {+                        pt[0] -= cx;+                        pt[1] -= cy;+                    }+                }+                Graphic::Rect { x1, y1, x2, y2, .. } => {+                    *x1 -= cx;+                    *x2 -= cx;+                    *y1 -= cy;+                    *y2 -= cy;+                }+                Graphic::Circle { cx: gcx, cy: gcy, .. }+                | Graphic::Arc { cx: gcx, cy: gcy, .. } => {+                    *gcx -= cx;+                    *gcy -= cy;+                }+            }+        }+    }+}++#[derive(Debug, Clone, Copy, Serialize, Deserialize)]+pub struct BodyRect {+    pub x1: i32,+    pub y1: i32,+    pub x2: i32,+    pub y2: i32,+}++impl Default for BodyRect {+    fn default() -> Self {+        BodyRect { x1: 0, y1: -50, x2: 200, y2: 50 }+    }+}++/// A PCB footprint: pads plus silk / courtyard / fab graphics. One PcbLib holds+/// many.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Footprint {+    /// Pattern / footprint name.+    pub name: String,+    pub pads: Vec<Pad>,+    /// Non-copper graphics (silkscreen, courtyard, fab/assembly outlines). Kept+    /// layer-tagged so every EDA target lands them on the right layer.+    #[serde(default)]+    pub graphics: Vec<FpGraphic>,+}++/// Footprint layer role — format-neutral. Each EDA adapter maps it to its own+/// layer (KiCad `F.SilkS`/`F.CrtYd`/`F.Fab` ↔ Altium TopOverlay(33)/Mech15(71)/+/// Mech13(69)). `Other` carries the raw Altium layer byte so unknown layers+/// survive a round trip.+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]+#[serde(tag = "role", rename_all = "lowercase")]+pub enum FpLayer {+    Silk,+    Courtyard,+    Fab,+    Copper,+    Other {+        /// Raw Altium layer id (for round-trip fidelity).+        #[serde(default)]+        altium: u8,+    },+}++/// A footprint graphic primitive on a non-copper layer. Units: mm, Y-down+/// (neutral convention). Format-neutral; each adapter maps it to its own record+/// (KiCad fp_line/fp_rect/fp_circle/fp_arc ↔ Altium Track/Arc).+#[derive(Debug, Clone, Serialize, Deserialize)]+#[serde(tag = "kind", rename_all = "lowercase")]+pub enum FpGraphic {+    /// Straight segment.+    Line { layer: FpLayer, x1: f64, y1: f64, x2: f64, y2: f64, #[serde(default)] width: f64 },+    /// Axis-aligned rectangle (emitted as 4 lines in most targets).+    Rect { layer: FpLayer, x1: f64, y1: f64, x2: f64, y2: f64, #[serde(default)] width: f64 },+    /// Circle.+    Circle { layer: FpLayer, cx: f64, cy: f64, r: f64, #[serde(default)] width: f64 },+    /// Arc, angles in degrees (CCW from start to end).+    Arc { layer: FpLayer, cx: f64, cy: f64, r: f64, start: f64, end: f64, #[serde(default)] width: f64 },+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Pad {+    /// Pad designator (e.g. "1", "2", "GND").+    pub number: String,+    /// Centre X in mm (KiCad/neutral convention, Y-down).+    pub x_mm: f64,+    /// Centre Y in mm (Y-down — the encoder flips to Altium's Y-up).+    pub y_mm: f64,+    /// Pad width / height in mm.+    pub w_mm: f64,+    pub h_mm: f64,+    /// Drill diameter in mm. 0 → SMD; > 0 → through-hole.+    #[serde(default)]+    pub drill_mm: f64,+    /// Rotation in degrees.+    #[serde(default)]+    pub rotation: f64,+    /// "rect" | "circle" | "oval" | "roundrect".+    #[serde(default = "default_shape")]+    pub shape: String,+}++fn default_shape() -> String {+    "rect".to_string()+}++/// The unified "Adom Library" part — one document carrying identity + symbol ++/// footprint + the pin↔pad link. This is the adom-lbr json the wiki stores and+/// every EDA adapter converts to/from.+#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct AdomLbrPart {+    pub schema_version: u32,+    pub mpn: String,+    #[serde(default)]+    pub manufacturer: String,+    #[serde(default)]+    pub package: String,+    #[serde(default)]+    pub value: String,+    #[serde(default)]+    pub category: String,+    pub symbol: Symbol,+    pub footprint: Footprint,+    #[serde(default)]+    pub connect: Vec<ConnectEntry>,+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub model_3d: Option<String>,+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub provenance: Option<serde_json::Value>,+}++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct ConnectEntry {+    pub pin: String,+    pub pad: String,+}
crates/altium-codec/src/pcblib.rsadded+532
@@ -0,0 +1,532 @@+//! Altium `.PcbLib` (footprint library) **encoder** + decoder.+//!+//! A PcbLib carries ~94 KB of `Library/*` boilerplate (master layer stack,+//! version info, pad/via library …) that is near-identical for every library,+//! plus per-component storages. So the encoder **rebuilds fresh from a real+//! Altium template**: every boilerplate stream is copied verbatim, the two+//! name-bearing library streams are patched, and one clean component is written+//! with the requested name + pad count. The template's own components are simply+//! not copied — so there's no stray default component.+//!+//! Pad record (type `0x02`) = six `[u32 len][bytes]` sub-blocks: designator+//! pstring, `00`, `|&|0` const, `00`, the 202-byte pad data block, empty. Pad+//! data offsets (from `altium-pcblib`): [0] layer, [13..17] i32 x,+//! [17..21] i32 y (Y-up), [21..25] u32 xsize, [25..29] u32 ysize,+//! [45..49] u32 hole, [49] shape, [52..60] f64 rotation.+//!+//! Component names + pad counts also live in `Library/ComponentParamsTOC/Data`+//! (one `Name=..|Pad Count=N|..` record), in a length-prefixed name list at the+//! **tail** of `Library/Data`, and in each `<comp>/Parameters`. All are rebuilt.++use crate::model::{Footprint, FpGraphic, FpLayer, Pad};+use anyhow::{bail, Context, Result};+use std::collections::BTreeMap;+use std::io::{Cursor, Read, Write};++const TEMPLATE: &[u8] = include_bytes!("../templates/pcblib_template.bin");+const TRACK_TPL: &[u8] = include_bytes!("../templates/track_tpl.bin");+const ARC_TPL: &[u8] = include_bytes!("../templates/arc_tpl.bin");+const ALTIUM_UNIT_TO_MM: f64 = 0.00000254;+const MM_TO_UNIT: f64 = 1.0 / ALTIUM_UNIT_TO_MM;++/// Component storage names in the template that we never copy (the real+/// components live in the model, not the skeleton).+const TEMPLATE_COMPONENTS: [&str; 2] = ["REFFP", "PCBComponent_1"];++/// Full layer encoding for a graphic record: `(layer_id [0], class_index [41],+/// class [43])`. Altium honours the tail pair `[41]/[43]` (not just `[0]`), so+/// both must be set or the primitive lands on whatever the template layer was.+///+/// Mapping (barrett's house convention): silk → Top Overlay; the component+/// outline (fab) → a Mechanical layer; courtyard → the Keep-Out layer.+fn layer_enc(l: &FpLayer) -> (u8, u8, u8) {+    match l {+        // Barrett's house convention (from his C0402): the part outline lives on+        // Mechanical 13 ("Assembly Top") and the courtyard on Mechanical 15+        // ("Courtyard Top") — nothing on Top Overlay. KiCad silk + fab both map+        // to the assembly outline layer.+        FpLayer::Silk => (69, 13, 2),       // Mechanical 13 (Assembly Top)+        FpLayer::Fab => (69, 13, 2),        // Mechanical 13 (Assembly Top)+        FpLayer::Courtyard => (71, 15, 2),  // Mechanical 15 (Courtyard Top)+        FpLayer::Copper => (1, 1, 1),       // Top Layer+        FpLayer::Other { altium } => {+            let a = *altium;+            match a {+                33 | 34 => (a, 6, 3),               // overlays+                57..=72 => (a, a - 56, 2),          // Mechanical 1..16+                _ => (a, 6, 3),+            }+        }+    }+}++fn ux(mm: f64) -> i32 {+    (mm * MM_TO_UNIT).round() as i32+}+/// Y flips: neutral Y-down → Altium Y-up.+fn uy(mm: f64) -> i32 {+    (-mm * MM_TO_UNIT).round() as i32+}++/// Patch the layer id `[0]` and the tail class pair — Track: `[41]/[43]`,+/// Arc: `[len-8]/[len-6]` — from the neutral layer role.+fn patch_layer(p: &mut [u8], l: &FpLayer, tail_b41: usize) {+    let (b0, b41, b43) = layer_enc(l);+    p[0] = b0;+    p[tail_b41] = b41;+    p[tail_b41 + 2] = b43;+}++/// A Track record (type 0x04, 1 sub-block): layer + endpoints + width.+fn emit_track(layer: &FpLayer, x1: f64, y1: f64, x2: f64, y2: f64, w: f64) -> Vec<u8> {+    let mut p = TRACK_TPL.to_vec();+    let tail = p.len() - 8; // class pair sits 8 bytes before the end+    patch_layer(&mut p, layer, tail);+    p[13..17].copy_from_slice(&ux(x1).to_le_bytes());+    p[17..21].copy_from_slice(&uy(y1).to_le_bytes());+    p[21..25].copy_from_slice(&ux(x2).to_le_bytes());+    p[25..29].copy_from_slice(&uy(y2).to_le_bytes());+    p[29..33].copy_from_slice(&((w * MM_TO_UNIT).round() as u32).to_le_bytes());+    frame_pcb_record(0x04, &p)+}++/// An Arc record (type 0x01, 1 sub-block): layer + centre + radius + angles + width.+fn emit_arc(layer: &FpLayer, cx: f64, cy: f64, r: f64, start: f64, end: f64, w: f64) -> Vec<u8> {+    let mut p = ARC_TPL.to_vec();+    let tail = p.len() - 8;+    patch_layer(&mut p, layer, tail);+    p[13..17].copy_from_slice(&ux(cx).to_le_bytes());+    p[17..21].copy_from_slice(&uy(cy).to_le_bytes());+    p[21..25].copy_from_slice(&((r * MM_TO_UNIT).round() as i32).to_le_bytes());+    p[25..33].copy_from_slice(&start.to_le_bytes());+    p[33..41].copy_from_slice(&end.to_le_bytes());+    p[41..45].copy_from_slice(&((w * MM_TO_UNIT).round() as u32).to_le_bytes());+    frame_pcb_record(0x01, &p)+}++/// `<u8 type><u32 len><payload>` — one single-block PCB record.+fn frame_pcb_record(rt: u8, payload: &[u8]) -> Vec<u8> {+    let mut rec = vec![rt];+    rec.extend_from_slice(&(payload.len() as u32).to_le_bytes());+    rec.extend_from_slice(payload);+    rec+}++/// Expand a footprint's graphics into Altium primitive records, each tagged with+/// its `(objtype, objid_name)` for the GUID/UID sidecars (Track=4, Arc=1). A Rect+/// becomes 4 tracks; a Circle becomes a full 0–360 arc; an Arc's angles get the+/// Y-flip (neutral Y-down → Altium Y-up) applied as `-end .. -start`.+fn expand_fp_graphics(fp: &Footprint) -> Vec<(u32, &'static str, Vec<u8>)> {+    let mut out = Vec::new();+    for g in &fp.graphics {+        match g {+            FpGraphic::Line { layer, x1, y1, x2, y2, width } => {+                out.push((4, "Track", emit_track(layer, *x1, *y1, *x2, *y2, *width)));+            }+            FpGraphic::Rect { layer, x1, y1, x2, y2, width } => {+                for (a, b, c, d) in [+                    (*x1, *y1, *x2, *y1),+                    (*x2, *y1, *x2, *y2),+                    (*x2, *y2, *x1, *y2),+                    (*x1, *y2, *x1, *y1),+                ] {+                    out.push((4, "Track", emit_track(layer, a, b, c, d, *width)));+                }+            }+            FpGraphic::Circle { layer, cx, cy, r, width } => {+                out.push((1, "Arc", emit_arc(layer, *cx, *cy, *r, 0.0, 360.0, *width)));+            }+            FpGraphic::Arc { layer, cx, cy, r, start, end, width } => {+                out.push((1, "Arc", emit_arc(layer, *cx, *cy, *r, -end, -start, *width)));+            }+        }+    }+    out+}++// ---------------------------------------------------------------------------+// Encode+// ---------------------------------------------------------------------------++/// Encode one footprint into a clean `.PcbLib` byte image.+pub fn encode_pcblib(fp: &Footprint) -> Result<Vec<u8>> {+    encode_pcblib_many(std::slice::from_ref(fp))+}++/// Encode several footprints into one `.PcbLib`.+pub fn encode_pcblib_many(fps: &[Footprint]) -> Result<Vec<u8>> {+    if fps.is_empty() {+        bail!("no footprints to encode");+    }+    let tpl = read_all_streams(TEMPLATE)?;+    let pad_data_tpl = harvest_pad_data_block(&tpl)?;++    // Fresh CFBF.+    let cursor = Cursor::new(Vec::<u8>::new());+    let mut comp = cfb::CompoundFile::create(cursor).context("create CFBF")?;+    let mut made_storages: std::collections::BTreeSet<String> = Default::default();++    // 1) Copy every boilerplate stream verbatim, except the template's own+    //    component storages. Patch the two name-bearing library streams.+    for (path, bytes) in &tpl {+        if is_template_component_stream(path) {+            continue;+        }+        let patched;+        let data: &[u8] = if path == "/Library/Data" {+            patched = patch_library_data_tail(bytes, fps);+            &patched+        } else if path == "/Library/ComponentParamsTOC/Data" {+            patched = build_component_toc(fps);+            &patched+        } else if path == "/Library/ComponentParamsTOC/Header" {+            patched = 1u32.to_le_bytes().to_vec();+            &patched+        } else {+            bytes+        };+        write_stream(&mut comp, path, data, &mut made_storages)?;+    }++    // 2) Write each component's storages.+    for fp in fps {+        write_component(&mut comp, fp, &pad_data_tpl, &mut made_storages)?;+    }++    comp.flush()?;+    Ok(comp.into_inner().into_inner())+}++/// Write the full set of streams for one footprint component.+fn write_component<F: Read + Write + std::io::Seek>(+    comp: &mut cfb::CompoundFile<F>,+    fp: &Footprint,+    pad_data_tpl: &[u8],+    made: &mut std::collections::BTreeSet<String>,+) -> Result<()> {+    let n = fp.pads.len();+    let base = format!("/{}", &fp.name);++    // <comp>/Data — name block + pad records.+    let mut data = Vec::new();+    let mut name_block = vec![fp.name.len() as u8];+    name_block.extend_from_slice(fp.name.as_bytes());+    data.extend_from_slice(&(name_block.len() as u32).to_le_bytes());+    data.extend_from_slice(&name_block);+    for pad in &fp.pads {+        data.extend(encode_pad_record(pad, pad_data_tpl));+    }+    // Silk / courtyard / fab graphics as Track/Arc records after the pads.+    let graphics = expand_fp_graphics(fp);+    for (_, _, rec) in &graphics {+        data.extend_from_slice(rec);+    }+    let total = n + graphics.len(); // every record is one primitive+    write_stream(comp, &format!("{base}/Data"), &data, made)?;+    write_stream(comp, &format!("{base}/Header"), &(total as u32).to_le_bytes(), made)?;++    // Parameters.+    let params = format!(+        "|PATTERN={}|HEIGHT=0mil|DESCRIPTION=|ITEMGUID=|REVISIONGUID=",+        fp.name+    );+    write_stream(comp, &format!("{base}/Parameters"), &u32_text(&params), made)?;++    // WideStrings — minimal constant.+    write_stream(comp, &format!("{base}/WideStrings"), &[0x01, 0, 0, 0, 0], made)?;++    // PrimitiveGuids/Data — [u32 hdr=0x55][objtype 0 + comp GUID][objtype 2 + idx + pad GUID]*+    let mut pg = 0x55u32.to_le_bytes().to_vec();+    pg.extend_from_slice(&0u32.to_le_bytes()); // component entry: objtype 0+    pg.extend_from_slice(&guid16(&fp.name, 0));+    for (i, _) in fp.pads.iter().enumerate() {+        pg.extend_from_slice(&2u32.to_le_bytes()); // objtype 2 = pad+        pg.extend_from_slice(&(i as u32).to_le_bytes());+        pg.extend_from_slice(&guid16(&fp.name, (i as u32) + 1));+    }+    for (gi, (objtype, _, _)) in graphics.iter().enumerate() {+        let idx = (n + gi) as u32;+        pg.extend_from_slice(&objtype.to_le_bytes());+        pg.extend_from_slice(&idx.to_le_bytes());+        pg.extend_from_slice(&guid16(&fp.name, idx + 1));+    }+    write_stream(comp, &format!("{base}/PrimitiveGuids/Data"), &pg, made)?;+    write_stream(+        comp,+        &format!("{base}/PrimitiveGuids/Header"),+        &((total as u32) + 1).to_le_bytes(),+        made,+    )?;++    // UniqueIDPrimitiveInformation/Data — one text record per primitive.+    let mut uid = Vec::new();+    for i in 0..n {+        let rec = format!("|PRIMITIVEINDEX={i}|PRIMITIVEOBJECTID=Pad|UNIQUEID=");+        uid.extend(u32_text(&rec));+    }+    for (gi, (_, objid, _)) in graphics.iter().enumerate() {+        let rec = format!("|PRIMITIVEINDEX={}|PRIMITIVEOBJECTID={objid}|UNIQUEID=", n + gi);+        uid.extend(u32_text(&rec));+    }+    write_stream(+        comp,+        &format!("{base}/UniqueIDPrimitiveInformation/Data"),+        &uid,+        made,+    )?;+    write_stream(+        comp,+        &format!("{base}/UniqueIDPrimitiveInformation/Header"),+        &(total as u32).to_le_bytes(),+        made,+    )?;+    Ok(())+}++/// `<u32 len>` + text + `\0`.+fn u32_text(text: &str) -> Vec<u8> {+    let mut payload = text.as_bytes().to_vec();+    payload.push(0);+    let mut out = (payload.len() as u32).to_le_bytes().to_vec();+    out.extend_from_slice(&payload);+    out+}++/// Build `Library/ComponentParamsTOC/Data`: one length-prefixed text record+/// holding a `Name=..|Pad Count=N|Height=0|Description=\r\n` line per component.+fn build_component_toc(fps: &[Footprint]) -> Vec<u8> {+    let mut text = String::new();+    for fp in fps {+        text.push_str(&format!(+            "Name={}|Pad Count={}|Height=0|Description=\r\n",+            fp.name,+            fp.pads.len()+        ));+    }+    u32_text(&text)+}++/// Rebuild the trailing component-name list of `Library/Data`:+/// `<u32 count>` then per component `<u32 namelen+1><u8 namelen><name>`.+/// The list sits at the very end after the last `...GUID=\0`; we locate it by+/// reading the existing trailing `<u32 count>` and slicing it off.+fn patch_library_data_tail(orig: &[u8], fps: &[Footprint]) -> Vec<u8> {+    // The original tail = [u32 count] + count*(name block). Walk backwards: the+    // template has exactly 2 names (REFFP, PCBComponent_1). Find the count u32 by+    // scanning from the known structure — simplest: reconstruct the prefix by+    // dropping the original list and appending ours.+    //+    // The list start = position of the u32 count. We find it by parsing from a+    // candidate: the byte right after the final "...GUID=\0" key. Robust approach:+    // try every position from the end that parses as a valid [count][names...]+    // consuming exactly to end-of-stream.+    if let Some(list_start) = find_name_list_start(orig) {+        let mut out = orig[..list_start].to_vec();+        out.extend_from_slice(&(fps.len() as u32).to_le_bytes());+        for fp in fps {+            let nb_len = fp.name.len() as u32 + 1;+            out.extend_from_slice(&nb_len.to_le_bytes());+            out.push(fp.name.len() as u8);+            out.extend_from_slice(fp.name.as_bytes());+        }+        out+    } else {+        // Couldn't locate — leave Library/Data unchanged (component still works+        // via storage enumeration; TOC + storages carry the truth).+        orig.to_vec()+    }+}++/// Scan from the end for a position P such that `orig[P..]` parses exactly as+/// `<u32 count>` followed by `count` blocks of `<u32 len><len bytes>` ending at+/// end-of-stream. Returns P.+fn find_name_list_start(orig: &[u8]) -> Option<usize> {+    let n = orig.len();+    // The list is short; only scan the last 256 bytes.+    let lo = n.saturating_sub(256);+    for p in (lo..=n.saturating_sub(4)).rev() {+        if parses_as_name_list(&orig[p..]) {+            return Some(p);+        }+    }+    None+}++fn parses_as_name_list(b: &[u8]) -> bool {+    if b.len() < 4 {+        return false;+    }+    let count = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize;+    if count == 0 || count > 64 {+        return false;+    }+    let mut cur = 4;+    for _ in 0..count {+        if cur + 4 > b.len() {+            return false;+        }+        let blen = u32::from_le_bytes([b[cur], b[cur + 1], b[cur + 2], b[cur + 3]]) as usize;+        cur += 4;+        if blen == 0 || blen > 256 || cur + blen > b.len() {+            return false;+        }+        // first byte is the pstring length and must equal blen-1+        if b[cur] as usize != blen - 1 {+            return false;+        }+        cur += blen;+    }+    cur == b.len()+}++/// Build one pad record (type 0x02 + 6 sub-blocks), patching the data block.+fn encode_pad_record(pad: &Pad, pad_tpl: &[u8]) -> Vec<u8> {+    let mut designator = vec![pad.number.len() as u8];+    designator.extend_from_slice(pad.number.as_bytes());++    let mut pd = pad_tpl.to_vec();+    let x_units = (pad.x_mm * MM_TO_UNIT).round() as i32;+    let y_units = (-pad.y_mm * MM_TO_UNIT).round() as i32; // Y-down → Altium Y-up+    let xs = (pad.w_mm * MM_TO_UNIT).round() as u32;+    let ys = (pad.h_mm * MM_TO_UNIT).round() as u32;+    let hole = (pad.drill_mm * MM_TO_UNIT).round() as u32;+    let shape = shape_byte(&pad.shape);+    pd[0] = if hole > 0 { 0x21 } else { 1 }; // multilayer (thru) vs top+    pd[13..17].copy_from_slice(&x_units.to_le_bytes());+    pd[17..21].copy_from_slice(&y_units.to_le_bytes());+    pd[21..25].copy_from_slice(&xs.to_le_bytes());+    pd[25..29].copy_from_slice(&ys.to_le_bytes());+    pd[29..33].copy_from_slice(&xs.to_le_bytes());+    pd[33..37].copy_from_slice(&ys.to_le_bytes());+    pd[37..41].copy_from_slice(&xs.to_le_bytes());+    pd[41..45].copy_from_slice(&ys.to_le_bytes());+    pd[45..49].copy_from_slice(&hole.to_le_bytes());+    pd[49] = shape;+    pd[50] = shape;+    pd[51] = shape;+    if pd.len() >= 60 {+        pd[52..60].copy_from_slice(&pad.rotation.to_le_bytes());+    }++    let blocks: [&[u8]; 6] = [+        &designator,+        &[0x00],+        &[0x04, 0x7c, 0x26, 0x7c, 0x30],+        &[0x00],+        &pd,+        &[],+    ];+    let mut rec = vec![0x02u8];+    for b in blocks {+        rec.extend_from_slice(&(b.len() as u32).to_le_bytes());+        rec.extend_from_slice(b);+    }+    rec+}++fn shape_byte(shape: &str) -> u8 {+    match shape {+        "circle" | "oval" => 1,+        "roundrect" => 9,+        _ => 2,+    }+}++/// Deterministic 16-byte GUID from a name + salt (no RNG; reproducible builds).+fn guid16(name: &str, salt: u32) -> [u8; 16] {+    let mut out = [0u8; 16];+    let mut h: u64 = 0x9e3779b97f4a7c15 ^ salt as u64;+    for (i, b) in name.bytes().chain(salt.to_le_bytes()).enumerate() {+        h ^= (b as u64).wrapping_shl(((i % 8) * 8) as u32);+        h = h.wrapping_mul(0xff51afd7ed558ccd);+        h ^= h >> 33;+    }+    for i in 0..16 {+        h = h.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);+        out[i] = (h >> 56) as u8;+    }+    out+}++// ---------------------------------------------------------------------------+// Template I/O+// ---------------------------------------------------------------------------++fn read_all_streams(bytes: &[u8]) -> Result<BTreeMap<String, Vec<u8>>> {+    let cursor = Cursor::new(bytes.to_vec());+    let mut comp = cfb::CompoundFile::open(cursor).context("open template")?;+    let paths: Vec<String> = comp+        .walk()+        .filter(|e| e.is_stream())+        .map(|e| e.path().to_string_lossy().to_string())+        .collect();+    let mut map = BTreeMap::new();+    for p in paths {+        let mut buf = Vec::new();+        comp.open_stream(&p)?.read_to_end(&mut buf)?;+        map.insert(p, buf);+    }+    Ok(map)+}++fn is_template_component_stream(path: &str) -> bool {+    TEMPLATE_COMPONENTS+        .iter()+        .any(|c| path == format!("/{c}") || path.starts_with(&format!("/{c}/")))+}++fn harvest_pad_data_block(tpl: &BTreeMap<String, Vec<u8>>) -> Result<Vec<u8>> {+    let data = tpl+        .get("/REFFP/Data")+        .context("template missing /REFFP/Data")?;+    let name_block_len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;+    let mut cur = 4 + name_block_len;+    if data.get(cur) != Some(&0x02) {+        bail!("template first record is not a pad");+    }+    cur += 1;+    let mut blocks = Vec::new();+    for _ in 0..6 {+        let blen =+            u32::from_le_bytes([data[cur], data[cur + 1], data[cur + 2], data[cur + 3]]) as usize;+        cur += 4;+        blocks.push(data[cur..cur + blen].to_vec());+        cur += blen;+    }+    Ok(blocks[4].clone())+}++/// Create any missing parent storages, then write `path` as a stream.+fn write_stream<F: Read + Write + std::io::Seek>(+    comp: &mut cfb::CompoundFile<F>,+    path: &str,+    bytes: &[u8],+    made: &mut std::collections::BTreeSet<String>,+) -> Result<()> {+    // ensure parent storages+    let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();+    let mut acc = String::new();+    for seg in &parts[..parts.len().saturating_sub(1)] {+        acc.push('/');+        acc.push_str(seg);+        if made.insert(acc.clone()) {+            comp.create_storage(&acc)+                .with_context(|| format!("create storage {acc}"))?;+        }+    }+    let mut s = comp+        .create_stream(path)+        .with_context(|| format!("create stream {path}"))?;+    s.write_all(bytes)?;+    s.flush()?;+    Ok(())+}++// ---------------------------------------------------------------------------+// Decode+// ---------------------------------------------------------------------------++/// Decode a `.PcbLib` byte image to footprint JSON via `altium-pcblib`.+pub fn decode_pcblib(bytes: &[u8]) -> Result<serde_json::Value> {+    altium_pcblib::parse_altium_pcblib_bytes(bytes).context("altium-pcblib decode")+}
crates/altium-codec/src/schlib.rsadded+537
@@ -0,0 +1,537 @@+//! Altium `.SchLib` (schematic symbol library) **encoder** + decoder.+//!+//! A SchLib is an OLE2/CFBF container whose payload is almost entirely ASCII+//! `|KEY=VALUE|` records. We learned the exact byte framing from a reference+//! library generated by Altium AD26 (see `templates/`), and validated the+//! decode against the `altium-schlib` reader.+//!+//! Stream layout this encoder writes:+//! ```text+//!   /FileHeader            <u32 len> "|HEADER=Protel ...|CompCount=N|LibRef0=..|PartCount0=..|..\0"+//!   /Storage               <u32 len> "|HEADER=Icon storage\0"+//!   /<LibRef>/Data         per-component record stream (one storage per component)+//! ```+//! Each `/<LibRef>/Data` is a back-to-back sequence of records:+//! ```text+//!   text record:  <u16 len><u16 0x0000>  "|RECORD=..|..\0"     (len counts the \0)+//!   pin  record:  <u16 36 ><u16 0x0100>  <36-byte binary pin payload>+//! ```++use crate::model::{Graphic, Pin, Symbol};+use anyhow::{bail, Context, Result};+use std::collections::BTreeSet;+use std::io::{Cursor, Read, Seek, Write};++/// The 26-byte fixed prefix of a binary pin record (tag .. just before the+/// Name pstring), captured from an Altium-generated reference pin. The encoder+/// patches conglomerate@14, [email protected], [email protected], [email protected] and leaves the+/// rest (which Altium fills with defaults) intact for guaranteed acceptance.+const PIN_PREFIX: [u8; 26] = [+    0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,+    0x04, // [14] conglomerate (orientation bits 0..1) — patched+    0x18, 0x1e, // [15..17] length i16 — patched+    0x00, 0xf6, // [17..19] x i16 — patched+    0xff, 0x00, // [19..21] y i16 — patched+    0x00, 0x00, 0x00, 0x00, 0x00, // [21..26]+];++/// Default Altium pin length (internal units) when the model leaves it unset.+const DEFAULT_PIN_LEN: i16 = 7704;++/// Altium schematic binary coordinate units per mil. Calibrated by differential+/// analysis against Altium AD26 (1000 mil → 25600 units, exact). The model+/// carries pin X/Y in mils; the encoder scales to these units.+const UNITS_PER_MIL: f64 = 25.6;++// ---------------------------------------------------------------------------+// Encode+// ---------------------------------------------------------------------------++/// Encode one or more symbols into a `.SchLib` byte image.+pub fn encode_schlib(symbols: &[Symbol]) -> Result<Vec<u8>> {+    let cursor = Cursor::new(Vec::<u8>::new());+    let mut comp = cfb::CompoundFile::create(cursor).context("create CFBF")?;++    // FileHeader — library index listing every component.+    let fh = build_file_header(symbols);+    write_root_stream(&mut comp, "FileHeader", &fh)?;++    // Storage — fixed "Icon storage" housekeeping stream.+    let storage = u32_text_record("|HEADER=Icon storage");+    write_root_stream(&mut comp, "Storage", &storage)?;++    // One storage per component, each holding a Data record stream.+    for (idx, sym) in symbols.iter().enumerate() {+        let store_name = sanitize_storage_name(&sym.name, idx);+        comp.create_storage(&format!("/{store_name}"))+            .with_context(|| format!("create storage /{store_name}"))?;+        let data = build_component_data(sym);+        let mut s = comp+            .create_stream(&format!("/{store_name}/Data"))+            .with_context(|| format!("create /{store_name}/Data"))?;+        s.write_all(&data)?;+        s.flush()?;+    }++    comp.flush()?;+    let cursor = comp.into_inner();+    Ok(cursor.into_inner())+}++/// Merge `symbols` into an existing `.SchLib` image, preserving every existing+/// component **byte-for-byte**. Existing streams are copied verbatim; the+/// FileHeader index is patched (CompCount + appended `LibRef{i}`/`PartCount{i}`)+/// and one new storage per added symbol is written. Symbols whose name already+/// matches an existing component storage are skipped (no clobber). This is the+/// "install a part into the user's library" primitive.+pub fn add_symbols_to_schlib(existing: &[u8], symbols: &[Symbol]) -> Result<Vec<u8>> {+    // 1) Read all streams from the source library.+    let mut src =+        cfb::CompoundFile::open(Cursor::new(existing.to_vec())).context("open existing SchLib")?;+    let paths: Vec<String> = src+        .walk()+        .filter(|e| e.is_stream())+        .map(|e| e.path().to_string_lossy().to_string())+        .collect();+    let mut streams: Vec<(String, Vec<u8>)> = Vec::new();+    for p in &paths {+        let mut buf = Vec::new();+        src.open_stream(p)?.read_to_end(&mut buf)?;+        streams.push((p.clone(), buf));+    }++    // Existing top-level component storage names (everything that isn't a root+    // housekeeping stream).+    let mut existing_comps: BTreeSet<String> = BTreeSet::new();+    for (p, _) in &streams {+        let seg: Vec<&str> = p.trim_start_matches('/').split('/').collect();+        if seg.len() >= 2 && seg[0] != "FileHeader" && seg[0] != "Storage" {+            existing_comps.insert(seg[0].to_string());+        }+    }+    let comp_count_existing = existing_comps.len();++    // Skip symbols whose name already exists.+    let to_add: Vec<&Symbol> = symbols+        .iter()+        .filter(|s| !existing_comps.contains(&s.name))+        .collect();+    if to_add.is_empty() {+        // Nothing new — return the original unchanged.+        return Ok(existing.to_vec());+    }++    // 2) Patch the FileHeader index.+    let fh = streams+        .iter()+        .find(|(p, _)| p == "/FileHeader")+        .context("existing SchLib has no FileHeader")?+        .1+        .clone();+    let patched_fh = patch_file_header_add(&fh, comp_count_existing, &to_add)?;++    // 3) Rebuild: copy every stream (FileHeader replaced), then add new comps.+    let mut dst = cfb::CompoundFile::create(Cursor::new(Vec::<u8>::new()))+        .context("create merged SchLib")?;+    let mut made: BTreeSet<String> = BTreeSet::new();+    for (p, bytes) in &streams {+        let data: &[u8] = if p == "/FileHeader" { &patched_fh } else { bytes };+        write_stream_deep(&mut dst, p, data, &mut made)?;+    }+    for (k, sym) in to_add.iter().enumerate() {+        let store = sanitize_storage_name(&sym.name, comp_count_existing + k);+        let data = build_component_data(sym);+        write_stream_deep(&mut dst, &format!("/{store}/Data"), &data, &mut made)?;+    }+    dst.flush()?;+    Ok(dst.into_inner().into_inner())+}++/// Patch a SchLib FileHeader text record: bump `CompCount` by `add.len()` and+/// append a `|LibRef{i}=..|PartCount{i}=1` entry per added symbol (indices+/// continue after the existing `old_n` components).+fn patch_file_header_add(fh: &[u8], old_n: usize, add: &[&Symbol]) -> Result<Vec<u8>> {+    if fh.len() < 5 {+        bail!("FileHeader too short");+    }+    let len = u32::from_le_bytes([fh[0], fh[1], fh[2], fh[3]]) as usize;+    let end = 4 + len.saturating_sub(1); // exclude trailing \0+    let text = std::str::from_utf8(fh.get(4..end).context("FileHeader bounds")?)+        .context("FileHeader not UTF-8")?+        .to_string();++    // Replace the CompCount value in place.+    const KEY: &str = "|CompCount=";+    let ci = text.find(KEY).context("FileHeader has no CompCount")?;+    let val_start = ci + KEY.len();+    let digits: String = text[val_start..]+        .chars()+        .take_while(|c| c.is_ascii_digit())+        .collect();+    let new_n = old_n + add.len();+    let mut new_text = String::new();+    new_text.push_str(&text[..val_start]);+    new_text.push_str(&new_n.to_string());+    new_text.push_str(&text[val_start + digits.len()..]);++    // Append the new component index entries.+    for (k, sym) in add.iter().enumerate() {+        let i = old_n + k;+        new_text.push_str(&format!("|LibRef{i}={}|PartCount{i}=1", sym.name));+    }+    Ok(u32_text_record(&new_text))+}++/// Create any missing parent storages, then write `path` as a stream.+fn write_stream_deep<F: Read + Write + Seek>(+    comp: &mut cfb::CompoundFile<F>,+    path: &str,+    bytes: &[u8],+    made: &mut BTreeSet<String>,+) -> Result<()> {+    let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();+    let mut acc = String::new();+    for seg in &parts[..parts.len().saturating_sub(1)] {+        acc.push('/');+        acc.push_str(seg);+        if made.insert(acc.clone()) {+            comp.create_storage(&acc)+                .with_context(|| format!("create storage {acc}"))?;+        }+    }+    let mut s = comp+        .create_stream(path)+        .with_context(|| format!("create stream {path}"))?;+    s.write_all(bytes)?;+    s.flush()?;+    Ok(())+}++fn write_root_stream<F: std::io::Read + std::io::Write + std::io::Seek>(+    comp: &mut cfb::CompoundFile<F>,+    name: &str,+    bytes: &[u8],+) -> Result<()> {+    let mut s = comp+        .create_stream(&format!("/{name}"))+        .with_context(|| format!("create /{name}"))?;+    s.write_all(bytes)?;+    s.flush()?;+    Ok(())+}++/// `<u32 len>` + text + `\0`. Used for FileHeader and Storage streams.+fn u32_text_record(text: &str) -> Vec<u8> {+    let mut payload = text.as_bytes().to_vec();+    payload.push(0);+    let mut out = (payload.len() as u32).to_le_bytes().to_vec();+    out.extend_from_slice(&payload);+    out+}++/// `<u16 len><u16 flags>` + text + `\0`. The component-data text record framing.+fn u16_text_record(text: &str) -> Vec<u8> {+    let mut payload = text.as_bytes().to_vec();+    payload.push(0);+    let mut out = (payload.len() as u16).to_le_bytes().to_vec();+    out.extend_from_slice(&0u16.to_le_bytes()); // flags = 0+    out.extend_from_slice(&payload);+    out+}++fn build_file_header(symbols: &[Symbol]) -> Vec<u8> {+    let mut s = String::from(+        "|HEADER=Protel for Windows - Schematic Library Editor Binary File Version 5.0\+|Weight=0|MinorVersion=9|UniqueID=ADOMSFHX|FontIdCount=1|Size1=10\+|FontName1=Times New Roman|UseMBCS=T|IsBOC=T|SheetStyle=9|BorderOn=T\+|SheetNumberSpaceSize=12|AreaColor=16317695|SnapGridOn=T|SnapGridSize=10\+|VisibleGridOn=T|VisibleGridSize=10|CustomX=18000|CustomY=18000|UseCustomSheet=T\+|ReferenceZonesOn=T|Display_Unit=0",+    );+    s.push_str(&format!("|CompCount={}", symbols.len()));+    for (i, sym) in symbols.iter().enumerate() {+        let parts = 1; // single-part symbols for now+        s.push_str(&format!("|LibRef{i}={}|PartCount{i}={}", sym.name, parts));+    }+    u32_text_record(&s)+}++fn build_component_data(sym: &Symbol) -> Vec<u8> {+    let mut out = Vec::new();+    let pin_count = sym.pins.len();+    let body = sym.body.unwrap_or_default();+    let prefix = sym.designator_prefix.clone().unwrap_or_else(|| "U".into());+    let uid = pseudo_uid(&sym.name, 0);++    // RECORD=1 — component header.+    out.extend(u16_text_record(&format!(+        "|RECORD=1|LibReference={name}|PartCount=1|DisplayModeCount=1|IndexInSheet=-1\+|OwnerPartId=-1|CurrentPartId=1|LibraryPath=*|SourceLibraryName=*|SheetPartFileName=*\+|TargetFileName=*|UniqueID={uid}|AreaColor=11599871|Color=128|PartIDLocked=T\+|AllPinCount={pin_count}",+        name = sym.name,+    )));++    // Body: emit the symbol's own graphics if it has any; otherwise a default+    // body rectangle. Either way these are the records between header and pins.+    if sym.graphics.is_empty() {+        out.extend(u16_text_record(&format!(+            "|RECORD=14|OwnerPartId=1|Location.X={x1}|Location.Y={y1}|Corner.X={x2}|Corner.Y={y2}\+|LineWidth=1|Color=16711680|AreaColor=11599871|IsSolid=T|UniqueID={uid2}",+            x1 = body.x1 / 10,+            y1 = body.y1 / 10,+            x2 = body.x2 / 10,+            y2 = body.y2 / 10,+            uid2 = pseudo_uid(&sym.name, 14),+        )));+    } else {+        for (gi, g) in sym.graphics.iter().enumerate() {+            out.extend(emit_graphic(g, &sym.name, gi));+        }+    }++    // Pin records (binary).+    for (i, pin) in sym.pins.iter().enumerate() {+        out.extend(frame_pin(encode_pin_payload(pin, i)));+    }++    // RECORD=34 — Designator label.+    out.extend(u16_text_record(&format!(+        "|RECORD=34|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=5|Color=8388608\+|FontID=1|Text={prefix}?|Name=Designator|ReadOnlyState=1|UniqueID={uid3}",+        uid3 = pseudo_uid(&sym.name, 34),+    )));++    // RECORD=41 — Comment label.+    out.extend(u16_text_record(&format!(+        "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=-15|Color=8388608\+|FontID=1|Text=*|Name=Comment|UniqueID={uid4}",+        uid4 = pseudo_uid(&sym.name, 41),+    )));++    // RECORD=41 — component property fields (Value, Manufacturer, MPN, …). Each+    // is another parameter record; visible ones are drawn (stacked below the+    // comment), the rest carry IsHidden=T like standard metadata parameters.+    for (pi, param) in sym.parameters.iter().enumerate() {+        let uidp = pseudo_uid(&sym.name, 4100 + pi as u32);+        let rec = if param.visible {+            let y = -25 - (pi as i32) * 10;+            format!(+                "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y={y}\+|Color=8388608|FontID=1|Text={t}|Name={n}|UniqueID={uidp}",+                t = param.value, n = param.name+            )+        } else {+            format!(+                "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Color=8388608|FontID=1|IsHidden=T\+|Text={t}|Name={n}|UniqueID={uidp}",+                t = param.value, n = param.name+            )+        };+        out.extend(u16_text_record(&rec));+    }++    // RECORD=44 — implementations list. Then, if the symbol names a footprint,+    // the implementation that links it: RECORD=45 (the PCBLIB model reference)+    // plus its RECORD=46/48 children. OwnerIndex values are record positions in+    // this stream: R1=0, R14=1, pins=2..(pin_count+1), R34, R41, params.., R44…+    out.extend(u16_text_record("|RECORD=44"));+    if let Some(fp) = sym.footprint.as_deref().filter(|s| !s.is_empty()) {+        // record positions: R1=0, body records, pins, R34, R41-comment, params, R44.+        let body_recs = if sym.graphics.is_empty() { 1 } else { sym.graphics.len() };+        let idx_44 = pin_count + body_recs + 3 + sym.parameters.len();+        let idx_45 = idx_44 + 1;+        // RECORD=45 — the PCBLIB model link. ModelName + a single datafile entry+        // both name the footprint; IsCurrent=T makes it the active footprint.+        out.extend(u16_text_record(&format!(+            "|RECORD=45|OwnerIndex={idx_44}|IndexInSheet=-1|ModelName={fp}|ModelType=PCBLIB\+|DatafileCount=1|ModelDatafileEntity0={fp}|ModelDatafileKind0=PCBLIB|IsCurrent=T|UniqueID={uid45}",+            uid45 = pseudo_uid(&sym.name, 45),+        )));+        // RECORD=46 / 48 — implementation children (empty → implicit identity+        // pin↔pad map, valid whenever pin numbers equal pad numbers).+        out.extend(u16_text_record(&format!("|RECORD=46|OwnerIndex={idx_45}")));+        out.extend(u16_text_record(&format!("|RECORD=48|OwnerIndex={idx_45}")));+    }++    out+}++/// Split a mil value into Altium schematic form: a base in 10-mil units plus a+/// `_Frac` in 1/100000 of that unit, so `value_mil = base*10 + frac/10000`. This+/// is how Altium stores sub-10-mil precision (e.g. a 25-mil radius = base 2,+/// frac 50000). `floor`+positive-frac keeps negatives exact (-25 → base -3,+/// frac 50000 → -30 + 5 = -25).+fn split_coord(mil: f64) -> (i64, i64) {+    let units = mil / 10.0;+    let mut base = units.floor() as i64;+    let mut frac = ((units - units.floor()) * 100_000.0).round() as i64;+    if frac >= 100_000 {+        base += 1;+        frac -= 100_000;+    }+    (base, frac)+}++/// `|{key}={base}` plus `|{key}_Frac={frac}` (the frac field omitted when zero,+/// matching how Altium itself writes grid-aligned coordinates).+fn coord_field(key: &str, mil: f64) -> String {+    let (b, f) = split_coord(mil);+    if f != 0 {+        format!("|{key}={b}|{key}_Frac={f}")+    } else {+        format!("|{key}={b}")+    }+}++/// Emit one symbol graphic as its Altium record, with sub-10-mil precision via+/// `_Frac` fields. Polyline→RECORD=6, Rect→RECORD=14, Circle→RECORD=11,+/// Arc→RECORD=12.+fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Vec<u8> {+    let uid = pseudo_uid(name, 1000 + gi as u32);+    // Altium standard schematic-symbol line color for passives = pure blue,+    // RGB(0,0,255) stored as R + G*256 + B*65536 = 16711680. (Was 128 = maroon.)+    const BLUE: u32 = 16711680;+    match g {+        Graphic::Polyline { points, .. } => {+            let mut s = format!(+                "|RECORD=6|OwnerPartId=1|LineWidth=1|Color={BLUE}|LocationCount={}",+                points.len()+            );+            for (i, p) in points.iter().enumerate() {+                s.push_str(&coord_field(&format!("X{}", i + 1), p[0]));+                s.push_str(&coord_field(&format!("Y{}", i + 1), p[1]));+            }+            s.push_str(&format!("|UniqueID={uid}"));+            u16_text_record(&s)+        }+        Graphic::Rect { x1, y1, x2, y2, filled, .. } => u16_text_record(&format!(+            "|RECORD=14|OwnerPartId=1{}{}{}{}\+|LineWidth=1|Color={BLUE}|AreaColor=11599871|IsSolid={}|UniqueID={uid}",+            coord_field("Location.X", *x1),+            coord_field("Location.Y", *y1),+            coord_field("Corner.X", *x2),+            coord_field("Corner.Y", *y2),+            if *filled { "T" } else { "F" }+        )),+        Graphic::Circle { cx, cy, r, .. } => u16_text_record(&format!(+            "|RECORD=11|OwnerPartId=1{}{}{}{}\+|LineWidth=1|Color={BLUE}|UniqueID={uid}",+            coord_field("Location.X", *cx),+            coord_field("Location.Y", *cy),+            coord_field("Radius", *r),+            coord_field("SecondaryRadius", *r),+        )),+        Graphic::Arc { cx, cy, r, start, end, .. } => u16_text_record(&format!(+            "|RECORD=12|OwnerPartId=1{}{}{}|StartAngle={:.3}|EndAngle={:.3}\+|LineWidth=1|Color={BLUE}|UniqueID={uid}",+            coord_field("Location.X", *cx),+            coord_field("Location.Y", *cy),+            coord_field("Radius", *r),+            start, end+        )),+    }+}++/// Build the 36+ byte binary pin payload (before framing).+fn encode_pin_payload(pin: &Pin, _idx: usize) -> Vec<u8> {+    let mut p = PIN_PREFIX.to_vec();+    // conglom byte[14] = 0x04 → a plain pin (no edge-symbol triangle), rotation+    // bits (0..1) = 0. Matches Altium's own pins (build_symbol ground truth):+    // orientation lives ONLY in the length field, so conglom's rotation bits must+    // be 0 — leaving them nonzero (was 0x07 = rot 3) misplaced the pin number.+    p[14] = 0x04;+    // PIN ORIENTATION lives in the LOW 2 BITS of the length field [15:17] — NOT+    // the conglom byte. Calibrated against Altium AD26 + LM555: byte-orient 0 =+    // points left, 2 = points right. KiCad angle and Altium orientation are 180°+    // apart, so Altium_orient = (kicad_angle/90 + 2) % 4.+    let aorient = ((pin.rotation as u32 / 90 + 2) % 4) as u16;+    let length = pin+        .length+        .map(|l| (l as f64 * UNITS_PER_MIL).round() as i32)+        .unwrap_or(DEFAULT_PIN_LEN as i32);+    let field = ((length as u16) & 0xFFFC) | aorient; // length in bits 2..15, orient in 0..1+    p[15..17].copy_from_slice(&field.to_le_bytes());+    // Altium's pin Location is the BODY end (where the pin meets the symbol),+    // not the connection point. The neutral model stores the connection point+    // (KiCad convention); convert by moving length-mils toward the body, i.e.+    // along the KiCad angle direction.+    let len_mil = pin+        .length+        .unwrap_or((DEFAULT_PIN_LEN as f64 / UNITS_PER_MIL).round() as i32);+    let (dx, dy) = match (pin.rotation / 90) % 4 {+        0 => (1, 0),   // angle 0: body is to the +x+        1 => (0, 1),   // angle 90: body to +y+        2 => (-1, 0),  // angle 180: body to -x+        _ => (0, -1),  // angle 270: body to -y+    };+    let body_x = pin.x + len_mil * dx;+    let body_y = pin.y + len_mil * dy;+    // X = round(mil*25.6) as i24 at [17:20]; Y = round(mil/10) as i16 at [20:22].+    let ux = (body_x as f64 * UNITS_PER_MIL).round() as i32;+    let xb = ux.to_le_bytes();+    p[17] = xb[0];+    p[18] = xb[1];+    p[19] = xb[2];+    let uy = (body_y as f64 / 10.0).round() as i32 as i16;+    p[20..22].copy_from_slice(&uy.to_le_bytes());+    // strings: Name, Designator, "", "|&|"+    push_pstr(&mut p, &pin.name);+    push_pstr(&mut p, &pin.number);+    push_pstr(&mut p, "");+    push_pstr(&mut p, "|&|");+    p.push(0x00);+    p+}++fn frame_pin(payload: Vec<u8>) -> Vec<u8> {+    let mut out = (payload.len() as u16).to_le_bytes().to_vec();+    out.extend_from_slice(&0x0100u16.to_le_bytes()); // flags = 0x0100 (binary pin)+    out.extend_from_slice(&payload);+    out+}++fn push_pstr(buf: &mut Vec<u8>, s: &str) {+    let b = s.as_bytes();+    buf.push(b.len() as u8);+    buf.extend_from_slice(b);+}++/// Altium UniqueIDs are 8 uppercase letters. Generate a stable pseudo-id from+/// the component name + a salt so distinct records don't collide. Deterministic+/// (no RNG) so encodes are reproducible.+fn pseudo_uid(name: &str, salt: u32) -> String {+    let mut h: u64 = 1469598103934665603;+    for b in name.bytes().chain(salt.to_le_bytes()) {+        h ^= b as u64;+        h = h.wrapping_mul(1099511628211);+    }+    let mut out = String::with_capacity(8);+    for i in 0..8 {+        let v = ((h >> (i * 5)) & 0x1f) as u8;+        out.push((b'A' + (v % 26)) as char);+    }+    out+}++fn sanitize_storage_name(name: &str, idx: usize) -> String {+    let cleaned: String = name+        .chars()+        .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })+        .collect();+    if cleaned.is_empty() {+        format!("Component_{idx}")+    } else {+        cleaned+    }+}++// ---------------------------------------------------------------------------+// Decode (thin wrapper over the validated altium-schlib reader)+// ---------------------------------------------------------------------------++/// Decode a `.SchLib` byte image into its first component's symbol JSON, via the+/// validated `altium-schlib` reader. (Multi-component decode lands next.)+pub fn decode_schlib_first(bytes: &[u8]) -> Result<serde_json::Value> {+    altium_schlib::parse_altium_schlib_bytes(bytes).context("altium-schlib decode")+}
crates/altium-codec/templates/arc_tpl.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__FileHeader.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__FileVersionInfo__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__FileVersionInfo__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__ComponentParamsTOC__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__ComponentParamsTOC__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__EmbeddedFonts.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__LayerKindMapping__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__LayerKindMapping__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__ModelsNoEmbed__Data.binadded

  
crates/altium-codec/templates/pcblib__Library__ModelsNoEmbed__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__Models__Data.binadded

  
crates/altium-codec/templates/pcblib__Library__Models__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__PadViaLibrary__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__PadViaLibrary__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__Library__Textures__Data.binadded

  
crates/altium-codec/templates/pcblib__Library__Textures__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__PCBComponent_1__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__PCBComponent_1__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__PCBComponent_1__Parameters.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__PCBComponent_1__PrimitiveGuids__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__PCBComponent_1__PrimitiveGuids__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__PCBComponent_1__WideStrings.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__REFFP__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__REFFP__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__REFFP__Parameters.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__REFFP__PrimitiveGuids__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__REFFP__PrimitiveGuids__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__REFFP__UniqueIDPrimitiveInformation__Data.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__REFFP__UniqueIDPrimitiveInformation__Header.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib__REFFP__WideStrings.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pcblib_template.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/pin_record.binadded
⋯ 1 unchanged line ⋯
crates/altium-codec/templates/track_tpl.binadded
⋯ 1 unchanged line ⋯
crates/altium-pcblib/Cargo.tomladded+17
@@ -0,0 +1,17 @@+[package]+name = "altium-pcblib"+version = "0.1.0"+edition = "2021"+description = "Native Rust parser for Altium .PcbLib (and .IntLib) footprint libraries — extracts pads as KiCad-shaped JSON"+authors = ["Adom <[email protected]>"]+license = "MIT"+repository = "https://github.com/adom-inc/altium-pcblib"++[dependencies]+anyhow = "1"+serde_json = "1"+cfb = "0.10"++[[bin]]+name = "altium-pcblib-svg"+path = "src/bin/svg.rs"
crates/altium-pcblib/src/bin/svg.rsadded+78
@@ -0,0 +1,78 @@+//! Authoritative SVG renderer for an Altium .PcbLib footprint.+//!+//! Usage:+//!   altium-pcblib-svg <path/to/Foo.PcbLib>            # primary footprint, SVG to stdout+//!   altium-pcblib-svg <path> --all                     # primary + every alternate, stacked+//!   altium-pcblib-svg <path> -o foo.svg                # write to file+//!+//! This is the GROUND TRUTH view of what the altium-pcblib parser sees. If+//! a downstream renderer (chipsmith overlay, concur, anything else) renders+//! a footprint differently from this output, the downstream renderer is+//! the bug — not the parser.++use std::path::PathBuf;++fn main() {+    let mut args = std::env::args().skip(1);+    let mut path: Option<PathBuf> = None;+    let mut out_path: Option<PathBuf> = None;+    let mut render_all = false;++    while let Some(arg) = args.next() {+        match arg.as_str() {+            "-h" | "--help" => { print_usage(); return; }+            "--all" => render_all = true,+            "-o" | "--out" => {+                out_path = args.next().map(PathBuf::from);+                if out_path.is_none() {+                    eprintln!("ERROR: -o requires a path");+                    std::process::exit(2);+                }+            }+            _ if path.is_none() => path = Some(PathBuf::from(arg)),+            other => {+                eprintln!("ERROR: unexpected argument: {other}");+                print_usage();+                std::process::exit(2);+            }+        }+    }++    let Some(path) = path else {+        print_usage();+        std::process::exit(2);+    };++    let parsed = match altium_pcblib::parse_altium_pcblib_path(&path) {+        Ok(v) => v,+        Err(e) => {+            eprintln!("ERROR: parse {}: {e}", path.display());+            std::process::exit(1);+        }+    };++    let svg = if render_all {+        altium_pcblib::render_all_footprints_svg(&parsed)+    } else {+        altium_pcblib::render_footprint_svg(&parsed)+    };++    match out_path {+        Some(p) => {+            if let Err(e) = std::fs::write(&p, svg.as_bytes()) {+                eprintln!("ERROR: write {}: {e}", p.display());+                std::process::exit(1);+            }+            eprintln!("OK: wrote {} ({} bytes)", p.display(), svg.len());+        }+        None => print!("{svg}"),+    }+}++fn print_usage() {+    eprintln!("Usage: altium-pcblib-svg <path/to/Foo.PcbLib> [--all] [-o out.svg]");+    eprintln!();+    eprintln!("Renders the parser's view of the footprint as SVG. With --all,");+    eprintln!("renders every variant the .PcbLib declares (primary + alternates)");+    eprintln!("stacked vertically.");+}
crates/altium-pcblib/src/lib.rsadded+737
@@ -0,0 +1,737 @@+//! Native Rust parser for Altium .PcbLib (and .IntLib) footprint libraries.+//!+//! Altium .PcbLib is a Microsoft OLE2 / Compound File Binary container. Each+//! footprint lives in its own top-level storage (e.g. `/D8`, `/SOP50P490X110-10N`)+//! and the interesting stream is `/<name>/Data` — Altium's binary record format+//! laid out as a sequence of typed records (Pad / Track / Arc / Text / ...).+//!+//! This crate extracts every footprint's pad list and emits JSON shaped like+//! KiCad's `parse_kicad_mod`-style output, so downstream tools (chipsmith's+//! cross-source agreement matrix, concur's consensus check) can compare KiCad+//! / Fusion / Altium pads side-by-side.+//!+//! ## Public API+//!+//! - [`parse_altium_pcblib_path`] — read a `.PcbLib` from disk.+//! - [`parse_altium_pcblib_bytes`] — parse from raw bytes (handy for tests+//!   and for streams already in memory).+//!+//! Both return a `serde_json::Value` with this shape:+//!+//! ```json+//! {+//!   "module_name": "D8",+//!   "pad_count": 8,+//!   "pads": [{+//!     "number": "1", "type": "smd", "shape": "rect",+//!     "at": [-2.4638, -1.905, 0.0],+//!     "size": [1.9812, 0.5588],+//!     "layers": ["F.Cu", "F.Paste", "F.Mask"],+//!     "drill": null+//!   }],+//!   "silk_markers": [],+//!   "pin1_silk": { "found": false },+//!   "_source": "altium_pcblib",+//!   "_alternates": [ /* other footprints in the lib */ ]+//! }+//! ```+//!+//! ## Record format (reverse-engineered from real-world chip libraries)+//!+//! Each footprint's `Data` stream begins with a length-prefixed Pascal-style+//! footprint name:+//!+//! ```text+//! [u32 LE name_block_len][u8 name_pstring_len][name bytes...]+//! ```+//!+//! After the name, records appear back-to-back. Every record starts with a+//! u8 type byte. Different record types have different shapes:+//!+//! - **Track (0x04)** / **Arc (0x01)** / **Region (0x0B)** / **Body (0x0C)** /+//!   **Fill (0x06)** / **Via (0x03)**: `[u8 type][u32 LE len][len bytes payload]`.+//!+//! - **Pad (0x02)**: a sequence of 6 length-prefixed sub-blocks following the+//!   type byte. Each sub-block is `[u32 LE block_len][block_len bytes]`. The+//!   first sub-block holds the pad designator (Pascal string with 1-byte len).+//!   The fifth sub-block holds the main pad data — usually 194 or 202 bytes+//!   depending on the Altium PcbLib format version. The sixth is typically+//!   length-zero (trailing padding).+//!+//! - **Text (0x05)**: two length-prefixed sub-blocks (main payload + style+//!   trailer). We don't decode it — it's only consumed to keep the cursor+//!   aligned past silkscreen text.+//!+//! ### Pad data block layout (block 5)+//!+//! ```text+//! [0]      u8 layer (1 = TOP_CU, 32 = BOTTOM_CU, 21 = TOP_OVERLAY, ...)+//! [1]      u8 net/flag (0x08 in v3, 0x0c in v4 — used as a version probe)+//! [2]      u8 reserved+//! [3..13]  10 bytes of 0xFF padding (component / net id sentinels)+//! [13..17] i32 LE x position (units = 1 / 10000 mil = 2.54e-6 mm)+//! [17..21] i32 LE y position (Altium Y-up — flip sign for KiCad's Y-down)+//! [21..25] u32 LE x size, top layer+//! [25..29] u32 LE y size, top layer+//! [29..33] u32 LE x size, mid layer (multi-layer thru-hole pads)+//! [33..37] u32 LE y size, mid layer+//! [37..41] u32 LE x size, bottom layer+//! [41..45] u32 LE y size, bottom layer+//! [45..49] u32 LE hole size (0 → SMD; > 0 → thru-hole)+//! [49]     u8 shape, top layer (1 round, 2 rect, 3 octagonal, 9 rounded-rect)+//! [50]     u8 shape, mid layer+//! [51]     u8 shape, bottom layer+//! [52..60] f64 LE rotation in degrees (only present in 202-byte v4 layout)+//! ```+//!+//! Past offset 60 the layout drifts between Altium releases (paste / mask+//! offsets, plated bool, GUIDs, primitive index, …). We don't need any of that+//! for cross-source agreement, so we stop reading.+//!+//! Rotation matters only for the rendered bounding box: KiCad records the+//! "logical" pad shape and rotates it; Altium 194-byte pads bake the rotation+//! into x_size/y_size and store rotation = 0. Both representations cover the+//! same physical region, which is what cross-source agreement actually+//! compares.++use anyhow::{anyhow, bail, Context, Result};+use std::io::{Cursor, Read};+use std::path::Path;++const ALTIUM_UNIT_TO_MM: f64 = 0.00000254; // 1 / 10000 mil → mm++/// Parse an Altium .PcbLib (or .IntLib's PCB lib stream) from a file path.+/// Returns the JSON for the FIRST footprint in the library that has at least+/// one pad. Other footprints, if present, ride along under `_alternates`.+pub fn parse_altium_pcblib_path(path: &Path) -> Result<serde_json::Value> {+    let bytes = std::fs::read(path)+        .with_context(|| format!("read .PcbLib: {}", path.display()))?;+    parse_altium_pcblib_bytes(&bytes)+}++/// Parse an Altium .PcbLib from raw bytes.+pub fn parse_altium_pcblib_bytes(bytes: &[u8]) -> Result<serde_json::Value> {+    if bytes.len() < 8 || &bytes[..8] != [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] {+        bail!("not an OLE2 compound document (bad magic)");+    }++    // Altium occasionally writes PcbLib FATs that overshoot the file's actual+    // sector count (cfb refuses with "Malformed FAT … FAT has N entries, but+    // file has only M sectors"). The unreferenced trailing sectors don't carry+    // real data — pad with 0xFF (CFBF "free sector" sentinel) and re-open. 1+    // MiB is plenty for any real chip footprint library we've seen.+    let owned;+    let cursor = if bytes.len() < 1024 * 1024 {+        owned = {+            let mut v = bytes.to_vec();+            v.resize(1024 * 1024, 0xFF);+            v+        };+        Cursor::new(owned.as_slice())+    } else {+        Cursor::new(bytes)+    };++    let mut comp = cfb::CompoundFile::open(cursor)+        .map_err(|e| anyhow!("open OLE2: {e}"))?;++    // Walk top-level storages. Skip housekeeping ones (`/Library`,+    // `/FileVersionInfo`); each remaining storage is a footprint.+    let footprint_storages: Vec<String> = comp.read_root_storage()+        .filter_map(|ent| {+            if !ent.is_storage() { return None; }+            let name = ent.name().to_string();+            if name == "Library" || name == "FileVersionInfo" { return None; }+            // Skip OLE2 metadata streams that start with the 0x01 control char+            if name.starts_with('\u{1}') { return None; }+            Some(name)+        })+        .collect();++    if footprint_storages.is_empty() {+        bail!("no footprint storages found in PcbLib");+    }++    let mut parsed: Vec<serde_json::Value> = Vec::new();+    for name in &footprint_storages {+        let stream_path = format!("/{name}/Data");+        let mut stream = match comp.open_stream(&stream_path) {+            Ok(s) => s,+            Err(_) => continue,+        };+        let mut data = Vec::new();+        if stream.read_to_end(&mut data).is_err() { continue; }+        match parse_footprint_stream(&data) {+            Ok(v) => parsed.push(v),+            Err(_e) => {+                // Surface the error in the JSON so the caller can flag it,+                // but keep walking — other footprints may parse cleanly.+                parsed.push(serde_json::json!({+                    "module_name": name,+                    "pad_count": 0,+                    "pads": [],+                    "silk_markers": [],+                    "pin1_silk": { "found": false },+                    "_source": "altium_pcblib",+                    "_parse_error": _e.to_string(),+                }));+            }+        }+    }++    if parsed.is_empty() {+        bail!("no parseable footprints in PcbLib");+    }++    // Pick the footprint with the most pads as primary; alternates ride along.+    parsed.sort_by(|a, b| {+        let pa = a.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);+        let pb = b.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);+        pb.cmp(&pa)+    });++    let mut primary = parsed.remove(0);+    if let Some(obj) = primary.as_object_mut() {+        obj.insert("_source".to_string(), serde_json::Value::String("altium_pcblib".into()));+        if !parsed.is_empty() {+            obj.insert("_alternates".to_string(), serde_json::Value::Array(parsed));+        }+    }+    Ok(primary)+}++fn parse_footprint_stream(data: &[u8]) -> Result<serde_json::Value> {+    if data.len() < 4 {+        bail!("stream too short ({} bytes)", data.len());+    }++    // Footprint name: [u32 LE block_len][u8 pstring_len][bytes...]+    let name_block_len = read_u32_le(data, 0)? as usize;+    if 4 + name_block_len > data.len() {+        bail!("footprint name block overruns stream");+    }+    let name_block = &data[4..4 + name_block_len];+    let module_name = if name_block.is_empty() {+        String::new()+    } else {+        let plen = name_block[0] as usize;+        if 1 + plen > name_block.len() {+            String::new()+        } else {+            String::from_utf8_lossy(&name_block[1..1 + plen]).to_string()+        }+    };++    let mut cursor = 4 + name_block_len;+    let mut pads: Vec<serde_json::Value> = Vec::new();+    let silk_markers: Vec<serde_json::Value> = Vec::new();++    // Altium footprints lay pads at the head of the stream, then silk /+    // courtyard / fab tracks, then optionally text / region / component-body+    // records that we don't model. Walk records until we run out, but stop+    // gracefully the moment we lose alignment — at that point we already+    // have the pads we need for cross-source agreement, and the silkscreen+    // / fab data isn't worth misreading.+    while cursor < data.len() {+        let record_type = data[cursor];+        cursor += 1;++        // Number of [u32 len][bytes] sub-blocks expected per record type.+        // Reverse-engineered from the bundled samples — most records carry a+        // single payload block; pads carry six (designator, layer-name, four+        // metadata blocks); text carries two (main + style trailer).+        let sub_blocks: usize = match record_type {+            0x02 => 6,                      // Pad+            0x05 => 2,                      // Text+            0x01 | 0x03 | 0x04 | 0x06 |+            0x09 | 0x0B | 0x0C => 1,        // Arc / Via / Track / Fill / Conn / Region / Body+            _ => {+                // Unknown record — stop without erroring out. Pads come+                // first; a stray byte mid-stream is usually a Text+                // sub-block we miscounted, not a real new record kind.+                break;+            }+        };++        let mut blocks: Vec<&[u8]> = Vec::with_capacity(sub_blocks);+        let mut ok = true;+        for _ in 0..sub_blocks {+            if cursor + 4 > data.len() { ok = false; break; }+            let blen = read_u32_le(data, cursor).ok().map(|v| v as usize).unwrap_or(usize::MAX);+            // Sanity bound: any sub-block bigger than the whole stream is+            // proof we lost alignment. Bail out and keep the pads we have.+            if blen > data.len() { ok = false; break; }+            cursor += 4;+            if cursor + blen > data.len() { ok = false; break; }+            blocks.push(&data[cursor..cursor + blen]);+            cursor += blen;+        }+        if !ok {+            break;+        }++        if record_type == 0x02 {+            if let Some(pad_json) = decode_pad(&blocks) {+                pads.push(pad_json);+            }+        }+    }++    let pad_count = pads.len();+    let pin1_silk = serde_json::json!({"found": false});+    Ok(serde_json::json!({+        "module_name": module_name,+        "pad_count": pad_count,+        "pads": pads,+        "silk_markers": silk_markers,+        "pin1_silk": pin1_silk,+    }))+}++fn decode_pad(blocks: &[&[u8]]) -> Option<serde_json::Value> {+    if blocks.len() < 5 { return None; }++    // Block 0: designator pstring (e.g. "01 31" = "1")+    let designator = decode_pstring(blocks[0]);++    // Block 4: main pad data, 194 bytes (v3) or 202 bytes (v4+)+    let pd = blocks[4];+    if pd.len() < 49 {+        return None;+    }++    let layer_byte = pd[0];++    // X / Y in 1/10000 mil units. Altium Y-up → KiCad Y-down (flip sign).+    let x_units = read_i32_le(pd, 13)?;+    let y_units = read_i32_le(pd, 17)?;+    let x_mm = x_units as f64 * ALTIUM_UNIT_TO_MM;+    let y_mm = -(y_units as f64 * ALTIUM_UNIT_TO_MM);++    let xsize_top = read_u32_le(pd, 21).ok()? as f64 * ALTIUM_UNIT_TO_MM;+    let ysize_top = read_u32_le(pd, 25).ok()? as f64 * ALTIUM_UNIT_TO_MM;+    // mid / bot sizes available at 29/33/37/41 if we ever need them+    let hole_units = read_u32_le(pd, 45).ok()?;+    let hole_mm = hole_units as f64 * ALTIUM_UNIT_TO_MM;+    let shape_byte = pd[49];++    // Rotation lives at block5[52..60] in v4+. v3 (194-byte) layout doesn't+    // store it (rotation is baked into xsize/ysize). Read what's there; if+    // the f64 isn't a finite angle we just default to 0. The bounding box is+    // still correct because Altium pre-rotates the size for v3.+    let rotation = if pd.len() >= 60 {+        let raw = read_f64_le(pd, 52).unwrap_or(0.0);+        if raw.is_finite() && raw.abs() < 360.0 + 1e-6 {+            // Normalize to [-360, 360); KiCad accepts any angle but typical+            // values are 0/90/180/270.+            raw+        } else {+            0.0+        }+    } else {+        0.0+    };++    // Altium "round" (1) covers both circular pads and stadium-shaped SMD+    // pads with rounded ends. KiCad splits those: "circle" for w == h,+    // "oval" for w != h.+    let shape = match shape_byte {+        1 if (xsize_top - ysize_top).abs() < 1e-3 => "circle",+        1 => "oval",+        _ => altium_shape_to_kicad(shape_byte),+    };+    let pad_type = if hole_units == 0 {+        "smd"+    } else {+        "thru_hole"+    };++    let layers = altium_layers_for_pad(layer_byte, pad_type);++    let drill = if hole_units == 0 {+        serde_json::Value::Null+    } else {+        serde_json::json!({+            "shape": "circle",+            "diameter": hole_mm,+        })+    };++    Some(serde_json::json!({+        "number": designator,+        "type": pad_type,+        "shape": shape,+        "at": [round6(x_mm), round6(y_mm), round4(rotation)],+        "size": [round6(xsize_top), round6(ysize_top)],+        "layers": layers,+        "drill": drill,+    }))+}++fn decode_pstring(block: &[u8]) -> String {+    if block.is_empty() { return "?".to_string(); }+    let plen = block[0] as usize;+    if 1 + plen > block.len() { return "?".to_string(); }+    let raw = &block[1..1 + plen];+    // Altium pads designators with NUL out to a fixed width; trim trailing NULs.+    let trimmed = raw.iter().take_while(|&&b| b != 0).copied().collect::<Vec<_>>();+    String::from_utf8_lossy(&trimmed).to_string()+}++fn altium_shape_to_kicad(shape: u8) -> &'static str {+    match shape {+        1 => "circle",     // round — caller specializes to "oval" when w != h+        2 => "rect",+        3 => "rect",       // octagonal — KiCad has no octagonal; rect is closest+        9 => "roundrect",+        _ => "rect",+    }+}++/// Map an Altium layer byte to a KiCad-style layer list. SMD pads claim+/// F.Cu + F.Paste + F.Mask (or B.* on the bottom). Thru-hole pads claim+/// *.Cu + *.Paste + *.Mask so they appear on every layer.+fn altium_layers_for_pad(layer: u8, pad_type: &str) -> Vec<String> {+    if pad_type == "thru_hole" {+        return vec!["*.Cu".into(), "*.Paste".into(), "*.Mask".into()];+    }+    let on_bottom = matches!(layer, 32);+    if on_bottom {+        vec!["B.Cu".into(), "B.Paste".into(), "B.Mask".into()]+    } else {+        vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()]+    }+}++fn read_u32_le(buf: &[u8], off: usize) -> Result<u32> {+    if off + 4 > buf.len() { bail!("u32 read past end of buffer at {off}"); }+    Ok(u32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]))+}++fn read_i32_le(buf: &[u8], off: usize) -> Option<i32> {+    if off + 4 > buf.len() { return None; }+    Some(i32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]))+}++fn read_f64_le(buf: &[u8], off: usize) -> Option<f64> {+    if off + 8 > buf.len() { return None; }+    Some(f64::from_le_bytes([+        buf[off], buf[off+1], buf[off+2], buf[off+3],+        buf[off+4], buf[off+5], buf[off+6], buf[off+7],+    ]))+}++fn round6(x: f64) -> f64 { (x * 1_000_000.0).round() / 1_000_000.0 }+fn round4(x: f64) -> f64 { (x * 10_000.0).round() / 10_000.0 }++// ----------------------------------------------------------------------------+// SVG renderer — authoritative debug visualisation+// ----------------------------------------------------------------------------+//+// Downstream tools (chipsmith's overlay, concur's consensus check) interpret+// the parsed JSON. If their renders disagree with the SVG produced here,+// THEY are the bug — this rendering is what the parser actually believes the+// pads to be, drawn directly from `at` / `size` / `rot` / `shape` with no+// further interpretation.+//+// Convention:+//   - Coordinates are in millimetres. SVG units = mm. No scaling applied.+//   - Y is positive-down (matches both KiCad and our parser's emitted JSON,+//     where Altium's Y-up is already flipped at parse time).+//   - Rotation is taken straight from `at[2]`. Applied via SVG's+//     `transform="rotate(deg cx cy)"` which rotates clockwise as seen on+//     screen — same visual effect as KiCad's rotation.+//+// What the SVG shows:+//   - Footprint name + pad count in the corner.+//   - One <rect>/<circle>/<ellipse> per pad, coloured by layer+//     (F.Cu = orange, B.Cu = blue, thru-hole = both).+//   - The drill hole as a white circle inside thru-hole pads.+//   - Pad number text un-rotated for readability (centred on the pad).+//   - Origin crosshair at (0, 0) so you can sanity-check pad placement.+//   - viewBox auto-fitted with 1 mm padding around the pad bounding box.++/// Render the parsed footprint JSON as a standalone SVG document. The input+/// must be the value returned by [`parse_altium_pcblib_bytes`] /+/// [`parse_altium_pcblib_path`] — either the primary footprint or one of its+/// `_alternates`.+pub fn render_footprint_svg(footprint: &serde_json::Value) -> String {+    let module_name = footprint+        .get("module_name").and_then(|v| v.as_str()).unwrap_or("?");+    let pads = footprint+        .get("pads").and_then(|v| v.as_array()).cloned().unwrap_or_default();++    // Compute the post-rotation axis-aligned bbox of every pad so the+    // viewBox covers the whole footprint with a small margin.+    let mut min_x = f64::INFINITY;+    let mut min_y = f64::INFINITY;+    let mut max_x = f64::NEG_INFINITY;+    let mut max_y = f64::NEG_INFINITY;+    for p in &pads {+        if let Some(bb) = pad_post_rotation_bbox(p) {+            min_x = min_x.min(bb.0);+            min_y = min_y.min(bb.1);+            max_x = max_x.max(bb.2);+            max_y = max_y.max(bb.3);+        }+    }+    if !min_x.is_finite() {+        // No pads with usable geometry — emit an empty placeholder so the+        // caller still gets a valid SVG document.+        return format!(+            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 10 10" width="200" height="200"><text x="0" y="0" font-size="1" text-anchor="middle" font-family="monospace">{module_name}: no pads</text></svg>"#+        );+    }+    let pad = 1.0_f64; // 1 mm margin around the pad cluster+    let vb_x = min_x - pad;+    let vb_y = min_y - pad;+    let vb_w = (max_x - min_x) + 2.0 * pad;+    let vb_h = (max_y - min_y) + 2.0 * pad;++    // Render each pad. Always 1 mm = 1 unit so callers can pixel-measure.+    let px_per_mm = 50.0_f64; // hint to image viewers (overridable via CSS)+    let mut body = String::new();+    body.push_str(&format!(+        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.4} {:.4} {:.4} {:.4}" width="{:.0}" height="{:.0}" font-family="monospace">"#,+        vb_x, vb_y, vb_w, vb_h,+        vb_w * px_per_mm, vb_h * px_per_mm,+    ));+    body.push('\n');++    // Background — pale grid so axes read clearly.+    body.push_str(&format!(+        r##"  <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" fill="#fafafa" stroke="none"/>"##,+        vb_x, vb_y, vb_w, vb_h,+    ));+    body.push('\n');++    // Origin crosshair — 1 mm long arms.+    body.push_str(r##"  <g stroke="#888" stroke-width="0.04" fill="none">"##);+    body.push('\n');+    body.push_str(r#"    <line x1="-0.5" y1="0" x2="0.5" y2="0"/>"#);+    body.push('\n');+    body.push_str(r#"    <line x1="0" y1="-0.5" x2="0" y2="0.5"/>"#);+    body.push('\n');+    body.push_str(r#"  </g>"#);+    body.push('\n');++    // Pads.+    for p in &pads {+        body.push_str(&render_pad_svg(p));+    }++    // Header text — top-left corner of viewBox.+    body.push_str(&format!(+        r##"  <text x="{:.4}" y="{:.4}" font-size="0.5" fill="#222">{} ({} pads)</text>"##,+        vb_x + 0.2, vb_y + 0.6, escape_xml(module_name), pads.len(),+    ));+    body.push('\n');+    body.push_str("</svg>\n");+    body+}++/// Convenience: render the primary footprint plus every `_alternates` entry,+/// one above the next, into a single SVG so a reviewer can flip through+/// every variant a `.PcbLib` declares.+pub fn render_all_footprints_svg(parsed: &serde_json::Value) -> String {+    // Collect primary + every alternate as separate SVG bodies, then stack+    // them vertically inside one outer <svg>.+    let mut footprints: Vec<&serde_json::Value> = vec![parsed];+    if let Some(alts) = parsed.get("_alternates").and_then(|v| v.as_array()) {+        footprints.extend(alts.iter());+    }+    // Render each independently, then string-concat into a vertical stack.+    let inner: Vec<String> = footprints.iter().map(|fp| render_footprint_svg(fp)).collect();+    // Crude but effective: inline each as <svg ... y="offset"> inside an+    // outer wrapper. SVG nesting works in browsers and most viewers.+    let mut out = String::new();+    let panel_height = 400.0_f64;+    let panel_gap = 40.0_f64;+    let total_h = footprints.len() as f64 * (panel_height + panel_gap);+    out.push_str(&format!(+        r#"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="{:.0}">"#,+        total_h,+    ));+    out.push('\n');+    for (i, svg) in inner.iter().enumerate() {+        let y = i as f64 * (panel_height + panel_gap);+        out.push_str(&format!(+            r#"  <svg x="0" y="{:.0}" width="800" height="{:.0}" preserveAspectRatio="xMidYMid meet">"#,+            y, panel_height,+        ));+        // strip the outer <svg> tag from the inner doc so we just splice+        // its content in.+        let trimmed = svg+            .trim_start_matches(|c: char| c != '>').trim_start_matches('>')+            .trim_end()+            .trim_end_matches("</svg>");+        out.push_str(trimmed);+        out.push_str("</svg>\n");+    }+    out.push_str("</svg>\n");+    out+}++fn render_pad_svg(p: &serde_json::Value) -> String {+    let number = p.get("number").and_then(|v| v.as_str()).unwrap_or("?");+    let shape = p.get("shape").and_then(|v| v.as_str()).unwrap_or("rect");+    let pad_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("smd");+    let at = p.get("at").and_then(|v| v.as_array()).cloned().unwrap_or_default();+    let size = p.get("size").and_then(|v| v.as_array()).cloned().unwrap_or_default();+    let cx = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);+    let cy = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);+    let rot = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);+    let w = size.first().and_then(|v| v.as_f64()).unwrap_or(0.0);+    let h = size.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);++    // Layer colour: F.Cu → warm orange, B.Cu → cool blue, thru-hole → both.+    let on_back = p.get("layers").and_then(|v| v.as_array())+        .map(|arr| arr.iter().any(|l| l.as_str() == Some("B.Cu")))+        .unwrap_or(false);+    let fill = if pad_type == "thru_hole" {+        "#a8d4a8" // soft green for thru-hole+    } else if on_back {+        "#7cb6ff"+    } else {+        "#ff9a4a"+    };+    let stroke = "#222";++    // Rotation: SVG's `rotate(angle cx cy)` rotates clockwise on screen+    // when applied to a Y-down coordinate system, which matches how KiCad+    // displays its rotated pads. So we use the angle as-is.+    let rot_attr = if rot.abs() > 1e-6 {+        format!(r#" transform="rotate({:.4} {:.4} {:.4})""#, rot, cx, cy)+    } else {+        String::new()+    };++    let mut out = String::new();+    out.push_str("  <g>\n");+    match shape {+        "circle" => {+            let r = w.max(h) / 2.0;+            out.push_str(&format!(+                r#"    <circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,+                cx, cy, r, fill, stroke, rot_attr,+            ));+            out.push('\n');+        }+        "oval" => {+            // KiCad "oval" = stadium: rect with semicircle ends. Approximate+            // with rounded-rect rx = min(w, h)/2.+            let r = w.min(h) / 2.0;+            out.push_str(&format!(+                r#"    <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" rx="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,+                cx - w / 2.0, cy - h / 2.0, w, h, r, fill, stroke, rot_attr,+            ));+            out.push('\n');+        }+        "roundrect" => {+            let r = (w.min(h) * 0.25).max(0.05);+            out.push_str(&format!(+                r#"    <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" rx="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,+                cx - w / 2.0, cy - h / 2.0, w, h, r, fill, stroke, rot_attr,+            ));+            out.push('\n');+        }+        _ => {+            // rect (or unknown — draw as rect)+            out.push_str(&format!(+                r#"    <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,+                cx - w / 2.0, cy - h / 2.0, w, h, fill, stroke, rot_attr,+            ));+            out.push('\n');+        }+    }++    // Drill hole — drawn AFTER the pad body so it sits on top.+    if let Some(drill) = p.get("drill") {+        if let Some(d) = drill.get("diameter").and_then(|v| v.as_f64()) {+            out.push_str(&format!(+                r##"    <circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="#fff" stroke="{}" stroke-width="0.02"/>"##,+                cx, cy, d / 2.0, stroke,+            ));+            out.push('\n');+        }+    }++    // Pad number — un-rotated so it stays readable regardless of rot.+    let label_size = (w.min(h) * 0.5).clamp(0.15, 0.6);+    out.push_str(&format!(+        r##"    <text x="{:.4}" y="{:.4}" font-size="{:.4}" text-anchor="middle" dominant-baseline="middle" fill="#111">{}</text>"##,+        cx, cy, label_size, escape_xml(number),+    ));+    out.push('\n');+    out.push_str("  </g>\n");+    out+}++/// Compute the axis-aligned bounding box of a pad after applying its rotation.+/// Returns (min_x, min_y, max_x, max_y) in mm.+fn pad_post_rotation_bbox(p: &serde_json::Value) -> Option<(f64, f64, f64, f64)> {+    let at = p.get("at").and_then(|v| v.as_array())?;+    let size = p.get("size").and_then(|v| v.as_array())?;+    let cx = at.first()?.as_f64()?;+    let cy = at.get(1)?.as_f64()?;+    let rot_deg = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);+    let w = size.first()?.as_f64()?;+    let h = size.get(1)?.as_f64()?;+    let theta = rot_deg.to_radians();+    let (s, c) = (theta.sin(), theta.cos());+    // Half-extents along rotated axes — corners are at ±(w/2)*(c, s) ± (h/2)*(-s, c)+    let hx = (w / 2.0 * c).abs() + (h / 2.0 * s).abs();+    let hy = (w / 2.0 * s).abs() + (h / 2.0 * c).abs();+    Some((cx - hx, cy - hy, cx + hx, cy + hy))+}++fn escape_xml(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    for ch in s.chars() {+        match ch {+            '&' => out.push_str("&amp;"),+            '<' => out.push_str("&lt;"),+            '>' => out.push_str("&gt;"),+            '"' => out.push_str("&quot;"),+            '\'' => out.push_str("&apos;"),+            _ => out.push(ch),+        }+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    /// LM358D is a SOIC-8 with 8 SMD pads at ±2.4638 mm × {±1.905, ±0.635} mm,+    /// each 1.9812 × 0.5588 mm. Pad 1 is at (-2.4638, -1.905) in KiCad+    /// convention.+    #[test]+    fn lm358d_decodes_eight_pads_pad1_at_top_left() {+        const LM358D_PCBLIB: &[u8] = include_bytes!(+            "../../chip-fetcher/library/LM358D/LM358D.PcbLib"+        );+        let parsed = parse_altium_pcblib_bytes(LM358D_PCBLIB)+            .expect("parse LM358D PcbLib");+        let pad_count = parsed.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);+        assert_eq!(pad_count, 8, "LM358D should have 8 pads, got {pad_count}");+        let pads = parsed.get("pads").and_then(|v| v.as_array()).expect("pads array");+        let pad1 = pads.iter().find(|p| {+            p.get("number").and_then(|v| v.as_str()) == Some("1")+        }).expect("pad 1");+        let at = pad1.get("at").and_then(|v| v.as_array()).expect("at");+        let x = at[0].as_f64().unwrap();+        let y = at[1].as_f64().unwrap();+        let size = pad1.get("size").and_then(|v| v.as_array()).expect("size");+        let w = size[0].as_f64().unwrap();+        let h = size[1].as_f64().unwrap();+        // Pad 1 centre is at roughly (-2.46, -1.91); width 1.98, height 0.56+        assert!((x - -2.4638).abs() < 0.05, "pad 1 X = {x}");+        assert!((y - -1.905).abs() < 0.05, "pad 1 Y = {y}");+        assert!((w - 1.9812).abs() < 0.05, "pad 1 W = {w}");+        assert!((h - 0.5588).abs() < 0.05, "pad 1 H = {h}");+        let layers = pad1.get("layers").and_then(|v| v.as_array()).expect("layers");+        assert!(layers.iter().any(|l| l.as_str() == Some("F.Cu")));+    }+}
crates/altium-schlib/Cargo.lockadded+262
@@ -0,0 +1,262 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "altium-schlib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "anyhow"+version = "1.0.102"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"++[[package]]+name = "bumpalo"+version = "3.20.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"++[[package]]+name = "byteorder"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"++[[package]]+name = "cfb"+version = "0.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"+dependencies = [+ "byteorder",+ "fnv",+ "uuid",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "fnv"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"++[[package]]+name = "futures-core"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"++[[package]]+name = "futures-task"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"++[[package]]+name = "futures-util"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"+dependencies = [+ "futures-core",+ "futures-task",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "js-sys"+version = "0.3.102"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"+dependencies = [+ "cfg-if",+ "futures-util",+ "wasm-bindgen",+]++[[package]]+name = "memchr"+version = "2.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.46"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "rustversion"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.150"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"++[[package]]+name = "syn"+version = "2.0.118"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "uuid"+version = "1.23.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"+dependencies = [+ "js-sys",+ "wasm-bindgen",+]++[[package]]+name = "wasm-bindgen"+version = "0.2.125"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"+dependencies = [+ "cfg-if",+ "once_cell",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.125"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.125"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.125"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
crates/altium-schlib/Cargo.tomladded+25
@@ -0,0 +1,25 @@+[package]+name = "altium-schlib"+version = "0.1.0"+edition = "2021"+description = "Native Rust parser for Altium .SchLib (and .IntLib) symbol libraries — extracts pins (number/name/xy/rotation) + body as KiCad-shaped JSON, renders SVG, emits .kicad_sym. Sibling to altium-pcblib."+authors = ["Adom <[email protected]>"]+license = "MIT"+repository = "https://github.com/adom-inc/altium-schlib"++[dependencies]+anyhow = "1"+serde_json = "1"+cfb = "0.10"++[[bin]]+name = "altium-schlib-svg"+path = "src/bin/svg.rs"++[[bin]]+name = "altium-schlib-kicad"+path = "src/bin/kicad.rs"++[[bin]]+name = "altium-schlib-json"+path = "src/bin/json.rs"
crates/altium-schlib/LICENSEadded+21
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Adom Industries Inc.++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
crates/altium-schlib/README.mdadded+89
@@ -0,0 +1,89 @@+## What this is++`altium-schlib` is a native Rust crate that reads Altium Designer's **binary+schematic-symbol libraries** — `.SchLib`, and the SchLib stream embedded inside+`.IntLib` — with **no Altium install and no KiCad round-trip**. It is the symbol+counterpart to [`altium-pcblib`](https://wiki.adom.inc/skill/altium-pcblib)+(footprints), and together they let the Adom EDA family read *both halves* of an+Altium library natively.++It emits pins (number, name, position, rotation) and the body as **KiCad-shaped+JSON**, renders a **native SVG** of the symbol, and can emit a **`.kicad_sym`**+for export.++![ADS1263IPWR symbol parsed natively from its Altium .SchLib — 28 pins, both banks, body](screenshots/ads1263-symbol.png)++*The ADS1263IPWR schematic symbol, rendered straight from its Altium `.SchLib` —+28 pins, both banks, correct names + numbers (validated pin-for-pin against KiCad).*++## Why it exists++Altium `.SchLib` is a Microsoft OLE2 / Compound File Binary container, and the+pins inside are **not** the pipe-delimited ASCII records the rest of the file+uses — they're a compact **binary** record format that no public Rust parser+read. Every Adom tool that wanted Altium *symbols* previously had to lean on+KiCad's importer (a lossy conversion that normalises everything to KiCad). This+crate reverse-engineers the binary pin record so a `.SchLib` renders its **own**+geometry — which is the whole point of comparing sources: you can only spot what+disagrees if each format shows its own truth.++## Validation++The binary decode was validated **pin-for-pin** against KiCad 10's Altium+importer on a real part (`ADS1263IPWR`): **28/28** pin numbers and names match —+overbars and all (`C̄S̄`, `R̄ĒS̄ĒT̄`).++![altium-schlib native symbol vs KiCad vs datasheet — pins validated 28/28](screenshots/validation.png)++*Left: the symbol `altium-schlib` parses straight from the `.SchLib`. Middle/right:+KiCad's own render and the datasheet extraction — every pin number and name agrees.*++## The format, briefly++```+.SchLib  =  OLE2 container+            └── /<ComponentName>/Data   ← record stream+                  ├── text records   <u16 len><u16 flags> |KEY=VAL|KEY=VAL+                  │     RECORD=1  component header (PartCount, AllPinCount…)+                  │     RECORD=14 rectangle (the symbol body)+                  │     RECORD=41 parameter+                  └── binary pin records  <u16 len><u16 flags> + payload[0]=0x02+                        [14]     PinConglomerate (orientation in bits 0..1)+                        [15..17] PinLength      (i16)+                        [17..19] Location.X     (i16)+                        [19..21] Location.Y     (i16)+                        [26..]   length-prefixed strings: Name, Designator+```++The OLE2 FAT that Altium writes overshoots the file's real sector count, so the+crate pads the buffer with the CFBF free-sector sentinel (`0xFF`) before opening+— the same trick `altium-pcblib` uses.++## Public API++```rust+// Parse → symbol JSON { module_name, pin_count, pins:[…], body, part_count }+altium_schlib::parse_altium_schlib_path(&Path) -> Result<serde_json::Value>+altium_schlib::parse_altium_schlib_bytes(&[u8]) -> Result<serde_json::Value>++// Native SVG — draws the symbol directly from the parsed pins (no KiCad).+altium_schlib::render_symbol_svg(&Value) -> String++// Export path — emit a .kicad_sym (rendering uses the SVG above).+altium_schlib::to_kicad_sym(&Value, lib_name: &str) -> String+```++## CLI++```bash+altium-schlib-json  part.SchLib            # parsed JSON+altium-schlib-svg   part.SchLib            # native symbol SVG → stdout+altium-schlib-kicad part.SchLib [name]     # .kicad_sym → stdout+```++## Who uses it++`adom-sfconvert` reads Altium symbols through this crate (fully native — no+service round-trip), and `chip-fetcher` shows the result as a per-source+thumbnail next to the KiCad, EAGLE/Fusion, and datasheet symbols so you can+compare what each source actually contains.
crates/altium-schlib/SKILL.mdadded+66
@@ -0,0 +1,66 @@+---+name: altium-schlib+description: >+  Native Rust crate that parses Altium .SchLib (and .IntLib) binary schematic-+  symbol libraries — an OLE2 Compound File container holding a reverse-engineered+  binary pin-record format. Emits KiCad-shaped pin JSON, renders a native symbol+  SVG, and emits .kicad_sym for export. The symbol counterpart to altium-pcblib+  (footprints); together they read both halves of an Altium library natively, with+  no Altium install and no KiCad/service round-trip. Used by adom-sfconvert ++  chip-fetcher so each format renders its OWN geometry for cross-source comparison.+  Public Rust API: parse_altium_schlib_path(&Path), parse_altium_schlib_bytes(&[u8]),+  render_symbol_svg(&Value), to_kicad_sym(&Value, name).+  Validated 28/28 pins (number + name, overbars included) against KiCad 10's+  Altium importer on ADS1263IPWR.+---++# altium-schlib++Read Altium schematic-symbol libraries (`.SchLib` / `.IntLib`) in pure Rust.++## When to reach for this++- A tool needs the **pins** (numbers, names, sides, rotation) out of an Altium+  `.SchLib` without installing Altium or converting through KiCad.+- You want a **native symbol thumbnail** (SVG) that shows the Altium library's+  own geometry, for side-by-side comparison against other sources.+- You need to **export** an Altium symbol to `.kicad_sym`.++## API++| Call | Returns |+|---|---|+| `parse_altium_schlib_path(&Path)` | symbol JSON (`module_name`, `pin_count`, `pins[]`, `body`, `part_count`) |+| `parse_altium_schlib_bytes(&[u8])` | same, from raw bytes |+| `render_symbol_svg(&Value)` | standalone SVG, drawn natively |+| `to_kicad_sym(&Value, name)` | `.kicad_sym` text (export only) |++Each pin: `{ number, name, x, y, length, rotation, conglomerate }`.++## The binary pin record (the hard-won part)++Inside `/<Component>/Data`, text records are `<u16 len><u16 flags>|KEY=VAL|…`,+but **pins are binary** records whose payload starts with `0x02`:++```+[14]     PinConglomerate   orientation in bits 0..1 → 0/90/180/270°+[15..17] PinLength         i16+[17..19] Location.X        i16+[19..21] Location.Y        i16+[26..]   length-prefixed strings: Name, then Designator+```++Open the OLE2 container after padding the buffer to ≥1 MiB with `0xFF` (Altium's+FAT overshoots the real sector count, which `cfb` otherwise rejects).++## Don't re-implement this++If another Adom tool needs Altium symbols, depend on this crate — do **not**+re-walk OLE2 + re-decode the pin format. One source of truth, like+`altium-pcblib` is for footprints.++## Pairing++- Footprints → [`altium-pcblib`](https://wiki.adom.inc/skill/altium-pcblib)+- Converter that uses both → `adom-sfconvert`+- Consumer that shows the result → `chip-fetcher`
crates/altium-schlib/install.shadded+7
@@ -0,0 +1,7 @@+#!/usr/bin/env bash+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"+DEST="$HOME/.claude/skills/altium-schlib"+mkdir -p "$DEST"+cp -f "$HERE/skills/altium-schlib/SKILL.md" "$DEST/SKILL.md"+echo "Installed altium-schlib skill → $DEST"
crates/altium-schlib/package.jsonadded+33
@@ -0,0 +1,33 @@+{+  "slug": "altium-schlib",+  "version": "1.0.4",+  "type": "skill",+  "description": "Native Rust parser for Altium .SchLib binary symbol libraries \u2014 pins + body to KiCad-shaped JSON, native SVG, .kicad_sym export. The symbol counterpart to altium-pcblib.",+  "dependencies": {},+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "tags": [+    "altium",+    "schlib",+    "kicad",+    "rust",+    "eda",+    "symbol",+    "parser"+  ],+  "hero": {+    "path": "screenshots/hero-v2.png"+  },+  "license": "MIT",+  "discovery_triggers": [+    "altium schlib",+    "parse altium symbol",+    "read altium .schlib",+    "altium symbol to kicad",+    "altium-schlib",+    "native altium symbol parser",+    "altium ole2 symbol"+  ]+}\ No newline at end of file
crates/altium-schlib/screenshots/ads1263-symbol.pngadded
⋯ 1 unchanged line ⋯
crates/altium-schlib/screenshots/hero-v2.pngadded
⋯ 1 unchanged line ⋯
crates/altium-schlib/screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
crates/altium-schlib/screenshots/validation.pngadded
⋯ 1 unchanged line ⋯
crates/altium-schlib/skills/altium-schlib/SKILL.mdadded+66
@@ -0,0 +1,66 @@+---+name: altium-schlib+description: >+  Native Rust crate that parses Altium .SchLib (and .IntLib) binary schematic-+  symbol libraries — an OLE2 Compound File container holding a reverse-engineered+  binary pin-record format. Emits KiCad-shaped pin JSON, renders a native symbol+  SVG, and emits .kicad_sym for export. The symbol counterpart to altium-pcblib+  (footprints); together they read both halves of an Altium library natively, with+  no Altium install and no KiCad/service round-trip. Used by adom-sfconvert ++  chip-fetcher so each format renders its OWN geometry for cross-source comparison.+  Public Rust API: parse_altium_schlib_path(&Path), parse_altium_schlib_bytes(&[u8]),+  render_symbol_svg(&Value), to_kicad_sym(&Value, name).+  Validated 28/28 pins (number + name, overbars included) against KiCad 10's+  Altium importer on ADS1263IPWR.+---++# altium-schlib++Read Altium schematic-symbol libraries (`.SchLib` / `.IntLib`) in pure Rust.++## When to reach for this++- A tool needs the **pins** (numbers, names, sides, rotation) out of an Altium+  `.SchLib` without installing Altium or converting through KiCad.+- You want a **native symbol thumbnail** (SVG) that shows the Altium library's+  own geometry, for side-by-side comparison against other sources.+- You need to **export** an Altium symbol to `.kicad_sym`.++## API++| Call | Returns |+|---|---|+| `parse_altium_schlib_path(&Path)` | symbol JSON (`module_name`, `pin_count`, `pins[]`, `body`, `part_count`) |+| `parse_altium_schlib_bytes(&[u8])` | same, from raw bytes |+| `render_symbol_svg(&Value)` | standalone SVG, drawn natively |+| `to_kicad_sym(&Value, name)` | `.kicad_sym` text (export only) |++Each pin: `{ number, name, x, y, length, rotation, conglomerate }`.++## The binary pin record (the hard-won part)++Inside `/<Component>/Data`, text records are `<u16 len><u16 flags>|KEY=VAL|…`,+but **pins are binary** records whose payload starts with `0x02`:++```+[14]     PinConglomerate   orientation in bits 0..1 → 0/90/180/270°+[15..17] PinLength         i16+[17..19] Location.X        i16+[19..21] Location.Y        i16+[26..]   length-prefixed strings: Name, then Designator+```++Open the OLE2 container after padding the buffer to ≥1 MiB with `0xFF` (Altium's+FAT overshoots the real sector count, which `cfb` otherwise rejects).++## Don't re-implement this++If another Adom tool needs Altium symbols, depend on this crate — do **not**+re-walk OLE2 + re-decode the pin format. One source of truth, like+`altium-pcblib` is for footprints.++## Pairing++- Footprints → [`altium-pcblib`](https://wiki.adom.inc/skill/altium-pcblib)+- Converter that uses both → `adom-sfconvert`+- Consumer that shows the result → `chip-fetcher`
crates/altium-schlib/src/bin/json.rsadded+6
@@ -0,0 +1,6 @@+fn main() -> anyhow::Result<()> {+    let path = std::env::args().nth(1).unwrap();+    let sym = altium_schlib::parse_altium_schlib_path(std::path::Path::new(&path))?;+    println!("{}", serde_json::to_string_pretty(&sym)?);+    Ok(())+}
crates/altium-schlib/src/bin/kicad.rsadded+10
@@ -0,0 +1,10 @@+//! altium-schlib-kicad <file.SchLib> [name] — emit a .kicad_sym (export path).+fn main() -> anyhow::Result<()> {+    let path = std::env::args().nth(1).ok_or_else(|| anyhow::anyhow!("usage: altium-schlib-kicad <file.SchLib> [name]"))?;+    let sym = altium_schlib::parse_altium_schlib_path(std::path::Path::new(&path))?;+    let name = std::env::args().nth(2).unwrap_or_else(|| {+        sym.get("module_name").and_then(|v| v.as_str()).unwrap_or("SYM").to_string()+    });+    print!("{}", altium_schlib::to_kicad_sym(&sym, &name));+    Ok(())+}
crates/altium-schlib/src/bin/svg.rsadded+7
@@ -0,0 +1,7 @@+//! altium-schlib-svg <file.SchLib> — print the native symbol SVG to stdout.+fn main() -> anyhow::Result<()> {+    let path = std::env::args().nth(1).ok_or_else(|| anyhow::anyhow!("usage: altium-schlib-svg <file.SchLib>"))?;+    let sym = altium_schlib::parse_altium_schlib_path(std::path::Path::new(&path))?;+    print!("{}", altium_schlib::render_symbol_svg(&sym));+    Ok(())+}
crates/altium-schlib/src/lib.rsadded+373
@@ -0,0 +1,373 @@+//! Native Rust parser for Altium `.SchLib` (and `.IntLib`'s SchLib stream)+//! schematic-symbol libraries — the symbol counterpart to `altium-pcblib`.+//!+//! Altium `.SchLib` is a Microsoft OLE2 / Compound File Binary container. Each+//! component is a storage holding a `Data` stream of records:+//!   * **text records** — `<u16 len><u16 flags>` then `|KEY=VAL|KEY=VAL` ASCII.+//!     RECORD=1 is the component header, RECORD=14 a rectangle (body), RECORD=41+//!     a parameter.+//!   * **binary pin records** — `<u16 len><u16 flags>` then a payload whose first+//!     byte is `0x02`. Layout (offsets into the payload), reverse-engineered and+//!     validated pin-for-pin against KiCad 10's Altium importer:+//!         [14]      PinConglomerate  (u8) — orientation in bits 0..1+//!         [15..17]  PinLength        (i16)+//!         [17..19]  Location.X       (i16)+//!         [19..21]  Location.Y       (i16)+//!         [26..]    length-prefixed strings: Name, then Designator+//!+//! Output JSON mirrors `altium-pcblib`'s KiCad-shaped style so the rest of the+//! Adom EDA family consumes both the same way.++use anyhow::{anyhow, bail, Result};+use std::io::{Cursor, Read};+use std::path::Path;++/// Parse an Altium `.SchLib` from a file path → symbol JSON.+pub fn parse_altium_schlib_path(path: &Path) -> Result<serde_json::Value> {+    let bytes = std::fs::read(path)+        .map_err(|e| anyhow!("read .SchLib {}: {e}", path.display()))?;+    parse_altium_schlib_bytes(&bytes)+}++/// Parse an Altium `.SchLib` from raw bytes → symbol JSON.+pub fn parse_altium_schlib_bytes(bytes: &[u8]) -> Result<serde_json::Value> {+    if bytes.len() < 8 || bytes[..8] != [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] {+        bail!("not an OLE2 compound document (bad magic)");+    }+    // Altium writes FATs that overshoot the file's real sector count; pad with+    // the CFBF free-sector sentinel (0xFF) and re-open. Same fix as altium-pcblib.+    let owned;+    let cursor = if bytes.len() < 1024 * 1024 {+        owned = {+            let mut v = bytes.to_vec();+            v.resize(1024 * 1024, 0xFF);+            v+        };+        Cursor::new(owned.as_slice())+    } else {+        Cursor::new(bytes)+    };+    let mut comp = cfb::CompoundFile::open(cursor).map_err(|e| anyhow!("open OLE2: {e}"))?;++    // Each non-housekeeping root storage is a component.+    let comps: Vec<String> = comp+        .read_root_storage()+        .filter_map(|e| {+            if !e.is_storage() {+                return None;+            }+            let n = e.name().to_string();+            if n.starts_with('\u{1}') || n == "Library" || n == "FileVersionInfo" {+                return None;+            }+            Some(n)+        })+        .collect();+    if comps.is_empty() {+        bail!("no component storages found in SchLib");+    }++    // Single-part library is the common chip-fetcher case: take the first.+    let name = &comps[0];+    let mut data = Vec::new();+    comp.open_stream(format!("/{name}/Data"))+        .map_err(|e| anyhow!("open /{name}/Data: {e}"))?+        .read_to_end(&mut data)?;+    parse_component_stream(name, &data)+}++fn parse_component_stream(comp_name: &str, d: &[u8]) -> Result<serde_json::Value> {+    let mut pins: Vec<serde_json::Value> = Vec::new();+    let mut description = String::new();+    let mut part_count = 1i64;+    let mut body: Option<serde_json::Value> = None;++    let mut i = 0usize;+    while i + 4 <= d.len() {+        let plen = u16::from_le_bytes([d[i], d[i + 1]]) as usize;+        let is_text = i + 4 < d.len() && d[i + 4] == b'|';+        i += 4;+        if plen == 0 || i + plen > d.len() {+            break;+        }+        let payload = &d[i..i + plen];+        i += plen;++        if is_text {+            let txt = String::from_utf8_lossy(payload);+            let rec = field(&txt, "RECORD").unwrap_or_default();+            match rec.as_str() {+                "1" => {+                    description = field(&txt, "ComponentDescription").unwrap_or_default();+                    part_count = field(&txt, "PartCount").and_then(|v| v.parse().ok()).unwrap_or(1);+                }+                "14" => {+                    // Rectangle = the symbol body. Coords are |Location.X|.Y|Corner.X|.Y.+                    let lx = field(&txt, "Location.X").and_then(|v| v.parse::<f64>().ok());+                    let ly = field(&txt, "Location.Y").and_then(|v| v.parse::<f64>().ok());+                    let cx = field(&txt, "Corner.X").and_then(|v| v.parse::<f64>().ok());+                    let cy = field(&txt, "Corner.Y").and_then(|v| v.parse::<f64>().ok());+                    if let (Some(lx), Some(ly), Some(cx), Some(cy)) = (lx, ly, cx, cy) {+                        body = Some(serde_json::json!({+                            "x1": lx, "y1": ly, "x2": cx, "y2": cy+                        }));+                    }+                }+                _ => {}+            }+            continue;+        }++        if payload.first() == Some(&0x02) {+            pins.push(parse_pin(payload));+        }+    }++    if pins.is_empty() {+        bail!("no pins parsed from component {comp_name}");+    }++    Ok(serde_json::json!({+        "module_name": comp_name,+        "description": description,+        "part_count": part_count,+        "pin_count": pins.len(),+        "pins": pins,+        "body": body,+        "_source": "altium_schlib",+    }))+}++/// Decode one binary pin record payload → pin JSON.+fn parse_pin(p: &[u8]) -> serde_json::Value {+    let i16at = |o: usize| p.get(o..o + 2).map(|b| i16::from_le_bytes([b[0], b[1]]) as i64).unwrap_or(0);+    // Calibrated against Altium AD26: X is a 3-byte signed i24 at [17:20] (unit+    // 1/25.6 mil); Y is i16 at [20:22] (unit 10 mil). Earlier code read both as+    // i16 at 17/19, which mis-read Y and broke on negative X.+    let i24at = |o: usize| {+        p.get(o..o + 3)+            .map(|b| {+                let v = (b[0] as i32) | ((b[1] as i32) << 8) | ((b[2] as i32) << 16);+                ((v << 8) >> 8) as i64 // sign-extend 24-bit+            })+            .unwrap_or(0)+    };+    let conglom = p.get(14).copied().unwrap_or(0);+    let length = i16at(15);+    let x = i24at(17);+    let y = i16at(20);+    // strings: Name then Designator, starting at offset 26+    let mut j = 26usize;+    let name = read_pstr(p, &mut j);+    let designator = read_pstr(p, &mut j);+    // Orientation: PinConglomerate bits 0..1 → 0/90/180/270°.+    let rotation = (conglom & 0x03) as i64 * 90;+    serde_json::json!({+        "number": designator,+        "name": name,+        "x": x,+        "y": y,+        "length": length,+        "rotation": rotation,+        "conglomerate": conglom,+    })+}++/// `<u8 len><bytes>` length-prefixed string.+fn read_pstr(d: &[u8], i: &mut usize) -> String {+    if *i >= d.len() {+        return String::new();+    }+    let l = d[*i] as usize;+    *i += 1;+    if *i + l > d.len() {+        return String::new();+    }+    let s = String::from_utf8_lossy(&d[*i..*i + l]).to_string();+    *i += l;+    s+}++/// Pull `KEY=VALUE` out of a `|KEY=VAL|KEY=VAL` Altium text record.+fn field(rec: &str, key: &str) -> Option<String> {+    let needle = format!("{key}=");+    for part in rec.split('|') {+        if let Some(v) = part.strip_prefix(&needle) {+            return Some(v.trim().to_string());+        }+    }+    None+}++// ------------------------------------------------------------------------+// Native SVG renderer — draws the symbol directly from the parsed pins, the+// same way altium-pcblib renders footprints. No KiCad, no service round-trip.+// ------------------------------------------------------------------------++/// Altium SCH coordinate → mm. Pin pitch is 2560 internal units = 100 mil =+/// 2.54 mm (the schematic-grid standard), so one unit = 2.54/2560 mm.+const MM_PER_UNIT: f64 = 2.54 / 2560.0;++/// Render the parsed symbol to a standalone SVG. Pins are grouped into a left+/// and right bank by X sign and stacked in designator order at a uniform pitch+/// — a clean, readable schematic symbol that preserves which side each pin is+/// on and its name/number (the data that actually matters for comparison).+pub fn render_symbol_svg(sym: &serde_json::Value) -> String {+    let module = sym.get("module_name").and_then(|v| v.as_str()).unwrap_or("?");+    let empty = vec![];+    let pins = sym.get("pins").and_then(|v| v.as_array()).unwrap_or(&empty);++    // Split into banks by X sign; preserve native vertical order (top→bottom).+    let mut left: Vec<&serde_json::Value> = Vec::new();+    let mut right: Vec<&serde_json::Value> = Vec::new();+    for p in pins {+        let x = p.get("x").and_then(|v| v.as_i64()).unwrap_or(0);+        if x < 0 { left.push(p); } else { right.push(p); }+    }+    let key = |p: &&serde_json::Value| -> i64 { -p.get("y").and_then(|v| v.as_i64()).unwrap_or(0) };+    left.sort_by_key(key);+    right.sort_by_key(key);++    let pitch = 2.54_f64; // mm between pins+    let rows = left.len().max(right.len()).max(1) as f64;+    let body_w = 38.0_f64;+    let body_h = rows * pitch + pitch; // one row of padding top+bottom+    let pin_len = 7.62_f64;+    let name_pad = 1.4_f64;++    let total_w = body_w + 2.0 * pin_len + 44.0; // room for pin names outside? names go inside+    let total_h = body_h + 8.0;+    let ox = total_w / 2.0; // body centered horizontally+    let bx1 = ox - body_w / 2.0;+    let bx2 = ox + body_w / 2.0;+    let by1 = 4.0;+    let by2 = by1 + body_h;++    let mut s = String::new();+    s.push_str(&format!(+        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {total_w:.1} {total_h:.1}" width="{:.0}" height="{:.0}" font-family="monospace">"#,+        total_w * 14.0, total_h * 14.0+    ));+    s.push('\n');+    s.push_str(&format!(r##"<rect x="0" y="0" width="{total_w:.1}" height="{total_h:.1}" fill="#0d1117"/>"##));+    // body+    s.push_str(&format!(+        r##"<rect x="{bx1:.2}" y="{by1:.2}" width="{:.2}" height="{:.2}" fill="#10212a" stroke="#00b8b1" stroke-width="0.25"/>"##,+        bx2 - bx1, by2 - by1+    ));+    s.push('\n');++    let draw = |bank: &Vec<&serde_json::Value>, is_left: bool, out: &mut String| {+        for (idx, p) in bank.iter().enumerate() {+            let num = p.get("number").and_then(|v| v.as_str()).unwrap_or("");+            let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");+            let cy = by1 + pitch + idx as f64 * pitch;+            let (x_body, x_tip, anchor, nx) = if is_left {+                (bx1, bx1 - pin_len, "start", bx1 + name_pad)+            } else {+                (bx2, bx2 + pin_len, "end", bx2 - name_pad)+            };+            // pin line+            out.push_str(&format!(+                r##"<line x1="{x_body:.2}" y1="{cy:.2}" x2="{x_tip:.2}" y2="{cy:.2}" stroke="#7cb6ff" stroke-width="0.18"/>"##+            ));+            // pin number above the line, near the tip+            let numx = if is_left { x_tip + 0.6 } else { x_tip - 0.6 };+            let numanchor = if is_left { "start" } else { "end" };+            out.push_str(&format!(+                r##"<text x="{numx:.2}" y="{:.2}" font-size="1.0" fill="#8b949e" text-anchor="{numanchor}">{}</text>"##,+                cy - 0.5, esc(num)+            ));+            // pin name inside the body+            let display = altium_name_to_plain(name);+            out.push_str(&format!(+                r##"<text x="{nx:.2}" y="{:.2}" font-size="1.25" fill="#e6edf3" text-anchor="{anchor}">{}</text>"##,+                cy + 0.45, esc(&display)+            ));+            out.push('\n');+        }+    };+    draw(&left, true, &mut s);+    draw(&right, false, &mut s);++    // header+    s.push_str(&format!(+        r##"<text x="{ox:.2}" y="{:.2}" font-size="1.5" fill="#00e6dc" text-anchor="middle">{} ({} pins)</text>"##,+        by1 - 1.2, esc(module), pins.len()+    ));+    let _ = MM_PER_UNIT;+    s.push_str("\n</svg>\n");+    s+}++/// Altium overbar/special markup → plain readable text. Altium marks an active-+/// low signal by following each char with a backslash (`C\S\` = C̄S̄); collapse+/// those so a name reads as "CS".+fn altium_name_to_plain(s: &str) -> String {+    s.replace('\\', "")+}++fn esc(s: &str) -> String {+    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")+}++// ------------------------------------------------------------------------+// .kicad_sym emitter — for EXPORT only (when the user wants KiCad output).+// Rendering uses the native SVG above; this is the convert-on-export path.+// ------------------------------------------------------------------------++/// Emit a `.kicad_sym` from the parsed symbol. Banks pins left/right like the+/// SVG so the exported KiCad symbol is laid out sensibly.+pub fn to_kicad_sym(sym: &serde_json::Value, lib_name: &str) -> String {+    let empty = vec![];+    let pins = sym.get("pins").and_then(|v| v.as_array()).unwrap_or(&empty);+    let mut left: Vec<&serde_json::Value> = Vec::new();+    let mut right: Vec<&serde_json::Value> = Vec::new();+    for p in pins {+        let x = p.get("x").and_then(|v| v.as_i64()).unwrap_or(0);+        if x < 0 { left.push(p); } else { right.push(p); }+    }+    let key = |p: &&serde_json::Value| -> i64 { -p.get("y").and_then(|v| v.as_i64()).unwrap_or(0) };+    left.sort_by_key(key);+    right.sort_by_key(key);++    let pitch = 2.54_f64;+    let rows = left.len().max(right.len()).max(1) as f64;+    let half_h = (rows * pitch) / 2.0 + pitch;+    let half_w = 19.05_f64;+    let pin_len = 5.08_f64;++    let mut body = String::new();+    let mut emit = |bank: &Vec<&serde_json::Value>, is_left: bool, out: &mut String| {+        for (idx, p) in bank.iter().enumerate() {+            let num = p.get("number").and_then(|v| v.as_str()).unwrap_or("");+            let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");+            let cy = half_h - pitch - idx as f64 * pitch;+            let (px, rot) = if is_left { (-half_w - pin_len, 0) } else { (half_w + pin_len, 180) };+            out.push_str(&format!(+                "      (pin bidirectional line (at {px:.2} {cy:.2} {rot}) (length {pin_len:.2})\n        (name \"{}\" (effects (font (size 1.27 1.27)))) (number \"{}\" (effects (font (size 1.27 1.27)))))\n",+                kicad_name(name), kesc(num)+            ));+        }+    };+    emit(&left, true, &mut body);+    emit(&right, false, &mut body);++    format!(+        "(kicad_symbol_lib (version 20211014) (generator altium-schlib)\n  (symbol \"{lib_name}\" (in_bom yes) (on_board yes)\n    (property \"Reference\" \"U\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n    (property \"Value\" \"{lib_name}\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n    (symbol \"{lib_name}_1_1\"\n      (rectangle (start {:.2} {:.2}) (end {:.2} {:.2}) (stroke (width 0.254) (type default)) (fill (type background)))\n{body}    )\n  )\n)\n",+        half_h + 1.27, -half_h - 1.27, -half_w, half_h, half_w, -half_h,+    )+}++/// KiCad uses `~{X}` for an overbar; Altium's `X\` per-char → wrap the whole+/// name in one overbar group when it's marked active-low.+fn kicad_name(s: &str) -> String {+    if s.contains('\\') {+        format!("~{{{}}}", s.replace('\\', ""))+    } else {+        kesc(s)+    }+}+fn kesc(s: &str) -> String {+    s.replace('"', "'")+}
crates/altium-schlib/uninstall.shadded+3
@@ -0,0 +1,3 @@+#!/usr/bin/env bash+rm -rf "$HOME/.claude/skills/altium-schlib"+echo "Removed altium-schlib skill"
package.json+23−7
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "2.0.0",+  "version": "2.1.0",   "type": "app",-  "description": "adom-lbr generator / EDA translator. One canonical adom-lbr JSON part \u2014 symbol + footprint + 3D + pin\u2194pad map \u2014 converts to native Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium is written natively, no Altium needed at conversion time.",+  "description": "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.",   "dependencies": {},   "tags": [     "eda",@@ -19,7 +19,7 @@   "hero": {     "type": "billboard",     "headline": "One part, every EDA tool.",-    "subhead": "The adom-lbr JSON is the hub \u2014 convert it to native Altium, EAGLE/Fusion, or KiCad. Altium output is written natively, no Altium at conversion time.",+    "subhead": "The adom-lbr JSON is the hub — convert it to native Altium, EAGLE/Fusion, or KiCad. Altium output is written natively, no Altium at conversion time.",     "screenshot": "screenshots/shot.png",     "accent": "#00b8b1"   },@@ -46,21 +46,37 @@     "adom-lbr json",     "convert footprint between eda tools",     "import altium symbol footprint",-    "footprint silk courtyard layers"+    "footprint silk courtyard layers",+    "convert between any eda tools",+    "adom-lbr manager",+    "view footprint layer stackup",+    "json to kicad"   ],-  "discovery_pitch": "One CLI to convert electronic-component symbols + footprints between Altium (written natively, no Altium needed), KiCad, and EAGLE/Fusion \u2014 through one canonical adom-lbr JSON, with silk/courtyard/fab layers preserved.",+  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools — KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion — through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",   "sample_prompts": [     {       "prompt": "Convert this KiCad symbol and footprint to an Altium SchLib and PcbLib",-      "label": "KiCad \u2192 Altium"+      "label": "KiCad → Altium"     },     {       "prompt": "Import this Altium footprint into the adom-lbr JSON",-      "label": "Altium \u2192 JSON"+      "label": "Altium → JSON"     },     {       "prompt": "Bundle this part into a single Altium IntLib",       "label": "Make IntLib"+    },+    {+      "prompt": "Convert this Altium SchLib and PcbLib to a KiCad symbol and footprint",+      "label": "Altium → KiCad"+    },+    {+      "prompt": "Export this adom-lbr JSON to a KiCad .kicad_sym and .kicad_mod",+      "label": "JSON → KiCad"+    },+    {+      "prompt": "Open the adom-lbr Manager to browse wiki components and view the layer stackup",+      "label": "Browse parts"     }   ] }\ No newline at end of file
src/embed.html+7−19
@@ -8,13 +8,11 @@    :root{--bg:#0d1117;--panel:#11161f;--panel2:#161c27;--border:#222b38;--text:#e6edf3;--muted:#8b949e;--accent:#5fe3c0}   *{box-sizing:border-box;margin:0;padding:0}html,body{height:100%}-  body{background:var(--bg);color:var(--text);font-family:'Satoshi','Segoe UI',sans-serif;display:flex;flex-direction:column;overflow:hidden}.below{flex:1;display:flex;min-height:0;min-width:0}.main{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}+  body{background:var(--bg);color:var(--text);font-family:'Satoshi','Segoe UI',sans-serif;display:flex;flex-direction:column;overflow:hidden}   header{display:flex;align-items:center;gap:9px;padding:9px 14px;border-bottom:1px solid var(--border);flex:0 0 auto}   header .dot{width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)}   header .b{font-weight:700;font-size:14px;font-family:'Familjen Grotesk',sans-serif}header .b span{color:var(--muted);font-weight:400}-  header .nm{margin-left:10px;font-weight:600;font-size:13px;color:#cfd8e2}-  header .picker{margin-left:auto;background:var(--panel2);color:#cfd8e2;border:1px solid var(--border);border-radius:6px;padding:3px 9px;font-size:12px;font-family:inherit;cursor:pointer}-  header .picker:hover{border-color:var(--accent)}+  header .nm{margin-left:auto;font-weight:600;font-size:13px;color:#cfd8e2}   .cols{flex:1 1 56%;display:flex;min-height:0;border-bottom:1px solid var(--border)}   .col{flex:1;display:flex;flex-direction:column;min-width:0;border-right:1px solid var(--border)}   .col:last-child{border-right:0}@@ -40,9 +38,6 @@ </style></head> <body>   <header><span class="dot"></span><span class="b">adom-lbr <span>· library viewer</span></span><span class="nm">__NAME__</span></header>-  <div class="below">-__PICKER__-  <div class="main" id="main">   <div class="cols">     <div class="col"><div class="ch">Symbol</div><div class="vp" data-vp>__SYM__</div></div>     <div class="col"><div class="ch">Footprint <span class="n">__PADN__ pads</span></div><div class="vp" data-vp>__FP__</div></div>@@ -52,16 +47,12 @@ __PICKER__     <div class="mh">Pin → pad mapping <span style="color:#7e8a99">· __PINN__ pins, with datasheet descriptions</span></div>     <table><thead><tr><th>Pin</th><th>Name</th><th></th><th>Pad</th><th>Type</th><th>Description</th></tr></thead>     <tbody id="rows"></tbody></table>-    <script type="application/json" id="pinsdata">__PINS__</script>-  </div>-  </div>   </div> <script>-// Wire the viewport pan/zoom + render the pin→pad table. A function so it can be-// re-run after the library manager swaps in a new chip's content (no reload).-function wireViewer(){+var PINS=__PINS__;+(function(){+  // each viewport: pan/zoom on its <svg> or <img>   document.querySelectorAll('[data-vp]').forEach(function(vp){-    if(vp.dataset.wired)return; vp.dataset.wired='1';     var el=vp.querySelector('svg,img'); if(!el) return;     if(el.tagName.toLowerCase()==='svg'){ el.removeAttribute('width'); el.removeAttribute('height'); }     var s=1,tx=0,ty=0;@@ -80,8 +71,7 @@ function wireViewer(){     ap();   });   function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }-  var rows=document.getElementById('rows'); rows.innerHTML='';-  var PINS=[]; try{PINS=JSON.parse((document.getElementById('pinsdata')||{}).textContent||'[]');}catch(e){}+  var rows=document.getElementById('rows');   PINS.forEach(function(p){     var col=p.color||'#8b949e', tr=document.createElement('tr');     tr.innerHTML='<td class="pin">'+esc(p.number)+'</td><td class="pin">'+esc(p.name)+'</td>'@@ -90,8 +80,6 @@ function wireViewer(){       +'<td class="d">'+esc(p.desc||'')+'</td>';     rows.appendChild(tr);   });-  if(!PINS.length){ rows.innerHTML='<tr><td colspan="6" style="padding:22px 14px;color:#8b949e;font-size:13px;line-height:1.75">No pin&nbsp;&rarr;&nbsp;pad data for this part yet. To populate it, add a <code style="background:#11161f;border:1px solid #222b38;padding:1px 5px;border-radius:4px;color:#79c0ff">&lt;MPN&gt;-symbol.extracted.json</code> (run the part through <b>ds2sf</b> / <b>adom-symbol</b>) <i>or</i> a <code style="background:#11161f;border:1px solid #222b38;padding:1px 5px;border-radius:4px;color:#79c0ff">.lbr</code> containing <code style="background:#11161f;border:1px solid #222b38;padding:1px 5px;border-radius:4px;color:#79c0ff">&lt;connects&gt;</code> into this chip\'s <code style="background:#11161f;border:1px solid #222b38;padding:1px 5px;border-radius:4px;color:#79c0ff">chip-fetcher/library/&lt;MPN&gt;/</code> folder, then reload.</td></tr>'; }-}-wireViewer();+})(); </script> </body></html>
src/main.rs+271−1836
@@ -1,13 +1,10 @@-mod provenance; mod deliver; mod server;+mod manage;  use clap::{Parser, Subcommand}; use serde::{Deserialize, Serialize}; use std::fs;-mod altium_schlib_write;-mod altium_pcblib_write;- use std::path::PathBuf; use std::process; @@ -40,76 +37,6 @@ enum Commands {         /// Symbol name override         #[arg(long)]         name: Option<String>,-        /// Chip outline to draw on the schematic symbol as `<wire>`s. PREFERRED:-        /// adom-symbol's OUTLINE CONTRACT `<mpn>-symbol-outline.mm.svg` (with its-        /// `.json` sidecar alongside) — already placed in symbol mm-space, so it's-        /// transcribed 1:1 with zero guessing. Otherwise a legacy flattened outline-        /// SVG (`<mpn>-3d-outline-*.svg`), which is centred + scaled by eye.-        #[arg(long)]-        symbol_art: Option<PathBuf>,-        /// Size of LEGACY symbol art as a fraction of the body's smaller side-        /// (default 0.38). Ignored for the `.mm.svg` contract (placement is exact).-        #[arg(long, default_value_t = 0.38)]-        symbol_art_scale: f64,-        /// Path to a 3D model (STEP/STP/GLB) to associate with the package.-        /// 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>,-        /// Manufacturer name → device attribute `MF`. chip-fetcher looks this up.-        #[arg(long)]-        mfr: Option<String>,-        /// Manufacturer part number → `MPN`. Defaults to the part name if omitted.-        #[arg(long)]-        mpn: Option<String>,-        /// Mouser part number → `MOUSER`. chip-fetcher looks this up.-        #[arg(long)]-        mouser: Option<String>,-        /// DigiKey part number → `DIGIKEY`. chip-fetcher looks this up.-        #[arg(long)]-        digikey: Option<String>,-        /// LCSC part number → `LCSC`. chip-fetcher looks this up.-        #[arg(long)]-        lcsc: Option<String>,-    },-    /// Pre-write a 3D-model binding into an existing .lbr (fast-path for a-    /// model that's already cloud-synced). Adds the `<packages3d>` entry + the-    /// device's `<package3dinstance>`. Idempotent. NB the normal path is to let-    /// the Fusion bridge attach the model (Create3DPackage → FINISH), which both-    /// binds AND renders; a pre-written self-hosted binding shows a gray-    /// placeholder until the model is brought into a Fusion session.-    #[command(name = "bind-3d")]-    Bind3d {-        /// Path to the .lbr to patch (omit with --record-only)-        #[arg(long)]-        lbr: Option<PathBuf>,-        /// Self-hosted model: the versioned file URN-        /// (urn:adsk.wipprod:fs.file:vf.<id>?version=N). The usual case.-        #[arg(long, conflicts_with = "urn")]-        wip_urn: Option<String>,-        /// Managed (library.io) part: an urn:adsk.eagle:package:NNN/V URN.-        #[arg(long)]-        urn: Option<String>,-        /// Optional dm.lineage URN — recorded as sidecar provenance ONLY,-        /// never written into the .lbr (Fusion pins the versioned fs.file URN).-        #[arg(long)]-        lineage_urn: Option<String>,-        /// Record the URN into the sidecar only; don't touch the .lbr. Use after-        /// the bridge attaches the model (in-Fusion library is source of truth).-        #[arg(long)]-        record_only: bool,-        /// 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>,-        /// Sidecar manifest to record the binding + status into-        #[arg(long)]-        sidecar: Option<PathBuf>,     },     /// Lint an existing .lbr file for Fusion 360 compatibility     Lint {@@ -134,63 +61,6 @@ enum Commands {         /// Path to the .kicad_sym file         path: PathBuf,     },-    /// Export a COMPLETE multi-tool library bundle from one KiCad part, one-    /// folder per tool: native KiCad (.kicad_sym + .pretty/.kicad_mod), Fusion-    /// (.lbr + 3D handoff sidecar), OrCAD (EDIF .edn via adom-orcad), and an-    /// Altium hand-off (the .lbr for direct import + staged inputs + the exact-    /// bridge command for native .SchLib/.PcbLib). Writes an EXPORT.md index.-    /// adom-lbr stays a pure exporter — it produces files and hands off; it-    /// never drives Altium/OrCAD/Fusion (the orchestrating AI does that).-    Export {-        /// Path to the .kicad_sym (recommended — OrCAD's EDIF needs a symbol)-        #[arg(long)]-        sym: Option<PathBuf>,-        /// Path to the .kicad_mod footprint (real pad geometry for every target)-        #[arg(long)]-        fp: Option<PathBuf>,-        /// 3D model (STEP/STP/GLB) → Fusion 3D handoff sidecar-        #[arg(long)]-        model: Option<PathBuf>,-        /// Manufacturer name → device attribute `MF`. chip-fetcher looks this up.-        #[arg(long)]-        mfr: Option<String>,-        /// Manufacturer part number → `MPN`. Defaults to the part name if omitted.-        #[arg(long)]-        mpn: Option<String>,-        /// Mouser part number → `MOUSER`. chip-fetcher looks this up.-        #[arg(long)]-        mouser: Option<String>,-        /// DigiKey part number → `DIGIKEY`. chip-fetcher looks this up.-        #[arg(long)]-        digikey: Option<String>,-        /// LCSC part number → `LCSC`. chip-fetcher looks this up.-        #[arg(long)]-        lcsc: Option<String>,-        /// Chip outline to draw on the schematic symbol (see `generate`)-        #[arg(long)]-        symbol_art: Option<PathBuf>,-        /// Legacy symbol-art scale (ignored for the .mm.svg contract)-        #[arg(long, default_value_t = 0.38)]-        symbol_art_scale: f64,-        /// Part name override (default: from the symbol/footprint)-        #[arg(long)]-        name: Option<String>,-        /// Output directory for the bundle (default: ./<name>-library)-        #[arg(short, long)]-        out: Option<PathBuf>,-        /// Comma-separated subset of targets to emit (default: all).-        /// Any of: kicad,fusion,orcad,altium-        #[arg(long)]-        targets: Option<String>,-        /// Altium destination library base name → `<base>.SchLib`/`<base>.PcbLib`-        /// in the Altium import manifest (the bridge surveys existing libs if omitted).-        #[arg(long)]-        altium_lib: Option<String>,-        /// Altium import mode hint for the bridge: create | append | reuse-        /// (default: survey — let the importer `--list-libs` and decide).-        #[arg(long)]-        altium_mode: Option<String>,-    },     /// Export a Fusion-style 3-column INTERACTIVE HTML package — symbol | footprint     /// | 3D, each pan/zoomable, with a pin→pad mapping table carrying the datasheet     /// descriptions below. Self-contained for embedding in an iframe (e.g. a wiki@@ -211,11 +81,80 @@ enum Commands {         /// Optional .lbr to load into the view on startup         #[arg(long)]         file: Option<PathBuf>,-        /// chip-fetcher library root (folder of <MPN>/ dirs) for the on-the-fly-        /// chip switcher + `?chip=<MPN>` URLs. Auto-detected from --file's-        /// grandparent, else chip-fetcher/library.+    },+    /// adom-lbr Manager: browse wiki components, view symbol/footprint/3D + the+    /// footprint layer stackup (KiCad + Altium). Renders SVG on the fly per click.+    Manage {+        /// Port to listen on+        #[arg(long, default_value_t = 8785)]+        port: u16,+    },+    /// Import a KiCad symbol + footprint into the canonical adom-lbr JSON (the hub).+    ImportKicad {+        /// Path to the .kicad_sym file+        #[arg(long)]+        sym: PathBuf,+        /// Path to the .kicad_mod footprint file+        #[arg(long)]+        fp: PathBuf,+        /// Manufacturer part number / component id+        #[arg(long, default_value = "PART")]+        mpn: String,+        /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)+        #[arg(long)]+        out: Option<PathBuf>,+    },+    /// Convert an adom-lbr JSON into native Altium .SchLib + .PcbLib (no Altium needed).+    ExportAltium {+        /// Path to the adom-lbr JSON+        part: PathBuf,+        /// Output directory (files named <mpn>.SchLib / <mpn>.PcbLib)+        #[arg(long, default_value = ".")]+        out_dir: PathBuf,+    },+    /// Convert an adom-lbr JSON into one self-contained Altium .IntLib.+    ExportIntlib {+        /// Path to the adom-lbr JSON+        part: PathBuf,+        /// Output .IntLib path (default: <mpn>.IntLib)         #[arg(long)]-        library: Option<PathBuf>,+        out: Option<PathBuf>,+    },+    /// Convert an adom-lbr JSON into KiCad .kicad_sym + .kicad_mod.+    ExportKicad {+        /// Path to the adom-lbr JSON+        part: PathBuf,+        /// Output directory (files named <mpn>.kicad_sym / <mpn>.kicad_mod)+        #[arg(long, default_value = ".")]+        out_dir: PathBuf,+    },+    /// Add an adom-lbr JSON's symbol into an EXISTING Altium .SchLib (non-destructive merge).+    AddToSchlib {+        /// Existing .SchLib to merge into+        existing: PathBuf,+        /// Path to the adom-lbr JSON (or a bare Symbol / array of Symbols)+        part: PathBuf,+        /// Output .SchLib path (default: merged.SchLib)+        #[arg(long)]+        out: Option<PathBuf>,+    },+    /// Convert an Altium .SchLib / .PcbLib / .IntLib into the canonical adom-lbr JSON.+    /// Pass a .SchLib or .PcbLib and the other half via --pcblib/--schlib, or one .IntLib.+    ImportAltium {+        /// Path to a .SchLib, .PcbLib, or .IntLib+        file: PathBuf,+        /// The .PcbLib half (when `file` is a .SchLib)+        #[arg(long)]+        pcblib: Option<PathBuf>,+        /// The .SchLib half (when `file` is a .PcbLib)+        #[arg(long)]+        schlib: Option<PathBuf>,+        /// MPN / component id for the output part (default: the symbol's name)+        #[arg(long, default_value = "")]+        mpn: String,+        /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)+        #[arg(long)]+        out: Option<PathBuf>,     },     /// Install skill + bash completions     Install,@@ -266,54 +205,6 @@ struct ParsedSymbol {     pins: Vec<KicadPin>,     body: Option<KicadBody>,     group_labels: Vec<GroupLabel>,-    // BOM / supply-chain identifiers, if the source .kicad_sym carried them as-    // properties. Flags (--mfr/--mpn/--mouser/--digikey/--lcsc) override these.-    mfr: String,-    mpn: String,-    mouser: String,-    digikey: String,-    lcsc: String,-}--/// The supply-chain identifiers adom-lbr writes into a device's `<technology>`-/// as EAGLE/Fusion attributes (MF, MPN, MOUSER, DIGIKEY, LCSC). chip-fetcher-/// looks all of these up, so the CONTRACT is: the caller passes them in. When-/// any are missing, adom-lbr writes the library anyway but REPRIMANDS the caller.-#[derive(Debug, Clone, Default)]-struct PartAttrs {-    mfr: Option<String>,-    mpn: Option<String>,-    mouser: Option<String>,-    digikey: Option<String>,-    lcsc: Option<String>,-}--impl PartAttrs {-    /// (EAGLE attribute name, value) for each non-empty field, in BOM order.-    fn pairs(&self) -> Vec<(&'static str, String)> {-        let mut v = Vec::new();-        let mut push = |k: &'static str, o: &Option<String>| {-            if let Some(s) = o { if !s.trim().is_empty() { v.push((k, s.trim().to_string())); } }-        };-        push("MF", &self.mfr);-        push("MPN", &self.mpn);-        push("MOUSER", &self.mouser);-        push("DIGIKEY", &self.digikey);-        push("LCSC", &self.lcsc);-        v-    }--    /// Human labels for the fields still missing — drives the reprimand.-    fn missing(&self) -> Vec<&'static str> {-        let empty = |o: &Option<String>| o.as_ref().map_or(true, |s| s.trim().is_empty());-        let mut m = Vec::new();-        if empty(&self.mfr) { m.push("--mfr      (manufacturer)"); }-        if empty(&self.mpn) { m.push("--mpn      (mfr part number)"); }-        if empty(&self.mouser) { m.push("--mouser   (Mouser part number)"); }-        if empty(&self.digikey) { m.push("--digikey  (DigiKey part number)"); }-        if empty(&self.lcsc) { m.push("--lcsc     (LCSC part number)"); }-        m-    } }  fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {@@ -372,22 +263,12 @@ fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedS     }      let prop = |key: &str| -> String {-        // Property keys can contain spaces/hyphens (e.g. "Mouser Part Number"),-        // so escape regex metacharacters in the key.-        let safe: String = key.chars().flat_map(|c| {-            if "\\.^$|?*+()[]{}".contains(c) { vec!['\\', c] } else { vec![c] }-        }).collect();-        let re = regex_lite::Regex::new(&format!(r#"\(property\s+"{}"\s+"([^"]*)""#, safe)).unwrap();+        let re = regex_lite::Regex::new(&format!(r#"\(property\s+"{}"\s+"([^"]*)""#, key)).unwrap();         re.captures(content)             .and_then(|c| c.get(1))             .map(|m| m.as_str().to_string())             .unwrap_or_default()     };-    // Accept the common aliases each EDA/distributor uses for these fields.-    let prop_any = |keys: &[&str]| -> String {-        for k in keys { let v = prop(k); if !v.trim().is_empty() { return v; } }-        String::new()-    };      Ok(ParsedSymbol {         name,@@ -398,11 +279,6 @@ fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedS         pins,         body,         group_labels,-        mfr: prop_any(&["Manufacturer", "MF", "Mfr", "manufacturer"]),-        mpn: prop_any(&["MPN", "Manufacturer_Part_Number", "Mfr_Part_Number", "Manufacturer Part Number", "mpn"]),-        mouser: prop_any(&["Mouser", "MOUSER", "Mouser Part Number", "Mouser_Part_Number"]),-        digikey: prop_any(&["Digikey", "DigiKey", "Digi-Key", "DIGIKEY", "Digi-Key Part Number", "DigiKey Part Number", "Digikey_Part_Number"]),-        lcsc: prop_any(&["LCSC", "LCSC Part Number", "LCSC_Part_Number"]),     }) } @@ -530,11 +406,6 @@ fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {         pins,         body,         group_labels: Vec::new(),-        mfr: String::new(),-        mpn: String::new(),-        mouser: String::new(),-        digikey: String::new(),-        lcsc: String::new(),     }) } @@ -697,573 +568,10 @@ fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {         pins,         body: Some(KicadBody { left: -7.62, top: body_h / 2.0, right: 7.62, bottom: -body_h / 2.0 }),         group_labels: Vec::new(),-        mfr: String::new(),-        mpn: String::new(),-        mouser: String::new(),-        digikey: String::new(),-        lcsc: String::new(),-    }-}--/// 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-}--/// One-line summary of a footprint's pad distribution for a moment-of-use hint,-/// e.g. "64 pads: 16 left / 16 right / 16 top / 16 bottom". Buckets by dominant-/// axis so a QFP reads as 4-sided and an SOIC/dual reads as left/right.-fn footprint_side_summary(fp_content: Option<&str>) -> String {-    let pads = fp_content.map(parse_mod_pads).unwrap_or_default();-    if pads.is_empty() {-        return "0 pads".to_string();-    }-    let (mut l, mut r, mut t, mut b) = (0u32, 0u32, 0u32, 0u32);-    for p in &pads {-        if p.x.abs() >= p.y.abs() {-            if p.x < 0.0 { l += 1 } else { r += 1 }-        } else if p.y > 0.0 { t += 1 } else { b += 1 }-    }-    let mut parts = Vec::new();-    if l > 0 { parts.push(format!("{l} left")); }-    if r > 0 { parts.push(format!("{r} right")); }-    if t > 0 { parts.push(format!("{t} top")); }-    if b > 0 { parts.push(format!("{b} bottom")); }-    format!("{} pads: {}", pads.len(), parts.join(" / "))-}--/// Map a KiCad pad rotation (degrees) to an EAGLE `rot=` attribute, matching-/// Fusion 360's own library export convention: the KiCad angle maps directly-/// (KiCad 90 → `R90`), so a non-square pad on a QFP/SOIC top/bottom side is-/// oriented along the lead. Rect pads are 180°-symmetric so the Y-flip sense-/// is moot; the direct mapping is what Fusion writes (verified by the Fusion-/// bridge against a round-tripped `.flbr`).-fn smd_rotation(angle: f64) -> &'static str {-    let a = ((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. The `.lbr` stays 2D; the-/// bridge attaches the body Fusion-side (Create3DPackage → import → orient →-/// FINISH → Save), which both binds the package AND renders it. The sidecar just-/// points the bridge at `model.path` + `package` — no URN is pre-minted.-///-/// Binding scheme (verified by the Fusion bridge against a round-tripped-/// `.flbr`): a SELF-UPLOADED model is linked by `wip_urn`-/// (`urn:adsk.wipprod:fs.file:vf.<id>?version=1` — the *versioned file* URN, not-/// the `dm.lineage` URN), with `urn=""` empty and `locally_modified="yes"`. The-/// `urn:adsk.eagle:package:NNN/V` form is only for MANAGED (library.io) parts.-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/2",-        "package": package,-        "library": lbr_path.to_string_lossy(),-        "model": {-            "path": model_path.to_string_lossy(),-            "format": fmt,-            "units": "mm"-        },-        "placement": {-            // Orientation hint/override for the bridge's auto-orient (which-            // rotates the smallest bbox dim to Z). .kicad_mod footprints are-            // centred on the package origin (0,0), seat plane Z=0; set a known-            // pin-1 rotation/offset here when a part needs it.-            "translation_mm": [0.0, 0.0, 0.0],-            "rotation_deg": [0.0, 0.0, 0.0],-            "note": "Orientation hint/override. Footprint origin is the package centre; pin 1 per the .lbr <smd>. Run chipfit to confirm pin-1 alignment / seat Z before the FINISH step."-        },-        // How the model is linked once the bridge has attached it. Self-hosted-        // is the normal path; managed is only for library.io parts. adom-lbr-        // OWNS this record (the in-Fusion library is the runtime source of-        // truth); it's filled in by `bind-3d` once the bridge reports the URN.-        "binding": {-            "scheme": "self-hosted",-            // The versioned file URN that goes in the .lbr. Recorded after the-            // attach (the bridge reports it via fusion_aps_file_info-            // {query:"<package>"} after Save). Always fs.file:vf …?version=N.-            "wip_urn": serde_json::Value::Null,-            // Optional provenance only — the stable cross-version id for-            // re-resolving the latest version. NEVER written into the .lbr.-            "lineage_urn": serde_json::Value::Null,-            "package3d_attrs": {-                "urn": "",-                "wip_urn": "<urn:adsk.wipprod:fs.file:vf.<id>?version=N>",-                "locally_modified": "yes",-                "type": "model"-            },-            "note": "Self-uploaded model → wip_urn = versioned fs.file:vf …?version=N (urn=\"\" empty). NEVER dm.lineage in the .lbr (keep lineage as sidecar provenance only). urn:adsk.eagle:package is MANAGED-only. A bare wip_urn does NOT auto-render on a cold open — the model must be brought into a session via the FINISH."-        },-        "status": "pending-fusion-attach",-        "bridge_steps": [-            "Open the .lbr in Fusion (library ACTIVE) — the 2D symbol + footprint are already valid.",-            format!("Electron.Create3DPackage for package '{package}' → import model.path → auto-orient (placement overrides if set) → Package3DStop (FINISH) → Save. This BOTH binds the package and renders the model."),-            "The sidecar needs no pre-minted URN; the FINISH step is what makes the body appear.",-            format!("After Save, report the URN: fusion_aps_file_info {{query:\"{package}\"}} returns the tip fs.file:vf.<id>?version=N. Record it: `adom-lbr bind-3d --record-only --sidecar <this.json> --wip-urn <that-urn>` (writes binding.wip_urn + status=bound; pass --lineage-urn for optional provenance)."),-            "(Optional fast-path) once recorded/known-synced, `adom-lbr bind-3d --lbr <lbr> --wip-urn <…fs.file:vf…?version=N>` pre-writes the binding into the .lbr. NB a pre-written wip_urn shows a gray placeholder until the model is brought into a session.",-            "Verify the 3D body seats on the footprint with pin 1 aligned."-        ]-    });-    fs::write(side_path, serde_json::to_string_pretty(&manifest).unwrap() + "\n")-}--/// Record a 3D binding into the `*.fusion3d.json` sidecar: the versioned URN-/// (`binding.wip_urn` for self-hosted, `binding.urn` for managed), an optional-/// `dm.lineage` provenance URN, and `status: "bound"`. adom-lbr owns this record;-/// the in-Fusion library is the runtime source of truth.-fn update_sidecar_binding(side: &std::path::Path, link: &str, managed: bool, lineage: Option<&str>) {-    match fs::read_to_string(side).ok().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()) {-        Some(mut v) => {-            if managed {-                v["binding"]["urn"] = serde_json::Value::String(link.to_string());-                v["binding"]["scheme"] = serde_json::Value::String("managed".into());-            } else {-                v["binding"]["wip_urn"] = serde_json::Value::String(link.to_string());-                v["binding"]["scheme"] = serde_json::Value::String("self-hosted".into());-            }-            if let Some(l) = lineage {-                v["binding"]["lineage_urn"] = serde_json::Value::String(l.to_string());-            }-            v["status"] = serde_json::Value::String("bound".into());-            let _ = fs::write(side, serde_json::to_string_pretty(&v).unwrap() + "\n");-            println!("OK: Recorded binding in sidecar {} (status -> bound)", side.display());-        }-        None => eprintln!("WARN: Could not update sidecar {}", side.display()),-    }-}--/// Patch a 3D-model binding into a `.lbr`, making the reference self-contained.-/// Adds (or replaces) the `<packages3d>` section after `</packages>` and inserts-/// a `<package3dinstance>` into the device(s) using `package` (defaults to the-/// device's `package=` attribute). Idempotent — any prior binding is replaced.-///-/// `managed` selects the URN scheme (verified against Fusion's own `.flbr`):-/// - `false` (self-hosted, the normal case): `link` is a `wip_urn`-///   (`urn:adsk.wipprod:fs.file:vf.<id>?version=1`); the `<package3d>` gets-///   `urn=""`, `wip_urn="<link>"`, `locally_modified="yes"`.-/// - `true` (managed / library.io): `link` is an `urn:adsk.eagle:package:NNN/V`-///   and goes straight into `urn="<link>"`.-///-/// Either way the device's `<package3dinstance package3d_urn="<link>"/>` uses-/// `link`. NB a pre-written self-hosted binding renders only once Fusion brings-/// the model into a session (cold-open shows a gray placeholder) — this is a-/// fast-path for known-synced models, not a substitute for the bridge attach.-fn bind_3d_urn(content: &str, link: &str, managed: bool, package: Option<&str>) -> Result<String, String> {-    let link = link.trim();-    if link.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>.-    //    Self-hosted models use wip_urn + empty urn + locally_modified; managed-    //    parts use the eagle:package urn directly.-    let pkg3d_attrs = if managed {-        format!("name=\"{p}\" urn=\"{u}\" type=\"model\"", p = esc_xml(&pkg), u = esc_xml(link))-    } else {-        format!(-            "name=\"{p}\" urn=\"\" wip_urn=\"{u}\" locally_modified=\"yes\" type=\"model\"",-            p = esc_xml(&pkg), u = esc_xml(link)-        )-    };-    let block = format!(-        "<packages3d>\n<package3d {attrs}>\n  <packageinstances>\n    <packageinstance name=\"{p}\"/>\n  </packageinstances>\n</package3d>\n</packages3d>\n",-        attrs = pkg3d_attrs, p = esc_xml(&pkg)-    );-    // 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(link)-    );-    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)-}--/// Transcribe an adom-symbol outline SVG (a flattened line drawing of the chip)-/// into EAGLE symbol `<wire>`s on layer 94, so the schematic symbol carries a-/// little vector picture of the part. The SVG uses `<polyline>`/`<polygon>`-/// `points=` in a Y-down pixel space; we decimate the dense sampling, fit the-/// drawing inside the symbol body (centred), and negate Y for EAGLE's Y-up.-/// Returns the `<wire>` block plus the segment count (for a hint).-fn symbol_art_wires(svg: &str, body: Option<&KicadBody>, scale_frac: f64) -> (String, usize) {-    let poly_re = regex_lite::Regex::new(r#"<(polyline|polygon)\b[^>]*\bpoints="([^"]+)""#).unwrap();-    let mut polys: Vec<Vec<(f64, f64)>> = Vec::new();-    for c in poly_re.captures_iter(svg) {-        let closed = &c[1] == "polygon";-        let mut pts: Vec<(f64, f64)> = c[2]-            .split_whitespace()-            .filter_map(|p| {-                let mut it = p.split(',');-                let x: f64 = it.next()?.trim().parse().ok()?;-                let y: f64 = it.next()?.trim().parse().ok()?;-                Some((x, y))-            })-            .collect();-        if closed {-            if let Some(&first) = pts.first() { pts.push(first); }-        }-        polys.push(pts);-    }-    // Global bbox of every point (in SVG units).-    let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);-    for poly in &polys {-        for &(x, y) in poly {-            minx = minx.min(x); miny = miny.min(y);-            maxx = maxx.max(x); maxy = maxy.max(y);-        }-    }-    let (sw, sh) = (maxx - minx, maxy - miny);-    if !(sw > 0.0 && sh > 0.0) {-        return (String::new(), 0);-    }-    // Fit a modest, centred icon — matching adom-symbol's own placement, which-    // sizes the outline to ~half the body's smaller side so it sits between the-    // pins/labels without crowding them.-    let (target, cx, cy) = match body {-        Some(b) => {-            let bw = (b.right - b.left).abs();-            let bh = (b.top - b.bottom).abs();-            (bw.min(bh) * scale_frac, (b.left + b.right) / 2.0, (b.top + b.bottom) / 2.0)-        }-        None => (26.0 * scale_frac, 0.0, 0.0),-    };-    let scale = target / sw.max(sh);-    // Decimate in OUTPUT space: drop points within `tol_mm` of the last kept one,-    // so curves stay smooth but the dense sampling collapses.-    let tol_mm = 0.12;-    let (smx, smy) = ((minx + maxx) / 2.0, (miny + maxy) / 2.0);-    let map = |x: f64, y: f64| (cx + (x - smx) * scale, cy - (y - smy) * scale);--    let mut out = String::new();-    let mut segs = 0usize;-    for poly in &polys {-        let mut kept: Vec<(f64, f64)> = Vec::new();-        let n = poly.len();-        for (i, &(px, py)) in poly.iter().enumerate() {-            let p = map(px, py);-            if i == 0 || i == n - 1 {-                kept.push(p);-            } else if let Some(&last) = kept.last() {-                if ((p.0 - last.0).powi(2) + (p.1 - last.1).powi(2)).sqrt() >= tol_mm {-                    kept.push(p);-                }-            }-        }-        for w in kept.windows(2) {-            out.push_str(&format!(-                "  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1016\" layer=\"94\"/>\n",-                w[0].0, w[0].1, w[1].0, w[1].1-            ));-            segs += 1;-        }-    }-    (out, segs)-}--/// Resolved symbol-art input. The PREFERRED form is an adom-symbol OUTLINE-/// CONTRACT: a `<mpn>-symbol-outline.mm.svg` whose polylines are ALREADY placed-/// in the symbol's mm space (lite/decimated), plus a `<mpn>-symbol-outline.json`-/// sidecar describing the transform into EAGLE's Y-up frame. We transcribe those-/// 1:1 — no fitting, no re-decimation, so the art lands EXACTLY where adom-symbol-/// drew it. Anything else is a `Legacy` raw outline we centre+scale by eye.-enum SymbolArt {-    /// `mm_svg`: the contract overlay. `dx`: x offset (svg→eagle). `svg_cy`/`e_cy`:-    /// the two frame centres, used to flip+offset Y. Derived from the sidecar so-    /// nothing about adom-symbol's frame is hardcoded here.-    Contract { mm_svg: String, dx: f64, svg_cy: f64, e_cy: f64, variant: String },-    Legacy(String),-}--/// Resolve a `--symbol-art` path into a `SymbolArt`. If the file is a-/// `*.mm.svg` with a readable sidecar `*.json`, it's a contract; otherwise legacy.-fn resolve_symbol_art(path: &std::path::Path) -> Result<SymbolArt, String> {-    let content = fs::read_to_string(path).map_err(|e| format!("Cannot read symbol art {}: {}", path.display(), e))?;-    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");-    if name.ends_with(".mm.svg") {-        // Sibling sidecar: `<…>.mm.svg` → `<…>.json`.-        let json_path = path.with_file_name(name.replace(".mm.svg", ".json"));-        if let Ok(js) = fs::read_to_string(&json_path) {-            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&js) {-                let g = |a: &str, b: &str| v.get(a).and_then(|o| o.get(b)).and_then(|n| n.as_f64());-                if let (Some(svg_cx), Some(svg_cy), Some(e_cx), Some(e_cy)) = (-                    g("placement_svg_mm", "center_x"), g("placement_svg_mm", "center_y"),-                    g("placement_eagle_mm", "center_x"), g("placement_eagle_mm", "center_y"),-                ) {-                    let variant = v.get("default_variant").and_then(|s| s.as_str()).unwrap_or("?").to_string();-                    return Ok(SymbolArt::Contract { mm_svg: content, dx: svg_cx - e_cx, svg_cy, e_cy, variant });-                }-            }-        }-        eprintln!("WARN: {name} looks like an outline contract but its .json sidecar is missing/unreadable — falling back to centre+scale guessing.");-    }-    Ok(SymbolArt::Legacy(content))-}--/// Transcribe a contract `.mm.svg` (already in symbol mm-space, Y-down) to EAGLE-/// `<wire>`s (Y-up). 1:1 — every polyline segment is kept (the producer already-/// decimated to a lite outline). `eagle_x = svg_x - dx`, `eagle_y = e_cy - (svg_y - svg_cy)`.-fn contract_wires(mm_svg: &str, dx: f64, svg_cy: f64, e_cy: f64) -> (String, usize) {-    let poly_re = regex_lite::Regex::new(r#"<(polyline|polygon)\b[^>]*\bpoints="([^"]+)""#).unwrap();-    let mut out = String::new();-    let mut segs = 0usize;-    for c in poly_re.captures_iter(mm_svg) {-        let closed = &c[1] == "polygon";-        let mut pts: Vec<(f64, f64)> = c[2]-            .split_whitespace()-            .filter_map(|p| {-                let mut it = p.split(',');-                let x: f64 = it.next()?.trim().parse().ok()?;-                let y: f64 = it.next()?.trim().parse().ok()?;-                Some((x - dx, e_cy - (y - svg_cy))) // svg(Y-down) → eagle(Y-up)-            })-            .collect();-        if closed {-            if let Some(&first) = pts.first() { pts.push(first); }-        }-        for w in pts.windows(2) {-            out.push_str(&format!(-                "  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1016\" layer=\"94\"/>\n",-                w[0].0, w[0].1, w[1].0, w[1].1-            ));-            segs += 1;-        }-    }-    (out, segs)-}--/// Generate the wire block for a resolved symbol-art input.-fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64) -> (String, usize) {-    match art {-        SymbolArt::Contract { mm_svg, dx, svg_cy, e_cy, .. } => contract_wires(mm_svg, *dx, *svg_cy, *e_cy),-        SymbolArt::Legacy(svg) => symbol_art_wires(svg, body, scale_frac),-    }-}--// The carried-through RefDes/Value text from a .kicad_mod, in KiCad space-// (mm, Y-down): position, rotation (deg), and text height (mm).-struct FpText { x: f64, y: f64, angle: f64, size: f64 }--// Read a `(fp_text reference|value "…" (at X Y [A]) … (effects (font (size W H))))`-// from a .kicad_mod so adom-lbr can CARRY THROUGH the full placement-// adom-footprint (and the user) set — position, rotation, AND size — rather than-// inventing its own or flattening it to a default.-fn fp_text_info(km: &str, kind: &str) -> Option<FpText> {-    // Locate this kind's fp_text element, then window to the next fp_text (or a-    // bounded span) so `(at …)`/`(size …)` are read from THIS element only.-    let start = regex_lite::Regex::new(&format!(r#"\(fp_text\s+{}\s+""#, kind)).ok()?.find(km)?;-    let rest = &km[start.start()..];-    let end = rest.get(1..)-        .and_then(|r| r.find("(fp_text ").map(|i| i + 1))-        .unwrap_or(rest.len())-        .min(rest.len())-        .min(400);-    let win = &rest[..end];--    let at = regex_lite::Regex::new(r#"\(at\s+([-\d.]+)\s+([-\d.]+)(\s+[-\d.]+)?\)"#).ok()?;-    let c = at.captures(win)?;-    let x = c[1].parse().unwrap_or(0.0);-    let y = c[2].parse().unwrap_or(0.0);-    let angle = c.get(3).and_then(|g| g.as_str().trim().parse::<f64>().ok()).unwrap_or(0.0);-    // Text height = the 2nd value of (font (size W H)); fall back to EAGLE's 1.27.-    let size = regex_lite::Regex::new(r#"\(size\s+([-\d.]+)\s+([-\d.]+)\)"#).ok()-        .and_then(|re| re.captures(win).and_then(|c| c[2].parse::<f64>().ok()))-        .filter(|s| *s > 0.0)-        .unwrap_or(1.27);-    Some(FpText { x, y, angle, size })-}--// Emit a package `>NAME`/`>VALUE` text element. When the footprint carried one-// through (`info`), preserve its position (Y-negated for EAGLE), height, and-// rotation; otherwise auto-place clear of the pads as a fallback.-fn emit_pkg_text(xml: &mut String, info: &Option<FpText>, token: &str, layer: u32, auto_y: f64, auto_align: &str) {-    match info {-        Some(t) => {-            // KiCad Y-down → EAGLE Y-up is a faithful (non-mirroring) convention-            // change, so the rotation value carries straight through.-            let rot = if t.angle.abs() > 0.01 {-                format!(" rot=\"R{}\"", (t.angle.round() as i64).rem_euclid(360))-            } else { String::new() };-            let size = if t.size > 0.0 { t.size } else { 1.27 };-            xml.push_str(&format!(-                "  <text x=\"{:.4}\" y=\"{:.4}\" size=\"{:.4}\" layer=\"{}\" align=\"center\"{}>&gt;{}</text>\n",-                t.x, -t.y, size, layer, rot, token-            ));-        }-        None => xml.push_str(&format!(-            "  <text x=\"0\" y=\"{:.4}\" size=\"1.27\" layer=\"{}\" align=\"{}\">&gt;{}</text>\n",-            auto_y, layer, auto_align, token-        )),-    }-}--fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, attrs: &PartAttrs) -> String {+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");@@ -1278,109 +586,53 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option     xml.push_str("<library>\n");     xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description))); -    // Packages section. Prefer the real .kicad_mod geometry; only fall back to-    // the synthesized two-column placeholder when no footprint was supplied.+    // Packages section — generate a placeholder SMD package from pin positions     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))); -    let mod_pads = fp_content.map(parse_mod_pads).unwrap_or_default();+    // 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; -    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)));--        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)-                ));-            }-        }--        // 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-            ));-        }--        // >NAME / >VALUE: CARRY THROUGH the footprint's own reference/value-        // placement, so what the user positions in adom-footprint is exactly what-        // lands in the .lbr (KiCad is Y-down → negate Y). adom-lbr does NOT own-        // this — adom-footprint does, and the user can see/tweak it there. Only-        // when the .kicad_mod carries no such text do we auto-place clear of the-        // pads as a safety net. Either way the linter flags `name-value-overlap`-        // if the result lands on the copper, so a bad placement gets reported back.-        let mut top = sy2;-        let mut bot = sy1;-        for p in &mod_pads {-            let py = -p.y;-            let r = p.dx.max(p.dy) / 2.0; // conservative half-extent (rotation-safe)-            top = top.max(py + r);-            bot = bot.min(py - r);-        }-        let margin = 0.6;-        emit_pkg_text(&mut xml, &fp_text_info(km, "reference"), "NAME", 25, top + margin, "bottom-center");-        emit_pkg_text(&mut xml, &fp_text_info(km, "value"), "VALUE", 27, bot - margin, "top-center");-    } 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-            ));-        }+    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-            ));-        }+    // 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+    )); -        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(&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");@@ -1396,13 +648,6 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option         xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.bottom, body.left, body.top));     } -    // Optional: a vector drawing of the chip (adom-symbol's outline SVG) drawn-    // as raw lines, centred inside the symbol body.-    if let Some(art) = symbol_art {-        let (wires, _) = symbol_art_block(art, sym.body.as_ref(), symbol_art_scale);-        xml.push_str(&wires);-    }-     let name_y = sym.body.as_ref().map(|b| b.top + 2.54).unwrap_or(10.0);     let value_y = sym.body.as_ref().map(|b| b.bottom - 2.54).unwrap_or(-10.0);     xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"95\" align=\"bottom-center\">&gt;NAME</text>\n", name_y));@@ -1458,23 +703,7 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option     }      xml.push_str("      </connects>\n");-    // Supply-chain attributes on the device technology — Fusion 360 surfaces MF-    // as Manufacturer, MPN as Manufacturer Part Number, and the rest as supplier-    // attributes in the BOM. Omitted entirely when nothing was supplied (valid-    // empty technology), so a bare generate still produces a clean library.-    let attr_pairs = attrs.pairs();-    if attr_pairs.is_empty() {-        xml.push_str("      <technologies><technology name=\"\"/></technologies>\n");-    } else {-        xml.push_str("      <technologies>\n        <technology name=\"\">\n");-        for (k, v) in attr_pairs {-            xml.push_str(&format!(-                "          <attribute name=\"{}\" value=\"{}\" constant=\"no\"/>\n",-                k, esc_xml(&v)-            ));-        }-        xml.push_str("        </technology>\n      </technologies>\n");-    }+    xml.push_str("      <technologies><technology name=\"\"/></technologies>\n");     xml.push_str("    </device>\n");     xml.push_str("  </devices>\n");     xml.push_str("</deviceset>\n");@@ -1500,14 +729,6 @@ pub(crate) struct LintError { }  pub(crate) fn lint_lbr(content: &str) -> LintResult {-    lint_lbr_with(content, None)-}--// `keepout` = an explicit (pbot, ptop) vertical keep-out span in EAGLE Y-up mm-// (e.g. derived from a footprint's `textPlacement.courtyard`). When supplied it-// OVERRIDES the pad-derived span for the name-value-overlap check, so the-// generator's intended keep-out zone is honored instead of re-guessed from pads.-pub(crate) fn lint_lbr_with(content: &str, keepout: Option<(f64, f64)>) -> LintResult {     let mut errors = Vec::new();     let mut warnings = Vec::new(); @@ -1575,48 +796,6 @@ pub(crate) fn lint_lbr_with(content: &str, keepout: Option<(f64, f64)>) -> LintR         warnings.push(LintError { code: "no-symbol-layer".into(), message: "No elements on layer 94 (Symbols) — the symbol body may be invisible".into() });     } -    // >NAME / >VALUE placement: warn when they sit on the footprint copper.-    // adom-lbr places them clear of the full pad extent, so this mainly flags-    // hand-made / imported libraries whose ref/value text overlaps the pads-    // (the "text all over the footprint" problem in Fusion).-    {-        let (mut ptop, mut pbot, mut npads) = (f64::MIN, f64::MAX, 0u32);-        let smd_pos = regex_lite::Regex::new(r#"<smd [^>]* y="([-\d.]+)"[^>]*dy="([-\d.]+)""#).unwrap();-        for c in smd_pos.captures_iter(content) {-            let y: f64 = c[1].parse().unwrap_or(0.0);-            let dy: f64 = c[2].parse().unwrap_or(0.0);-            ptop = ptop.max(y + dy / 2.0); pbot = pbot.min(y - dy / 2.0); npads += 1;-        }-        let pad_pos = regex_lite::Regex::new(r#"<pad [^>]* y="([-\d.]+)"[^>]*diameter="([-\d.]+)""#).unwrap();-        for c in pad_pos.captures_iter(content) {-            let y: f64 = c[1].parse().unwrap_or(0.0);-            let d: f64 = c[2].parse().unwrap_or(0.0);-            ptop = ptop.max(y + d / 2.0); pbot = pbot.min(y - d / 2.0); npads += 1;-        }-        // Prefer an explicit courtyard keep-out; else fall back to the pad span.-        let span = match keepout {-            Some((kb, kt)) => Some((kb, kt)),-            None if npads > 0 => Some((pbot, ptop)),-            None => None,-        };-        if let Some((pbot, ptop)) = span {-            let zone = if keepout.is_some() { "courtyard" } else { "pad span" };-            let txt = regex_lite::Regex::new(r#"<text [^>]* y="([-\d.]+)"[^>]*layer="(25|27)"[^>]*>&gt;(NAME|VALUE)</text>"#).unwrap();-            for c in txt.captures_iter(content) {-                let y: f64 = c[1].parse().unwrap_or(0.0);-                if y <= ptop && y >= pbot {-                    warnings.push(LintError {-                        code: "name-value-overlap".into(),-                        message: format!(-                            ">{} text sits on the footprint (y={:.2} is within the {} [{:.2}, {:.2}]) — it will overlap the part in Fusion. Move it clear of the keep-out (adom-lbr auto-places it ~0.6mm beyond the outer edge).",-                            &c[3], y, zone, pbot, ptop-                        ),-                    });-                }-            }-        }-    }-     LintResult {         passed: errors.is_empty(),         errors,@@ -1675,545 +854,46 @@ const EAGLE_LAYERS: &str = r#"<layers>  // ── Main ── -// Locate the adom-orcad converter: PATH first, then the in-container build.-// Returns the invocable command, or None when it isn't installed.-fn find_adom_orcad() -> Option<String> {-    for c in ["adom-orcad", "/home/adom/project/adom-orcad/target/release/adom-orcad"] {-        let usable = if c.starts_with('/') {-            std::path::Path::new(c).exists()-        } else {-            std::process::Command::new(c).arg("--help").output().is_ok()-        };-        if usable {-            return Some(c.to_string());-        }-    }-    None-}--// `export` — one KiCad part → a complete bundle, one folder per EDA tool.-// adom-lbr stays a pure exporter: it WRITES files and hands off; it never-// drives Altium/OrCAD/Fusion. OrCAD is produced via the adom-orcad converter-// (a CLI, not an app); Altium native is left to its bridge with a staged-// hand-off, since "Altium has no headless mode".-// Pull a manufacturer name from a sibling `<mpn>-symbol.extracted.json` (the-// file chip-fetcher writes next to the symbol), so MF can auto-fill when the-// caller didn't pass --mfr.-fn sibling_manufacturer(sym_path: &std::path::Path) -> Option<String> {-    let dir = sym_path.parent()?;-    for entry in std::fs::read_dir(dir).ok()?.flatten() {-        let p = entry.path();-        let Some(name) = p.file_name().and_then(|n| n.to_str()) else { continue };-        if name.ends_with("-symbol.extracted.json") || name == "symbol.extracted.json" {-            if let Ok(txt) = std::fs::read_to_string(&p) {-                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {-                    if let Some(m) = v.get("manufacturer").and_then(|x| x.as_str()) {-                        if !m.trim().is_empty() { return Some(m.trim().to_string()); }-                    }-                }-            }-        }-    }-    None-}--// Read an explicit text keep-out from a sibling `<mpn>-footprint.extracted.json`-// `textPlacement.courtyard` (KiCad mm, Y-down) and convert it to an EAGLE Y-up-// (pbot, ptop) span for the linter. None when the file/field is absent — the-// lint then falls back to the pad-derived span.-fn courtyard_keepout(fp_path: &std::path::Path) -> Option<(f64, f64)> {-    let dir = fp_path.parent()?;-    for entry in std::fs::read_dir(dir).ok()?.flatten() {-        let p = entry.path();-        let Some(name) = p.file_name().and_then(|n| n.to_str()) else { continue };-        if name.ends_with("-footprint.extracted.json") || name == "footprint.extracted.json" {-            let Ok(txt) = std::fs::read_to_string(&p) else { continue };-            let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) else { continue };-            let cy = match v.get("textPlacement").and_then(|t| t.get("courtyard")) {-                Some(c) => c, None => continue,-            };-            if let (Some(top), Some(bottom)) = (-                cy.get("top").and_then(|x| x.as_f64()),-                cy.get("bottom").and_then(|x| x.as_f64()),-            ) {-                // KiCad Y-down → EAGLE Y-up: ptop = -top, pbot = -bottom.-                return Some((-bottom, -top));-            }-        }-    }-    None-}--// Resolve BOM attributes by priority: explicit flag → .kicad_sym property →-// auto-fill (MF from the extracted.json, MPN from the part name). Returns the-// attrs plus transparency notes about anything auto-derived.-#[allow(clippy::too_many_arguments)]-fn resolve_part_attrs(-    parsed: &ParsedSymbol,-    sym_path: Option<&std::path::Path>,-    flag_mfr: &Option<String>, flag_mpn: &Option<String>,-    flag_mouser: &Option<String>, flag_digikey: &Option<String>, flag_lcsc: &Option<String>,-) -> (PartAttrs, Vec<String>) {-    let mut notes = Vec::new();-    let pick = |flag: &Option<String>, prop: &str| -> Option<String> {-        if let Some(f) = flag { if !f.trim().is_empty() { return Some(f.trim().to_string()); } }-        if !prop.trim().is_empty() { return Some(prop.trim().to_string()); }-        None-    };-    let mut mfr = pick(flag_mfr, &parsed.mfr);-    let mpn = pick(flag_mpn, &parsed.mpn);-    let mouser = pick(flag_mouser, &parsed.mouser);-    let digikey = pick(flag_digikey, &parsed.digikey);-    let lcsc = pick(flag_lcsc, &parsed.lcsc);--    if mfr.is_none() {-        if let Some(m) = sym_path.and_then(sibling_manufacturer) {-            notes.push(format!("MF auto-filled from the part's extracted.json: \"{m}\" (pass --mfr to override)"));-            mfr = Some(m);-        }-    }-    let mpn = mpn.or_else(|| {-        let n = parsed.name.trim();-        if n.is_empty() { None } else {-            notes.push(format!("MPN defaulted to the part name \"{n}\" (pass --mpn for the exact orderable part number)"));-            Some(n.to_string())-        }-    });-    (PartAttrs { mfr, mpn, mouser, digikey, lcsc }, notes)-}--// Print what landed in the library, plus a loud REPRIMAND for any part number-// chip-fetcher should have supplied but didn't. Non-fatal: the file is still-// written, but the caller is told to re-run with the missing data.-fn report_bom(attrs: &PartAttrs, notes: &[String]) {-    let have = attrs.pairs();-    if !have.is_empty() {-        let shown: Vec<String> = have.iter().map(|(k, v)| format!("{k}={v}")).collect();-        println!("OK: BOM attributes on the device technology → {}", shown.join(", "));-    }-    for n in notes { println!("HINT: {n}"); }-    let missing = attrs.missing();-    if !missing.is_empty() {-        eprintln!("⛔ INCOMPLETE BOM DATA — the library was written WITHOUT these part numbers:");-        for m in &missing { eprintln!("       {m}"); }-        eprintln!("   chip-fetcher LOOKS ALL OF THESE UP. The caller MUST pass them so the Fusion");-        eprintln!("   library + BOM are complete. Re-run including the missing flags, e.g.:");-        eprintln!("       --mpn <MPN> --mfr \"<Manufacturer>\" --mouser <Mouser#> --digikey <DigiKey#> --lcsc <LCSC#>");-    }-}--// ── Native Altium writers (no Altium needed) ──-// Altium internal units: schematic 1 unit = 2.54/2560 mm; PCB 1 unit = 2.54e-6 mm.-const ALTIUM_SCH_UNIT_PER_MM: f64 = 2560.0 / 2.54;-const ALTIUM_PCB_UNIT_PER_MM: f64 = 1.0 / 0.000_002_54;--/// BOM attributes → Altium component parameter names (what Altium's BOM reads).-fn altium_params(attrs: &PartAttrs) -> Vec<(String, String)> {-    let mut v = Vec::new();-    let mut push = |k: &str, o: &Option<String>| {-        if let Some(s) = o { if !s.trim().is_empty() { v.push((k.to_string(), s.trim().to_string())); } }-    };-    push("Manufacturer", &attrs.mfr);-    push("Manufacturer Part Number", &attrs.mpn);-    push("Mouser Part Number", &attrs.mouser);-    push("Digi-Key Part Number", &attrs.digikey);-    push("LCSC Part #", &attrs.lcsc);-    v-}--/// Native `.SchLib` from the parsed symbol (KiCad mm → Altium SCH units; both-/// Y-up, so no flip). Pins carry through at their real positions.-fn build_altium_schlib(sym: &ParsedSymbol, attrs: &PartAttrs) -> anyhow::Result<Vec<u8>> {-    use altium_schlib_write::WritePin;-    let u = ALTIUM_SCH_UNIT_PER_MM;-    let pins: Vec<WritePin> = sym.pins.iter().map(|p| WritePin {-        name: p.name.clone(),-        designator: p.number.clone(),-        x: (p.x * u).round() as i32,-        y: (p.y * u).round() as i32,-        length: (p.length * u).round() as i32,-        rotation: p.angle as i32,-    }).collect();-    let body = sym.body.as_ref()-        .map(|b| ((b.left * u).round() as i32, (b.bottom * u).round() as i32,-                  (b.right * u).round() as i32, (b.top * u).round() as i32))-        .unwrap_or((0, 0, 2000, 2000));-    altium_schlib_write::to_schlib_bytes(&sym.name, &sym.description, body, &pins, &altium_params(attrs))-}--/// Native `.PcbLib` from the `.kicad_mod` pads (KiCad mm, Y-down → Altium units,-/// Y-up). Uses an embedded real `.PcbLib` for the library/board housekeeping.-fn build_altium_pcblib(fpname: &str, mod_pads: &[ModPad], descr: &str) -> anyhow::Result<Vec<u8>> {-    use altium_pcblib_write::WritePad;-    const TEMPLATE: &[u8] = include_bytes!("../assets/altium-template.PcbLib");-    let u = ALTIUM_PCB_UNIT_PER_MM;-    let pads: Vec<WritePad> = mod_pads.iter().map(|p| WritePad {-        designator: p.name.clone(),-        x: (p.x * u).round() as i32,-        y: (-p.y * u).round() as i32,-        xsize: (p.dx * u).round() as u32,-        ysize: (p.dy * u).round() as u32,-        hole: p.drill.map(|d| (d * u).round() as u32).unwrap_or(0),-        shape: 2,              // rectangular — universally valid; refine per-pad later-        rotation: p.angle,-        layer: 1,              // top copper; hole>0 marks thru-hole-    }).collect();-    altium_pcblib_write::to_pcblib_bytes(TEMPLATE, fpname, descr, "0mil", &pads)+/// Read a text file or exit with a clear error.+fn read_or_exit(path: &std::path::Path) -> String {+    fs::read_to_string(path).unwrap_or_else(|e| {+        eprintln!("ERROR: read {}: {e}", path.display());+        process::exit(1);+    }) } -#[allow(clippy::too_many_arguments)]-fn run_export(-    sym: Option<PathBuf>,-    fp: Option<PathBuf>,-    model: Option<PathBuf>,-    symbol_art: Option<PathBuf>,-    symbol_art_scale: f64,-    name: Option<String>,-    out: Option<PathBuf>,-    targets: Option<String>,-    mfr: Option<String>,-    mpn: Option<String>,-    mouser: Option<String>,-    digikey: Option<String>,-    lcsc: Option<String>,-    altium_lib: Option<String>,-    altium_mode: Option<String>,-) {-    use std::process::Command;--    // ── resolve inputs (same contract as `generate`) ──-    let symbol_art_resolved = symbol_art.as_ref().map(|p| {-        resolve_symbol_art(p).unwrap_or_else(|e| {-            eprintln!("ERROR: {e}");-            process::exit(1);-        })-    });-    let fp_content = fp.as_ref().map(|p| {-        fs::read_to_string(p).unwrap_or_else(|e| {-            eprintln!("ERROR: Cannot read footprint {}: {}", p.display(), e);-            process::exit(1);-        })-    });-    let sym_content = sym.as_ref().map(|p| {-        fs::read_to_string(p).unwrap_or_else(|e| {-            eprintln!("ERROR: Cannot read {}: {}", p.display(), e);-            process::exit(1);-        })-    });-    let parsed = match (&sym_content, &fp_content) {-        (Some(sc), _) => parse_kicad_sym(sc, name.as_deref()).unwrap_or_else(|e| {-            eprintln!("ERROR: {e}");-            process::exit(1);-        }),-        (None, Some(fc)) => {-            let syn = name.clone().or_else(|| first_footprint_name(fc)).unwrap_or_else(|| "FOOTPRINT".into());-            synth_symbol_from_mod(fc, &syn)-        }-        (None, None) => {-            eprintln!("ERROR: export needs at least --sym or --fp.");-            eprintln!("Hint: `adom-lbr export --sym part.kicad_sym --fp part.kicad_mod`.");-            process::exit(1);-        }-    };-    let part = parsed.name.clone();--    // ── BOM attributes (chip-fetcher supplies these; reprimand if missing) ──-    let (attrs, attr_notes) = resolve_part_attrs(&parsed, sym.as_deref(), &mfr, &mpn, &mouser, &digikey, &lcsc);--    // ── target selection ──-    let targets_set: Vec<String> = match &targets {-        Some(t) => t.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect(),-        None => vec!["kicad".into(), "fusion".into(), "orcad".into(), "altium".into()],-    };-    let want = |t: &str| targets_set.iter().any(|w| w == t);--    // ── output dir ──-    let out_dir = out.unwrap_or_else(|| PathBuf::from(format!("{part}-library")));-    fs::create_dir_all(&out_dir).unwrap_or_else(|e| {-        eprintln!("ERROR: cannot create {}: {}", out_dir.display(), e);+/// Load an adom-lbr JSON into the canonical part.+fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart {+    let json = read_or_exit(path);+    serde_json::from_str(&json).unwrap_or_else(|e| {+        eprintln!("ERROR: parse adom-lbr JSON {}: {e}", path.display());         process::exit(1);-    });--    // (tool, state ∈ {ok,warn,note,skip}, detail)-    let mut status: Vec<(String, String, String)> = Vec::new();--    // Shared EAGLE artifact (Fusion + Altium direct import) + native KiCad sym-    // (also OrCAD's EDIF input). Build once, reuse.-    let lbr = generate_lbr(&parsed, fp_content.as_deref(), symbol_art_resolved.as_ref(), symbol_art_scale, &attrs);-    let lint = lint_lbr_with(&lbr, fp.as_deref().and_then(courtyard_keepout));-    let kicad_sym_text = match &sym_content {-        Some(sc) => sc.clone(),            // already native KiCad — carry through 1:1-        None => generate_kicad_sym(&parsed),-    };--    // ── KiCad ──-    let kicad_dir = out_dir.join("kicad");-    let kicad_sym_path = kicad_dir.join(format!("{part}.kicad_sym"));-    if want("kicad") {-        let _ = fs::create_dir_all(&kicad_dir);-        let _ = fs::write(&kicad_sym_path, &kicad_sym_text);-        let mut detail = format!("{part}.kicad_sym");-        if let Some(fc) = &fp_content {-            let pretty = kicad_dir.join(format!("{part}.pretty"));-            let _ = fs::create_dir_all(&pretty);-            let _ = fs::write(pretty.join(format!("{part}.kicad_mod")), fc);-            detail.push_str(&format!(" + {part}.pretty/{part}.kicad_mod"));-        }-        status.push(("KiCad".into(), "ok".into(), detail));-    }-    // OrCAD reads a .kicad_sym on disk — make sure one exists even if the-    // 'kicad' target was excluded.-    if want("orcad") && !kicad_sym_path.exists() {-        let _ = fs::create_dir_all(&kicad_dir);-        let _ = fs::write(&kicad_sym_path, &kicad_sym_text);-    }--    // ── Fusion 360 ──-    if want("fusion") {-        let fdir = out_dir.join("fusion");-        let _ = fs::create_dir_all(&fdir);-        let lbr_path = fdir.join(format!("{part}.lbr"));-        let _ = fs::write(&lbr_path, &lbr);-        let mut detail = format!("{part}.lbr (lint {})", if lint.passed { "PASS" } else { "FAIL" });-        if let Some(mp) = &model {-            let side = fdir.join(format!("{part}.fusion3d.json"));-            match write_fusion3d_sidecar(&side, &part, &lbr_path, mp) {-                Ok(()) => detail.push_str(&format!(" + {part}.fusion3d.json (bridge attaches the body)")),-                Err(e) => detail.push_str(&format!(" + (sidecar FAILED: {e})")),-            }-        }-        let st = if lint.passed { "ok" } else { "warn" };-        status.push(("Fusion 360".into(), st.into(), detail));-    }--    // ── OrCAD (EDIF via adom-orcad) ──-    if want("orcad") {-        let odir = out_dir.join("orcad");-        let _ = fs::create_dir_all(&odir);-        let edn = odir.join(format!("{part}.edn"));-        match find_adom_orcad() {-            Some(bin) => {-                let res = Command::new(&bin)-                    .arg("edif").arg("--sym").arg(&kicad_sym_path)-                    .arg("--name").arg(&part)-                    .arg("--output").arg(&edn)-                    .output();-                match res {-                    Ok(o) if o.status.success() && edn.exists() => {-                        status.push(("OrCAD".into(), "ok".into(), format!("{part}.edn (EDIF 2.0.0 — Capture ▸ Import ▸ EDIF)")));-                    }-                    Ok(o) => {-                        let err = String::from_utf8_lossy(&o.stderr);-                        let _ = fs::write(odir.join("ORCAD-README.md"),-                            format!("# {part} → OrCAD\n\nadom-orcad could not produce EDIF.\n\nstderr:\n```\n{err}\n```\n\nRun manually:\n```\nadom-orcad edif --sym ../kicad/{part}.kicad_sym --name {part} --output {part}.edn\n```\n"));-                        status.push(("OrCAD".into(), "skip".into(), "adom-orcad failed — see ORCAD-README.md".into()));-                    }-                    Err(e) => status.push(("OrCAD".into(), "skip".into(), format!("could not run adom-orcad: {e}"))),-                }-            }-            None => {-                let _ = fs::write(odir.join("ORCAD-README.md"),-                    format!("# {part} → OrCAD\n\nThe OrCAD EDIF export needs **adom-orcad** (not found on PATH).\n\nInstall it, then:\n```\nadom-orcad edif --sym ../kicad/{part}.kicad_sym --name {part} --output {part}.edn\n```\n\nThen in OrCAD Capture: **Accessories/Import ▸ EDIF 2.0.0** ▸ select `{part}.edn` → native `.olb` symbol.\n"));-                status.push(("OrCAD".into(), "note".into(), "adom-orcad not installed — staged ORCAD-README.md".into()));-            }-        }-    }--    // ── Altium (hand-off) ──-    if want("altium") {-        let adir = out_dir.join("altium");-        let _ = fs::create_dir_all(&adir);-        // DEFAULT: native binary Altium libraries written in pure Rust (no Altium).-        // EXPERIMENTAL — byte-structurally conformant to real Altium output but not-        // yet round-tripped through an actual Altium install.-        let mut native = Vec::new();-        match build_altium_schlib(&parsed, &attrs) {-            Ok(b) => { let _ = fs::write(adir.join(format!("{part}.SchLib")), &b); native.push(format!("{part}.SchLib")); }-            Err(e) => eprintln!("WARN: native .SchLib failed: {e}"),-        }-        let mod_pads = fp_content.as_deref().map(parse_mod_pads).unwrap_or_default();-        if !mod_pads.is_empty() {-            match build_altium_pcblib(&part, &mod_pads, &parsed.description) {-                Ok(b) => { let _ = fs::write(adir.join(format!("{part}.PcbLib")), &b); native.push(format!("{part}.PcbLib")); }-                Err(e) => eprintln!("WARN: native .PcbLib failed: {e}"),-            }-        }-        // Also stage the .lbr (Import-Wizard fallback) + KiCad sources + bridge manifest.-        let _ = fs::write(adir.join(format!("{part}.lbr")), &lbr);-        let _ = fs::write(adir.join(format!("{part}.kicad_sym")), &kicad_sym_text);-        if let Some(fc) = &fp_content {-            let _ = fs::write(adir.join(format!("{part}.kicad_mod")), fc);-        }-        let mut model_ext: Option<String> = None;-        if let Some(mp) = &model {-            if let Ok(b) = fs::read(mp) {-                let ext = mp.extension().and_then(|e| e.to_str()).unwrap_or("step").to_string();-                let _ = fs::write(adir.join(format!("{part}.{ext}")), b);-                model_ext = Some(ext);-            }-        }-        // The bridge-ready import manifest (the Altium analog of fusion3d.json).-        let _ = write_altium_manifest(-            &adir.join(format!("{part}.altium.json")),-            &part, &parsed.description, &attrs,-            fp_content.is_some(), model_ext.as_deref(),-            altium_lib.as_deref(), altium_mode.as_deref(),-        );-        let _ = fs::write(adir.join("ALTIUM-README.md"), altium_readme(&part, fp_content.is_some(), model.is_some()));-        let native_str = if native.is_empty() { "(native write failed)".to_string() } else { format!("native {}", native.join(" + ")) };-        status.push(("Altium".into(), "ok".into(), format!("{native_str} [experimental] + {part}.lbr fallback + bridge manifest")));-    }--    // ── index + summary ──-    let _ = fs::write(out_dir.join("EXPORT.md"),-        export_index(&part, &status, fp_content.is_some(), model.is_some(), symbol_art_resolved.is_some()));--    println!("OK: exported '{part}' → {}", out_dir.display());-    for (tool, state, detail) in &status {-        let tag = match state.as_str() { "ok" => "✓", "warn" => "▲", "note" => "·", _ => "—" };-        println!("  {tag} {:<11} {}", tool, detail);-    }-    for w in &lint.warnings { println!("  LINT WARN [{}] {}", w.code, w.message); }-    for e in &lint.errors { println!("  LINT ERROR [{}] {}", e.code, e.message); }-    report_bom(&attrs, &attr_notes);-    println!("NEXT: open {}/EXPORT.md for per-tool import steps.", out_dir.display());-    if model.is_some() {-        println!("NEXT (Fusion 3D): hand fusion/{part}.fusion3d.json to the Fusion bridge (Create3DPackage → FINISH → Save), then record the minted URN with `adom-lbr bind-3d --record-only`.");-    }-    if fp.is_none() {-        println!("HINT: no --fp → every target got a 2-COLUMN PLACEHOLDER footprint. Pass --fp <part.kicad_mod> for the real geometry.");-    }+    }) } -// The Altium import manifest — the Altium analog of fusion3d.json. adom-lbr is a-// pure exporter, so it can't write binary .SchLib/.PcbLib itself; instead it-// emits this machine-readable package describing the part + BOM + target library-// so the **altium bridge** (altium-part-importer / adom-desktop-altium-bridge,-// on the machine running Altium) can drive Altium to create native libraries.-#[allow(clippy::too_many_arguments)]-fn write_altium_manifest(-    path: &std::path::Path,-    part: &str,-    desc: &str,-    attrs: &PartAttrs,-    has_fp: bool,-    model_ext: Option<&str>,-    lib: Option<&str>,-    mode: Option<&str>,-) -> std::io::Result<()> {-    // BOM parameters under Altium's own field names; the importer keys off-    // "LCSC Part #" so JLCPCB BOM + pick-and-place exports carry it.-    let mut params = serde_json::Map::new();-    let mut put = |k: &str, v: &Option<String>| {-        if let Some(s) = v { if !s.trim().is_empty() {-            params.insert(k.to_string(), serde_json::Value::String(s.trim().to_string()));-        } }-    };-    put("Manufacturer", &attrs.mfr);-    put("Manufacturer Part Number", &attrs.mpn);-    put("Mouser Part Number", &attrs.mouser);-    put("Digi-Key Part Number", &attrs.digikey);-    put("LCSC Part #", &attrs.lcsc);--    let model = model_ext.map(|e| serde_json::json!(format!("{part}.{e}"))).unwrap_or(serde_json::Value::Null);-    let footprint = if has_fp { serde_json::json!(format!("{part}.kicad_mod")) } else { serde_json::Value::Null };-    let (schlib, pcblib) = match lib {-        Some(b) => (-            serde_json::json!(format!("{b}.SchLib")),-            if has_fp { serde_json::json!(format!("{b}.PcbLib")) } else { serde_json::Value::Null },-        ),-        None => (serde_json::Value::Null, serde_json::Value::Null),+/// Seed the symbol's Altium property fields from the part identity (Value drawn;+/// Manufacturer/MPN/Package hidden), skipping empties + names already present.+fn seed_parameters(symbol: &mut altium_codec::Symbol, part: &altium_codec::AdomLbrPart) {+    use altium_codec::Parameter;+    let mut push = |name: &str, value: &str, visible: bool| {+        if value.is_empty()+            || symbol.parameters.iter().any(|p| p.name.eq_ignore_ascii_case(name))+        {+            return;+        }+        symbol.parameters.push(Parameter { name: name.into(), value: value.into(), visible });     };--    let manifest = serde_json::json!({-        "schema": "adom-lbr.altium/1",-        "generated_by": format!("adom-lbr {}", VERSION.trim()),-        "part": { "name": part, "value": part, "description": desc },-        // Everything the bridge needs as input — all relative to this manifest's dir.-        "sources": {-            "symbol_kicad_sym": format!("{part}.kicad_sym"),-            "footprint_kicad_mod": footprint,-            "eagle_lbr": format!("{part}.lbr"),-            "model_3d": model-        },-        "parameters": serde_json::Value::Object(params),-        "target": {-            "schlib": schlib,-            "pcblib": pcblib,-            "intlib": serde_json::Value::Null,-            "mode": mode.unwrap_or("survey")-        },-        "import": {-            "preferred": "bridge",-            "bridge": {-                "importer_skill": "barrett-land/altium-part-importer",-                "desktop_bridge": "adom/adom-desktop-altium-bridge",-                "verb_prefix": "altium_",-                "note": "Run on the machine with Altium + Adom Desktop. The importer surveys existing libraries (--list-libs) before creating, attaches the LCSC Part #, and builds symbol + footprint (+3D) natively."-            },-            "fallback_import_wizard": format!("Altium ▸ File ▸ Import Wizard ▸ EAGLE Projects and Libraries ▸ {part}.lbr")-        },-        "status": "pending-altium-bridge"-    });-    std::fs::write(path, serde_json::to_string_pretty(&manifest)?)-}--fn altium_readme(part: &str, has_fp: bool, has_model: bool) -> String {-    let mut s = String::new();-    s.push_str(&format!("# {part} → Altium\n\n"));-    s.push_str(&format!("**`{part}.SchLib` + `{part}.PcbLib` are native Altium binary libraries**, written\ndirectly by adom-lbr in pure Rust — **no Altium required**. Add them to a project\nor integrated library and use them as-is.\n\n> ⚠️ EXPERIMENTAL: byte-structurally conformant to real Altium output (streams +\n> pin/pad records match manufacturer libraries) but **not yet round-tripped\n> through an actual Altium install**. If a part won't open, use the `.lbr`\n> Import-Wizard fallback or the bridge manifest below — and please report it.\n\nOther paths to the same library:\n\n"));-    s.push_str("## 1. Bridge (drives a real Altium to native libraries)\n\n");-    s.push_str(&format!("Hand `{part}.altium.json` (manifest, schema `adom-lbr.altium/1`) to the **Altium\nbridge** on a machine running Altium + Adom Desktop (Altium has no headless mode,\nso the bridge drives a real Altium via DelphiScript). The manifest points at the\nstaged sources:\n\n"));-    s.push_str(&format!("- `{part}.kicad_sym` — schematic symbol\n"));-    if has_fp { s.push_str(&format!("- `{part}.kicad_mod` — PCB footprint\n")); }-    if has_model { s.push_str("- the 3D model (`*.step` / `*.glb`) — 3D body\n"); }-    s.push_str("\nThe shipped tools that consume it (install on the Altium machine):\n\n");-    s.push_str(&format!("- `{part}.kicad_sym` — schematic symbol\n"));-    if has_fp { s.push_str(&format!("- `{part}.kicad_mod` — PCB footprint\n")); }-    if has_model { s.push_str("- the 3D model (`*.step` / `*.glb`) — 3D body\n"); }-    s.push_str("\nThe shipped tools that do this (install on the Altium machine):\n\n");-    s.push_str("- **`adom/adom-desktop-altium-bridge`** — the Adom Desktop bridge that drives\n  Altium via DelphiScript to add symbols + footprints (with 3D) to a file-based\n  library:\n  ```\n  adompkg install adom/adom-desktop-altium-bridge\n  ```\n");-    s.push_str("- **`altium-part-importer`** — adds a catalog part to an Altium library;\n  CREATE a new `.IntLib`, APPEND to an existing one, or REUSE existing symbols:\n  ```\n  adompkg install altium-part-importer\n  ```\n");-    s.push_str("- **`adom/adom-altium-library-importer`** — one-command install of pre-converted\n  `.SchLib` + `.PcbLib` (with linked 3D) into Altium.\n\n");-    s.push_str("## 2. Direct (nothing else needed)\n\n");-    s.push_str(&format!("No bridge handy? Altium imports EAGLE libraries directly: **File ▸ Import Wizard\n▸ EAGLE Projects and Libraries** ▸ select `{part}.lbr`. You get a schematic symbol\n+ PCB footprint. (The Import Wizard may re-place the designator at its default —\nthe bridge path preserves the exact placement.)\n\n"));-    s.push_str("## Reverse (Altium → KiCad)\n\n");-    s.push_str("Already have an Altium library? Convert it natively (no Altium, no KiCad install):\n\n");-    s.push_str("```\nadompkg install adom/adom-sfconvert   # .SchLib/.PcbLib/.IntLib → .kicad_sym/.kicad_mod\n# parsers: adom/adom-altium-schlib (symbols), adom/altium-pcblib (footprints)\n```\n");-    s-}--fn export_index(part: &str, status: &[(String, String, String)], has_fp: bool, has_model: bool, has_art: bool) -> String {-    let mut s = String::new();-    s.push_str(&format!("# {part} — multi-tool library bundle\n\n"));-    s.push_str("Generated by **`adom-lbr export`** from one KiCad part. One folder per EDA tool — all four read from the *same* source, so the symbol, the real footprint, and the chip outline are identical across them.\n\n");-    s.push_str(&format!("- Footprint geometry: **{}**\n", if has_fp { "real `.kicad_mod`" } else { "placeholder (no `--fp`)" }));-    s.push_str(&format!("- Chip outline on the schematic symbol: **{}**\n", if has_art { "embedded" } else { "none" }));-    s.push_str(&format!("- 3D-model hand-off: **{}**\n\n", if has_model { "yes (Fusion sidecar)" } else { "none" }));-    s.push_str("| Tool | Output | How to import |\n|---|---|---|\n");-    let kfp = if has_fp { format!(" + `kicad/{part}.pretty/`") } else { String::new() };-    s.push_str(&format!("| **KiCad** | `kicad/{part}.kicad_sym`{kfp} | Add the `.kicad_sym` as a symbol library; add the `.pretty` folder as a footprint library. |\n"));-    let fsc = if has_model { format!(" + `{part}.fusion3d.json`") } else { String::new() };-    let fnote = if has_model { " Then the bridge attaches the 3D body (Create3DPackage → FINISH → Save)." } else { "" };-    s.push_str(&format!("| **Fusion 360** | `fusion/{part}.lbr`{fsc} | Open the `.lbr` as an Electronics library.{fnote} |\n"));-    s.push_str(&format!("| **OrCAD** | `orcad/{part}.edn` | Capture ▸ Accessories/Import ▸ EDIF 2.0.0 ▸ select the `.edn` → native `.olb`. (Falls back to a README if adom-orcad isn't installed.) |\n"));-    s.push_str(&format!("| **Altium** | `altium/{part}.SchLib` + `{part}.PcbLib` (native, **experimental**) | Add the native binary libraries directly — no Altium needed to create them. Fallbacks: `.lbr` Import Wizard or the bridge manifest. See `altium/ALTIUM-README.md`. |\n\n"));-    let _ = status;-    s.push_str("adom-lbr is a pure exporter: it writes these files and hands off. The orchestrating AI does any app-driving (Fusion bridge, Altium bridge).\n");-    s+    push("Value", &part.value, true);+    push("Manufacturer", &part.manufacturer, false);+    push("Manufacturer Part Number", &part.mpn, false);+    push("Package", &part.package, false); }  fn main() {     let cli = Cli::parse();      match cli.command {-        Commands::Generate { sym, fp, output, name, model, symbol_art, symbol_art_scale, mfr, mpn, mouser, digikey, lcsc } => {-            let symbol_art_resolved = symbol_art.as_ref().map(|p| {-                resolve_symbol_art(p).unwrap_or_else(|e| {-                    eprintln!("ERROR: {e}");-                    process::exit(1);-                })-            });+        Commands::Generate { sym, fp, output, name } => {             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);@@ -2248,12 +928,10 @@ fn main() {             };              let out_default = sym.as_ref().or(fp.as_ref()).cloned().unwrap_or_else(|| PathBuf::from(&parsed.name));-            // BOM attributes: flags → .kicad_sym props → auto-fill; reprimand if any missing.-            let (attrs, attr_notes) = resolve_part_attrs(&parsed, sym.as_deref(), &mfr, &mpn, &mouser, &digikey, &lcsc);-            let lbr = generate_lbr(&parsed, fp_content.as_deref(), symbol_art_resolved.as_ref(), symbol_art_scale, &attrs);+            let lbr = generate_lbr(&parsed, fp_content.as_deref()); -            // Lint before writing (courtyard keep-out from sibling metadata if present)-            let lint = lint_lbr_with(&lbr, fp.as_deref().and_then(courtyard_keepout));+            // Lint before writing+            let lint = lint_lbr(&lbr);             for err in &lint.errors {                 eprintln!("LINT ERROR: [{}] {}", err.code, err.message);             }@@ -2272,130 +950,6 @@ 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());-            report_bom(&attrs, &attr_notes);--            // Progressive, moment-of-use hints — surface the pitfalls right where-            // they bite, so an agent that never read the skill still does the-            // right thing. (Same idea as adom-wiki/adom-desktop `hint[…]` lines.)-            if fp.is_none() {-                println!("HINT: no --fp given → the package is a 2-COLUMN PLACEHOLDER, not the real footprint.");-                println!("      For anything you'll fabricate, pass --fp <part.kicad_mod> so pads land in their true positions.");-            } else {-                let sides = footprint_side_summary(fp_content.as_deref());-                println!("HINT: footprint built from the real .kicad_mod ({sides}).");-                if sym.is_none() {-                    println!("HINT: no --sym → the symbol was SYNTHESIZED from the footprint pads (balanced L/R). Pass --sym <part.kicad_sym> for the real schematic symbol.");-                }-                if model.is_none() {-                    println!("HINT: add --model <part.step> to also emit a Fusion 3D handoff sidecar (the bridge attaches the body).");-                }-            }-            if let Some(art) = &symbol_art_resolved {-                let (_, segs) = symbol_art_block(art, parsed.body.as_ref(), symbol_art_scale);-                let kb = lbr.len() / 1024;-                if segs == 0 {-                    println!("HINT: --symbol-art had NO <polyline>/<polygon> points — nothing drawn. Give an outline contract (`<mpn>-symbol-outline.mm.svg`) or a flattened outline SVG, not a raster or a <path>-only file.");-                } else if let SymbolArt::Contract { variant, .. } = art {-                    println!("HINT: drew the chip outline via the OUTLINE CONTRACT (`{variant}` variant) — {segs} segments on layer 94, transcribed 1:1 from adom-symbol's mm-space placement (zero guessing).");-                    if kb > 200 {-                        println!("WARN: ~{kb} KB .lbr — heavier than expected for a lite contract; check the producer's segment cap.");-                    }-                } else {-                    println!("HINT: drew the chip outline (LEGACY centre+scale guess) — {segs} segments on layer 94. TIP: render `<mpn>-symbol.svg` with adom-symbol and pass its `<mpn>-symbol-outline.mm.svg` instead for an exact 1:1 placement.");-                    if segs > 2500 || kb > 200 {-                        println!("WARN: HEAVY symbol art — {segs} segments, ~{kb} KB .lbr. Detailed outlines (e.g. `-blueprint`) bloat the library and can lag Fusion's symbol editor.");-                        println!("      → Pass a SIMPLER outline instead (`<mpn>-3d-outline-bold.svg` or `-thin.svg` are ~1/3 the size), or the lite `-symbol-outline.mm.svg` contract.");-                    } else if kb > 80 {-                        println!("HINT: moderate weight (~{kb} KB). Fine for one part; if you're batching a whole library, prefer the lite `-symbol-outline.mm.svg` contract.");-                    }-                }-            }-            println!("NEXT: this .lbr imports directly into Fusion 360 / Altium / OrCAD. For KiCad: `adom-lbr import {} -o part.kicad_sym`.", out_path.display());--            // 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 attach the model (Create3DPackage → FINISH)", 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::Export { sym, fp, model, symbol_art, symbol_art_scale, name, out, targets, mfr, mpn, mouser, digikey, lcsc, altium_lib, altium_mode } => {-            run_export(sym, fp, model, symbol_art, symbol_art_scale, name, out, targets, mfr, mpn, mouser, digikey, lcsc, altium_lib, altium_mode);-        }--        Commands::Bind3d { lbr, wip_urn, urn, lineage_urn, record_only, package, output, sidecar } => {-            // Exactly one of --wip-urn (self-hosted) or --urn (managed).-            let (link, managed) = match (wip_urn.as_deref(), urn.as_deref()) {-                (Some(w), None) => (w.to_string(), false),-                (None, Some(u)) => (u.to_string(), true),-                (None, None) => {-                    eprintln!("ERROR: bind-3d needs --wip-urn <fs.file:vf URN> (self-hosted, usual) or --urn <eagle:package URN> (managed).");-                    process::exit(1);-                }-                (Some(_), Some(_)) => {-                    eprintln!("ERROR: pass only one of --wip-urn or --urn.");-                    process::exit(1);-                }-            };-            // A dm.lineage URN must never land in the .lbr — Fusion pins the-            // versioned fs.file URN. Route lineage to provenance instead.-            if link.contains("dm.lineage") {-                eprintln!("ERROR: that's a dm.lineage URN — it must not go in the .lbr. Pass the versioned fs.file:vf…?version=N URN to --wip-urn, and the lineage URN to --lineage-urn (sidecar provenance only).");-                process::exit(1);-            }--            // Record-only: update the sidecar provenance, leave the .lbr alone.-            if record_only {-                let Some(side) = sidecar.as_ref() else {-                    eprintln!("ERROR: --record-only needs --sidecar <manifest.json> to write into.");-                    process::exit(1);-                };-                update_sidecar_binding(side, &link, managed, lineage_urn.as_deref());-                return;-            }--            let Some(lbr) = lbr else {-                eprintln!("ERROR: bind-3d needs --lbr <file.lbr> (or --record-only to update just the sidecar).");-                process::exit(1);-            };-            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, &link, managed, 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 {} ({}) into {}", link, if managed { "managed" } else { "self-hosted wip_urn" }, out_path.display());-            if !managed {-                println!("NOTE: a pre-written wip_urn renders only once the model is brought into a Fusion session (cold-open shows a gray placeholder).");-            }--            if let Some(side) = &sidecar {-                update_sidecar_binding(side, &link, managed, lineage_urn.as_deref());-            }              if !lint.passed {                 process::exit(2);@@ -2536,87 +1090,21 @@ fn main() {                 process::exit(1);             }         }-        Commands::Serve { port, file, library } => {-            // Resolve the chip-fetcher library root (folder of <MPN>/ dirs) for-            // the chip switcher: --library, else --file's grandparent, else the-            // default chip-fetcher/library.-            let default_lib = PathBuf::from("/home/adom/project/chip-fetcher/library");-            let lib_root = library.clone()-                .or_else(|| file.as_ref().and_then(|f| f.parent()).and_then(|d| d.parent()).map(|p| p.to_path_buf()))-                .filter(|p| p.is_dir())-                .or_else(|| default_lib.is_dir().then(|| default_lib.clone()));-            let chips = lib_root.as_ref().map(|r| viewable_chips(r)).unwrap_or_default();-            // chip-fetcher's project groupings (filtered to viewable chips).-            let projects = load_chipfetcher_projects(&chips);--            // The current chip dir (from --file), and whether it has renders.-            let current_dir = file.as_ref().and_then(|f| f.parent()).map(|d| d.to_path_buf());-            let current_chip = current_dir.as_ref().and_then(|d| d.file_name()).and_then(|s| s.to_str()).map(String::from);--            // ── Reprimand: launched without a real chip-fetcher folder ──-            let renders_ok = current_dir.as_ref().map(|d| {-                let mpn = d.file_name().and_then(|s| s.to_str()).unwrap_or("");-                d.join(format!("{mpn}-symbol.svg")).exists() || d.join("symbol.svg").exists()-            }).unwrap_or(false);-            if file.is_none() && chips.is_empty() {-                eprintln!("⛔ adom-lbr serve: launched WITHOUT a chip-fetcher library folder, and no library root was found.");-                eprintln!("   The viewer renders a chip from its chip-fetcher dir (chip-fetcher/library/<MPN>/), which holds");-                eprintln!("   <MPN>-symbol.svg, -footprint.svg, the 3D render, and <MPN>-symbol.extracted.json.");-                eprintln!("   FIX: point --file at a <MPN>.lbr INSIDE such a folder, or pass --library <root>, e.g.:");-                eprintln!("     adom-lbr serve --file /home/adom/project/chip-fetcher/library/ADS8588SIPM/ADS8588SIPM.lbr");-            } else if file.is_some() && !renders_ok {-                eprintln!("⛔ adom-lbr serve: --file's folder ({}) is NOT a chip-fetcher chip dir —", current_dir.as_ref().map(|d| d.display().to_string()).unwrap_or_default());-                eprintln!("   no <MPN>-symbol.svg there, so the viewer can't show symbol/footprint/3D (you'll get empty panels).");-                eprintln!("   FIX: put the .lbr in chip-fetcher/library/<MPN>/ (next to its renders), e.g.:");-                eprintln!("     adom-lbr serve --file /home/adom/project/chip-fetcher/library/<MPN>/<MPN>.lbr");-            }--            // Register the on-the-fly chip switcher (builds any chip in the root).-            if let Some(root) = lib_root.clone() {-                if !chips.is_empty() {-                    let chips_for_builder = chips.clone();-                    let root_for_builder = root.clone();-                    let projects_for_builder = projects.clone();-                    server::set_chip_switcher(Box::new(move |mpn: &str| {-                        // Only serve chips that exist under the root (no path escape).-                        if !chips_for_builder.iter().any(|c| c == mpn) { return None; }-                        let dir = root_for_builder.join(mpn);-                        let picker = chip_picker_html(&chips_for_builder, mpn, &projects_for_builder);-                        build_embed_html(&dir, &picker).ok().map(|(h, _)| h)-                    }));-                    eprintln!("[adom-lbr] chip switcher: {} chips, {} chip-fetcher projects, under {} (sidebar + ?chip=<MPN>)", chips.len(), projects.len(), root.display());-                }+        Commands::Manage { port } => {+            if let Err(e) = manage::run(port) {+                eprintln!("ERROR: {e}");+                process::exit(1);             }--            // Load + render the startup chip.-            if let Some(f) = &file {-                match fs::read_to_string(f) {+        }+        Commands::Serve { port, file } => {+            if let Some(f) = file {+                match fs::read_to_string(&f) {                     Ok(content) => server::set_state(server::analyze_lbr(&content)),                     Err(e) => {                         eprintln!("ERROR: Cannot read {}: {}", f.display(), e);                         process::exit(1);                     }                 }-                if let Some(dir) = f.parent() {-                    let picker = current_chip.as_deref()-                        .map(|c| chip_picker_html(&chips, c, &projects)).unwrap_or_default();-                    match build_embed_html(dir, &picker) {-                        Ok((html, pins)) => {-                            eprintln!("[adom-lbr] 3-column viewer ready ({pins} pins) from {}", dir.display());-                            server::set_embed(html);-                        }-                        Err(e) => eprintln!("[adom-lbr] no 3-column viewer ({e}); serving lint card"),-                    }-                }-            } else if let Some(first) = chips.first() {-                // No --file but we have a library: open on the first chip.-                if let Some(root) = &lib_root {-                    let picker = chip_picker_html(&chips, first, &projects);-                    if let Ok((html, pins)) = build_embed_html(&root.join(first), &picker) {-                        eprintln!("[adom-lbr] opened on {first} ({pins} pins); switch via the dropdown or ?chip=<MPN>");-                        server::set_embed(html);-                    }-                }             }             if let Err(e) = server::run(port) {                 eprintln!("ERROR: {e:#}");@@ -2624,6 +1112,107 @@ fn main() {             }         } +        Commands::ImportKicad { sym, fp, mpn, out } => {+            let sym_txt = read_or_exit(&sym);+            let fp_txt = read_or_exit(&fp);+            let part = altium_codec::import_kicad(&sym_txt, &fp_txt, &mpn)+                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{mpn}.adom-lbr.json")));+            fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();+            println!("OK: wrote {} (adom-lbr JSON — the canonical hub)", outp.display());+            println!("Hint: convert it with `adom-lbr export-altium {}` or `export-intlib`.", outp.display());+        }++        Commands::ExportAltium { part, out_dir } => {+            let p = load_part(&part);+            let mut symbol = p.symbol.clone();+            seed_parameters(&mut symbol, &p);+            let sym_bytes = altium_codec::encode_schlib(&[symbol])+                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+            let fp_bytes = altium_codec::encode_pcblib(&p.footprint)+                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+            let sp = out_dir.join(format!("{}.SchLib", p.mpn));+            let pp = out_dir.join(format!("{}.PcbLib", p.mpn));+            fs::write(&sp, &sym_bytes).ok();+            fs::write(&pp, &fp_bytes).ok();+            println!("OK: wrote {} ({} pins) + {} ({} pads)",+                sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());+        }++        Commands::ExportIntlib { part, out } => {+            let p = load_part(&part);+            let mut symbol = p.symbol.clone();+            seed_parameters(&mut symbol, &p);+            let descr = if p.value.is_empty() { p.mpn.clone() } else { format!("{} {}", p.value, p.package) };+            let bytes = altium_codec::encode_intlib(&symbol, &p.footprint, &descr)+                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{}.IntLib", p.mpn)));+            fs::write(&outp, &bytes).ok();+            println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());+        }++        Commands::ExportKicad { part, out_dir } => {+            let p = load_part(&part);+            let mut symbol = p.symbol.clone();+            seed_parameters(&mut symbol, &p);+            let sym_txt = altium_codec::export_kicad_symbol(&symbol);+            let fp_txt = altium_codec::export_kicad_footprint(&p.footprint);+            let sp = out_dir.join(format!("{}.kicad_sym", p.mpn));+            let fp = out_dir.join(format!("{}.kicad_mod", p.mpn));+            fs::write(&sp, sym_txt).ok();+            fs::write(&fp, fp_txt).ok();+            println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)",+                sp.display(), p.symbol.pins.len(), fp.display(),+                p.footprint.pads.len(), p.footprint.graphics.len());+        }++        Commands::AddToSchlib { existing, part, out } => {+            let existing_bytes = fs::read(&existing)+                .unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", existing.display()); process::exit(1); });+            let json = read_or_exit(&part);+            let syms: Vec<altium_codec::Symbol> =+                if let Ok(p) = serde_json::from_str::<altium_codec::AdomLbrPart>(&json) {+                    vec![p.symbol]+                } else if let Ok(v) = serde_json::from_str::<Vec<altium_codec::Symbol>>(&json) {+                    v+                } else {+                    vec![serde_json::from_str::<altium_codec::Symbol>(&json)+                        .unwrap_or_else(|e| { eprintln!("ERROR: parse Symbol/AdomLbrPart: {e}"); process::exit(1); })]+                };+            let merged = altium_codec::add_symbols_to_schlib(&existing_bytes, &syms)+                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+            let outp = out.unwrap_or_else(|| PathBuf::from("merged.SchLib"));+            fs::write(&outp, &merged).ok();+            println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());+        }++        Commands::ImportAltium { file, pcblib, schlib, mpn, out } => {+            let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();+            let read = |p: &std::path::Path| -> Vec<u8> {+                fs::read(p).unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", p.display()); process::exit(1); })+            };+            let part = if ext == "intlib" {+                altium_codec::decode_intlib_part(&read(&file), &mpn)+            } else if ext == "pcblib" {+                let sch = schlib.as_ref().map(|p| read(p));+                altium_codec::decode_altium(sch.as_deref(), Some(&read(&file)), &mpn)+            } else {+                // treat as .SchLib; auto-find the .PcbLib next to it if not given+                let pcb_path = pcblib.or_else(|| {+                    let cand = file.with_extension("PcbLib");+                    cand.exists().then_some(cand)+                });+                let pcb = pcb_path.as_ref().map(|p| read(p));+                altium_codec::decode_altium(Some(&read(&file)), pcb.as_deref(), &mpn)+            }+            .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+            let name = if part.mpn.is_empty() { "part".to_string() } else { part.mpn.clone() };+            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{name}.adom-lbr.json")));+            fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();+            println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",+                outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());+        }+         Commands::Install => {             let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());             let skill_dir = PathBuf::from(&home).join(".claude/skills/adom-lbr");@@ -2690,170 +1279,34 @@ 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.-/// List the chip-fetcher library folders under `root` that have a viewable-/// render (a `<MPN>-symbol.svg` / `symbol.svg`), sorted — the chip switcher's menu.-pub fn viewable_chips(root: &std::path::Path) -> Vec<String> {-    let mut chips: Vec<String> = fs::read_dir(root).into_iter().flatten().filter_map(|e| e.ok())-        .filter(|e| e.path().is_dir())-        .filter_map(|e| {-            let name = e.file_name().to_str()?.to_string();-            let has_sym = e.path().join(format!("{name}-symbol.svg")).exists()-                || e.path().join("symbol.svg").exists();-            has_sym.then_some(name)-        })-        .collect();-    chips.sort();-    chips-}--/// Read chip-fetcher's `projects.json` and return its groupings as-/// (project_name, [mpns]) — filtered to the chips that are actually viewable —-/// so the library manager's filter lists match chip-fetcher's projects exactly.-/// Empty projects (after filtering) are dropped. Alphabetical (BTreeMap order).-fn load_chipfetcher_projects(viewable: &[String]) -> Vec<(String, Vec<String>)> {-    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());-    let path = std::path::PathBuf::from(home).join(".config/adom-chip-fetcher/projects.json");-    let Ok(txt) = fs::read_to_string(&path) else { return Vec::new() };-    let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) else { return Vec::new() };-    let vset: std::collections::HashSet<&str> = viewable.iter().map(|s| s.as_str()).collect();-    let mut out = Vec::new();-    if let Some(projs) = v.get("projects").and_then(|p| p.as_object()) {-        for (name, p) in projs {-            let mpns: Vec<String> = p.get("mpns").and_then(|m| m.as_array())-                .map(|a| a.iter().filter_map(|x| x.as_str())-                    .filter(|m| vset.contains(m)).map(String::from).collect())-                .unwrap_or_default();-            if !mpns.is_empty() {-                out.push((name.clone(), mpns));-            }-        }-    }-    out-}--/// Build the collapsible LEFT chip-list sidebar for the viewer. Collapsed to a-/// thin rail by default; click to expand the scrollable list (the current chip-/// auto-highlighted + scrolled into view). When open, ↑/↓ flip to the prev/next-/// chip (reloads `?chip=<MPN>`) so you can fan through the library. A project-/// selector at the top mirrors chip-fetcher's groupings: "All chips" (global) +-/// each project; picking one filters the list (and the ↑/↓ range) to that-/// project, persisted across reloads. Empty when there's nothing to switch to.-fn chip_picker_html(chips: &[String], current: &str, projects: &[(String, Vec<String>)]) -> String {-    if chips.len() < 2 {-        return String::new();-    }-    let items: String = chips.iter().map(|c| {-        let cur = if c == current { " class=\"cur\"" } else { "" };-        format!("<a href=\"?chip={enc}\" data-mpn=\"{enc}\" onclick=\"return lmGo(this)\"{cur}>{name}</a>", enc = esc_html(c), name = esc_html(c))-    }).collect();-    let chips_json = serde_json::to_string(chips).unwrap_or_else(|_| "[]".into());-    let n = chips.len();-    // Project selector (chip-fetcher groupings): "All chips (N)" + each project.-    let proj_opts: String = projects.iter().map(|(name, m)| {-        format!("<option value=\"{}\">{} ({})</option>", esc_html(name), esc_html(name), m.len())-    }).collect();-    let proj_map: serde_json::Map<String, serde_json::Value> = projects.iter()-        .map(|(name, m)| (name.clone(), serde_json::to_value(m).unwrap_or(serde_json::Value::Null))).collect();-    let proj_json = serde_json::to_string(&proj_map).unwrap_or_else(|_| "{}".into());-    let proj_select = if projects.is_empty() { String::new() } else {-        format!("<div class=\"lmproj\"><select id=\"projsel\" onchange=\"filterProj(this.value)\"><option value=\"\">All chips · chip-fetcher ({n})</option>{proj_opts}</select></div>")-    };-    format!(-        "<style>\-        .lm{{flex:0 0 auto;width:30px;background:var(--panel);border-right:1px solid var(--border);transition:width .15s ease;overflow:hidden;display:flex;flex-direction:column;height:100%}}\-        .lm.open{{width:240px}}\-        .lmrail{{flex:1;display:flex;flex-direction:column;align-items:center;cursor:pointer;color:#cfd8e2}}\-        .lmrail:hover{{background:var(--panel2)}}.lm.open .lmrail{{display:none}}\-        .lmrail .tg{{margin-top:10px;font-size:15px}}\-        .lmrail .lbl{{writing-mode:vertical-rl;margin-top:10px;color:var(--muted);font-size:10px;letter-spacing:2px}}\-        .lmpanel{{display:none;flex-direction:column;width:240px;height:100%}}\-        .lm.open .lmpanel{{display:flex}}\-        .lmtop{{flex:0 0 auto;border-bottom:1px solid var(--border)}}\-        .lmh{{padding:9px 12px;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);display:flex;justify-content:space-between}}\-        .lmh .x{{cursor:pointer;color:#cfd8e2}}\-        .lmproj{{padding:0 10px 8px}}\-        .lmproj select{{width:100%;background:var(--panel2);color:#cfd8e2;border:1px solid var(--border);border-radius:5px;padding:4px 6px;font-size:12px;font-family:inherit;cursor:pointer}}\-        .lmlist{{flex:1;overflow-y:auto;scrollbar-width:thin;scrollbar-color:#323c4a transparent}}\-        .lmlist::-webkit-scrollbar{{width:9px}}\-        .lmlist::-webkit-scrollbar-track{{background:transparent}}\-        .lmlist::-webkit-scrollbar-thumb{{background:#323c4a;border-radius:6px;border:2px solid var(--panel)}}\-        .lmlist::-webkit-scrollbar-thumb:hover{{background:#46525f}}\-        .lmlist a{{display:block;padding:5px 12px;color:#cfd8e2;text-decoration:none;font-size:12.5px;border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}\-        .lmlist a:hover{{background:var(--panel2)}}\-        .lmlist a.cur{{background:#1f6feb22;border-left-color:var(--accent);color:#fff;font-weight:600}}\-        #main{{transition:opacity .1s ease}}\-        </style>\-        <aside class=\"lm\" id=\"lm\">\-        <div class=\"lmrail\" onclick=\"lmToggle()\" title=\"chip library — click to expand (↑/↓ to flip)\"><div class=\"tg\">☰</div><div class=\"lbl\">CHIPS {n}</div></div>\-        <div class=\"lmpanel\"><div class=\"lmtop\"><div class=\"lmh\"><span id=\"chcount\">{n} chips · ↑/↓ to flip</span><span class=\"x\" onclick=\"lmToggle()\">✕</span></div>{proj_select}</div><div class=\"lmlist\" id=\"lmlist\">{items}</div></div>\-        </aside>\-        <script>\-        var ALL={chips_json},CUR={cur_json},PROJECTS={proj_json},CHIPS=ALL.slice();\-        function lmToggle(){{var l=document.getElementById('lm');l.classList.toggle('open');if(l.classList.contains('open')){{var c=l.querySelector('a.cur');if(c)c.scrollIntoView({{block:'center'}});}}}}\-        function filterProj(p){{var set=p&&PROJECTS[p]?PROJECTS[p]:null;var links=[].slice.call(document.querySelectorAll('#lmlist a[data-mpn]'));links.forEach(function(a){{a.style.display=(!set||set.indexOf(a.getAttribute('data-mpn'))>=0)?'':'none';}});CHIPS=links.filter(function(a){{return a.style.display!=='none';}}).map(function(a){{return a.getAttribute('data-mpn');}});var s=document.getElementById('projsel');if(s)s.value=p||'';var cc=document.getElementById('chcount');if(cc)cc.textContent=CHIPS.length+(p?' in '+p:' chips')+' · ↑/↓ to flip';var c=document.querySelector('#lmlist a.cur');if(c&&c.style.display!=='none')c.scrollIntoView({{block:'center'}});}}\-        function setCur(mpn){{document.querySelectorAll('#lmlist a[data-mpn]').forEach(function(a){{a.classList.toggle('cur',a.getAttribute('data-mpn')===mpn);}});CUR=mpn;var c=document.querySelector('#lmlist a.cur');if(c)c.scrollIntoView({{block:'nearest'}});}}\-        var lmReq=0;\-        function loadChip(mpn){{if(!mpn||mpn===CUR)return;var my=++lmReq;setCur(mpn);var m=document.getElementById('main');if(!m)return;m.style.opacity='.45';fetch('?chip='+encodeURIComponent(mpn)).then(function(r){{return r.text();}}).then(function(html){{if(my!==lmReq)return;var d=new DOMParser().parseFromString(html,'text/html');var nm=d.getElementById('main');if(nm){{m.innerHTML=nm.innerHTML;if(window.wireViewer)wireViewer();}}var hn=d.querySelector('header .nm'),ch=document.querySelector('header .nm');if(hn&&ch)ch.textContent=hn.textContent;m.style.opacity='';if(d.title)document.title=d.title;try{{history.replaceState(null,'','?chip='+encodeURIComponent(mpn));}}catch(e){{}}}}).catch(function(){{if(my===lmReq)m.style.opacity='';}});}}\-        function lmGo(a){{loadChip(a.getAttribute('data-mpn'));return false;}}\-        addEventListener('keydown',function(e){{var l=document.getElementById('lm');if(!l||!l.classList.contains('open'))return;if(e.target&&/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;var i=CHIPS.indexOf(CUR);if(e.key==='ArrowDown'){{e.preventDefault();loadChip(CHIPS[Math.min((i<0?-1:i)+1,CHIPS.length-1)]);}}else if(e.key==='ArrowUp'){{e.preventDefault();loadChip(CHIPS[Math.max((i<0?0:i)-1,0)]);}}else if(e.key==='Escape'){{lmToggle();}}}});\-        </script>",-        cur_json = serde_json::to_string(current).unwrap_or_else(|_| "\"\"".into())-    )-}--pub fn build_embed_html(dir: &std::path::Path, picker: &str) -> Result<(String, usize), String> {     let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string();     // renders-    let sym_svg = read_first(dir, &[format!("{mpn}-symbol.svg"), "symbol.svg".into(), "assets/symbol.svg".into()])+    let sym_svg = read_first(dir, &[format!("{mpn}-symbol.svg")])         .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"), "footprint.svg".into(), "assets/footprint.svg".into()])+    let fp_svg = read_first(dir, &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")])         .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into());     let threed = {-        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());+        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());         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 (tolerate generic name)+    // optional pin→pad connects from a .lbr     let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();-    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 Some(lbr) = read_first(dir, &[format!("{mpn}.lbr")]) {         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"), "symbol.extracted.json".into(), "assets/symbol.extracted.json".into()]) {+    if let Some(ext) = read_first(dir, &[format!("{mpn}-symbol.extracted.json")]) {         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| {@@ -2876,37 +1329,19 @@ pub fn build_embed_html(dir: &std::path::Path, picker: &str) -> 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("__PICKER__", picker)-        .replace("__NAME__", &esc_html(&display_name))+        .replace("__NAME__", &esc_html(&mpn))         .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());-    Ok((html, pin_count))+    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(()) }
src/manage.htmladded+160
@@ -0,0 +1,160 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>adom-lbr Manager</title>+<script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script>+<style>+  :root{--bg:#0f1115;--panel:#171a21;--edge:#252a34;--ink:#e6e9ef;--mut:#8b93a3;--acc:#6ea8fe}+  *{box-sizing:border-box}+  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex}+  #list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}+  #list h1{font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}+  #list h1 small{color:var(--mut);font-weight:400}+  #q{margin:10px;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)}+  #rows{overflow:auto;flex:1}+  .row{padding:10px 16px;border-bottom:1px solid var(--edge);cursor:pointer}+  .row:hover{background:#1e222b}+  .row.sel{background:#243049;border-left:3px solid var(--acc);padding-left:13px}+  .row .mpn{font-weight:600}+  .row .sub{color:var(--mut);font-size:12px}+  #view{flex:1;overflow:auto;padding:18px}+  #hint{color:var(--mut);text-align:center;margin-top:20vh}+  .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}+  .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}+  .card h3{margin:0;padding:9px 12px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:1px solid var(--edge)}+  .card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;padding:10px;background:#161b22;overflow:hidden;cursor:grab;touch-action:none;position:relative}+  .card .body svg{max-width:100%;max-height:100%;will-change:transform}+  .card .hint2{position:absolute;bottom:6px;right:8px;font-size:10px;color:#b9c0cc;background:#0d0f14aa;border-radius:5px;padding:1px 6px;pointer-events:none}+  .lfilter{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--edge)}+  .chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--ink);background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:3px 7px;cursor:pointer;user-select:none}+  .chip input{margin:0;cursor:pointer}+  .chip .sw{width:9px;height:9px;border-radius:2px;display:inline-block}+  .chip:has(input:not(:checked)){opacity:.4;text-decoration:line-through}+  model-viewer{width:100%;aspect-ratio:1;height:auto;background:#161b22}+  h2.title{margin:0 0 14px}+  h2.title small{color:var(--mut);font-weight:400;font-size:14px}+  table{width:100%;border-collapse:collapse;margin-top:16px;background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden}+  th,td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--edge);font-size:13px}+  th{color:var(--mut);font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}+  tr:last-child td{border-bottom:none}+  .swatch{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:7px;vertical-align:-1px}+  .badge{font:12px ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:2px 7px}+  .stacktitle{margin:22px 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}+  .err{color:#f87171;padding:16px}+  .card .body [data-num]{cursor:pointer}+  .pinhi .pinvis{stroke:#6ea8fe !important;vector-effect:non-scaling-stroke;stroke-width:3}+  rect.pad.pinhi{stroke:#6ea8fe;vector-effect:non-scaling-stroke;stroke-width:3}+  #tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}+  #tip b{color:var(--acc)}+</style>+</head>+<body>+  <aside id="list">+    <h1>adom-lbr <small>Manager</small></h1>+    <input id="q" placeholder="filter components…">+    <div id="rows"></div>+  </aside>+  <main id="view"><div id="hint">← pick a component from the list to open its viewer</div></main>++<script>+const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly':'#f472b6',Copper:'#f59e0b',Other:'#94a3b8'};+let all=[];+async function loadList(){+  document.getElementById('rows').innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading components from wiki…</div>';+  try{+    const r=await fetch('api/components').then(r=>r.json());+    all=r.components||[];+    render();+  }catch(e){+    document.getElementById('rows').innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load list — retry</div>';+  }+}+function render(){+  const q=document.getElementById('q').value.toLowerCase();+  const rows=document.getElementById('rows');+  rows.innerHTML='';+  all.filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q)).forEach(c=>{+    const d=document.createElement('div');d.className='row';d.dataset.slug=c.slug;+    d.innerHTML=`<div class="mpn">${c.title||c.slug}</div><div class="sub">${c.slug}</div>`;+    d.onclick=()=>openPart(c.slug,d);+    rows.appendChild(d);+  });+}+async function openPart(slug,el){+  document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel'));+  if(el)el.classList.add('sel');+  const v=document.getElementById('view');+  v.innerHTML='<div id="hint">loading '+slug+'…</div>';+  const p=await fetch('api/part/'+slug).then(r=>r.json());+  if(p.status!=='ok'){v.innerHTML='<div class="err">'+(p.error||'failed')+'</div>';return;}+  const glb=p.glb?`<model-viewer src="glb/${slug}" camera-controls auto-rotate ar shadow-intensity="1"></model-viewer>`:'<div class="body" style="color:#888;background:#0d0f14">no 3D model</div>';+  let rows=(p.layers||[]).map(l=>`<tr>+      <td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td>+      <td><span class="badge">${l.kicad}</span></td>+      <td><span class="badge">${l.altium}</span></td></tr>`).join('');+  v.innerHTML=`+    <h2 class="title">${p.mpn} <small>· ${p.pins} pins · ${p.pads} pads</small></h2>+    <div class="cols">+      <div class="card"><h3>Schematic Symbol</h3><div class="body">${p.symbol_svg}<span class="hint2">drag · scroll to zoom</span></div></div>+      <div class="card"><h3>Footprint</h3>+        <div class="lfilter">${(p.layers||[]).map(l=>`<label class="chip"><input type="checkbox" checked data-key="${l.key}"><span class="sw" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}</label>`).join('')}</div>+        <div class="body" id="fpbody">${p.footprint_svg}<span class="hint2">drag · scroll to zoom</span></div></div>+      <div class="card"><h3>3D Model</h3>${glb}</div>+    </div>+    <div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div>+    <table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead>+    <tbody>${rows}</tbody></table>`;+  v.querySelectorAll('.card .body').forEach(makePannable);+  // footprint layer filter — toggle visibility of each layer's SVG elements+  v.querySelectorAll('.lfilter input').forEach(cb=>{+    cb.onchange=()=>{+      v.querySelectorAll('#fpbody svg .ly-'+cb.dataset.key).forEach(el=>{el.style.display=cb.checked?'':'none';});+    };+  });+  setupHighlight(v);+}++// Hover a pin (symbol) or pad (footprint) → highlight it + show its number and+// name/description in a tooltip. Pins/pads carry data-num + data-name.+function setupHighlight(v){+  let tip=document.getElementById('tip');+  if(!tip){tip=document.createElement('div');tip.id='tip';document.body.appendChild(tip);}+  const clear=box=>box.querySelectorAll('.pinhi').forEach(x=>x.classList.remove('pinhi'));+  v.querySelectorAll('.card .body').forEach(box=>{+    box.addEventListener('pointermove',e=>{+      const hit=e.target.closest('[data-num]');+      clear(box);+      if(hit){+        hit.classList.add('pinhi');+        const num=hit.dataset.num, nm=hit.dataset.name;+        const kind=hit.closest('#fpbody')?'Pad':'Pin';+        tip.innerHTML=(nm&&nm!=='~')?`<b>${kind} ${num}</b> · ${nm}`:`<b>${kind} ${num}</b>`;+        tip.style.display='block';+        tip.style.left=(e.clientX+14)+'px';+        tip.style.top=(e.clientY+14)+'px';+      } else { tip.style.display='none'; }+    });+    box.addEventListener('pointerleave',()=>{clear(box);tip.style.display='none';});+  });+}++// Drag-to-pan + scroll-to-zoom on an SVG viewer box (pointer capture so a drag+// that leaves the box keeps tracking). Re-assigning on* handlers avoids stacking.+function makePannable(box){+  const svg=box.querySelector('svg');+  if(!svg) return;+  let tx=0,ty=0,scale=1,sx=0,sy=0,drag=false;+  const apply=()=>{svg.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`;};+  box.onpointerdown=e=>{drag=true;sx=e.clientX-tx;sy=e.clientY-ty;box.setPointerCapture(e.pointerId);box.style.cursor='grabbing';};+  box.onpointermove=e=>{if(drag){tx=e.clientX-sx;ty=e.clientY-sy;apply();}};+  box.onpointerup=box.onpointercancel=()=>{drag=false;box.style.cursor='grab';};+  box.onwheel=e=>{e.preventDefault();const f=e.deltaY<0?1.12:0.89;scale=Math.max(0.25,Math.min(12,scale*f));apply();};+  box.ondblclick=()=>{tx=ty=0;scale=1;apply();};+}+document.getElementById('q').oninput=render;+loadList();+</script>+</body>+</html>
src/manage.rsadded+421
@@ -0,0 +1,421 @@+//! `adom-lbr manage` — the adom-lbr Manager. A small web app (tiny_http →+//! Hydrogen webview) that lists wiki components, and on click opens a viewer:+//! symbol + footprint + 3D, plus the footprint LAYER STACKUP showing what layer+//! each graphic becomes in KiCad and Altium.+//!+//! Routes:+//!   GET  /                      the UI (list + viewer)+//!   GET  /api/components        list of wiki components+//!   GET  /api/part/<slug>       {part, symbol_svg, footprint_svg, layers, glb}+//!   GET  /glb/<slug>            proxied 3D model (GLB)++use altium_codec::{AdomLbrPart, Footprint, FpGraphic, FpLayer, Graphic, Symbol};+use anyhow::Result;+use serde_json::{json, Value};+use std::io::Cursor;+use std::process::Command;+use std::sync::Mutex;+use tiny_http::{Header, Method, Response, Server};++const WIKI: &str = "https://wiki.adom.inc";++/// The wiki component list is slow (~8s) but stable — fetch it once, serve+/// instantly after. First click blocks; the rest are snappy.+static LIST_CACHE: Mutex<Option<String>> = Mutex::new(None);++fn cached_list() -> String {+    let mut c = LIST_CACHE.lock().unwrap();+    if let Some(s) = c.as_ref() {+        return s.clone();+    }+    let s = list_components().to_string();+    *c = Some(s.clone());+    s+}++pub fn run(port: u16) -> Result<()> {+    let server = Server::http(format!("0.0.0.0:{port}"))+        .map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;+    println!("OK: adom-lbr Manager on http://localhost:{port}");+    println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`.");+    println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer.");+    for mut req in server.incoming_requests() {+        let method = req.method().clone();+        let url = req.url().to_string();+        let resp = route(&method, &url);+        let _ = req.respond(resp);+    }+    Ok(())+}++fn route(method: &Method, url: &str) -> Response<Cursor<Vec<u8>>> {+    let path = url.split('?').next().unwrap_or(url);+    match (method, path) {+        (Method::Get, "/") => html(INDEX_HTML),+        (Method::Get, "/api/components") => json_resp(&cached_list()),+        (Method::Get, p) if p.starts_with("/api/part/") => {+            let slug = &p["/api/part/".len()..];+            json_resp(&get_part(slug).to_string())+        }+        (Method::Get, p) if p.starts_with("/glb/") => {+            let slug = &p["/glb/".len()..];+            match wiki_bytes(slug, &format!("{}.glb", up(slug))) {+                Some(b) => Response::from_data(b).with_header(hdr("model/gltf-binary")),+                None => Response::from_string("no glb").with_status_code(404),+            }+        }+        _ => Response::from_string("not found").with_status_code(404),+    }+}++// ---------------------------------------------------------------------------+// Wiki access (via curl — no HTTP-client dep)+// ---------------------------------------------------------------------------++fn curl(url: &str) -> Option<Vec<u8>> {+    let out = Command::new("curl")+        .args(["-s", "-L", "--max-time", "12", url])+        .output()+        .ok()?;+    if out.status.success() && !out.stdout.is_empty() {+        Some(out.stdout)+    } else {+        None+    }+}++fn wiki_text(slug: &str, file: &str) -> Option<String> {+    let url = format!("{WIKI}/api/v1/pages/{slug}/files/{file}");+    curl(&url).map(|b| String::from_utf8_lossy(&b).to_string())+}+fn wiki_bytes(slug: &str, file: &str) -> Option<Vec<u8>> {+    curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{file}"))+}++/// Uppercase-MPN filename stem for a slug (files are named `<MPN>.ext`).+fn up(slug: &str) -> String {+    slug.to_uppercase()+}++/// List component pages from the wiki API (fast + reliable; `adompkg` is dead).+fn list_components() -> Value {+    let mut items = Vec::new();+    if let Some(body) = curl(&format!("{WIKI}/api/v1/pages?type=component&limit=300")) {+        if let Ok(v) = serde_json::from_slice::<Value>(&body) {+            if let Some(pages) = v.get("pages").and_then(|p| p.as_array()) {+                for pg in pages {+                    let slug = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("").to_string();+                    if slug.is_empty() {+                        continue;+                    }+                    let title = pg.get("title").and_then(|s| s.as_str()).unwrap_or(&slug).to_string();+                    let brief = pg.get("brief").and_then(|s| s.as_str()).unwrap_or("").to_string();+                    items.push(json!({"slug": slug, "title": title, "brief": brief}));+                }+            }+        }+    }+    if items.is_empty() {+        for s in ["cl21a476mqynnne", "cq03saf4702t5e", "lqm18pnr47mfhd", "machinepinlargestandard", "machinecontactmedium"] {+            items.push(json!({"slug": s, "title": up(s), "brief": ""}));+        }+    }+    json!({ "status": "ok", "components": items,+        "hints": ["click a component to open its viewer"] })+}++/// Fetch a component and build its adom-lbr part FRESH on every call (nothing is+/// cached), then render symbol/footprint SVG on the fly. The canonical adom-lbr+/// JSON is the primary source; if the page has none, we import the KiCad files.+fn get_part(slug: &str) -> Value {+    let mpn = up(slug);+    let (part, src): (AdomLbrPart, &str) = match load_part(slug, &mpn) {+        Ok(v) => v,+        Err(e) => {+            return json!({"status":"error","error":e,+                "hints":["the page needs an adom-lbr JSON, or <MPN>.kicad_sym + <MPN>.kicad_mod"]})+        }+    };+    let has_glb = wiki_bytes(slug, &format!("{mpn}.glb")).is_some();+    json!({+        "status": "ok",+        "slug": slug,+        "mpn": part.mpn,+        "source": src,                       // "adom-lbr json" or "kicad (generated)"+        "symbol_svg": symbol_svg(&part.symbol),   // rendered on the fly, every click+        "footprint_svg": footprint_svg(&part.footprint, &pin_names(&part.symbol)),+        "layers": layer_stackup(&part.footprint),+        "glb": has_glb,+        "pins": part.symbol.pins.len(),+        "pads": part.footprint.pads.len(),+    })+}++/// Load a part, preferring the canonical adom-lbr JSON on the page; else generate+/// it from the KiCad files. Returns the part + a label of where it came from.+fn load_part(slug: &str, mpn: &str) -> Result<(AdomLbrPart, &'static str), String> {+    // 1) canonical adom-lbr JSON (try the common filenames)+    for name in [+        format!("{mpn}.adom-lbr.json"),+        format!("{mpn}.adomlbr.json"),+        "adom-lbr.json".to_string(),+        format!("{mpn}.json"),+    ] {+        if let Some(txt) = wiki_text(slug, &name) {+            if let Ok(p) = serde_json::from_str::<AdomLbrPart>(&txt) {+                return Ok((p, "adom-lbr json"));+            }+        }+    }+    // 2) fall back to the KiCad source files+    match (+        wiki_text(slug, &format!("{mpn}.kicad_sym")),+        wiki_text(slug, &format!("{mpn}.kicad_mod")),+    ) {+        (Some(s), Some(m)) => altium_codec::import_kicad(&s, &m, mpn)+            .map(|p| (p, "kicad (generated)"))+            .map_err(|e| e.to_string()),+        _ => Err(format!("no adom-lbr JSON or KiCad files on {slug}")),+    }+}++// ---------------------------------------------------------------------------+// Layer stackup — the cross-EDA mapping (KiCad + Altium)+// ---------------------------------------------------------------------------++fn role_name(l: &FpLayer) -> &'static str {+    match l {+        FpLayer::Silk => "Silkscreen",+        FpLayer::Courtyard => "Courtyard",+        FpLayer::Fab => "Fabrication / Assembly",+        FpLayer::Copper => "Copper",+        FpLayer::Other { .. } => "Other",+    }+}+fn kicad_layer(l: &FpLayer) -> &'static str {+    match l {+        FpLayer::Silk => "F.SilkS",+        FpLayer::Courtyard => "F.CrtYd",+        FpLayer::Fab => "F.Fab",+        FpLayer::Copper => "F.Cu",+        FpLayer::Other { .. } => "F.Fab",+    }+}+/// Barrett's house convention (from the C0402 reference): outline+silk → Mech13+/// "Assembly Top", courtyard → Mech15 "Courtyard Top".+fn altium_layer(l: &FpLayer) -> &'static str {+    match l {+        FpLayer::Silk => "Mechanical 13 (Assembly Top)",+        FpLayer::Fab => "Mechanical 13 (Assembly Top)",+        FpLayer::Courtyard => "Mechanical 15 (Courtyard Top)",+        FpLayer::Copper => "Top Layer",+        FpLayer::Other { .. } => "Mechanical 1",+    }+}++/// CSS-safe token per layer role — used to class SVG elements so the footprint+/// view can toggle layers on/off.+fn layer_key(l: &FpLayer) -> &'static str {+    match l {+        FpLayer::Silk => "silk",+        FpLayer::Courtyard => "courtyard",+        FpLayer::Fab => "fab",+        FpLayer::Copper => "copper",+        FpLayer::Other { .. } => "other",+    }+}++fn layer_stackup(fp: &Footprint) -> Value {+    use std::collections::BTreeMap;+    // count graphics per role (+ the pad copper layer)+    let mut counts: BTreeMap<&str, (FpLayer, usize)> = BTreeMap::new();+    if !fp.pads.is_empty() {+        counts.insert("Copper", (FpLayer::Copper, fp.pads.len()));+    }+    for g in &fp.graphics {+        let l = match g {+            FpGraphic::Line { layer, .. }+            | FpGraphic::Rect { layer, .. }+            | FpGraphic::Circle { layer, .. }+            | FpGraphic::Arc { layer, .. } => *layer,+        };+        let e = counts.entry(role_name(&l)).or_insert((l, 0));+        e.1 += 1;+    }+    let rows: Vec<Value> = counts+        .values()+        .map(|(l, n)| {+            json!({+                "role": role_name(l),+                "key": layer_key(l),+                "count": n,+                "kicad": kicad_layer(l),+                "altium": altium_layer(l),+            })+        })+        .collect();+    Value::Array(rows)+}++// ---------------------------------------------------------------------------+// SVG renderers (symbol in mils→viewport, footprint in mm)+// ---------------------------------------------------------------------------++/// Layer → SVG colour (KiCad-ish palette for the footprint view).+fn layer_color(l: &FpLayer) -> &'static str {+    match l {+        FpLayer::Silk => "#d8b4fe",+        FpLayer::Courtyard => "#4ade80",+        FpLayer::Fab => "#f472b6",+        FpLayer::Copper => "#f59e0b",+        FpLayer::Other { .. } => "#94a3b8",+    }+}++fn symbol_svg(sym: &Symbol) -> String {+    // bounds in mils over pins + graphics+    let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    let mut upd = |x: f64, y: f64| {+        a = a.min(x);+        b = b.min(y);+        c = c.max(x);+        d = d.max(y);+    };+    for p in &sym.pins {+        upd(p.x as f64, p.y as f64);+    }+    for g in &sym.graphics {+        match g {+            Graphic::Rect { x1, y1, x2, y2, .. } => { upd(*x1, *y1); upd(*x2, *y2); }+            Graphic::Circle { cx, cy, r, .. } | Graphic::Arc { cx, cy, r, .. } => { upd(cx - r, cy - r); upd(cx + r, cy + r); }+            Graphic::Polyline { points, .. } => { for pt in points { upd(pt[0], pt[1]); } }+        }+    }+    if a > c { return empty_svg("no symbol"); }+    let pad = 50.0;+    let (a, b, c, d) = (a - pad, b - pad, c + pad, d + pad);+    // SVG y-down; symbol y-up → flip y+    let fy = |y: f64| -y;+    let mut body = String::new();+    for g in &sym.graphics {+        match g {+            Graphic::Rect { x1, y1, x2, y2, filled, .. } => {+                let fill = if *filled { "#e35d5d" } else { "none" };+                body += &format!("<rect x='{:.1}' y='{:.1}' width='{:.1}' height='{:.1}' fill='{}' stroke='#e35d5d' stroke-width='4'/>",+                    x1.min(*x2), fy(y1.max(*y2)), (x2 - x1).abs(), (y2 - y1).abs(), fill);+            }+            Graphic::Circle { cx, cy, r, .. } => {+                body += &format!("<circle cx='{:.1}' cy='{:.1}' r='{:.1}' fill='none' stroke='#e35d5d' stroke-width='4'/>", cx, fy(*cy), r);+            }+            Graphic::Arc { cx, cy, r, start, end, .. } => {+                let p = |deg: f64| { let t = deg.to_radians(); (cx + r * t.cos(), fy(cy + r * t.sin())) };+                let (sx, sy) = p(*start); let (ex, ey) = p(*end);+                let large = if ((end - start + 360.0) % 360.0) > 180.0 { 1 } else { 0 };+                body += &format!("<path d='M{:.1} {:.1} A{:.1} {:.1} 0 {} 0 {:.1} {:.1}' fill='none' stroke='#e35d5d' stroke-width='4'/>", sx, sy, r, r, large, ex, ey);+            }+            Graphic::Polyline { points, .. } => {+                let pts: String = points.iter().map(|pt| format!("{:.1},{:.1} ", pt[0], fy(pt[1]))).collect();+                body += &format!("<polyline points='{}' fill='none' stroke='#e35d5d' stroke-width='4'/>", pts.trim());+            }+        }+    }+    for p in &sym.pins {+        let len = p.length.unwrap_or(200) as f64;+        let (dx, dy) = match (p.rotation / 90) % 4 { 0 => (len, 0.0), 1 => (0.0, len), 2 => (-len, 0.0), _ => (0.0, -len) };+        let (x2, y2) = (p.x as f64 + dx, p.y as f64 + dy);+        let (x1, y1) = (p.x as f64, fy(p.y as f64));+        // grouped so hover highlights the pin + reads its number/name; the fat+        // transparent line is the (easy-to-hit) hover target.+        body += &format!(+            "<g class='pingroup' data-num='{}' data-name='{}'>\+             <line class='pinvis' x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='#dfe4ec' stroke-width='4'/>\+             <line x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='transparent' stroke-width='26' pointer-events='stroke'/></g>",+            esc_attr(&p.number), esc_attr(&p.name), x1, y1, x2, fy(y2), x1, y1, x2, fy(y2)+        );+    }+    format!(+        "<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.0} {:.0} {:.0} {:.0}' style='width:100%;height:100%'>{}</svg>",+        a, -d, c - a, d - b, body+    )+}++/// number → pin name, so a pad can show the connected pin's name on hover.+fn pin_names(sym: &Symbol) -> std::collections::HashMap<String, String> {+    sym.pins+        .iter()+        .filter(|p| !p.name.is_empty() && p.name != "~")+        .map(|p| (p.number.clone(), p.name.clone()))+        .collect()+}++fn footprint_svg(fp: &Footprint, names: &std::collections::HashMap<String, String>) -> String {+    let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    let mut upd = |x: f64, y: f64| { a = a.min(x); b = b.min(y); c = c.max(x); d = d.max(y); };+    for p in &fp.pads {+        upd(p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0);+        upd(p.x_mm + p.w_mm / 2.0, p.y_mm + p.h_mm / 2.0);+    }+    for g in &fp.graphics {+        match g {+            FpGraphic::Line { x1, y1, x2, y2, .. } | FpGraphic::Rect { x1, y1, x2, y2, .. } => { upd(*x1, *y1); upd(*x2, *y2); }+            FpGraphic::Circle { cx, cy, r, .. } | FpGraphic::Arc { cx, cy, r, .. } => { upd(cx - r, cy - r); upd(cx + r, cy + r); }+        }+    }+    if a > c { return empty_svg("no footprint"); }+    let pad = 0.4;+    let (a, b, c, d) = (a - pad, b - pad, c + pad, d + pad);+    let mut body = String::new();+    // graphics first (under pads)+    for g in &fp.graphics {+        let (layer, s) = match g {+            FpGraphic::Line { layer, x1, y1, x2, y2, width } =>+                (layer, format!("<line class='ly-{}' x1='{:.3}' y1='{:.3}' x2='{:.3}' y2='{:.3}' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), x1, y1, x2, y2, width.max(0.05))),+            FpGraphic::Rect { layer, x1, y1, x2, y2, width } =>+                (layer, format!("<rect class='ly-{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), x1.min(*x2), y1.min(*y2), (x2-x1).abs(), (y2-y1).abs(), width.max(0.05))),+            FpGraphic::Circle { layer, cx, cy, r, width } =>+                (layer, format!("<circle class='ly-{}' cx='{:.3}' cy='{:.3}' r='{:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), cx, cy, r, width.max(0.05))),+            FpGraphic::Arc { layer, cx, cy, r, start, end, width } => {+                let p = |deg: f64| { let t = deg.to_radians(); (cx + r * t.cos(), cy + r * t.sin()) };+                let (sx, sy) = p(*start); let (ex, ey) = p(*end);+                (layer, format!("<path class='ly-{}' d='M{:.3} {:.3} A{:.3} {:.3} 0 0 1 {:.3} {:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), sx, sy, r, r, ex, ey, width.max(0.05)))+            }+        };+        body += &s.replace("{}", layer_color(layer));+    }+    for p in &fp.pads {+        let col = layer_color(&FpLayer::Copper);+        let rx = if p.shape == "circle" || p.shape == "roundrect" { p.w_mm.min(p.h_mm) * 0.25 } else { 0.0 };+        // the connected pin name (if the symbol has one) becomes the pad's label+        let name = names.get(&p.number).cloned().unwrap_or_default();+        body += &format!("<rect class='ly-copper pad' data-num='{}' data-name='{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' rx='{:.3}' fill='{}' opacity='0.9'/>",+            esc_attr(&p.number), esc_attr(&name), p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0, p.w_mm, p.h_mm, rx, col);+    }+    format!(+        "<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.3} {:.3} {:.3} {:.3}' style='width:100%;height:100%'>{}</svg>",+        a, b, c - a, d - b, body+    )+}++fn esc_attr(s: &str) -> String {+    s.replace('&', "&amp;").replace('\'', "&#39;").replace('"', "&quot;").replace('<', "&lt;")+}++fn empty_svg(msg: &str) -> String {+    format!("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 40'><text x='50' y='24' text-anchor='middle' fill='#888' font-size='8'>{msg}</text></svg>")+}++// ---------------------------------------------------------------------------+// HTTP helpers+// ---------------------------------------------------------------------------++fn hdr(ct: &str) -> Header {+    Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()+}+fn html(s: &str) -> Response<Cursor<Vec<u8>>> {+    Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))+}+fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> {+    Response::from_string(s).with_header(hdr("application/json"))+}++const INDEX_HTML: &str = include_str!("manage.html");
src/server.rs+4−73
@@ -8,7 +8,7 @@ use anyhow::Result; use serde_json::{json, Value}; use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering};-use std::sync::{Mutex, OnceLock};+use std::sync::Mutex; use tiny_http::{Header, Method, Response, Server};  #[derive(Default, Clone)]@@ -28,8 +28,6 @@ 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);@@ -42,32 +40,6 @@ 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);-}--/// Builds a chip's viewer HTML on demand, keyed by MPN — lets `GET /?chip=<MPN>`-/// switch chips against a chip-fetcher library root.-type ChipBuilder = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;-static CHIP_BUILDER: OnceLock<ChipBuilder> = OnceLock::new();-static CHIP_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);--/// Register the on-the-fly chip switcher (the library root's builder closure).-pub fn set_chip_switcher(builder: ChipBuilder) {-    let _ = CHIP_BUILDER.set(builder);-}--/// HTML for `GET /?chip=<MPN>`: build (and cache) that chip's viewer, else None.-fn chip_html(mpn: &str) -> Option<String> {-    if let Some(h) = CHIP_CACHE.lock().unwrap().as_ref().and_then(|m| m.get(mpn).cloned()) {-        return Some(h);-    }-    let html = (CHIP_BUILDER.get()?)(mpn)?;-    CHIP_CACHE.lock().unwrap().get_or_insert_with(HashMap::new).insert(mpn.to_string(), html.clone());-    Some(html)-}- /// Parse + lint an `.lbr` (EAGLE XML) into the displayable app state. pub fn analyze_lbr(content: &str) -> AppState {     let lint = crate::lint_lbr(content);@@ -100,29 +72,6 @@ fn extract_attr(content: &str, tag: &str) -> Option<String> {     Some(rest[k..j].to_string()) } -/// Minimal percent-decoder for the `chip=` query value (handles `%XX` and `+`).-fn urldecode(s: &str) -> String {-    let b = s.as_bytes();-    let mut out = Vec::with_capacity(b.len());-    let mut i = 0;-    while i < b.len() {-        match b[i] {-            b'%' if i + 2 < b.len() => {-                if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {-                    out.push(v);-                    i += 3;-                    continue;-                }-                out.push(b'%');-                i += 1;-            }-            b'+' => { out.push(b' '); i += 1; }-            c => { out.push(c); i += 1; }-        }-    }-    String::from_utf8_lossy(&out).into_owned()-}- fn pairs_json(v: &[(String, String)]) -> Value {     Value::Array(v.iter().map(|(c, m)| json!({ "code": c, "message": m })).collect()) }@@ -155,9 +104,7 @@ pub fn run(port: u16) -> Result<()> {     register_port(port, "adom-lbr", "EAGLE .lbr linter live app (AI-drivable)");      for mut req in server.incoming_requests() {-        // Full request target incl. query — route() splits path/query itself-        // (needed for `/?chip=<MPN>`).-        let url = req.url().to_string();+        let url = req.url().split('?').next().unwrap_or("/").to_string();         let method = req.method().clone();         let mut body = String::new();         if matches!(method, Method::Post) {@@ -205,24 +152,8 @@ fn hd_post(body: &str) { }  fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {-    // Split path from query so `/?chip=ADS1115IDGSR` still routes to "/".-    let (path, query) = url.split_once('?').unwrap_or((url, ""));-    match (method, path) {-        (Method::Get, "/") | (Method::Get, "/index.html") => {-            // `?chip=<MPN>` switches chips against the library root (on the fly).-            if let Some(chip) = query.split('&').find_map(|kv| kv.strip_prefix("chip=")) {-                let chip = urldecode(chip);-                if let Some(h) = chip_html(&chip) {-                    return html(&h);-                }-            }-            // 3-column viewer when render assets are loaded; lint card otherwise.-            match &*EMBED_HTML.lock().unwrap() {-                Some(h) => html(h),-                None => html(APP_HTML),-            }-        }-        (Method::Get, "/lint") => html(APP_HTML),+    match (method, url) {+        (Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),         (Method::Get, "/favicon.svg") => svg(FAVICON),         (Method::Get, "/state") => json_resp(&state_json()),         (Method::Post, "/load") => {