← Commit history
Cargo.tomladded+25
@@ -0,0 +1,25 @@+[package]+name = "adom-symbol"+version = "0.1.6"+edition = "2021"+description = "Create and preview KiCad/Fusion 360 schematic symbols with interactive viewer"++[[bin]]+name = "adom-symbol"+path = "src/main.rs"++[dependencies]+anyhow = "1"+regex = "1"+clap = { version = "4", features = ["derive"] }+serde = { version = "1", features = ["derive"] }+serde_json = "1"+reqwest = { version = "0.12", features = ["blocking", "json"] }+tiny_http = "0.12"+include_dir = "0.7"+clap_complete = "4"++[profile.release]+opt-level = "z"+lto = true+strip = true
README.md+24−9
@@ -1,27 +1,42 @@ # adom-symbol -**A live, AI-drivable KiCad schematic-symbol studio — with the chip's real 3D model marked right on the symbol.**+**A live, AI-drivable KiCad schematic-symbol studio — pick the source, lay out the pins, and emboss the chip's real 3D model right onto the symbol, then send it straight to KiCad, Fusion, Altium or OrCAD.**  adom-symbol renders a `.kicad_sym` through the shared `service-kicad` (no local KiCad), themes it for dark mode, and gives you a studio to shape it: pick the **source** (auto-grouped from a datasheet via ds2sf, the as-fetched manufacturer symbol, or a vendor variant), the **pin layout** (left/right or square), **grouping** (by function, GPIO split by port), and where the **reference** and **chip name** sit — with automatic overlap avoidance. Standalone Rust, no Node, no gallia. +## The Layout studio++![The Layout studio panel](docs/studio.png)++Every control lives in one floating panel. **Source** switches between auto-grouped (from the datasheet via ds2sf), the manufacturer symbol, or a vendor variant — the symbol re-renders in place. **Layout** flips between a left/right pinout and a square quad. **Groups** clusters pins by function (and splits GPIO by port). **Ref** and **Name** each get a direction pad and an inside/outside toggle, with overlap-aware placement so labels never collide. **3D chip** picks the render style, **Send to** exports, and every choice is sticky between sessions. On a wide screen the panel is a draggable floating toolbar; on a phone it folds into an inline panel.+ ## The signature feature: the chip on the symbol -Toggle a **3D chip overlay** into the body of the symbol — the part's real STEP model, rendered at the orientation you picked, sitting behind the pin text. Choose a shaded isometric render or a crisp black-and-white **vector outline** (OCCT hidden-line removal) in several styles.+![The 3D chip embossed on the symbol body](docs/chip3d.png)++Toggle a **3D chip overlay** into the body of the symbol — the part's real STEP model, rendered at the orientation you picked, sitting behind the pin text. Choose a shaded isometric render or a crisp black-and-white **vector outline** (OCCT hidden-line removal) in several styles (thin / bold / blueprint / teal / dark). -The part number can be **"laser-marked" onto the chip's top face** — real embossed geometry in the STEP (so it travels to KiCad), drawn in correct perspective, fit to the package, with an optional second line for a variant suffix. Show the name on the chip, in the schematic, both, or neither.+The part number is **"laser-marked" onto the chip's top face** — real embossed geometry in the STEP (so it travels to KiCad), drawn in correct perspective, fit to the package, with an optional second line for a variant suffix. Show the name on the chip, in the schematic, both, or neither. -## Studio+## The rendered symbol -- **Source** — auto / ds2sf / manufacturer / each vendor variant (SnapEDA, Ultra Librarian, …).-- **Layout** — left-right or square; grouping on/off.-- **Ref / Name placement** — top/bottom/left/right/center, inside or outside, overlap-aware.-- **3D chip** — off / iso / thin / bold / blueprint / teal / dark.-- On a wide screen the studio becomes a **draggable floating toolbar**; on a phone it's an inline panel. Choices are sticky.+![The full themed symbol with grouped pins](docs/symbol.png)++The finished symbol: pins grouped and labelled, the reference and name placed clear of the pins, and the embossed package centred in the body — all themed onto the dark canvas and ready to drop into a schematic. This is exactly what `Send to → KiCad` writes out, and what the Fusion/Altium/OrCAD `.lbr` is built from.  ## Send to  KiCad (native symbol), Fusion 360 / Altium / OrCAD (a true `.lbr` built by **adom-lbr**). +## Open source — check it out, vibe-code it, send it back++adom-symbol is MIT and **fully open**. The complete Rust source is in this page's **Files** tab — browse it, or grab `src/` + `Cargo.toml`.++1. **Build it.** `cargo build --release` drops a standalone binary (no Node, no gallia).+2. **Point your install at your checkout.** `adompkg link john/adom-symbol ./adom-symbol` so the `adom-symbol` you run is your local copy.+3. **Vibe-code a change.** Open the folder in Claude Code and describe what you want — a new layout mode, a chip-render style, another Send-to target. The app is small and single-purpose by design.+4. **Send it back.** Open a **pull request** on this page (the wiki reviews and merges per-repo PRs), or start a discussion thread. Good additions get folded into the shipped app.+ ## Part of the Adom EDA family  Opened from **chip-fetcher**; pairs with **adom-footprint**, **adom-lbr**, and **ds2sf**.
docs/chip3d.pngadded
⋯ 1 unchanged line ⋯
docs/hero.pngadded
⋯ 1 unchanged line ⋯
docs/studio.pngadded
⋯ 1 unchanged line ⋯
docs/symbol.pngadded
⋯ 1 unchanged line ⋯
src/app.htmladded+704
@@ -0,0 +1,704 @@+<!doctype html>+<html lang="en">+<head>+  <meta charset="utf-8">+  <meta name="viewport" content="width=device-width, initial-scale=1">+  <title>adom-symbol</title>+  <link rel="icon" type="image/svg+xml" href="favicon.svg">+  <style>+    :root{+      --bg:#0d1117;--panel:#11161f;--panel2:#0f141c;--border:#222b38;--border2:#2b3645;+      --text:#e6edf3;--muted:#8b949e;--text3:#5b6675;--accent:#00b8b1;--accent2:#00e6dc;+    }+    *{box-sizing:border-box}+    html,body{margin:0;height:100%}+    body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;display:flex;flex-direction:column;overflow:hidden}++    header.app{height:44px;flex:0 0 44px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;padding:0 12px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,#11161f,#0d1117);z-index:5}+    header.app .ident{display:flex;align-items:center;gap:8px;min-width:0}+    header.app .ident svg{width:16px;height:16px;color:var(--accent2);flex:0 0 16px}+    header.app .app-name{font-weight:700;font-size:13px;white-space:nowrap}+    header.app .app-sub{color:var(--muted);font-size:12px;white-space:nowrap}+    header.app .app-sub::before{content:"·";margin:0 6px;color:var(--text3)}+    header.app .subject{justify-self:center;display:flex;align-items:baseline;gap:9px;min-width:0}+    header.app .subject-name{font-weight:600;font-size:13px;max-width:34vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+    header.app .subject-meta{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);white-space:nowrap}+    header.app .actions{display:flex;align-items:center;gap:5px}+    .tbtn{display:inline-flex;align-items:center;justify-content:center;height:26px;min-width:26px;padding:0 7px;font-size:12px;font-weight:600;color:var(--muted);background:rgba(0,184,177,.10);border:1px solid rgba(0,184,177,.22);border-radius:6px;cursor:pointer;transition:.13s;white-space:nowrap}+    .tbtn:hover{background:rgba(0,184,177,.18);color:#fff;border-color:rgba(0,184,177,.4)}+    .tbtn.active{background:rgba(0,184,177,.22);color:var(--accent2);border-color:rgba(0,184,177,.5)}+    .zlabel{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);min-width:40px;text-align:center;font-variant-numeric:tabular-nums}+    .vsep{width:1px;height:20px;background:var(--border2);margin:0 3px}+    .conn{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-left:2px}+    .conn .dot{width:8px;height:8px;border-radius:50%;background:var(--text3);transition:.2s}+    .conn.live .dot{background:var(--accent2);box-shadow:0 0 8px var(--accent2)}++    /* ── responsive header: drop secondary controls as width shrinks ── */+    @media (max-width:820px){+      header.app{gap:8px;padding:0 8px}+      header.app .actions{gap:4px}+      .app-sub{display:none}+      .subject-meta{display:none}+      #zlbl,#zone,#bboxb,#hitsb,#connT{display:none}+    }+    @media (max-width:560px){+      #zin,#zout{display:none}                          /* pinch handles zoom */+      .subject-name{max-width:38vw;font-size:12px}+      .tbtn{height:30px;min-width:32px;padding:0 10px}  /* bigger tap targets */+    }+    @media (max-width:380px){ .app-name{display:none} } /* very narrow: icon only */++    /* ── layout studio panel ── */+    #studio{position:absolute;top:44px;right:10px;left:auto;z-index:8;width:min(94vw,420px);+      background:rgba(17,22,31,.97);backdrop-filter:blur(18px);border:1px solid var(--border2);+      border-radius:14px;padding:12px 14px;box-shadow:0 24px 80px rgba(0,0,0,.6);+      display:flex;flex-direction:column;gap:9px;animation:pop .14s ease}+    @keyframes pop{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}+    #studio[hidden]{display:none}+    /* Drag-handle header — only meaningful when the studio floats (wide screens). */+    #studio .studio-head{display:none}+    /* Wide screens (laptop/desktop): the studio becomes a movable floaty HUD off+       to the side — draggable by its header, collapsible, never pinned to an edge.+       (per adom-ui-design / floating-windows) */+    @media (min-width:821px){+      #studio{position:fixed;top:56px;right:18px;max-height:calc(100vh - 80px);overflow:auto;animation:none}+      #studio .studio-head{display:flex;align-items:center;gap:8px;margin:-4px -4px 4px;padding:3px 2px 8px;+        border-bottom:1px solid var(--border);cursor:grab;position:sticky;top:0;background:rgba(17,22,31,.97);z-index:2}+      #studio .studio-head:active{cursor:grabbing}+      #studio .studio-head .grip{color:var(--text3);font-size:13px;letter-spacing:1px}+      #studio .studio-head .studio-title{flex:1;font-size:12px;font-weight:700}+      #studio .studio-head .shbtn{width:18px;height:18px;display:grid;place-items:center;border:0;background:transparent;+        color:var(--muted);cursor:pointer;border-radius:4px;font-size:12px}+      #studio .studio-head .shbtn:hover{background:rgba(255,255,255,.08);color:var(--text)}+      #studio.collapsed > .srow{display:none}+    }+    #studio .srow{display:flex;align-items:center;gap:10px;justify-content:space-between}+    #studio .slab{font-size:12px;font-weight:600;color:var(--muted);flex:0 0 auto;cursor:help}+    #studio .seg{display:inline-flex;background:var(--panel2);border:1px solid var(--border);border-radius:9px;padding:2px;gap:2px;flex-wrap:wrap;justify-content:flex-end}+    #studio .seg button{appearance:none;border:0;background:transparent;color:var(--muted);font-size:11.5px;font-weight:600;+      padding:5px 10px;border-radius:7px;cursor:pointer;transition:.12s;white-space:nowrap}+    #studio .seg button:hover{color:#fff;background:rgba(0,184,177,.12)}+    #studio .seg button.on{background:linear-gradient(180deg,#00b8b1,#009a94);color:#04201f;box-shadow:0 2px 8px rgba(0,184,177,.35)}+    @media (max-width:560px){ #studio{right:6px;left:6px;width:auto} #studio .srow{flex-wrap:wrap;gap:6px} }++    main{flex:1;position:relative;min-height:0}+    #stage{position:absolute;inset:0;overflow:hidden;cursor:grab;background:+      radial-gradient(120% 120% at 50% 30%,rgba(0,184,177,.05),transparent 60%),+      linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px) 0 0/22px 22px,+      linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px) 0 0/22px 22px}+    #stage.no-grid{background:radial-gradient(120% 120% at 50% 30%,rgba(0,184,177,.05),transparent 60%)}+    #stage.drag{cursor:grabbing}+    #content{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}+    #content svg{display:block}+    .hit{fill:rgba(0,230,220,0);stroke:none;cursor:pointer}+    .hit.show{fill:rgba(0,230,220,.06);stroke:#00e6dc;stroke-width:.05;stroke-dasharray:.3,.2}+    #pinmark{pointer-events:none;fill:rgba(0,230,220,.18);stroke:#00e6dc;stroke-width:.12;opacity:0;transition:opacity .1s}+    #bbox{pointer-events:none;fill:none;stroke:#00e6dc;stroke-width:.08;stroke-dasharray:.4,.3;opacity:0}+    /* highlight the selected pin's NAME + NUMBER glyphs and its wire (high specificity to beat the embedded white-text rule) */+    #content svg g.stroked-text.hl path{stroke:#00e6dc !important;fill:#00e6dc !important}+    #content svg path.hl{stroke:#00e6dc !important;stroke-width:.22 !important}++    .empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:13px;text-align:center;padding:30px}++    #tip{position:fixed;z-index:9999;max-width:300px;background:rgba(22,27,34,.97);backdrop-filter:blur(14px);border:1px solid rgba(0,184,177,.3);border-radius:10px;padding:9px 12px;color:var(--text);font-size:12px;line-height:1.5;pointer-events:none;opacity:0;transition:opacity .12s;box-shadow:0 12px 40px rgba(0,0,0,.55)}+    #tip.show{opacity:1}+    #tip.sticky{pointer-events:auto;user-select:text;border-color:var(--accent2)}+    #tip .r1{display:flex;align-items:baseline;gap:8px;justify-content:space-between}+    #tip .nm{font-weight:700;font-size:13px}+    #tip .pn{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted)}+    #tip .badge{display:inline-block;margin-top:5px;font-size:10px;font-weight:700;padding:2px 7px;border-radius:5px;letter-spacing:.2px}+    #tip .desc{margin-top:6px;color:#aeb6c2;font-size:11.5px}+    #tip .src{margin-top:7px;padding-top:6px;border-top:1px solid var(--border);font-size:11px;color:var(--muted)}+    #tip .src a{color:var(--accent2);text-decoration:none}+    #tip .hint{margin-top:6px;font-size:10px;color:var(--text3)}+    #tip.sticky .hint{display:none}++    footer{flex:0 0 auto;display:flex;align-items:center;gap:0;height:26px;border-top:1px solid var(--border);background:var(--panel2);font-size:11px;color:var(--muted);padding:0 12px;overflow:hidden}+    footer .it{display:flex;align-items:center;gap:5px;white-space:nowrap}+    footer .it+.it::before{content:"";width:1px;height:13px;background:var(--border2);margin:0 11px}+    footer .k{color:var(--text3);text-transform:uppercase;font-size:9px;letter-spacing:.4px}+    footer .v{color:var(--text)}+    footer a{color:var(--accent2);text-decoration:none}+    footer .cur{margin-left:auto;font-family:ui-monospace,monospace;color:var(--text3)}++    #toast{position:fixed;left:50%;bottom:30px;transform:translateX(-50%) translateY(20px);z-index:100000;+      max-width:min(92vw,460px);background:rgba(17,22,31,.98);backdrop-filter:blur(16px);border:1px solid var(--border2);+      border-radius:12px;padding:12px 16px;color:var(--text);font-size:13px;line-height:1.45;box-shadow:0 16px 50px rgba(0,0,0,.6);+      opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;text-align:center}+    #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}+    #toast.ok{border-color:rgba(46,160,67,.5)}#toast.err{border-color:rgba(248,81,73,.5)}#toast.warn{border-color:rgba(210,153,34,.5)}+    #bridgeModal{position:fixed;inset:0;z-index:100001;display:none;align-items:center;justify-content:center;background:rgba(2,5,10,.62);backdrop-filter:blur(4px);padding:20px}+    #bridgeModal.show{display:flex;animation:pop .16s ease}+    #bridgeModal .bm-card{max-width:min(94vw,440px);background:rgba(17,22,31,.99);border:1px solid var(--border2);border-radius:16px;padding:22px 22px 18px;box-shadow:0 30px 90px rgba(0,0,0,.7);text-align:center}+    #bridgeModal .bm-icon{font-size:34px;margin-bottom:6px}+    #bridgeModal .bm-title{font-size:16px;font-weight:700;margin-bottom:8px}+    #bridgeModal .bm-body{font-size:13px;line-height:1.55;color:var(--muted);margin-bottom:16px}+    #bridgeModal .bm-actions{display:flex;flex-direction:column;gap:9px}+    #bridgeModal .bm-go{display:block;text-decoration:none;background:linear-gradient(180deg,#00b8b1,#009a94);color:#04201f;font-weight:700;font-size:13px;padding:10px;border-radius:10px;box-shadow:0 4px 16px rgba(0,184,177,.35)}+    #bridgeModal .bm-dismiss{background:transparent;border:1px solid var(--border2);color:var(--muted);font-size:12.5px;padding:8px;border-radius:10px;cursor:pointer}+    #bridgeModal .bm-dismiss:hover{color:var(--text);border-color:var(--text3)}+    #dtt{position:fixed;z-index:99999;max-width:300px;background:rgba(22,27,34,.97);backdrop-filter:blur(14px);border:1px solid rgba(0,184,177,.25);border-radius:9px;padding:8px 11px;color:var(--text);font-size:11.5px;line-height:1.5;pointer-events:none;opacity:0;transition:opacity .15s;box-shadow:0 10px 34px rgba(0,0,0,.5);white-space:pre-wrap}+    #dtt.show{opacity:1}+    [data-tooltip]{cursor:help}+  </style>+</head>+<body>+  <header class="app">+    <div class="ident" data-tooltip="adom-symbol — a standalone Rust tool that generates KiCad schematic symbols and renders them via the shared service-kicad container. Hover any pin for its name, number and electrical type.">+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="4" width="10" height="16" rx="1.5"/><path d="M7 8H3M7 12H3M7 16H3M21 8h-4M21 12h-4M21 16h-4"/></svg>+      <span class="app-name">adom-symbol</span><span class="app-sub">symbol viewer</span>+    </div>+    <div class="subject">+      <span class="subject-name" id="sName">—</span>+      <span class="subject-meta" id="sMeta"></span>+    </div>+    <div class="actions">+      <button class="tbtn" id="zout" data-tooltip="Zoom out (−)">−</button>+      <span class="zlabel" id="zlbl">100%</span>+      <button class="tbtn" id="zin" data-tooltip="Zoom in (+)">+</button>+      <button class="tbtn" id="zfit" data-tooltip="Fit symbol to the window (F)">Fit</button>+      <button class="tbtn" id="zone" data-tooltip="Reset to 100% (1)">1:1</button>+      <span class="vsep"></span>+      <button class="tbtn active" id="grid" data-tooltip="Toggle the background grid (G)">Grid</button>+      <button class="tbtn" id="bboxb" data-tooltip="Toggle the symbol bounding box.">Box</button>+      <button class="tbtn" id="hitsb" data-tooltip="Show the invisible pin hover/click zones (for debugging hit areas).">Hits</button>+      <span class="vsep"></span>+      <button class="tbtn" id="studioBtn" data-tooltip="Layout studio — choose the source (auto-grouped / manufacturer / ds2sf), pin arrangement, grouping, reference position, and the 3D chip overlay.">⚙ Layout</button>+      <span class="vsep"></span>+      <span class="conn" id="conn" data-tooltip="Live link to the adom-symbol Rust server. Green = polling /state; the AI can push a new symbol or run /eval in real time."><span class="dot"></span><span id="connT">connecting…</span></span>+    </div>+  </header>++  <div id="studio" hidden>+    <div class="studio-head" id="studioHead">+      <span class="grip">⠿</span>+      <span class="studio-title">Layout studio</span>+      <button class="shbtn" id="studioCollapse" data-tooltip="Collapse / expand">▾</button>+      <button class="shbtn" id="studioClose" data-tooltip="Close (reopen from ⚙ Layout)">✕</button>+    </div>+    <div class="srow">+      <span class="slab" data-tooltip="Where the symbol comes from. Auto = our generator using ds2sf's logical groups (balanced sides). Manufacturer = the as-fetched vendor symbol, verbatim. ds2sf = generated from the datasheet-extracted pins.">Source</span>+      <div class="seg" id="segSource"></div>+    </div>+    <div class="srow" data-auto>+      <span class="slab" data-tooltip="Left/Right puts pins on two sides. Square balances them across all four sides (top/right/bottom/left).">Layout</span>+      <div class="seg" id="segLayout">+        <button data-v="left-right">Left / Right</button>+        <button data-v="all-sides">Square</button>+      </div>+    </div>+    <div class="srow" data-auto>+      <span class="slab" data-tooltip="Group pins by function (POWER, GPIO ports P0/P1/P2, DEBUG…) with labels, or list them flat with no group labels.">Groups</span>+      <div class="seg" id="segGroups">+        <button data-v="1">Grouped</button>+        <button data-v="0">Flat</button>+      </div>+    </div>+    <div class="srow" data-auto>+      <span class="slab" data-tooltip="Where the U? reference designator sits: pick a side (top/bottom/left/right or • center) and whether it's Outside or Inside the body.">Ref U?</span>+      <div class="seg" id="segRefSide">+        <button data-v="top" data-tooltip="Top">↑</button><button data-v="bot" data-tooltip="Bottom">↓</button>+        <button data-v="left" data-tooltip="Left">←</button><button data-v="right" data-tooltip="Right">→</button>+        <button data-v="center" data-tooltip="Center">•</button>+      </div>+      <div class="seg" id="segRefIO" style="margin-left:6px">+        <button data-v="out">Out</button><button data-v="in">In</button>+      </div>+    </div>+    <div class="srow" data-auto>+      <span class="slab" data-tooltip="Where the chip NAME (Value) sits: pick a side and Outside/Inside the body.">Name</span>+      <div class="seg" id="segValSide">+        <button data-v="top" data-tooltip="Top">↑</button><button data-v="bot" data-tooltip="Bottom">↓</button>+        <button data-v="left" data-tooltip="Left">←</button><button data-v="right" data-tooltip="Right">→</button>+        <button data-v="center" data-tooltip="Center">•</button>+      </div>+      <div class="seg" id="segValIO" style="margin-left:6px">+        <button data-v="out">Out</button><button data-v="in">In</button>+      </div>+    </div>+    <div class="srow" data-auto>+      <span class="slab" data-tooltip="Where the chip NAME shows. Real chips are laser-marked on their top face — 'On chip' laser-etches the name onto the 3D chip and hides the schematic label; 'In symbol' uses the schematic label (positioned above); 'Both' shows each; 'None' hides both. Needs the 3D chip on for the on-chip mark.">Chip name</span>+      <div class="seg" id="segName">+        <button data-v="chip" data-tooltip="Laser-mark the name on the 3D chip only">On chip</button>+        <button data-v="symbol" data-tooltip="Schematic label only (positioned above)">In symbol</button>+        <button data-v="both" data-tooltip="Laser mark + schematic label">Both</button>+        <button data-v="none" data-tooltip="Hide the name everywhere">None</button>+      </div>+    </div>+    <div class="srow">+      <span class="slab" data-tooltip="Overlay a 3D representation of the chip in the centre of the body — the shaded iso render, or a crisp black-&-white vector OUTLINE icon (OCCT hidden-line removal) in several styles. All rendered at your chosen orientation.">3D chip</span>+      <div class="seg" id="seg3d"><button data-v="off">Off</button></div>+      <img id="td3dPrev" alt="3D preview" style="width:28px;height:28px;object-fit:contain;background:#6b7580;border:1px solid var(--border);border-radius:5px;margin-left:7px;display:none">+    </div>+    <div class="srow" style="border-top:1px solid var(--border);padding-top:9px">+      <span class="slab" data-tooltip="Push this symbol to a desktop EDA tool via Adom Desktop. KiCad gets the native symbol; Fusion 360 / Altium / OrCAD get a true EAGLE .lbr library built by adom-lbr. (Adom Desktop must be running on your laptop.)">Send to</span>+      <div class="seg" id="segSend">+        <button data-v="kicad">KiCad</button>+        <button data-v="fusion360">Fusion</button>+        <button data-v="altium">Altium</button>+        <button data-v="orcad">OrCAD</button>+      </div>+    </div>+    <div class="srow" style="justify-content:flex-end">+      <span class="slab" id="stickyNote" data-tooltip="Your studio choices are remembered and re-applied to every symbol you open. Reset returns to the defaults (auto · grouped · left/right · ref top · 3D off).">★ choices are sticky</span>+      <button class="tbtn" id="studioReset" data-tooltip="Forget the sticky studio preferences and return this symbol to the defaults.">Reset to default</button>+    </div>+  </div>+  <div id="toast"></div>++  <main>+    <div id="stage">+      <div id="content"></div>+    </div>+    <div class="empty" id="empty">Waiting for a symbol… run <code>adom-symbol create</code> or click a SYM tile in chip-fetcher.</div>+  </main>++  <footer id="foot" style="display:none"></footer>++  <div id="tip"></div>+  <div id="dtt"></div>++  <script>+  (function(){+    const post=(lvl,a)=>{try{navigator.sendBeacon('console',JSON.stringify({level:lvl,msg:a.map(x=>{try{return typeof x==='string'?x:JSON.stringify(x)}catch{return String(x)}}).join(' ')}))}catch(e){}};+    ['log','warn','error','info'].forEach(l=>{const o=console[l].bind(console);console[l]=(...a)=>{post(l,a);o(...a)}});+    window.addEventListener('error',e=>post('error',[e.message+' @'+(e.filename||'')+':'+e.lineno]));+  })();++  // generic data-tooltip (buttons)+  (function(){+    const tt=document.getElementById('dtt');let t=null,cur=null;+    function place(el){const r=el.getBoundingClientRect();tt.classList.add('show');const b=tt.getBoundingClientRect();let x=r.left+r.width/2-b.width/2,y=r.bottom+8;if(y+b.height>innerHeight-8)y=r.top-b.height-8;x=Math.max(8,Math.min(x,innerWidth-b.width-8));tt.style.left=x+'px';tt.style.top=y+'px'}+    document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-tooltip]');if(!el||el===cur)return;cur=el;clearTimeout(t);t=setTimeout(()=>{tt.textContent=el.getAttribute('data-tooltip');place(el)},500)});+    document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tooltip]')===cur){clearTimeout(t);tt.classList.remove('show');cur=null}});+  })();++  const MM=3.78;+  const stage=document.getElementById('content'), stageWrap=document.getElementById('stage');+  const tip=document.getElementById('tip');+  let scale=1,panX=0,panY=0, svgEl=null, pins=[], offset=null, sticky=null, hitsOn=false, bboxOn=false;++  function setConn(v){const c=document.getElementById('conn');c.classList.toggle('live',v);document.getElementById('connT').textContent=v?'live':'offline'}+  function applyT(){stage.style.transform=`translate(${panX}px,${panY}px) scale(${scale})`;document.getElementById('zlbl').textContent=Math.round(scale*100)+'%'}+  function zoomAt(cx,cy,f){const ns=Math.max(.05,Math.min(scale*f,60));const r=ns/scale;panX=cx-(cx-panX)*r;panY=cy-(cy-panY)*r;scale=ns;applyT()}+  function center(){if(!svgEl)return;const w=stageWrap.clientWidth,h=stageWrap.clientHeight;const sw=svgEl.clientWidth*scale,sh=svgEl.clientHeight*scale;panX=(w-sw)/2;panY=(h-sh)/2;applyT()}+  function fit(){if(!svgEl)return;const w=stageWrap.clientWidth,h=stageWrap.clientHeight;const sw=svgEl.clientWidth||1,sh=svgEl.clientHeight||1;scale=Math.min(w/sw,h/sh)*0.86;scale=Math.max(.05,Math.min(scale,60));center()}++  document.getElementById('zin').onclick=()=>{const r=stageWrap.getBoundingClientRect();zoomAt(r.width/2,r.height/2,1.2)};+  document.getElementById('zout').onclick=()=>{const r=stageWrap.getBoundingClientRect();zoomAt(r.width/2,r.height/2,.83)};+  document.getElementById('zfit').onclick=fit;+  document.getElementById('zone').onclick=()=>{scale=1;center()};+  document.getElementById('grid').onclick=e=>{stageWrap.classList.toggle('no-grid');e.currentTarget.classList.toggle('active')};+  document.getElementById('bboxb').onclick=e=>{bboxOn=!bboxOn;e.currentTarget.classList.toggle('active',bboxOn);const b=document.getElementById('bbox');if(b)b.style.opacity=bboxOn?'1':'0'};+  document.getElementById('hitsb').onclick=e=>{hitsOn=!hitsOn;e.currentTarget.classList.toggle('active',hitsOn);document.querySelectorAll('.hit').forEach(h=>h.classList.toggle('show',hitsOn))};++  stageWrap.addEventListener('wheel',e=>{e.preventDefault();const r=stageWrap.getBoundingClientRect();zoomAt(e.clientX-r.left,e.clientY-r.top,e.deltaY<0?1.12:.89)},{passive:false});+  let dragging=false,dsx=0,dsy=0,pStartX=0,pStartY=0;+  stageWrap.addEventListener('mousedown',e=>{if(e.target.closest('.hit'))return;dragging=true;stageWrap.classList.add('drag');dsx=e.clientX;dsy=e.clientY;pStartX=panX;pStartY=panY});+  window.addEventListener('mousemove',e=>{+    if(dragging){panX=pStartX+(e.clientX-dsx);panY=pStartY+(e.clientY-dsy);applyT()}+    if(svgEl&&offset){const r=stageWrap.getBoundingClientRect();const ux=(e.clientX-r.left-panX)/scale/MM, uy=(e.clientY-r.top-panY)/scale/MM;const kx=ux-offset.x, ky=offset.y-uy;const c=document.getElementById('curpos');if(c)c.textContent=`X ${kx.toFixed(2)}  Y ${ky.toFixed(2)} mm`}+  });+  window.addEventListener('mouseup',()=>{dragging=false;stageWrap.classList.remove('drag')});++  // ── touch: 1-finger pan, 2-finger pinch-zoom, tap-to-inspect ──+  function tdist(t){const dx=t[0].clientX-t[1].clientX,dy=t[0].clientY-t[1].clientY;return Math.hypot(dx,dy)}+  function tmid(t){return{x:(t[0].clientX+t[1].clientX)/2,y:(t[0].clientY+t[1].clientY)/2}}+  let tMode=null,tLastDist=0,tSX=0,tSY=0,tPX=0,tPY=0,tMoved=0;+  stageWrap.addEventListener('touchstart',e=>{+    if(e.touches.length===1){tMode='pan';tSX=e.touches[0].clientX;tSY=e.touches[0].clientY;tPX=panX;tPY=panY;tMoved=0;}+    else if(e.touches.length>=2){tMode='pinch';tLastDist=tdist(e.touches);}+  },{passive:false});+  stageWrap.addEventListener('touchmove',e=>{+    e.preventDefault();+    if(tMode==='pan'&&e.touches.length===1){const nx=e.touches[0].clientX,ny=e.touches[0].clientY;tMoved+=Math.abs(nx-tSX)+Math.abs(ny-tSY);panX=tPX+(nx-tSX);panY=tPY+(ny-tSY);applyT();}+    else if(tMode==='pinch'&&e.touches.length>=2){const d=tdist(e.touches),m=tmid(e.touches),r=stageWrap.getBoundingClientRect();if(tLastDist>0)zoomAt(m.x-r.left,m.y-r.top,d/tLastDist);tLastDist=d;}+  },{passive:false});+  stageWrap.addEventListener('touchend',e=>{+    if(tMode==='pan'&&tMoved<12){+      // a tap — find the nearest pin (forgiving), not just exact hit+      const t=e.changedTouches[0];const p=nearestPin(t.clientX,t.clientY);+      if(p){if(sticky===p){sticky=null;tip.classList.remove('show','sticky');unmark();}else{sticky=p;showPin(p,t.clientX,t.clientY,true);mark(p);}}+      else{sticky=null;tip.classList.remove('show','sticky');unmark();}+    }+    tMode=null;+  });++  document.addEventListener('keydown',e=>{+    if(e.target.tagName==='INPUT')return;+    if(e.key==='f'||e.key==='F')fit();+    else if(e.key==='1'){scale=1;center()}+    else if(e.key==='+'||e.key==='='){const r=stageWrap.getBoundingClientRect();zoomAt(r.width/2,r.height/2,1.2)}+    else if(e.key==='-'){const r=stageWrap.getBoundingClientRect();zoomAt(r.width/2,r.height/2,.83)}+    else if(e.key==='g'||e.key==='G')document.getElementById('grid').click();+    else if(e.key==='Escape'){sticky=null;tip.classList.remove('show','sticky')}+  });++  // ── tooltips ──+  function badge(p){return `<span class="badge" style="background:${p.color}22;color:${p.color};border:1px solid ${p.color}44">${esc(p.type)}</span>`}+  function srcHtml(){const d=window._ds,m=window._mfr;if(!d)return '';return `<div class="src">Source: <a href="${esc(d)}" target="_blank">${esc(m||'Datasheet')}${m?' Datasheet':''}</a></div>`}+  function showPin(p,x,y,sk){+    let h=`<div class="r1"><span class="nm">${esc(p.name)}</span><span class="pn">Pin ${esc(p.number)}</span></div>${badge(p)}`;+    if(p.desc)h+=`<div class="desc">${esc(p.desc)}</div>`;+    h+=srcHtml();+    if(!sk)h+=`<div class="hint">click to pin · select text</div>`;+    tip.innerHTML=h;tip.classList.add('show');tip.classList.toggle('sticky',!!sk);posTip(x,y);+  }+  function posTip(x,y){const w=tip.offsetWidth||260,hh=tip.offsetHeight||110;let tx=x+14,ty=y+14;if(tx+w>innerWidth-14)tx=x-w-14;if(ty+hh>innerHeight-14)ty=y-hh-14;tip.style.left=Math.max(8,tx)+'px';tip.style.top=Math.max(8,ty)+'px'}+  function mark(p){const m=document.getElementById('pinmark');if(m&&offset){m.setAttribute('cx',offset.x+p.x);m.setAttribute('cy',offset.y-p.y);m.setAttribute('r',0.7);m.style.opacity='1'}highlightGlyphs(p)}+  function unmark(){const m=document.getElementById('pinmark');if(m)m.style.opacity='0';clearHL()}++  // Map the rendered text glyphs: invisible <text opacity="0">NAME</text> is+  // immediately followed by its visible <g class="stroked-text"> glyphs. Build+  // name/number → glyph-group map + pin → wire-path map for highlight-on-select.+  let textMap=new Map(), wireMap=new Map();+  function norm(s){return String(s||'').replace(/[~{}]/g,'').trim()}+  function buildMaps(){+    textMap=new Map();wireMap=new Map();+    if(!svgEl)return;+    svgEl.querySelectorAll('text').forEach(t=>{+      if(t.getAttribute('opacity')!=='0')return;+      const g=t.nextElementSibling;+      if(!g||!g.classList||!g.classList.contains('stroked-text'))return;+      const k=norm(t.textContent);if(!k)return;+      if(!textMap.has(k))textMap.set(k,[]);+      textMap.get(k).push(g);+    });+    if(offset){+      const paths=[...svgEl.querySelectorAll('path')];+      pins.forEach(p=>{+        const tx=offset.x+p.x,ty=offset.y-p.y;+        for(const pe of paths){+          const m=(pe.getAttribute('d')||'').match(/^M(-?[\d.]+)\s+(-?[\d.]+)\s+L(-?[\d.]+)\s+(-?[\d.]+)\s*$/);+          if(!m)continue;const x1=+m[1],y1=+m[2],x2=+m[3],y2=+m[4];+          if((Math.abs(x1-tx)<.12&&Math.abs(y1-ty)<.12)||(Math.abs(x2-tx)<.12&&Math.abs(y2-ty)<.12)){wireMap.set(p,pe);break}+        }+      });+    }+  }+  function highlightGlyphs(p){+    [p.name,p.number].forEach(k=>{const a=textMap.get(norm(k));if(a)a.forEach(g=>g.classList.add('hl'))});+    const w=wireMap.get(p);if(w)w.classList.add('hl');+  }+  function clearHL(){if(svgEl)svgEl.querySelectorAll('.hl').forEach(e=>e.classList.remove('hl'))}+  document.addEventListener('click',e=>{if(!sticky)return;if(tip.contains(e.target))return;if(e.target.closest('.hit'))return;sticky=null;tip.classList.remove('show','sticky');unmark()});++  function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}++  // ── build hit zones on the SVG ──+  // Each zone covers the pin WIRE + NUMBER (outside the body) AND the pin NAME+  // (inside the body), extended along the wire into the body. nameDepth is+  // capped by body size so opposite rows never collide. Zone rects are also+  // stored for the nearest-pin tap fallback (forgiving on touch).+  let pinZones=[];+  function buildHits(){+    if(!svgEl||!offset)return;+    pinZones=[];+    const NS='http://www.w3.org/2000/svg';+    const bb=computeBounds();+    if(bb){const r=document.createElementNS(NS,'rect');r.id='bbox';r.setAttribute('x',bb.x);r.setAttribute('y',bb.y);r.setAttribute('width',bb.w);r.setAttribute('height',bb.h);svgEl.appendChild(r)}+    const pm=document.createElementNS(NS,'circle');pm.id='pinmark';svgEl.appendChild(pm);+    const HT=1.05;                       // half-thickness across the wire (< 2.54 pitch)+    const bw=window._body?Math.abs(window._body.x2-window._body.x1):14;+    const bh=window._body?Math.abs(window._body.y2-window._body.y1):14;+    pins.forEach((p,idx)=>{+      const tipX=offset.x+p.x, tipY=offset.y-p.y;const L=p.length||2.54;+      // unit direction from tip INTO the body+      let dx=0,dy=0;+      if(p.angle===0)dx=1; else if(p.angle===180)dx=-1; else if(p.angle===90)dy=-1; else if(p.angle===270)dy=1;+      const horiz=dx!==0;+      const nameDepth=Math.min(7,(horiz?bw:bh)*0.42);+      const reach=L+nameDepth;           // wire + into-body name region+      const outX=tipX-dx*0.6, outY=tipY-dy*0.6;        // a touch beyond the tip (number)+      const inX=tipX+dx*reach, inY=tipY+dy*reach;+      let x,y,w,h;+      if(horiz){x=Math.min(outX,inX);w=Math.abs(inX-outX);y=tipY-HT;h=HT*2;}+      else{y=Math.min(outY,inY);h=Math.abs(inY-outY);x=tipX-HT;w=HT*2;}+      pinZones.push({p,x,y,w,h});+      const r=document.createElementNS(NS,'rect');+      r.setAttribute('class','hit'+(hitsOn?' show':''));r.setAttribute('x',x);r.setAttribute('y',y);r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('rx',.3);+      r.dataset.pi=idx;+      let ht=null;+      r.addEventListener('mouseenter',ev=>{if(sticky)return;clearTimeout(ht);ht=setTimeout(()=>{showPin(p,ev.clientX,ev.clientY,false);mark(p)},120)});+      r.addEventListener('mousemove',ev=>{if(sticky)return;if(tip.classList.contains('show'))posTip(ev.clientX,ev.clientY)});+      r.addEventListener('mouseleave',()=>{if(sticky)return;clearTimeout(ht);tip.classList.remove('show');unmark()});+      r.addEventListener('click',ev=>{ev.stopPropagation();if(sticky===p){sticky=null;tip.classList.remove('show','sticky');unmark()}else{sticky=p;showPin(p,ev.clientX,ev.clientY,true);mark(p)}});+      svgEl.appendChild(r);+    });+  }+  // Nearest pin to a screen point (for forgiving touch). Returns pin or null.+  function nearestPin(clientX,clientY){+    if(!svgEl||!offset||!pinZones.length)return null;+    const r=stageWrap.getBoundingClientRect();+    const ux=(clientX-r.left-panX)/scale/MM, uy=(clientY-r.top-panY)/scale/MM;+    let best=null,bd=1e9;+    for(const z of pinZones){+      const ddx=Math.max(z.x-ux,0,ux-(z.x+z.w)), ddy=Math.max(z.y-uy,0,uy-(z.y+z.h));+      const d=Math.hypot(ddx,ddy);+      if(d<bd){bd=d;best=z.p}+    }+    return bd<1.3?best:null;       // within 1.3mm of a pin zone+  }+  function computeBounds(){+    if(!pins.length||!offset)return null;+    let minX=1e9,maxX=-1e9,minY=1e9,maxY=-1e9;+    pins.forEach(p=>{const tx=offset.x+p.x,ty=offset.y-p.y;let bx=tx,by=ty;const L=p.length||2.54;+      if(p.angle===0)bx+=L;else if(p.angle===180)bx-=L;else if(p.angle===90)by-=L;else if(p.angle===270)by+=L;+      minX=Math.min(minX,tx,bx);maxX=Math.max(maxX,tx,bx);minY=Math.min(minY,ty,by);maxY=Math.max(maxY,ty,by)});+    if(window._body){const b=window._body;[b.x1,b.x2].forEach(x=>{minX=Math.min(minX,offset.x+x);maxX=Math.max(maxX,offset.x+x)});[b.y1,b.y2].forEach(y=>{minY=Math.min(minY,offset.y-y);maxY=Math.max(maxY,offset.y-y)})}+    const pad=1.0;return{x:minX-pad,y:minY-pad,w:(maxX-minX)+pad*2,h:(maxY-minY)+pad*2};+  }++  // ── footer / metadata ──+  function footer(s){+    const items=[];+    if(s.manufacturer)items.push(['Mfr',esc(s.manufacturer)]);+    if(s.package)items.push(['Pkg',esc(s.package.split(':').pop())]);+    items.push(['Pins',String(s.pinCount||(s.pins||[]).length)]);+    if((s.groups||[]).length)items.push(['Groups',String(s.groups.length)]);+    if(s.datasheet)items.push(['Datasheet',`<a href="${esc(s.datasheet)}" target="_blank">PDF</a>`]);+    const desc=(s.description||'').replace(/\\r|\\n|[\r\n]+/g,' ').replace(/\s+/g,' ').trim();+    if(desc)items.push(['Desc',`<span>${esc(desc.slice(0,70))}${desc.length>70?'…':''}</span>`]);+    const f=document.getElementById('foot');+    f.innerHTML=items.map(([k,v])=>`<span class="it">${k?`<span class="k">${k}</span>`:''}<span class="v">${v}</span></span>`).join('')+`<span class="cur" id="curpos"></span>`;+    f.style.display='';+  }++  // ── state ── lightweight poll; heavy SVG + 3D assets fetched on demand ──+  let lastRev=-1, cur3dMode=null, cur3dAsset='', cur3dLaser=null;+  async function poll(){+    try{+      const s=await (await fetch('state',{cache:'no-store'})).json();setConn(true);+      window._lastState=s;+      const has=!!s.symbolName;+      document.getElementById('empty').style.display=has?'none':'';+      document.getElementById('sName').textContent=has?s.symbolName:'—';+      document.getElementById('sMeta').textContent=has?((s.pinCount||(s.pins||[]).length)+' pins'+(s.package?' · '+s.package.split(':').pop():'')):'';+      footer(s); syncStudio(s);+      if(!has){stage.innerHTML='';document.getElementById('foot').style.display='none';lastRev=-1;return}+      if(s.rev!==lastRev){            // symbol regenerated → fetch the (heavy) SVG once+        lastRev=s.rev;+        const sv=await (await fetch('svg',{cache:'no-store'})).json();+        renderSvg(s, sv.svg);+      }+      apply3d(s);+      maybeApplyPrefs(s);+    }catch(e){setConn(false)}+  }+  function renderSvg(s, svg){+    pins=s.pins||[];offset=s.offset||null;window._ds=s.datasheet||'';window._mfr=s.manufacturer||'';window._body=s.bodyRect||null;+    sticky=null;tip.classList.remove('show','sticky');+    cur3dMode=null; cur3dAsset=''; cur3dLaser=null;   // 3D geometry changes with the layout+    stage.innerHTML=svg||'';+    svgEl=stage.querySelector('svg');+    if(svgEl){+      const vb=(svgEl.getAttribute('viewBox')||'').split(/\s+/).map(Number);+      if(vb.length===4){svgEl.style.width=(vb[2]*MM)+'px';svgEl.style.height=(vb[3]*MM)+'px';svgEl.removeAttribute('width');svgEl.removeAttribute('height')}+      buildHits();+      buildMaps();+      requestAnimationFrame(fit);+    }+  }+  setInterval(poll,1000);poll();+  new ResizeObserver(()=>{if(svgEl)fit()}).observe(stageWrap);++  // ── layout studio ──+  const studio=document.getElementById('studio');+  const SRC_LABEL={auto:'Auto · grouped',ds2sf:'ds2sf',manufacturer:'Manufacturer',+    snapeda:'SnapEDA',snapmagic:'SnapEDA',ultralibrarian:'Ultra Librarian',cse_ul:'Ultra Librarian',+    componentsearch:'Component Search',mouser:'Mouser',digikey:'DigiKey',arrow:'Arrow',lcsc:'LCSC',vendor:'Vendor'};+  document.getElementById('studioBtn').onclick=()=>{studio.hidden=!studio.hidden;document.getElementById('studioBtn').classList.toggle('active',!studio.hidden)};+  // ── floaty studio on wide screens: drag / collapse / close + remembered pos ──+  (function(){+    const wide=()=>matchMedia('(min-width:821px)').matches;+    const head=document.getElementById('studioHead');+    const FK='adomSymbolStudioHud';+    let st={}; try{st=JSON.parse(localStorage.getItem(FK))||{}}catch{}+    function applyPos(){ if(!wide())return; if(st.x!=null){studio.style.right='auto';studio.style.left=st.x+'px';studio.style.top=st.y+'px'} }+    document.getElementById('studioClose').onclick=()=>{studio.hidden=true;document.getElementById('studioBtn').classList.remove('active')};+    document.getElementById('studioCollapse').onclick=()=>{const c=studio.classList.toggle('collapsed');document.getElementById('studioCollapse').textContent=c?'▸':'▾'};+    head.addEventListener('dblclick',e=>{if(!e.target.closest('.shbtn'))document.getElementById('studioCollapse').click()});+    let drag=false,dx=0,dy=0;+    head.addEventListener('pointerdown',e=>{ if(!wide()||e.target.closest('.shbtn'))return; drag=true; const r=studio.getBoundingClientRect(); dx=e.clientX-r.left; dy=e.clientY-r.top; studio.style.right='auto'; studio.style.left=r.left+'px'; studio.style.top=r.top+'px'; head.setPointerCapture(e.pointerId); });+    head.addEventListener('pointermove',e=>{ if(!drag)return; let x=e.clientX-dx,y=e.clientY-dy; x=Math.max(6,Math.min(x,innerWidth-studio.offsetWidth-6)); y=Math.max(6,Math.min(y,innerHeight-44)); studio.style.left=x+'px'; studio.style.top=y+'px'; st.x=x;st.y=y; });+    head.addEventListener('pointerup',()=>{ if(drag){drag=false; try{localStorage.setItem(FK,JSON.stringify(st))}catch{}} });+    applyPos();+  })();+  // sticky studio preferences (per browser)+  const PREF_KEY='adomSymbolStudio2'; // bumped: clears stale 3D="bold" sticky so the iso default shows+  function loadPrefs(){try{return JSON.parse(localStorage.getItem(PREF_KEY)||'{}')||{}}catch{return {}}}+  function savePrefs(p){try{localStorage.setItem(PREF_KEY,JSON.stringify(p))}catch(e){}}+  let relaying=false, prefsAppliedFor=null;+  async function relayout(patch,remember=true){+    relaying=true;+    if(remember){const p=loadPrefs();Object.assign(p,patch);savePrefs(p)}+    try{await fetch('relayout',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});}+    catch(e){console.warn('relayout failed',e)}+    lastHash='';                       // force the next poll to re-render+    setTimeout(()=>{relaying=false},200);+  }+  // Re-apply the user's remembered choices to each newly-loaded symbol.+  function maybeApplyPrefs(s){+    if(!s.symbolName || prefsAppliedFor===s.symbolName) return;+    prefsAppliedFor=s.symbolName;+    const p=loadPrefs(), patch={};+    if(p.pinLayout && p.pinLayout!==s.pinLayout) patch.pinLayout=p.pinLayout;+    if(typeof p.grouped==='boolean' && p.grouped!==s.grouped) patch.grouped=p.grouped;+    if(p.refPos && p.refPos!==s.refPos) patch.refPos=p.refPos;+    if(p.valuePos && p.valuePos!==s.valuePos) patch.valuePos=p.valuePos;+    if(p.threeD && p.threeD!==s.threeD && (p.threeD==='off'||(s.threeDModes||[]).includes(p.threeD))) patch.threeD=p.threeD;+    if(p.source && p.source!==s.source && (s.sources||[]).includes(p.source)) patch.source=p.source;+    if(Object.keys(patch).length) relayout(patch,false);   // already saved+  }+  document.getElementById('studioReset').onclick=()=>{+    savePrefs({});+    const s=window._lastState||{};+    const src=(s.sources||[]).includes('auto')?'auto':((s.sources||[])[0]||'manufacturer');+    prefsAppliedFor=s.symbolName||'__reset__';   // don't re-apply old prefs+    relayout({source:src,pinLayout:'left-right',grouped:true,refPos:'out-top',valuePos:'out-bot',threeD:'iso'},false);+  };+  function seg(id,onClick){document.getElementById(id).addEventListener('click',e=>{const b=e.target.closest('button[data-v]');if(b)onClick(b.dataset.v)})}+  seg('segSource',v=>relayout({source:v}));+  seg('segLayout',v=>relayout({pinLayout:v}));+  seg('segGroups',v=>relayout({grouped:v==='1'}));+  // Ref + Name positioning: side (top/bot/left/right/center) × in/out+  let refSide='top',refIO='out',valSide='bot',valIO='out';+  function posOf(side,io){ return side==='center'?'center':(io+'-'+side); }+  function splitPos(pos){ if(pos==='center')return{side:'center',io:'out'}; const m=/^(out|in)-(top|bot|left|right)$/.exec(pos||''); return m?{side:m[2],io:m[1]}:{side:'top',io:'out'}; }+  seg('segRefSide',v=>{refSide=v;relayout({refPos:posOf(refSide,refIO)})});+  seg('segRefIO',v=>{refIO=v;relayout({refPos:posOf(refSide,refIO)})});+  seg('segValSide',v=>{valSide=v;if(nameMode==='symbol'||nameMode==='both')relayout({valuePos:posOf(valSide,valIO)})});+  seg('segValIO',v=>{valIO=v;if(nameMode==='symbol'||nameMode==='both')relayout({valuePos:posOf(valSide,valIO)})});+  // Chip-name display mode: maps to the schematic Value position + laser-on-chip flag.+  let nameMode='symbol';+  seg('segName',v=>{ nameMode=v;+    const showSym=(v==='symbol'||v==='both');+    relayout({ valuePos: showSym?posOf(valSide,valIO):'none', laserName: (v==='chip'||v==='both') });+  });+  seg('seg3d',v=>relayout({threeD:v}));+  // Send to desktop EDA tools+  const toast=document.getElementById('toast');let toastT=null;+  function showToast(msg,kind){toast.textContent=msg;toast.className=kind||'';toast.classList.add('show');clearTimeout(toastT);toastT=setTimeout(()=>toast.classList.remove('show'),4500)}+  seg('segSend',async v=>{+    const names={kicad:'KiCad',fusion360:'Fusion 360',altium:'Altium',orcad:'OrCAD'};+    showToast('Sending to '+names[v]+'…','warn');+    try{+      const r=await fetch('deliver',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target:v})});+      const j=await r.json();+      if(j.needsBridge){toast.classList.remove('show');showBridgeModal(j);}+      else if(j.ok)showToast('✓ '+(j.message||('Sent to '+names[v])),'ok');+      else showToast('⚠ '+(j.message||j.error||('Could not send to '+names[v])),j.offline?'warn':'err');+    }catch(e){showToast('⚠ Send failed: '+e.message,'err')}+  });+  // Build-a-bridge modal: Altium/OrCAD have no Adom Desktop bridge yet → invite+  // the user to build it and contribute on the wiki.+  function showBridgeModal(j){+    let m=document.getElementById('bridgeModal');+    if(!m){ m=document.createElement('div'); m.id='bridgeModal'; document.body.appendChild(m); }+    m.innerHTML='<div class="bm-card">'+      +'<div class="bm-icon">🌉</div>'+      +'<div class="bm-title">Build the '+(j.target||'')+' bridge</div>'+      +'<div class="bm-body">'+(j.message||'')+'</div>'+      +'<div class="bm-actions">'+      +'<a class="bm-go" href="'+(j.contribute||'https://wiki.adom.inc')+'" target="_blank" rel="noopener">Contribute on the wiki →</a>'+      +'<button class="bm-dismiss">Got it</button>'+      +'</div></div>';+    m.classList.add('show');+    m.querySelector('.bm-dismiss').onclick=()=>m.classList.remove('show');+    m.onclick=e=>{if(e.target===m)m.classList.remove('show')};+  }+  const TD_LABEL={off:'Off',iso:'Iso',thin:'Thin',bold:'Bold',blueprint:'Blue',teal:'Teal',dark:'Dark'};+  const TD_ORDER=['off','iso','thin','bold','blueprint','teal','dark'];+  function syncStudio(s){+    // build the Source segment from available sources+    const ss=document.getElementById('segSource');+    const avail=s.sources||[];+    const lbl=s.sourceLabels||{};+    ss.innerHTML=avail.map(v=>`<button data-v="${v}">${lbl[v]||SRC_LABEL[v]||v}</button>`).join('')||'<button data-v="manufacturer">Manufacturer</button>';+    const setOn=(id,val)=>document.querySelectorAll('#'+id+' button').forEach(b=>b.classList.toggle('on',b.dataset.v===String(val)));+    // build the 3D segment from available assets (off + iso + each outline style)+    const modes=s.threeDModes||[];+    const avail3d=TD_ORDER.filter(k=>k==='off'||modes.includes(k));+    document.getElementById('seg3d').innerHTML=avail3d.map(k=>`<button data-v="${k}">${TD_LABEL[k]||k}</button>`).join('');+    // live preview of the current 3D asset (verifies the path: if this shows, the overlay will too)+    const prev=document.getElementById('td3dPrev');+    if(s.threeD&&s.threeD!=='off'&&s.threeDAsset){prev.src=s.threeDAsset;prev.style.display='';}+    else{prev.removeAttribute('src');prev.style.display='none';}+    setOn('segSource',s.source);+    setOn('segLayout',s.pinLayout);+    setOn('segGroups',s.grouped?'1':'0');+    const rp=splitPos(s.refPos);refSide=rp.side;refIO=rp.io;setOn('segRefSide',rp.side);setOn('segRefIO',rp.io);+    const hiddenName=(s.valuePos==='none');+    const vp=splitPos(hiddenName?'out-bot':s.valuePos);valSide=vp.side;valIO=vp.io;setOn('segValSide',vp.side);setOn('segValIO',vp.io);+    // derive the chip-name display mode from (valuePos hidden?, laserName)+    nameMode = s.laserName ? (hiddenName?'chip':'both') : (hiddenName?'none':'symbol');+    setOn('segName', nameMode);+    setOn('seg3d',s.threeD||'off');+    // layout/groups/ref only apply to the generated sources+    const gen=s.source==='auto'||s.source==='ds2sf';+    studio.querySelectorAll('[data-auto]').forEach(r=>r.style.opacity=gen?'1':'.4');+    studio.querySelectorAll('[data-auto] .seg button').forEach(b=>b.style.pointerEvents=gen?'auto':'none');+  }+  // 3D chip overlay in the symbol-body centre — asset comes straight from+  // /state (the current mode only), so it's synchronous and reliable.+  function apply3d(s){+    const mode=s.threeD||'off';+    const present=svgEl&&svgEl.querySelector('#iso3d');+    const asset=s.threeDAsset||'';+    // redraw when mode OR asset changed, or the overlay is missing+    const laserKey=(s.laserName?'1':'0')+'|'+(s.symbolName||'');+    if(mode===cur3dMode && cur3dAsset===asset && laserKey===cur3dLaser && (mode==='off'||present)) return;+    cur3dMode=mode; cur3dAsset=asset; cur3dLaser=laserKey;+    if(present)present.remove();+    const oldBg=svgEl&&svgEl.querySelector('#iso3d-bg'); if(oldBg)oldBg.remove();+    if(!svgEl||mode==='off'||!asset||!offset||!s.bodyRect)return;+    const b=s.bodyRect;if(!b)return;+    const cx=offset.x+(b.x1+b.x2)/2, cy=offset.y-(b.y1+b.y2)/2;+    const w=Math.abs(b.x2-b.x1), h=Math.abs(b.y2-b.y1);+    // Size to the body so it's prominent on tall/narrow parts too (BMI270 etc.),+    // not just big square ones. ~72% of the smaller body dimension.+    const sz=Math.min(w,h)*(mode==='iso'?0.72:0.74);+    const NS='http://www.w3.org/2000/svg';+    // Contrast disc behind the (often dark) iso package so it reads on the dark+    // body fill. Subtle; skipped for the line-art outline modes.+    if(mode==='iso'){+      const bg=document.createElementNS(NS,'ellipse');bg.id='iso3d-bg';+      bg.setAttribute('cx',cx);bg.setAttribute('cy',cy);bg.setAttribute('rx',sz*0.62);bg.setAttribute('ry',sz*0.52);+      bg.setAttribute('fill','rgba(150,170,200,0.14)');bg.style.pointerEvents='none';+      svgEl.appendChild(bg);+    }+    const img=document.createElementNS(NS,'image');img.id='iso3d';+    img.setAttribute('x',cx-sz/2);img.setAttribute('y',cy-sz/2);img.setAttribute('width',sz);img.setAttribute('height',sz);+    img.setAttribute('href',asset);img.setAttributeNS('http://www.w3.org/1999/xlink','href',asset);img.setAttribute('preserveAspectRatio','xMidYMid meet');+    img.setAttribute('opacity',mode==='iso'?'0.95':'1');img.style.pointerEvents='none';+    // Layering: body fill paints in a later group (it occludes anything below),+    // so we append the chip ON TOP of the body, then lift every text/label group+    // back above the chip → final order is body → chip → text (labels stay pure+    // white and readable; chip sits over the body fill, not under it).+    svgEl.appendChild(img);+    const tops=new Set();+    svgEl.querySelectorAll('text, .stroked-text').forEach(t=>{+      if(t.id==='laser3d') return;+      let a=t; while(a && a.parentNode && a.parentNode!==svgEl) a=a.parentNode;+      if(a && a!==img && a.parentNode===svgEl) tops.add(a);+    });+    tops.forEach(a=>svgEl.appendChild(a));   // re-append in order → on top of the chip+    // NOTE: the laser-marked name is NOT drawn here as SVG text. It is embossed+    // as real geometry on the chip's STEP top face (occt-name-on-chip.py) and+    // rendered into the "iso-named" asset, which the server serves as threeDAsset+    // when laserName is on — so the mark is perspective-correct and matches what+    // ships to KiCad. The <image> above already shows it.+  }++  // ── eval channel ──+  async function evalLoop(){+    try{const r=await fetch('eval/pending',{cache:'no-store'});const j=await r.json();+      if(j&&j.id){let res,err=null;try{res=await(async()=>eval(j.code))()}catch(e){err=String(e&&e.stack||e)}+        await fetch('eval/'+j.id+'/result',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({result:err?undefined:safe(res),error:err})})}+    }catch(e){}+  }+  function safe(v){try{JSON.stringify(v);return v}catch{return String(v)}}+  setInterval(evalLoop,400);evalLoop();+  </script>+</body>+</html>
src/deliver.rsadded+109
@@ -0,0 +1,109 @@+//! "Send to" delivery — push the current symbol to the user's desktop EDA tools+//! via the adom-desktop bridge. KiCad takes the native `.kicad_sym` directly+//! (symbol-only); Fusion 360 needs a true EAGLE `.lbr`, which we build with+//! adom-lbr first. Best-effort: a clear message when the bridge is offline.++use serde_json::{json, Value};+use std::process::Command;++/// Run an adom-desktop verb with a JSON payload; return its parsed JSON (or a+/// wrapped raw result). Errors if adom-desktop itself can't be spawned.+fn ad(verb: &str, payload: Value) -> Result<Value, String> {+    let out = Command::new("adom-desktop")+        .args([verb, &payload.to_string()])+        .output()+        .map_err(|e| format!("adom-desktop unreachable: {e}"))?;+    let txt = String::from_utf8_lossy(&out.stdout).to_string();+    Ok(serde_json::from_str::<Value>(&txt).unwrap_or_else(|_| json!({ "ok": out.status.success(), "raw": txt })))+}++fn looks_offline(v: &Value) -> bool {+    let s = v.to_string().to_lowercase();+    v.get("bridgeConnected").and_then(|b| b.as_bool()) == Some(false)+        || s.contains("not connected")+        || s.contains("no client")+        || s.contains("offline")+        || s.contains("timeout")+}++/// Deliver the symbol. `scope` is "symbol" (KiCad symbol lib) — Fusion always+/// gets a full `.lbr`. Returns a JSON result for the UI toast.+pub fn deliver(symbol_name: &str, kicad_sym: &str, target: &str) -> Value {+    if kicad_sym.is_empty() {+        return json!({ "ok": false, "error": "no symbol loaded" });+    }+    let dir = std::env::temp_dir().join(format!("adom-symbol-deliver-{}", std::process::id()));+    let _ = std::fs::create_dir_all(&dir);+    let safe = symbol_name.replace(['/', ' '], "_");+    let sym_path = dir.join(format!("{safe}.kicad_sym"));+    if std::fs::write(&sym_path, kicad_sym).is_err() {+        return json!({ "ok": false, "error": "could not write temp symbol" });+    }++    // KiCad takes the native symbol directly (symbol-only). The EAGLE-family+    // tools (Fusion / Altium / OrCAD) import a true `.lbr` built by adom-lbr.+    if target == "kicad" {+        let send = ad("send_files", json!({ "filePaths": [sym_path.to_string_lossy()], "targetApp": "kicad", "destinationFolder": "symbols" }));+        let inst = ad("kicad_install_library", json!({ "libraryPath": sym_path.to_string_lossy(), "libraryName": symbol_name, "libraryType": "symbol" }));+        return summarize("KiCad", "symbol", &[send, inst]);+    }++    let (tool, app, folder) = match target {+        "fusion360" | "fusion" => ("Fusion 360", "fusion360", "fusion"),+        "altium" => ("Altium", "altium", "altium"),+        "orcad" => ("OrCAD", "orcad", "orcad"),+        _ => return json!({ "ok": false, "error": format!("unknown target {target}") }),+    };++    // Build the true library (.lbr) via adom-lbr.+    let lbr_path = dir.join(format!("{safe}.lbr"));+    match Command::new("adom-lbr").args(["generate", "--sym"]).arg(&sym_path).arg("--output").arg(&lbr_path).output() {+        Ok(o) if o.status.success() => {}+        Ok(o) => return json!({ "ok": false, "error": "adom-lbr generate failed", "detail": String::from_utf8_lossy(&o.stderr) }),+        Err(e) => return json!({ "ok": false, "error": format!("adom-lbr not available: {e}") }),+    }+    // Altium & OrCAD have no Adom Desktop bridge YET — the EAGLE .lbr is built and+    // ready, but there's no app integration to import it. Rather than pretend,+    // invite the user to build the bridge (the wiki makes that a contribution).+    if app == "altium" || app == "orcad" {+        return bridge_not_built(tool, &lbr_path.to_string_lossy());+    }+    let send = ad("send_files", json!({ "filePaths": [lbr_path.to_string_lossy()], "targetApp": app, "destinationFolder": folder }));+    let win = send.as_ref().ok()+        .and_then(|s| s.get("destinationPaths").and_then(|p| p.as_array()).and_then(|a| a.first()).and_then(|x| x.as_str()).map(String::from))+        .unwrap_or_else(|| lbr_path.to_string_lossy().to_string());+    // Fusion has a dedicated open verb; Altium/OrCAD just receive the .lbr to import.+    let mut steps = vec![send];+    if app == "fusion360" {+        steps.push(ad("fusion_open_lbr", json!({ "filePath": win })));+    }+    summarize(tool, "library (.lbr)", &steps)+}++/// Altium / OrCAD: the library is built but no Adom Desktop bridge exists for the+/// tool yet. Return a `needsBridge` signal the UI turns into a "build the bridge,+/// contribute on the wiki" call to action — that's how Adom grows its EDA reach.+fn bridge_not_built(tool: &str, file: &str) -> Value {+    json!({+        "ok": false,+        "needsBridge": true,+        "target": tool,+        "file": file,+        "contribute": "https://wiki.adom.inc/adom/adom-desktop",+        "message": format!("Your EAGLE .lbr for {tool} is built and saved — but the {tool} ↔ Adom Desktop bridge isn't built yet. Want {tool} connectivity in the Adom ecosystem? Be the one to build the bridge and contribute it on the wiki.")+    })+}++fn summarize(tool: &str, what: &str, results: &[Result<Value, String>]) -> Value {+    for r in results {+        match r {+            Err(e) => return json!({ "ok": false, "target": tool, "error": e, "offline": e.contains("unreachable") }),+            Ok(v) if looks_offline(v) => {+                return json!({ "ok": false, "target": tool, "offline": true,+                    "message": format!("File generated, but Adom Desktop isn't connected to {tool}. Open Adom Desktop on your laptop and try again.") });+            }+            _ => {}+        }+    }+    json!({ "ok": true, "target": tool, "message": format!("Delivered {what} to {tool}.") })+}
src/discrete_gen.rsadded+316
@@ -0,0 +1,316 @@+//! Category B (discrete) `.kicad_sym` generator — Rust port of the Node+//! `generateDiscreteSym`, **with the symbol-resolution bug fixed**.+//!+//! The Node version fetched the whole `<lib>.kicad_sym`, ignored the requested+//! symbol, and keyed every extends/property/header lookup off the FIRST match+//! in the entire file — so every passive came out as `C_Feedthrough`. This port+//! extracts the **specific requested symbol's** block and follows **its own**+//! `(extends …)`, so `Device/R` yields a real 2-pin resistor.+//!+//! The library is fetched via `service-kicad` (`GET /symbols/<lib>.kicad_sym`).+//! No gallia, no Node.++use anyhow::{anyhow, Result};+use regex::Regex;++pub struct DiscreteInput {+    pub symbol_name: String,+    pub manufacturer: String,+    pub package: String,+    pub description: String,+    pub datasheet_url: String,+    pub reference_prefix: Option<String>,+    pub baseline_library: String,+    pub baseline_symbol: String,+    /// (old_number,new_number) and/or (old_name,new_name)+    pub pin_overrides: Vec<PinOverride>,+}++#[derive(Default)]+pub struct PinOverride {+    pub old_number: Option<String>,+    pub new_number: Option<String>,+    pub old_name: Option<String>,+    pub new_name: Option<String>,+}++struct Header {+    hide_pin_names: bool,+    pin_name_offset: String,+}++/// `fetch_lib(library)` returns the raw `<library>.kicad_sym` contents.+pub fn generate_discrete_sym(+    input: &DiscreteInput,+    fetch_lib: &dyn Fn(&str) -> Result<String>,+) -> Result<String> {+    let lib = fetch_lib(&input.baseline_library)?;++    // The requested symbol's OWN top-level block (the fix: not the whole file).+    let baseline = extract_top_symbol(&lib, &input.baseline_symbol)+        .ok_or_else(|| anyhow!("symbol \"{}\" not found in library \"{}\"", input.baseline_symbol, input.baseline_library))?;++    // Follow THIS symbol's extends (if any) to the graphics/pins source.+    let extends = Regex::new(r#"\(extends "([^"]+)"\)"#).unwrap();+    let (graphics_source, graphics_source_name): (String, String) = if let Some(c) = extends.captures(&baseline) {+        let parent = c[1].to_string();+        let parent_block = extract_top_symbol(&lib, &parent)+            .ok_or_else(|| anyhow!("parent symbol \"{}\" not found", parent))?;+        (parent_block, parent)+    } else {+        (baseline.clone(), input.baseline_symbol.clone())+    };++    let graphics_block = extract_sub_symbol(&graphics_source, &graphics_source_name, "_0_1");+    let pins_block = extract_sub_symbol(&graphics_source, &graphics_source_name, "_1_1");+    let header = extract_header(&graphics_source);++    let ref_prefix = match &input.reference_prefix {+        Some(p) if !p.is_empty() => p.clone(),+        _ => extract_property(&graphics_source, "Reference").unwrap_or_else(|| "U".to_string()),+    };++    // Properties come from the requested symbol's block; input overrides win.+    let prop_value = input.symbol_name.clone();+    let prop_footprint = first_nonempty(&[+        Some(input.package.clone()),+        extract_property(&baseline, "Footprint"),+        extract_property(&graphics_source, "Footprint"),+    ]);+    let prop_datasheet = first_nonempty(&[Some(input.datasheet_url.clone()), extract_property(&baseline, "Datasheet")]);+    let prop_description = first_nonempty(&[Some(input.description.clone()), extract_property(&baseline, "Description")]);+    let prop_manufacturer = first_nonempty(&[Some(input.manufacturer.clone()), extract_property(&baseline, "Manufacturer")]);++    let ref_at = extract_property_at(&graphics_source, "Reference").unwrap_or_else(|| "0 2.54 0".to_string());+    let val_at = extract_property_at(&graphics_source, "Value").unwrap_or_else(|| "0 -2.54 0".to_string());+    let ref_effects = extract_property_effects(&graphics_source, "Reference").unwrap_or_else(|| "(effects (font (size 1.27 1.27)))".to_string());+    let val_effects = extract_property_effects(&graphics_source, "Value").unwrap_or_else(|| "(effects (font (size 1.27 1.27)))".to_string());++    // Rename the sub-symbol blocks to the new symbol name.+    let mut renamed_graphics = graphics_block.replacen(+        &format!("(symbol \"{}_0_1\"", graphics_source_name),+        &format!("(symbol \"{}_0_1\"", input.symbol_name),+        1,+    );+    let mut renamed_pins = pins_block.replacen(+        &format!("(symbol \"{}_1_1\"", graphics_source_name),+        &format!("(symbol \"{}_1_1\"", input.symbol_name),+        1,+    );++    // Apply pin overrides.+    for ov in &input.pin_overrides {+        if let (Some(o), Some(n)) = (&ov.old_number, &ov.new_number) {+            let re = Regex::new(&format!(r#"\(number "{}""#, esc_regex(o))).unwrap();+            renamed_pins = re.replace_all(&renamed_pins, format!("(number \"{}\"", n).as_str()).into_owned();+        }+        if let (Some(o), Some(n)) = (&ov.old_name, &ov.new_name) {+            let re = Regex::new(&format!(r#"\(name "{}""#, esc_regex(o))).unwrap();+            renamed_pins = re.replace_all(&renamed_pins, format!("(name \"{}\"", n).as_str()).into_owned();+        }+    }+    // (renamed_graphics is not mutated after this point; bind to silence warning)+    let _ = &mut renamed_graphics;++    let mut lines: Vec<String> = Vec::new();+    lines.push("(kicad_symbol_lib".to_string());+    lines.push("  (version 20231120)".to_string());+    lines.push("  (generator \"adom\")".to_string());+    lines.push(format!("  (symbol \"{}\"", input.symbol_name));++    if header.hide_pin_names {+        lines.push("    (pin_numbers (hide yes))".to_string());+        lines.push(format!("    (pin_names (offset {}) (hide yes))", header.pin_name_offset));+    } else {+        lines.push(format!("    (pin_names (offset {}))", header.pin_name_offset));+    }+    lines.push("    (exclude_from_sim no)".to_string());+    lines.push("    (in_bom yes)".to_string());+    lines.push("    (on_board yes)".to_string());++    lines.push(format!("    (property \"Reference\" \"{}\"", ref_prefix));+    lines.push(format!("      (at {})", ref_at));+    lines.push(format!("      {}", ref_effects));+    lines.push("    )".to_string());+    lines.push(format!("    (property \"Value\" \"{}\"", prop_value));+    lines.push(format!("      (at {})", val_at));+    lines.push(format!("      {}", val_effects));+    lines.push("    )".to_string());+    lines.push(format!("    (property \"Footprint\" \"{}\"", prop_footprint));+    lines.push("      (at 0 0 0)".to_string());+    lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());+    lines.push("    )".to_string());+    lines.push(format!("    (property \"Datasheet\" \"{}\"", prop_datasheet));+    lines.push("      (at 0 0 0)".to_string());+    lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());+    lines.push("    )".to_string());+    lines.push(format!("    (property \"Description\" \"{}\"", prop_description));+    lines.push("      (at 0 0 0)".to_string());+    lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());+    lines.push("    )".to_string());+    if !prop_manufacturer.is_empty() {+        lines.push(format!("    (property \"Manufacturer\" \"{}\"", prop_manufacturer));+        lines.push("      (at 0 0 0)".to_string());+        lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());+        lines.push("    )".to_string());+    }++    lines.push(renamed_graphics);+    lines.push(renamed_pins);+    lines.push("  )".to_string());+    lines.push(")".to_string());+    lines.push(String::new());++    Ok(lines.join("\n"))+}++fn first_nonempty(opts: &[Option<String>]) -> String {+    for o in opts {+        if let Some(s) = o {+            if !s.is_empty() {+                return s.clone();+            }+        }+    }+    String::new()+}++/// Extract a top-level `(symbol "NAME" …)` block by exact-quote match + paren balance.+fn extract_top_symbol(lib: &str, name: &str) -> Option<String> {+    let marker = format!("(symbol \"{}\"", name);+    let start = lib.find(&marker)?;+    let bytes = lib.as_bytes();+    let mut depth = 0i32;+    let mut end = start;+    for i in start..lib.len() {+        match bytes[i] {+            b'(' => depth += 1,+            b')' => {+                depth -= 1;+                if depth == 0 {+                    end = i;+                    break;+                }+            }+            _ => {}+        }+    }+    Some(lib[start..=end].to_string())+}++/// Extract a `(symbol "NAME<suffix>" …)` sub-block, preserving the leading+/// indentation of its line (matches Node `extractSubSymbol`).+fn extract_sub_symbol(content: &str, name: &str, suffix: &str) -> String {+    let target = format!("(symbol \"{}{}\"", name, suffix);+    let start = match content.find(&target) {+        Some(s) => s,+        None => return String::new(),+    };+    let bytes = content.as_bytes();+    let mut depth = 0i32;+    let mut end = start;+    for i in start..content.len() {+        match bytes[i] {+            b'(' => depth += 1,+            b')' => {+                depth -= 1;+                if depth == 0 {+                    end = i;+                    break;+                }+            }+            _ => {}+        }+    }+    let line_start = content[..start].rfind('\n').map(|p| p + 1).unwrap_or(0);+    content[line_start..=end].to_string()+}++fn extract_header(content: &str) -> Header {+    let hide_pin_names = Regex::new(r"(?s)\(pin_names.*?\(hide\s+yes\)").unwrap().is_match(content);+    let offset = Regex::new(r"\(pin_names\s*\n?\s*\(offset\s+([\d.]+)\)")+        .unwrap()+        .captures(content)+        .map(|c| c[1].to_string())+        .unwrap_or_else(|| "1.016".to_string());+    Header { hide_pin_names, pin_name_offset: offset }+}++fn extract_property(content: &str, prop: &str) -> Option<String> {+    let re = Regex::new(&format!(r#"\(property "{}" "([^"]*)""#, esc_regex(prop))).unwrap();+    re.captures(content).map(|c| c[1].to_string())+}++fn extract_property_at(content: &str, prop: &str) -> Option<String> {+    let re = Regex::new(&format!(r#"\(property "{}" "[^"]*"\s*\n\s*\(at\s+([^)]+)\)"#, esc_regex(prop))).unwrap();+    re.captures(content).map(|c| c[1].trim().to_string())+}++fn extract_property_effects(content: &str, prop: &str) -> Option<String> {+    let prop_start = content.find(&format!("(property \"{}\"", prop))?;+    let effects_rel = content[prop_start..].find("(effects")?;+    let effects_start = prop_start + effects_rel;+    let prop_end = find_matching_paren(content, prop_start);+    if effects_start > prop_end {+        return None;+    }+    let effects_end = find_matching_paren(content, effects_start);+    Some(content[effects_start..=effects_end].to_string())+}++fn find_matching_paren(content: &str, start: usize) -> usize {+    let bytes = content.as_bytes();+    let mut depth = 0i32;+    for i in start..content.len() {+        match bytes[i] {+            b'(' => depth += 1,+            b')' => {+                depth -= 1;+                if depth == 0 {+                    return i;+                }+            }+            _ => {}+        }+    }+    content.len().saturating_sub(1)+}++fn esc_regex(s: &str) -> String {+    regex::escape(s)+}++#[cfg(test)]+mod tests {+    use super::*;++    fn fixture_fetch(_lib: &str) -> Result<String> {+        Ok(include_str!("../tests/golden/Device.kicad_sym").to_string())+    }++    #[test]+    fn discrete_r_is_a_real_resistor_not_feedthrough() {+        let input = DiscreteInput {+            symbol_name: "R-TEST-10K".into(),+            manufacturer: "Yageo".into(),+            package: "R_0402_1005Metric".into(),+            description: "10k 1% 0402".into(),+            datasheet_url: "https://yageo/r.pdf".into(),+            reference_prefix: Some("R".into()),+            baseline_library: "Device".into(),+            baseline_symbol: "R".into(),+            pin_overrides: vec![],+        };+        let out = generate_discrete_sym(&input, &fixture_fetch).unwrap();++        // The bug fix: a resistor has exactly 2 pins (the broken Node path gave 3 = C_Feedthrough).+        assert_eq!(out.matches("(pin ").count(), 2, "resistor must have 2 pins, not C_Feedthrough's 3");+        assert!(out.contains("(symbol \"R-TEST-10K_0_1\""), "renamed graphics sub-symbol");+        assert!(out.contains("(symbol \"R-TEST-10K_1_1\""), "renamed pins sub-symbol");+        assert!(out.contains("(property \"Reference\" \"R\""), "reference prefix R");+        assert!(out.contains("(property \"Value\" \"R-TEST-10K\""));+        assert!(out.contains("(property \"Footprint\" \"R_0402_1005Metric\""));+        assert!(!out.contains("C_Feedthrough"), "must not leak the C_Feedthrough baseline");+        assert!(out.starts_with("(kicad_symbol_lib"));+    }+}
src/ds2sf.rsadded+179
@@ -0,0 +1,179 @@+//! Read a ds2sf `<mpn>-symbol.extracted.json` (datasheet-extracted pins with+//! logical groups + rich descriptions) and build an `IcInput` for our generator+//! with **balanced side assignment** and optional logical grouping. Also returns+//! the pin/group description maps for the viewer tooltips.+//!+//! ds2sf already groups pins (POWER, GROUND, GPIO, DEBUG, …); GPIO is sub-split+//! by port (P0/P1/P2) so a 31-pin GPIO group doesn't pile onto one side.++use crate::sym_gen::{IcInput, Pin};+use regex::Regex;+use serde::Deserialize;+use std::collections::HashMap;++#[derive(Deserialize, Clone)]+pub struct Ds2sfPin {+    pub name: String,+    pub number: String,+    #[serde(rename = "type", default)]+    pub ptype: String,+    #[serde(default)]+    pub group: String,+    #[serde(default)]+    pub description: String,+}++#[derive(Deserialize, Clone, Default)]+pub struct Ds2sfGroup {+    pub name: String,+    #[serde(default)]+    pub description: String,+}++#[derive(Deserialize, Default)]+pub struct Ds2sfSymbol {+    #[serde(rename = "symbolName", default)]+    pub symbol_name: String,+    #[serde(rename = "referencePrefix", default)]+    pub reference_prefix: String,+    #[serde(default)]+    pub package: String,+    #[serde(default)]+    pub manufacturer: String,+    #[serde(default)]+    pub description: String,+    #[serde(default)]+    pub pins: Vec<Ds2sfPin>,+    #[serde(default)]+    pub groups: Vec<Ds2sfGroup>,+}++pub fn parse(json: &str) -> anyhow::Result<Ds2sfSymbol> {+    Ok(serde_json::from_str(json)?)+}++pub struct LayoutOpts {+    pub pin_layout: String, // "left-right" | "all-sides"+    pub grouped: bool,+    pub ref_pos: String,+    pub value_pos: String,+}++/// Build an IcInput from ds2sf pins. Returns (input, pin name/number→desc,+/// group→desc).+pub fn build_ic_input(+    sym: &Ds2sfSymbol,+    opts: &LayoutOpts,+) -> (IcInput, HashMap<String, String>, HashMap<String, String>) {+    // Description maps for the viewer.+    let mut pin_desc: HashMap<String, String> = HashMap::new();+    for p in &sym.pins {+        if !p.description.is_empty() {+            pin_desc.insert(p.name.clone(), p.description.clone());+            pin_desc.insert(p.number.clone(), p.description.clone());+        }+    }+    let mut group_desc: HashMap<String, String> = HashMap::new();+    for g in &sym.groups {+        group_desc.insert(g.name.clone(), g.description.clone());+    }++    // Effective group label per pin: sub-split GPIO by port (P0/P1/P2).+    let port_re = Regex::new(r"^P(\d+)").unwrap();+    let eff_group = |p: &Ds2sfPin| -> String {+        if p.group.eq_ignore_ascii_case("GPIO") {+            if let Some(c) = port_re.captures(&p.name) {+                return format!("P{}", &c[1]);+            }+        }+        p.group.clone()+    };+    // Carry port sub-group descriptions from the parent GPIO group.+    if let Some(gpio) = sym.groups.iter().find(|g| g.name.eq_ignore_ascii_case("GPIO")) {+        for p in &sym.pins {+            let eg = eff_group(p);+            if eg.starts_with('P') && eg.len() <= 3 {+                group_desc.entry(eg).or_insert_with(|| gpio.description.clone());+            }+        }+    }++    // Collect groups in first-seen order with their pins.+    let mut order: Vec<String> = Vec::new();+    let mut by_group: HashMap<String, Vec<&Ds2sfPin>> = HashMap::new();+    for p in &sym.pins {+        let g = eff_group(p);+        if !by_group.contains_key(&g) {+            order.push(g.clone());+        }+        by_group.entry(g).or_default().push(p);+    }++    // Sides to balance across.+    let sides: Vec<&str> = if opts.pin_layout == "all-sides" {+        vec!["left", "top", "right", "bottom"]+    } else {+        vec!["left", "right"]+    };++    // Greedy bin-pack groups (largest first) into the least-full side.+    let mut group_side: HashMap<String, String> = HashMap::new();+    if opts.grouped {+        let mut sized: Vec<(String, usize)> =+            order.iter().map(|g| (g.clone(), by_group[g].len())).collect();+        sized.sort_by(|a, b| b.1.cmp(&a.1));+        let mut load: Vec<usize> = vec![0; sides.len()];+        for (g, n) in sized {+            let i = (0..sides.len()).min_by_key(|&i| load[i]).unwrap();+            load[i] += n;+            group_side.insert(g, sides[i].to_string());+        }+    } else {+        // Ungrouped: split the flat pin list evenly across the sides.+        let total = sym.pins.len();+        let per = (total + sides.len() - 1) / sides.len();+        for (i, p) in sym.pins.iter().enumerate() {+            group_side.insert(format!("__{}", p.number), sides[(i / per).min(sides.len() - 1)].to_string());+        }+    }++    // Emit pins ordered by side, then group order, each with explicit side.+    let mut out: Vec<Pin> = Vec::new();+    for side in &sides {+        for g in &order {+            for p in &by_group[g] {+                let pin_side = if opts.grouped {+                    group_side.get(g).cloned()+                } else {+                    group_side.get(&format!("__{}", p.number)).cloned()+                };+                if pin_side.as_deref() != Some(*side) {+                    continue;+                }+                out.push(Pin {+                    name: p.name.clone(),+                    number: p.number.clone(),+                    ptype: p.ptype.clone(),+                    group: if opts.grouped { Some(g.clone()) } else { None },+                    side: Some(side.to_string()),+                });+            }+        }+    }++    let input = IcInput {+        symbol_name: sym.symbol_name.clone(),+        manufacturer: sym.manufacturer.clone(),+        package: sym.package.clone(),+        description: sym.description.clone(),+        datasheet_url: String::new(),+        reference_prefix: if sym.reference_prefix.is_empty() { "U".into() } else { sym.reference_prefix.clone() },+        pins: out,+        pin_layout: opts.pin_layout.clone(),+        chip_name_centered: false,+        ref_pos: opts.ref_pos.clone(),+        value_pos: opts.value_pos.clone(),+        distributor_pn: Vec::new(),+    };+    (input, pin_desc, group_desc)+}
src/main.rsadded+378
@@ -0,0 +1,378 @@+//! adom-symbol — standalone Rust CLI for creating + previewing KiCad symbols.+//! No Node, no gallia. Generation is native Rust (`sym_gen`/`discrete_gen`);+//! SVG rendering goes through service-kicad (`render`).++mod discrete_gen;+mod deliver;+mod ds2sf;+mod parse;+mod render;+mod server;+mod sym_gen;+mod theme;++use anyhow::{anyhow, Context, Result};+use clap::{Parser, Subcommand};+use std::fs;+use std::path::PathBuf;++#[derive(Parser)]+#[command(name = "adom-symbol", version, about = "Create and preview KiCad/Fusion 360 schematic symbols (standalone Rust, no gallia)")]+struct Cli {+    #[command(subcommand)]+    command: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// Create a symbol from structured pin data (JSON on stdin or --file).+    Create {+        #[arg(long)]+        file: Option<PathBuf>,+        /// Output directory (default: ./<symbolName>/)+        #[arg(long)]+        out: Option<PathBuf>,+    },+    /// Render an existing .kicad_sym to a themed viewer HTML.+    Preview {+        /// Path to a .kicad_sym file+        #[arg(long)]+        file: PathBuf,+        /// Symbol name (defaults to the first symbol in the file)+        #[arg(long)]+        name: Option<String>,+        /// Where to write the viewer HTML (default: alongside the .kicad_sym)+        #[arg(long)]+        out: Option<PathBuf>,+    },+    /// Run the live app server (AI-drivable: /state, /eval, /console).+    Serve {+        #[arg(long, default_value_t = 8781)]+        port: u16,+        /// Optional .kicad_sym to load into the view on startup+        #[arg(long)]+        file: Option<PathBuf>,+        #[arg(long)]+        name: Option<String>,+    },+    /// Render any `.kicad_sym` to a themed thumbnail SVG (for source variants).+    Render {+        #[arg(long)]+        file: PathBuf,+        #[arg(long)]+        out: PathBuf,+    },+    /// Render a ds2sf `<mpn>-symbol.extracted.json` to a themed thumbnail SVG+    /// (auto-grouped via our generator). Used by chip-fetcher for the ds2sf+    /// source variant. Also writes the generated .kicad_sym if --sym is given.+    RenderDs2sf {+        /// Path to the ds2sf extracted JSON+        #[arg(long)]+        file: PathBuf,+        /// Output SVG path+        #[arg(long)]+        out: PathBuf,+        /// Pin layout: left-right | all-sides+        #[arg(long, default_value = "left-right")]+        layout: String,+        /// Also write the generated .kicad_sym here+        #[arg(long)]+        sym: Option<PathBuf>,+    },+}++// ── create input (matches the Node /sym/create body) ──+#[derive(serde::Deserialize)]+struct CreateInput {+    #[serde(rename = "symbolName")]+    symbol_name: String,+    #[serde(default)]+    manufacturer: String,+    #[serde(default)]+    package: String,+    #[serde(default)]+    description: String,+    #[serde(default, rename = "datasheetUrl")]+    datasheet_url: String,+    #[serde(default = "default_category")]+    category: String,+    #[serde(default)]+    pins: Vec<JsonPin>,+    #[serde(default, rename = "referencePrefix")]+    reference_prefix: Option<String>,+    #[serde(default, rename = "discreteBaseline")]+    discrete_baseline: Option<DiscreteBaseline>,+    #[serde(default, rename = "pinOverrides")]+    pin_overrides: Vec<JsonPinOverride>,+    #[serde(default)]+    preferences: Preferences,+}++fn default_category() -> String {+    "ic".to_string()+}++#[derive(serde::Deserialize)]+struct JsonPin {+    name: String,+    number: String,+    #[serde(rename = "type")]+    ptype: String,+    #[serde(default)]+    group: Option<String>,+    #[serde(default)]+    side: Option<String>,+}++#[derive(serde::Deserialize)]+struct DiscreteBaseline {+    library: String,+    symbol: String,+}++#[derive(serde::Deserialize, Default)]+struct JsonPinOverride {+    #[serde(rename = "oldNumber")]+    old_number: Option<String>,+    #[serde(rename = "newNumber")]+    new_number: Option<String>,+    #[serde(rename = "oldName")]+    old_name: Option<String>,+    #[serde(rename = "newName")]+    new_name: Option<String>,+}++#[derive(serde::Deserialize, Default)]+struct Preferences {+    #[serde(default, rename = "pinLayout")]+    pin_layout: Option<String>,+    #[serde(default, rename = "chipNameCentered")]+    chip_name_centered: bool,+    #[serde(default, rename = "distributorPn")]+    distributor_pn: Option<serde_json::Map<String, serde_json::Value>>,+}++fn main() {+    if let Err(e) = run() {+        eprintln!("ERROR: {e:#}");+        std::process::exit(1);+    }+}++fn run() -> Result<()> {+    match Cli::parse().command {+        Cmd::Create { file, out } => cmd_create(file, out),+        Cmd::Render { file, out } => {+            let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;+            let name = first_symbol_name(&content).unwrap_or_else(|| "symbol".to_string());+            let svg = render::render_symbol_svg(&content, &name)?;+            fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;+            println!("OK: rendered {} → {}", file.display(), out.display());+            Ok(())+        }+        Cmd::RenderDs2sf { file, out, layout, sym } => cmd_render_ds2sf(file, out, layout, sym),+        Cmd::Preview { file, name, out } => cmd_preview(file, name, out),+        Cmd::Serve { port, file, name } => {+            if let Some(f) = file {+                server::load_symbol_into_state(&f, name.as_deref())?;+            }+            server::run(port)+        }+    }+}++fn cmd_create(file: Option<PathBuf>, out: Option<PathBuf>) -> Result<()> {+    let body = match file {+        Some(p) => fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?,+        None => {+            use std::io::Read;+            let mut s = String::new();+            std::io::stdin().read_to_string(&mut s)?;+            s+        }+    };+    let input: CreateInput = serde_json::from_str(&body).context("parsing create JSON")?;+    if input.symbol_name.is_empty() {+        return Err(anyhow!("symbolName is required"));+    }++    let kicad_sym = if input.category == "discrete" {+        let baseline = input+            .discrete_baseline+            .as_ref()+            .ok_or_else(|| anyhow!("discreteBaseline {{library,symbol}} required for category=discrete"))?;+        let dinput = discrete_gen::DiscreteInput {+            symbol_name: input.symbol_name.clone(),+            manufacturer: input.manufacturer.clone(),+            package: input.package.clone(),+            description: input.description.clone(),+            datasheet_url: input.datasheet_url.clone(),+            reference_prefix: input.reference_prefix.clone(),+            baseline_library: baseline.library.clone(),+            baseline_symbol: baseline.symbol.clone(),+            pin_overrides: input+                .pin_overrides+                .iter()+                .map(|o| discrete_gen::PinOverride {+                    old_number: o.old_number.clone(),+                    new_number: o.new_number.clone(),+                    old_name: o.old_name.clone(),+                    new_name: o.new_name.clone(),+                })+                .collect(),+        };+        discrete_gen::generate_discrete_sym(&dinput, &fetch_lib_from_service)?+    } else {+        if input.pins.is_empty() {+            return Err(anyhow!("pins array is required for category=ic"));+        }+        let icinput = sym_gen::IcInput {+            symbol_name: input.symbol_name.clone(),+            manufacturer: input.manufacturer.clone(),+            package: input.package.clone(),+            description: input.description.clone(),+            datasheet_url: input.datasheet_url.clone(),+            reference_prefix: input.reference_prefix.clone().unwrap_or_else(|| "U".to_string()),+            pins: input+                .pins+                .iter()+                .map(|p| sym_gen::Pin {+                    name: p.name.clone(),+                    number: p.number.clone(),+                    ptype: p.ptype.clone(),+                    group: p.group.clone(),+                    side: p.side.clone(),+                })+                .collect(),+            pin_layout: input.preferences.pin_layout.clone().unwrap_or_else(|| "left-right".to_string()),+            chip_name_centered: input.preferences.chip_name_centered,+            ref_pos: "top".to_string(),+            value_pos: "out-bot".to_string(),+            distributor_pn: input+                .preferences+                .distributor_pn+                .as_ref()+                .map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect())+                .unwrap_or_default(),+        };+        sym_gen::generate_ic_sym(&icinput)+    };++    let folder = out.unwrap_or_else(|| PathBuf::from(&input.symbol_name));+    fs::create_dir_all(&folder)?;+    let sym_path = folder.join(format!("{}.kicad_sym", input.symbol_name));+    fs::write(&sym_path, &kicad_sym)?;++    let (svg, html_path) = render_and_view(&kicad_sym, &input.symbol_name, &folder, Some(&input))?;+    let svg_path = folder.join(format!("{}.svg", input.symbol_name));+    fs::write(&svg_path, &svg)?;++    println!("created {}", sym_path.display());+    println!("svg     {}", svg_path.display());+    println!("viewer  {}", html_path.display());+    Ok(())+}++fn cmd_render_ds2sf(file: PathBuf, out: PathBuf, layout: String, sym: Option<PathBuf>) -> Result<()> {+    let json = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;+    let parsed = ds2sf::parse(&json)?;+    let opts = ds2sf::LayoutOpts { pin_layout: layout, grouped: true, ref_pos: "out-top".to_string(), value_pos: "out-bot".to_string() };+    let (input, _pin_desc, _group_desc) = ds2sf::build_ic_input(&parsed, &opts);+    if input.pins.is_empty() {+        return Err(anyhow!("ds2sf extraction has no pins"));+    }+    let kicad_sym = sym_gen::generate_ic_sym(&input);+    if let Some(sp) = sym {+        fs::write(&sp, &kicad_sym).with_context(|| format!("writing {}", sp.display()))?;+    }+    let svg = render::render_symbol_svg(&kicad_sym, &input.symbol_name)?;+    fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;+    println!("OK: rendered ds2sf symbol → {} ({} pins)", out.display(), input.pins.len());+    Ok(())+}++fn cmd_preview(file: PathBuf, name: Option<String>, out: Option<PathBuf>) -> Result<()> {+    let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;+    let symbol_name = name.unwrap_or_else(|| first_symbol_name(&content).unwrap_or_else(|| "symbol".to_string()));+    let folder = out.unwrap_or_else(|| file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")));+    let (_svg, html_path) = render_and_view(&content, &symbol_name, &folder, None)?;+    println!("viewer {}", html_path.display());+    Ok(())+}++fn render_and_view(kicad_sym: &str, symbol_name: &str, folder: &std::path::Path, input: Option<&CreateInput>) -> Result<(String, PathBuf)> {+    let svg = render::render_symbol_svg(kicad_sym, symbol_name)?;+    let (mfr, pkg, desc) = match input {+        Some(i) => (i.manufacturer.clone(), i.package.clone(), i.description.clone()),+        None => (String::new(), String::new(), String::new()),+    };+    let html = build_viewer_html(symbol_name, &svg, &mfr, &pkg, &desc);+    let html_path = folder.join(format!("{symbol_name}-viewer.html"));+    fs::write(&html_path, &html)?;+    Ok((svg, html_path))+}++fn fetch_lib_from_service(library: &str) -> Result<String> {+    let url = format!(+        "{}/symbols/{}.kicad_sym",+        std::env::var("KICAD_SERVICE_API").unwrap_or_else(|_| "https://kicad-rk5ue5pcfemi.adom.cloud".to_string()).trim_end_matches('/'),+        library+    );+    let resp = reqwest::blocking::get(&url).with_context(|| format!("GET {url}"))?;+    if !resp.status().is_success() {+        return Err(anyhow!("service-kicad {} for library {library}", resp.status()));+    }+    Ok(resp.text()?)+}++pub(crate) fn first_symbol_name(content: &str) -> Option<String> {+    let i = content.find("(symbol \"")? + "(symbol \"".len();+    let j = content[i..].find('"')? + i;+    Some(content[i..j].to_string())+}++fn build_viewer_html(name: &str, svg: &str, mfr: &str, pkg: &str, desc: &str) -> String {+    let meta: String = [mfr, pkg].iter().filter(|s| !s.is_empty()).cloned().collect::<Vec<_>>().join(" · ");+    let desc_html = if desc.is_empty() { String::new() } else { format!("<div class=\"desc\">{}</div>", esc(desc)) };+    let meta_html = if meta.is_empty() { String::new() } else { format!("<div class=\"meta\">{}</div>", esc(&meta)) };+    format!(+        r#"<!doctype html><html><head><meta charset="utf-8"><title>{title} — adom-symbol</title>+<style>+  :root {{ --bg:#0d1117; --panel:#11161f; --border:#222b38; --text:#e6edf3; --muted:#8b949e; --accent:#00e6dc; }}+  * {{ box-sizing:border-box; }}+  body {{ margin:0; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; min-height:100vh; display:flex; flex-direction:column; }}+  header {{ display:flex; align-items:center; gap:10px; padding:14px 22px; border-bottom:1px solid var(--border); }}+  header .dot {{ width:10px; height:10px; border-radius:50%; background:var(--accent); box-shadow:0 0 12px var(--accent); }}+  header .brand {{ font-weight:700; letter-spacing:.2px; }}+  header .brand span {{ color:var(--muted); font-weight:400; }}+  .wrap {{ flex:1; display:flex; align-items:center; justify-content:center; padding:32px; }}+  .card {{ background:var(--panel); border:1px solid var(--border); border-radius:14px; padding:22px 26px; box-shadow:0 24px 80px rgba(0,0,0,.5); max-width:min(92vw,720px); }}+  .name {{ font-size:22px; font-weight:700; }}+  .meta {{ color:var(--muted); font-size:13px; margin-top:2px; }}+  .desc {{ color:#aeb6c2; font-size:13px; margin-top:6px; line-height:1.4; }}+  .svgbox {{ margin-top:16px; border:1px solid var(--border); border-radius:10px; background:#0d1117; overflow:hidden; }}+  .svgbox svg {{ display:block; width:100%; height:auto; max-height:64vh; }}+  footer {{ text-align:center; color:var(--muted); font-size:11px; padding:10px; }}+</style></head>+<body>+  <header><span class="dot"></span><span class="brand">adom-symbol <span>· KiCad symbol viewer</span></span></header>+  <div class="wrap">+    <div class="card">+      <div class="name">{title}</div>+      {meta_html}+      {desc_html}+      <div class="svgbox">{svg}</div>+    </div>+  </div>+  <footer>rendered via service-kicad — standalone, no local KiCad</footer>+</body></html>"#,+        title = esc(name),+        meta_html = meta_html,+        desc_html = desc_html,+        svg = svg,+    )+}++fn esc(s: &str) -> String {+    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")+}
src/parse.rsadded+252
@@ -0,0 +1,252 @@+//! Parse a `.kicad_sym` for the interactive viewer: pin list (name/number/type/+//! position/angle/length), group labels, body rectangle, and the KiCad→SVG+//! coordinate offset (matched against the rendered SVG's pin-wire paths so the+//! browser can overlay hit-zones exactly on the rendered symbol).+//!+//! Faithful port of gallia `viewer-gen.js` `parseKicadSym` ++//! `computeKicadToSvgOffset`. No gallia, no Node.++use regex::Regex;+use serde::Serialize;++#[derive(Serialize, Clone, Default)]+pub struct Pin {+    pub name: String,+    pub number: String,+    /// human label, e.g. "Power Input"+    pub r#type: String,+    /// raw enum, e.g. "power_in"+    pub etype: String,+    /// hex color for the type badge+    pub color: String,+    pub x: f64,+    pub y: f64,+    pub angle: i64,+    pub length: f64,+    pub desc: String,+}++#[derive(Serialize, Clone, Default)]+pub struct Group {+    pub name: String,+    pub desc: String,+}++#[derive(Serialize, Clone, Default)]+pub struct BodyRect {+    pub x1: f64,+    pub y1: f64,+    pub x2: f64,+    pub y2: f64,+}++#[derive(Serialize, Clone, Default)]+pub struct SymbolMeta {+    pub name: String,+    pub reference: String,+    pub value: String,+    pub footprint: String,+    pub datasheet: String,+    pub description: String,+    pub manufacturer: String,+    pub pins: Vec<Pin>,+    pub groups: Vec<Group>,+    pub hide_pin_names: bool,+    pub body_rect: Option<BodyRect>,+}++fn type_label(t: &str) -> String {+    match t {+        "power_in" => "Power Input",+        "power_out" => "Power Output",+        "input" => "Input",+        "output" => "Output",+        "bidirectional" => "Bidirectional",+        "passive" => "Passive",+        "tri_state" => "Tri-State",+        "unspecified" => "Unspecified",+        "open_collector" => "Open Collector",+        "open_emitter" => "Open Emitter",+        "unconnected" => "Unconnected",+        "free" => "Free",+        other => other,+    }+    .to_string()+}++fn type_color(t: &str) -> String {+    match t {+        "power_in" | "power_out" => "#ef5350",+        "input" => "#66bb6a",+        "output" => "#42a5f5",+        "bidirectional" => "#ab47bc",+        "passive" => "#78909c",+        _ => "#90a4ae",+    }+    .to_string()+}++/// Parse the `.kicad_sym`. `symbol_name` is the symbol block to read (must exist).+pub fn parse_kicad_sym(content: &str, symbol_name: &str) -> SymbolMeta {+    let mut meta = SymbolMeta { name: symbol_name.to_string(), ..Default::default() };++    let hide_names = Regex::new(r"(?m)\(pin_names[^\n]*\(hide\s+yes\)").unwrap();+    if hide_names.is_match(content) {+        meta.hide_pin_names = true;+    }++    // The requested symbol's block to end of file (single-symbol files).+    let esc = regex::escape(symbol_name);+    let sym_re = Regex::new(&format!(r#"(?s)\(symbol "{esc}".*"#)).unwrap();+    let mut sym_block = match sym_re.find(content) {+        Some(m) => m.as_str().to_string(),+        None => return meta,+    };++    // Properties from the requested block.+    let prop_re = Regex::new(r#"\(property "(\w+)" "([^"]*)""#).unwrap();+    for c in prop_re.captures_iter(&sym_block) {+        let key = c[1].to_lowercase();+        let val = c[2].to_string();+        match key.as_str() {+            "reference" => meta.reference = val,+            "value" => meta.value = val,+            "footprint" => meta.footprint = val,+            "datasheet" => meta.datasheet = val,+            "description" => meta.description = val,+            "manufacturer" => meta.manufacturer = val,+            _ => {}+        }+    }+    if meta.name.is_empty() {+        meta.name = if !meta.value.is_empty() { meta.value.clone() } else { symbol_name.to_string() };+    }++    // Follow extends for pin/geometry (child only overrides properties).+    let ext_re = Regex::new(r#"\(extends\s+"([^"]+)"\)"#).unwrap();+    if let Some(c) = ext_re.captures(&sym_block) {+        let parent = regex::escape(&c[1]);+        let parent_re = Regex::new(&format!(r#"(?s)\(symbol "{parent}".*"#)).unwrap();+        if let Some(pm) = parent_re.find(content) {+            sym_block = pm.as_str().to_string();+            if hide_names.is_match(&sym_block) {+                meta.hide_pin_names = true;+            }+        }+    }++    // Pins — compact and multi-line.+    // Angle may be integer (gallia-generated) or decimal "180.0"+    // (manufacturer/SnapEDA files). `\)\s+\(name` requires only whitespace+    // before (name, which naturally skips `hide` pins (not drawn in the SVG,+    // so they'd get floating hit-zones).+    let pin_re = Regex::new(+        r#"(?s)\(pin\s+(\w+)\s+(\w+)\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s+\(length\s+([-\d.]+)\)\s+\(name\s+"([^"]*)".*?\(number\s+"([^"]*)""#,+    )+    .unwrap();+    for c in pin_re.captures_iter(&sym_block) {+        let etype = c[1].to_string();+        let name_raw = c[7].to_string();+        let number = c[8].to_string();+        let name = if name_raw == "~" { number.clone() } else { name_raw };+        meta.pins.push(Pin {+            color: type_color(&etype),+            r#type: type_label(&etype),+            etype,+            x: c[3].parse().unwrap_or(0.0),+            y: c[4].parse().unwrap_or(0.0),+            angle: c[5].parse::<f64>().unwrap_or(0.0).round() as i64,+            length: c[6].parse().unwrap_or(2.54),+            name,+            number,+            desc: String::new(),+        });+    }++    // Group labels — (text "LABEL" (at ..) \n (effects (font (size 0.762 0.762) (color 132 0 0 1))+    let grp_re = Regex::new(+        r#"(?s)\(text "([^"]+)"\s+\(at\s+[-\d.]+\s+[-\d.]+\s+\d+\)\s*\(effects\s+\(font\s+\(size\s+0\.762\s+0\.762\)\s+\(color\s+132\s+0\s+0\s+1\)\)"#,+    )+    .unwrap();+    for c in grp_re.captures_iter(&sym_block) {+        meta.groups.push(Group { name: c[1].to_string(), desc: String::new() });+    }++    // Body rectangle.+    let rect_re = Regex::new(+        r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#,+    )+    .unwrap();+    if let Some(c) = rect_re.captures(&sym_block) {+        meta.body_rect = Some(BodyRect {+            x1: c[1].parse().unwrap_or(0.0),+            y1: c[2].parse().unwrap_or(0.0),+            x2: c[3].parse().unwrap_or(0.0),+            y2: c[4].parse().unwrap_or(0.0),+        });+    }++    meta+}++/// Recover the KiCad→SVG offset by matching pin-wire paths in the rendered SVG+/// against pin coords. Mapping: svgX = ox + kicadX, svgY = oy - kicadY.+///+/// Every pin wire is the same length, so a single pin×wire match is ambiguous.+/// Instead we compute a candidate offset for EVERY plausible (pin, wire) pair+/// and take the **consensus** — the offset the most pins agree on. Correctly+/// matched pins all vote for the true global translation; mismatches scatter.+pub fn compute_offset(svg: &str, pins: &[Pin]) -> Option<(f64, f64)> {+    if pins.is_empty() {+        return None;+    }+    let line_re = Regex::new(r#"d="M(-?[\d.]+)\s+(-?[\d.]+)\s+L(-?[\d.]+)\s+(-?[\d.]+)\s*""#).unwrap();+    let lines: Vec<(f64, f64, f64, f64)> = line_re+        .captures_iter(svg)+        .filter_map(|c| Some((c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?, c[4].parse().ok()?)))+        .collect();+    if lines.is_empty() {+        return None;+    }++    use std::collections::HashMap;+    // Bucket candidate offsets to 0.01mm; vote.+    let mut votes: HashMap<(i64, i64), (u32, f64, f64)> = HashMap::new();+    for pin in pins {+        let expected = if pin.length > 0.0 { pin.length } else { 2.54 };+        let is_horiz = pin.angle == 0 || pin.angle == 180;+        for &(x1, y1, x2, y2) in &lines {+            let dx = (x2 - x1).abs();+            let dy = (y2 - y1).abs();+            let len = (dx * dx + dy * dy).sqrt();+            if (len - expected).abs() > 0.05 {+                continue;+            }+            if is_horiz && dy > 0.01 {+                continue;+            }+            if !is_horiz && dx > 0.01 {+                continue;+            }+            let (tip_x, tip_y) = match pin.angle {+                0 => (x1.min(x2), (y1 + y2) / 2.0),+                180 => (x1.max(x2), (y1 + y2) / 2.0),+                90 => ((x1 + x2) / 2.0, y1.max(y2)),+                270 => ((x1 + x2) / 2.0, y1.min(y2)),+                _ => continue,+            };+            let ox = tip_x - pin.x;+            let oy = tip_y + pin.y;+            let key = ((ox * 100.0).round() as i64, (oy * 100.0).round() as i64);+            let e = votes.entry(key).or_insert((0, 0.0, 0.0));+            e.0 += 1;+            e.1 += ox;+            e.2 += oy;+        }+    }+    // Most-voted offset wins; average the exact values in that bucket.+    votes+        .into_values()+        .max_by_key(|&(n, _, _)| n)+        .map(|(n, sx, sy)| (sx / n as f64, sy / n as f64))+}
src/render.rsadded+206
@@ -0,0 +1,206 @@+//! Render a `.kicad_sym` to a themed, cropped dark SVG via **service-kicad**+//! (no local KiCad, no gallia). Wraps the symbol in a one-symbol schematic,+//! POSTs to `/kicad/sch/export/svg`, strips KiCad's page background, crops to+//! the symbol's bounding box, and applies the Adom dark theme.++use anyhow::{anyhow, Context, Result};+use regex::Regex;++const KICAD_SERVICE_DEFAULT: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";++fn service_url() -> String {+    std::env::var("KICAD_SERVICE_API")+        .unwrap_or_else(|_| KICAD_SERVICE_DEFAULT.to_string())+        .trim_end_matches('/')+        .to_string()+}++/// Render the named symbol from `.kicad_sym` content to a themed SVG string.+pub fn render_symbol_svg(kicad_sym: &str, symbol_name: &str) -> Result<String> {+    let block = extract_symbol_block(kicad_sym, symbol_name)+        .ok_or_else(|| anyhow!("symbol \"{symbol_name}\" not found in .kicad_sym"))?;+    let sch = build_schematic(symbol_name, &block);++    let url = format!("{}/kicad/sch/export/svg", service_url());+    let resp = reqwest::blocking::Client::builder()+        .timeout(std::time::Duration::from_secs(60))+        .build()?+        .post(&url)+        .header("Content-Type", "application/octet-stream")+        .body(sch)+        .send()+        .with_context(|| format!("POST {url}"))?;+    if !resp.status().is_success() {+        return Err(anyhow!("service-kicad {} — {}", resp.status(), resp.text().unwrap_or_default()));+    }+    let svg = resp.text()?;++    // Drop KiCad 10's warm-white page background so the dark canvas shows.+    let svg = svg.replace("fill:#F5F4EF", "fill:none").replace("stroke:#F5F4EF", "stroke:none");+    let cropped = crop_to_symbol(&svg, &block);+    Ok(crate::theme::apply_dark_theme_to_symbol(&cropped))+}++fn extract_symbol_block(content: &str, name: &str) -> Option<String> {+    let marker = format!("(symbol \"{name}\"");+    let start = content.find(&marker)?;+    let bytes = content.as_bytes();+    let mut depth = 0i32;+    let mut end = start;+    for i in start..content.len() {+        match bytes[i] {+            b'(' => depth += 1,+            b')' => {+                depth -= 1;+                if depth == 0 {+                    end = i;+                    break;+                }+            }+            _ => {}+        }+    }+    Some(content[start..=end].to_string())+}++fn build_schematic(name: &str, block: &str) -> String {+    // KiCad ignores the library symbol's Reference/Value position unless the+    // schematic INSTANCE carries explicit property placements. Mirror the lib+    // symbol's positions (symbol placed at 100,100; schematic Y flips) so the+    // designator renders exactly where ref_pos put it.+    let (rx, ry) = prop_at(block, "Reference").unwrap_or((0.0, 12.7));+    let (vx, vy) = prop_at(block, "Value").unwrap_or((0.0, -12.7));+    let rpre = prop_val(block, "Reference").unwrap_or_else(|| "U".to_string());+    let rjust = prop_justify(block, "Reference");+    let vjust = prop_justify(block, "Value");+    // If the library symbol hides the Value (chip name laser-marked on the 3D+    // chip instead), the schematic instance must hide it too — otherwise the+    // visible instance overrides the hidden library property and the label+    // reappears under the symbol.+    let vhide = if prop_hidden(block, "Value") { " hide" } else { "" };+    let (srx, sry) = (100.0 + rx, 100.0 - ry);+    let (svx, svy) = (100.0 + vx, 100.0 - vy);+    format!(+        "(kicad_sch (version 20211123) (generator eeschema)\n  \+         (uuid \"00000000-0000-0000-0000-000000000001\")\n  (paper \"A4\")\n  \+         (lib_symbols\n    {block}\n  )\n  \+         (symbol (lib_id \"{name}\") (at 100 100 0) (unit 1)\n    \+         (in_bom yes) (on_board yes)\n    \+         (uuid \"00000000-0000-0000-0000-000000000002\")\n    \+         (property \"Reference\" \"{rpre}?\" (at {srx:.3} {sry:.3} 0) (effects (font (size 1.27 1.27)){rjust}))\n    \+         (property \"Value\" \"{name}\" (at {svx:.3} {svy:.3} 0) (effects (font (size 1.27 1.27)){vjust}{vhide}))\n  )\n  \+         (sheet_instances (path \"/\" (page \"1\")))\n)\n"+    )+}++/// Extract the `(at x y …)` of a named visible property in the symbol block.+fn prop_at(block: &str, name: &str) -> Option<(f64, f64)> {+    let re = Regex::new(&format!(r#"(?s)\(property "{name}" "[^"]*"\s*\(at (-?[\d.]+) (-?[\d.]+)"#)).unwrap();+    let c = re.captures(block)?;+    Some((c[1].parse().ok()?, c[2].parse().ok()?))+}++fn prop_val(block: &str, name: &str) -> Option<String> {+    let re = Regex::new(&format!(r#"\(property "{name}" "([^"]*)""#)).unwrap();+    re.captures(block).map(|c| c[1].to_string())+}++/// Whether a named property carries the `hide` flag — extract just that+/// property's balanced-paren block and look for `hide` inside it (so a later+/// always-hidden property like Footprint can't false-positive a visible Value).+fn prop_hidden(block: &str, name: &str) -> bool {+    let marker = format!("(property \"{name}\"");+    let Some(start) = block.find(&marker) else { return false };+    let bytes = block.as_bytes();+    let mut depth = 0i32;+    for i in start..block.len() {+        match bytes[i] {+            b'(' => depth += 1,+            b')' => {+                depth -= 1;+                if depth == 0 {+                    return block[start..=i].contains("hide");+                }+            }+            _ => {}+        }+    }+    false+}++/// Extract the property's ` (justify …)` clause (so left/right placements render+/// aligned), or empty if none.+fn prop_justify(block: &str, name: &str) -> String {+    let re = Regex::new(&format!(r#"(?s)\(property "{name}".*?\(effects[^)]*?(\(justify [^)]+\))"#)).unwrap();+    re.captures(block).map(|c| format!(" {}", &c[1])).unwrap_or_default()+}++fn crop_to_symbol(svg: &str, block: &str) -> String {+    let (minx, maxx, miny, maxy) = match symbol_local_bbox(block) {+        Some(b) => b,+        None => return svg.to_string(),+    };+    const M: f64 = 4.0;+    const PX: f64 = 100.0;+    const PY: f64 = 100.0;+    let (sx0, sx1) = (PX + minx - M, PX + maxx + M);+    let (sy0, sy1) = (PY - maxy - M, PY - miny + M); // KiCad placement Y-flip+    let vbx = sx0.min(sx1);+    let vby = sy0.min(sy1);+    let vbw = (sx1 - sx0).abs();+    let vbh = (sy1 - sy0).abs();+    let re = Regex::new(r#"width="[^"]*mm" height="[^"]*mm" viewBox="[^"]*""#).unwrap();+    let replacement = format!(+        "width=\"{vbw:.3}mm\" height=\"{vbh:.3}mm\" viewBox=\"{vbx:.4} {vby:.4} {vbw:.4} {vbh:.4}\""+    );+    re.replace(svg, replacement.as_str()).into_owned()+}++fn symbol_local_bbox(block: &str) -> Option<(f64, f64, f64, f64)> {+    let mut xs: Vec<f64> = Vec::new();+    let mut ys: Vec<f64> = Vec::new();+    let xy = Regex::new(r"\(xy (-?[\d.]+) (-?[\d.]+)\)").unwrap();+    for c in xy.captures_iter(block) {+        if let (Ok(x), Ok(y)) = (c[1].parse(), c[2].parse()) {+            xs.push(x);+            ys.push(y);+        }+    }+    let se = Regex::new(r"\((?:start|end) (-?[\d.]+) (-?[\d.]+)\)").unwrap();+    for c in se.captures_iter(block) {+        if let (Ok(x), Ok(y)) = (c[1].parse(), c[2].parse()) {+            xs.push(x);+            ys.push(y);+        }+    }+    let pin = Regex::new(r"(?s)\(pin\b.*?\(at (-?[\d.]+) (-?[\d.]+) (-?[\d.]+)\).*?\(length (-?[\d.]+)\)").unwrap();+    for c in pin.captures_iter(block) {+        if let (Ok(x), Ok(y), Ok(a), Ok(l)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {+            let r = a.to_radians();+            for (px, py) in [(x, y), (x + l * r.cos(), y + l * r.sin()), (x - l * r.cos(), y - l * r.sin())] {+                xs.push(px);+                ys.push(py);+            }+        }+    }+    // Include the VISIBLE Reference + Value designators so they aren't cropped+    // out wherever the user places them (ref_pos: top / center / corners).+    let prop = Regex::new(r#"(?s)\(property "(?:Reference|Value)" "[^"]*"\s*\(at (-?[\d.]+) (-?[\d.]+)"#).unwrap();+    for c in prop.captures_iter(block) {+        if let (Ok(x), Ok(y)) = (c[1].parse::<f64>(), c[2].parse::<f64>()) {+            // pad a little for the glyph extent+            for (px, py) in [(x - 3.0, y), (x + 3.0, y), (x, y - 1.5), (x, y + 1.5)] {+                xs.push(px);+                ys.push(py);+            }+        }+    }+    if xs.is_empty() {+        return None;+    }+    let minx = xs.iter().cloned().fold(f64::INFINITY, f64::min);+    let maxx = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);+    let miny = ys.iter().cloned().fold(f64::INFINITY, f64::min);+    let maxy = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);+    Some((minx, maxx, miny, maxy))+}
src/server.rsadded+640
@@ -0,0 +1,640 @@+//! adom-symbol live app server — the AI-drivable surface (app-creator §7/§7b/§8).+//! tiny_http server: GET / (UI), GET /state (poller), POST /load (push a symbol),+//! POST /eval + GET /eval/pending + POST /eval/:id/result + GET /eval/:id (eval+//! channel), POST/GET /console (console forwarding), POST /shutdown, GET+//! /favicon.svg (monochrome). No gallia, no Node.++use anyhow::{Context, Result};+use serde_json::{json, Value};+use std::collections::{HashMap, VecDeque};+use std::io::Read;+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::Mutex;+use tiny_http::{Header, Method, Response, Server};++#[derive(Default, Clone)]+pub struct AppState {+    pub symbol_name: String,+    pub svg: String,+    pub manufacturer: String,+    pub package: String,+    pub description: String,+    pub datasheet: String,+    pub pin_count: usize,+    pub pins: Vec<crate::parse::Pin>,+    pub groups: Vec<crate::parse::Group>,+    pub offset: Option<(f64, f64)>,+    pub hide_pin_names: bool,+    pub body_rect: Option<crate::parse::BodyRect>,+    // ── studio: source + layout options ──+    pub sources: Vec<String>, // available, e.g. ["auto","manufacturer","ds2sf"]+    pub source: String,       // current+    pub pin_layout: String,   // "left-right" | "all-sides"+    pub grouped: bool,+    pub ref_pos: String,+    pub value_pos: String,    // chip-name placement (same set as ref_pos; "none" = hidden)+    pub laser_name: bool,     // "laser" the chip name onto the 3D chip's top surface+    pub three_d: String,      // "off" | "iso" | "thin" | "bold" | "blueprint" | "teal" | "dark"+    pub three_d_assets: std::collections::HashMap<String, String>, // mode → data: URL+    pub source_labels: std::collections::HashMap<String, String>,  // method → display label+    pub kicad_sym: String,    // raw .kicad_sym of the current symbol (for Send-to delivery)+}++/// Map a source slug to a friendly label (mirrors chip-fetcher's source_label).+fn source_label(slug: &str) -> String {+    match slug {+        "auto" => "Auto · grouped",+        "ds2sf" => "ds2sf · datasheet",+        "snapmagic" | "snapeda" => "SnapEDA",+        "ultralibrarian" | "cse_ul" | "ul" => "Ultra Librarian",+        "cse" | "componentsearch" => "Component Search",+        "manufacturer" | "mfr" => "Manufacturer",+        "mouser" => "Mouser",+        "digikey" => "DigiKey",+        "arrow" => "Arrow",+        "lcsc" => "LCSC",+        "kicad_community" => "KiCad community",+        other => other,+    }+    .to_string()+}++/// What the studio is currently showing — drives /relayout regeneration.+#[derive(Default, Clone)]+struct Model {+    dir: String,+    mpn: String,+    has_ds2sf: bool,+    has_mfr: bool,+    variants: Vec<String>, // vendor variant methods: <mpn>.<method>.kicad_sym+    dir_for_3d: String,    // chip folder (to load iso PNG + outline SVGs)+    mpn_axis: String,      // chosen_up_axis (for label only)+    source: String,+    pin_layout: String,+    grouped: bool,+    ref_pos: String,+    value_pos: String,+    laser_name: bool,+    three_d: String,       // "off" | "iso" | outline style+}+static MODEL: Mutex<Option<Model>> = Mutex::new(None);++/// Render `kicad_sym` (by its real `render_name`) and parse it into a fully+/// interactive AppState. `display_name` is the label shown (e.g. the MPN);+/// the metadata args override parsed values when non-empty.+pub fn build_symbol_state(+    kicad_sym: &str,+    render_name: &str,+    display_name: &str,+    manufacturer: &str,+    package: &str,+    description: &str,+    datasheet: &str,+) -> Result<AppState> {+    let svg = crate::render::render_symbol_svg(kicad_sym, render_name)?;+    let meta = crate::parse::parse_kicad_sym(kicad_sym, render_name);+    let offset = crate::parse::compute_offset(&svg, &meta.pins);+    let pick = |over: &str, parsed: &str| if over.is_empty() { parsed.to_string() } else { over.to_string() };+    Ok(AppState {+        symbol_name: if display_name.is_empty() { render_name.to_string() } else { display_name.to_string() },+        svg,+        manufacturer: pick(manufacturer, &meta.manufacturer),+        package: pick(package, &meta.footprint),+        description: pick(description, &meta.description),+        datasheet: pick(datasheet, &meta.datasheet),+        pin_count: meta.pins.len(),+        pins: meta.pins,+        groups: meta.groups,+        offset,+        hide_pin_names: meta.hide_pin_names,+        body_rect: meta.body_rect,+        kicad_sym: kicad_sym.to_string(),+        ..Default::default()+    })+}++static STATE: Mutex<Option<AppState>> = 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);+static EVAL_ID: AtomicU64 = AtomicU64::new(1);++const APP_HTML: &str = include_str!("app.html");+const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="4" width="10" height="16" rx="1.5"/><path d="M7 8H3M7 12H3M7 16H3M21 8h-4M21 12h-4M21 16h-4"/></svg>"##;++static REV: AtomicU64 = AtomicU64::new(0);++pub fn set_state(s: AppState) {+    REV.fetch_add(1, Ordering::SeqCst);+    *STATE.lock().unwrap() = Some(s);+}++fn state_json() -> String {+    match &*STATE.lock().unwrap() {+        // Lightweight: NO svg, NO 3D asset data (those are fetched on demand via+        // /svg and /3d/:mode). `rev` bumps whenever the symbol is regenerated so+        // the client knows when to re-fetch the heavy SVG. Keeps the 1s poll tiny.+        Some(s) => {+            // Hide the internal "<style>-named" keys from the mode list (they're+            // selected implicitly by the laser-name toggle, not user-facing styles).+            let mut modes: Vec<&String> = s.three_d_assets.keys().filter(|k| !k.ends_with("-named")).collect();+            modes.sort();+            // When the chip name is laser-marked on the chip, serve the+            // name-marked variant of the current 3D style (iso emboss render, or+            // the flat-top outline) when one exists.+            let named_key = format!("{}-named", s.three_d);+            let cur_asset = if s.laser_name && s.three_d_assets.contains_key(&named_key) {+                s.three_d_assets.get(&named_key)+            } else {+                s.three_d_assets.get(&s.three_d)+            }+            .cloned()+            .unwrap_or_default();+            json!({+                "rev": REV.load(Ordering::SeqCst),+                "symbolName": s.symbol_name, "manufacturer": s.manufacturer,+                "package": s.package, "description": s.description, "datasheet": s.datasheet,+                "pinCount": s.pin_count, "pins": s.pins, "groups": s.groups,+                "offset": s.offset.map(|(x, y)| json!({ "x": x, "y": y })),+                "hidePinNames": s.hide_pin_names,+                "bodyRect": s.body_rect,+                "sources": s.sources, "source": s.source, "pinLayout": s.pin_layout,+                "grouped": s.grouped, "refPos": s.ref_pos, "valuePos": s.value_pos, "laserName": s.laser_name, "threeD": s.three_d,+                "threeDModes": modes,+                // only the CURRENT mode's asset (sync, reliable) — not all 6.+                "threeDAsset": cur_asset,+                "sourceLabels": s.source_labels,+            })+            .to_string()+        }+        None => "{}".to_string(),+    }+}++fn read_file(dir: &str, name: &str) -> Option<String> {+    std::fs::read_to_string(std::path::Path::new(dir).join(name)).ok()+}++/// Load the centered-3D assets for the studio: the shaded iso PNG (at the user's+/// chosen axis) keyed "iso", plus every vector outline `<mpn>-3d-outline-<style>.svg`+/// keyed by its style. Values are data: URLs the viewer overlays in the body centre.+fn load_3d_assets(dir: &str, mpn: &str, axis: &str) -> std::collections::HashMap<String, String> {+    let mut out = std::collections::HashMap::new();+    let p = std::path::Path::new(dir);+    let iso_file = match axis {+        "y" | "yup" => format!("{mpn}-3d-iso-yup.png"),+        "x" | "xup" => format!("{mpn}-3d-iso-xup.png"),+        "xdown" | "-x" => format!("{mpn}-3d-iso-xdown.png"),+        "ydown" | "-y" => format!("{mpn}-3d-iso-ydown.png"),+        "zdown" | "-z" => format!("{mpn}-3d-iso-zdown.png"),+        _ => format!("{mpn}-3d-iso.png"),+    };+    // Prefer the transparent-background iso icon; fall back to the shaded ones.+    if let Ok(b) = std::fs::read(p.join(format!("{mpn}-3d-iso-icon.png")))+        .or_else(|_| std::fs::read(p.join(&iso_file)))+        .or_else(|_| std::fs::read(p.join(format!("{mpn}-3d-iso.png"))))+    {+        out.insert("iso".to_string(), format!("data:image/png;base64,{}", b64(&b)));+    }+    // Name-embossed iso (OCCT-marked STEP rendered, then made transparent): the+    // part number is real raised geometry on the chip's top face. Used by the+    // "laser the name on the chip" mode so the mark is perspective-correct and+    // matches what ships to KiCad. Optional — only present once generated.+    if let Ok(b) = std::fs::read(p.join(format!("{mpn}-3d-iso-named-icon.png")))+        .or_else(|_| std::fs::read(p.join(format!("{mpn}-3d-iso-named.png"))))+    {+        out.insert("iso-named".to_string(), format!("data:image/png;base64,{}", b64(&b)));+    }+    // Vector outlines: <mpn>-3d-outline-<style>.svg → data:image/svg+xml. The+    // name-marked variants are <style>-named.svg (the part number drawn flat on+    // the top face — top-edge only, no extrusion clutter); they map to the+    // "<style>-named" key, served when the laser-name mode is on.+    let prefix = format!("{mpn}-3d-outline-");+    if let Ok(rd) = std::fs::read_dir(p) {+        for e in rd.flatten() {+            let name = e.file_name().to_string_lossy().to_string();+            if let Some(rest) = name.strip_prefix(&prefix) {+                if let Some(style) = rest.strip_suffix(".svg") {+                    if let Ok(s) = std::fs::read_to_string(e.path()) {+                        // style may be "thin" or "thin-named" — both become keys verbatim.+                        out.insert(style.to_string(), format!("data:image/svg+xml;base64,{}", b64(s.as_bytes())));+                    }+                }+            }+        }+    }+    out+}++/// Discover the symbol sources in a chip-fetcher library folder and render the+/// default (auto-grouped from ds2sf if present, else the manufacturer symbol).+fn load_from_dir(dir: &str, mpn: &str, init_source: &str) {+    let has_ds2sf = read_file(dir, &format!("{mpn}-symbol.extracted.json")).is_some();+    let has_mfr = read_file(dir, &format!("{mpn}.kicad_sym")).is_some();+    // Discover per-source vendor variants: <mpn>.<method>.kicad_sym+    let mut variants = Vec::new();+    if let Ok(rd) = std::fs::read_dir(dir) {+        let suffix = ".kicad_sym";+        let prefix = format!("{mpn}.");+        for e in rd.flatten() {+            let name = e.file_name().to_string_lossy().to_string();+            if name == format!("{mpn}.kicad_sym") || !name.ends_with(suffix) || !name.starts_with(&prefix) {+                continue;+            }+            let method = &name[prefix.len()..name.len() - suffix.len()];+            if !method.is_empty() && !method.contains('.') {+                variants.push(method.to_string());+            }+        }+    }+    let axis = read_file(dir, "info.json")+        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+        .and_then(|v| v.get("chosen_up_axis").and_then(|a| a.as_str()).map(String::from))+        .unwrap_or_default();+    // Honor an explicit initial source from the carat (ds2sf vs a vendor method);+    // otherwise default to auto-grouped (ds2sf) when available.+    let source = match init_source {+        "ds2sf" if has_ds2sf => "ds2sf".to_string(),+        "auto" if has_ds2sf => "auto".to_string(),+        s if !s.is_empty() && s != "ds2sf" && has_mfr => "manufacturer".to_string(),+        _ if has_ds2sf => "auto".to_string(),+        _ => "manufacturer".to_string(),+    };+    *MODEL.lock().unwrap() = Some(Model {+        dir: dir.to_string(),+        mpn: mpn.to_string(),+        has_ds2sf,+        has_mfr,+        variants,+        dir_for_3d: dir.to_string(),+        mpn_axis: axis,+        source,+        pin_layout: "left-right".to_string(),+        grouped: true,+        ref_pos: "out-top".to_string(),+        value_pos: "out-bot".to_string(),+        laser_name: false,+        three_d: "iso".to_string(), // default: show the iso chip in the body centre+    });+    regenerate();+}++/// Regenerate the AppState from the current Model (source + options).+fn regenerate() {+    let m = match MODEL.lock().unwrap().clone() {+        Some(m) => m,+        None => return,+    };+    let mut sources = Vec::new();+    if m.has_ds2sf {+        sources.push("auto".to_string());+        sources.push("ds2sf".to_string());+    }+    if m.has_mfr {+        sources.push("manufacturer".to_string());+    }+    for v in &m.variants {+        sources.push(v.clone());+    }++    // Friendly labels per source. The canonical "manufacturer" entry is shown by+    // its REAL origin (e.g. "SnapEDA") from info.json.source_of_record / provenance,+    // so the user sees where it came from, not a generic word.+    let mut source_labels: std::collections::HashMap<String, String> = std::collections::HashMap::new();+    for s in &sources {+        source_labels.insert(s.clone(), source_label(s));+    }+    if m.has_mfr {+        let origin = read_file(&m.dir, "info.json")+            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+            .and_then(|v| v.get("source_of_record").and_then(|s| s.get("kicad_sym")).and_then(|s| s.as_str()).map(String::from))+            .or_else(|| {+                read_file(&m.dir, &format!("{}-file-provenance.json", m.mpn))+                    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())+                    .and_then(|v| v.get(format!("{}.kicad_sym", m.mpn)).and_then(|e| e.get("content_origin").or_else(|| e.get("discovery_source"))).and_then(|s| s.as_str()).map(String::from))+            });+        if let Some(o) = origin {+            source_labels.insert("manufacturer".to_string(), source_label(&o));+        }+    }++    // A vendor variant source = a discovered <mpn>.<method>.kicad_sym.+    let is_variant = m.variants.iter().any(|v| v == &m.source);++    let result: Result<AppState> = (|| {+        let mut st = if is_variant {+            let ks = read_file(&m.dir, &format!("{}.{}.kicad_sym", m.mpn, m.source))+                .ok_or_else(|| anyhow::anyhow!("no {}.{}.kicad_sym", m.mpn, m.source))?;+            let rn = crate::first_symbol_name(&ks).unwrap_or_else(|| m.mpn.clone());+            build_symbol_state(&ks, &rn, &m.mpn, "", "", "", "")?+        } else if m.source == "manufacturer" || (!m.has_ds2sf) {+            let ks = read_file(&m.dir, &format!("{}.kicad_sym", m.mpn))+                .ok_or_else(|| anyhow::anyhow!("no {}.kicad_sym", m.mpn))?;+            let rn = crate::first_symbol_name(&ks).unwrap_or_else(|| m.mpn.clone());+            build_symbol_state(&ks, &rn, &m.mpn, "", "", "", "")?+        } else {+            // auto / ds2sf → generate via our generator from ds2sf pins+groups.+            let js = read_file(&m.dir, &format!("{}-symbol.extracted.json", m.mpn))+                .ok_or_else(|| anyhow::anyhow!("no ds2sf extraction"))?;+            let sym = crate::ds2sf::parse(&js)?;+            let opts = crate::ds2sf::LayoutOpts {+                pin_layout: m.pin_layout.clone(),+                grouped: m.grouped,+                ref_pos: m.ref_pos.clone(),+                value_pos: m.value_pos.clone(),+            };+            let (input, pin_desc, group_desc) = crate::ds2sf::build_ic_input(&sym, &opts);+            let ks = crate::sym_gen::generate_ic_sym(&input);+            let mut st = build_symbol_state(&ks, &input.symbol_name, &m.mpn, &sym.manufacturer, &sym.package, &sym.description, "")?;+            // merge ds2sf descriptions onto the parsed pins + groups (for tooltips)+            for p in st.pins.iter_mut() {+                if let Some(d) = pin_desc.get(&p.name).or_else(|| pin_desc.get(&p.number)) {+                    p.desc = d.clone();+                }+            }+            for g in st.groups.iter_mut() {+                if let Some(d) = group_desc.get(&g.name) {+                    g.desc = d.clone();+                }+            }+            st+        };+        st.sources = sources.clone();+        st.source = m.source.clone();+        st.pin_layout = m.pin_layout.clone();+        st.grouped = m.grouped;+        st.ref_pos = m.ref_pos.clone();+        st.value_pos = m.value_pos.clone();+        st.laser_name = m.laser_name;+        st.three_d = m.three_d.clone();+        st.three_d_assets = load_3d_assets(&m.dir_for_3d, &m.mpn, &m.mpn_axis);+        st.source_labels = source_labels.clone();+        Ok(st)+    })();++    if let Ok(st) = result {+        set_state(st);+    }+}++/// Minimal base64 (std-only) for the iso-render data URL.+fn b64(data: &[u8]) -> String {+    const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity((data.len() + 2) / 3 * 4);+    for c in data.chunks(3) {+        let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];+        let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;+        out.push(T[(n >> 18 & 63) as usize] as char);+        out.push(T[(n >> 12 & 63) as usize] as char);+        out.push(if c.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });+        out.push(if c.len() > 2 { T[(n & 63) as usize] as char } else { '=' });+    }+    out+}++pub fn run(port: u16) -> Result<()> {+    *EVAL_RESULTS.lock().unwrap() = Some(HashMap::new());+    *CONSOLE.lock().unwrap() = Some(Vec::new());+    let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;+    eprintln!("[adom-symbol] serving on http://127.0.0.1:{port}");+    register_port(port, "adom-symbol", "KiCad symbol viewer live app (AI-drivable)");++    for mut req in server.incoming_requests() {+        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) {+            let _ = req.as_reader().read_to_string(&mut body);+        }+        let resp = route(&method, &url, &body);+        let _ = req.respond(resp);+        if url == "/shutdown" && matches!(method, Method::Post) {+            break;+        }+    }+    unregister_port(port);+    Ok(())+}++/// Best-effort HD port registration (app-creator §6a / adom-port-hints).+/// Raw TCP so we add no HTTP-client dependency; never blocks serving, never+/// fails the app if HD is absent (outside Hydrogen Desktop).+fn register_port(port: u16, name: &str, reason: &str) {+    let body = format!(+        "{{\"port\":{port},\"action\":\"register\",\"name\":\"{name}\",\"reason\":\"{reason}\",\"expose\":\"internal\"}}"+    );+    std::thread::spawn(move || hd_post(&body));+}+fn unregister_port(port: u16) {+    hd_post(&format!("{{\"port\":{port},\"action\":\"unregister\"}}"));+}+fn hd_post(body: &str) {+    use std::io::Write;+    use std::net::{TcpStream, ToSocketAddrs};+    use std::time::Duration;+    let addr = match ("host.docker.internal", 47084u16).to_socket_addrs().ok().and_then(|mut a| a.next()) {+        Some(a) => a,+        None => return,+    };+    if let Ok(mut s) = TcpStream::connect_timeout(&addr, Duration::from_millis(800)) {+        let _ = s.set_write_timeout(Some(Duration::from_millis(800)));+        let req = format!(+            "POST /port-forward HTTP/1.1\r\nHost: host.docker.internal\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",+            body.len(),+            body+        );+        let _ = s.write_all(req.as_bytes());+    }+}++fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    match (method, url) {+        (Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),+        (Method::Get, "/favicon.svg") => svg(FAVICON),+        (Method::Get, "/state") => json_resp(&state_json()),+        (Method::Get, "/svg") => {+            let (svg, rev) = match &*STATE.lock().unwrap() {+                Some(s) => (s.svg.clone(), REV.load(Ordering::SeqCst)),+                None => (String::new(), 0),+            };+            json_resp(&json!({ "svg": svg, "rev": rev }).to_string())+        }+        _ if url.starts_with("/3d/") => {+            let mode = &url["/3d/".len()..];+            let asset = STATE.lock().unwrap().as_ref().and_then(|s| s.three_d_assets.get(mode).cloned()).unwrap_or_default();+            json_resp(&json!({ "asset": asset }).to_string())+        }+        (Method::Post, "/load") => {+            if let Ok(v) = serde_json::from_str::<Value>(body) {+                // Preferred: push raw .kicad_sym; the server renders it via+                // service-kicad (mirrors adom-step's /api/load). This is how+                // chip-fetcher drives the app — push a file, it renders.+                let g = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();+                // Folder mode (preferred): discover sources + render auto-grouped default.+                if !g("dir").is_empty() && !g("mpn").is_empty() {+                    load_from_dir(&g("dir"), &g("mpn"), &g("source"));+                    return json_resp("{\"ok\":true}");+                }+                if let Some(ks) = v.get("kicadSym").and_then(|x| x.as_str()) {+                    // Render by the file's ACTUAL symbol name; display the+                    // caller's label (e.g. the MPN). Parses pins + offset.+                    let render_name = crate::first_symbol_name(ks).unwrap_or_else(|| "symbol".to_string());+                    let display = { let d = g("symbolName"); if d.is_empty() { render_name.clone() } else { d } };+                    match build_symbol_state(ks, &render_name, &display, &g("manufacturer"), &g("package"), &g("description"), &g("datasheet")) {+                        Ok(st) => set_state(st),+                        Err(e) => return json_err(422, &e.to_string()),+                    }+                } else {+                    // Pre-rendered: caller already has the SVG (no interactive data).+                    set_state(AppState {+                        symbol_name: g("symbolName"),+                        svg: g("svg"),+                        manufacturer: g("manufacturer"),+                        package: g("package"),+                        description: g("description"),+                        pin_count: v.get("pinCount").and_then(|x| x.as_u64()).unwrap_or(0) as usize,+                        ..Default::default()+                    });+                }+            }+            json_resp("{\"ok\":true}")+        }+        (Method::Post, "/deliver") => {+            let target = serde_json::from_str::<Value>(body)+                .ok()+                .and_then(|v| v.get("target").and_then(|t| t.as_str()).map(String::from))+                .unwrap_or_default();+            let (name, ks) = match &*STATE.lock().unwrap() {+                Some(s) => (s.symbol_name.clone(), s.kicad_sym.clone()),+                None => (String::new(), String::new()),+            };+            json_resp(&crate::deliver::deliver(&name, &ks, &target).to_string())+        }+        (Method::Post, "/relayout") => {+            let mut non_3d_change = false;+            let mut laser_only = false;+            let mut new_3d: Option<String> = None;+            if let Ok(v) = serde_json::from_str::<Value>(body) {+                if let Some(m) = MODEL.lock().unwrap().as_mut() {+                    let g = |k: &str| v.get(k).and_then(|x| x.as_str()).map(String::from);+                    if let Some(s) = g("source") { m.source = s; non_3d_change = true; }+                    if let Some(s) = g("pinLayout") { m.pin_layout = s; non_3d_change = true; }+                    if let Some(s) = g("refPos") { m.ref_pos = s; non_3d_change = true; }+                    if let Some(s) = g("valuePos") { m.value_pos = s; non_3d_change = true; }+                    if let Some(b) = v.get("grouped").and_then(|x| x.as_bool()) { m.grouped = b; non_3d_change = true; }+                    if let Some(b) = v.get("laserName").and_then(|x| x.as_bool()) { m.laser_name = b; laser_only = true; }+                    if let Some(s) = g("threeD") { m.three_d = s.clone(); new_3d = Some(s); }+                }+            }+            if non_3d_change {+                regenerate(); // re-renders the symbol (+ refreshes 3D mode + laser flag)+            } else if laser_only {+                // Laser-name toggle only: client overlay change — update state ++                // bump rev so the poller re-fetches, no expensive re-render.+                let laser = MODEL.lock().unwrap().as_ref().map(|m| m.laser_name).unwrap_or(false);+                if let Some(st) = STATE.lock().unwrap().as_mut() { st.laser_name = laser; }+                REV.fetch_add(1, Ordering::SeqCst);+            } else if let Some(td) = new_3d {+                // 3D-only toggle: update in place, no expensive re-render / no rev bump.+                if let Some(st) = STATE.lock().unwrap().as_mut() {+                    st.three_d = td;+                }+            }+            json_resp("{\"ok\":true}")+        }+        (Method::Post, "/eval") => {+            let code = serde_json::from_str::<Value>(body)+                .ok()+                .and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from))+                .unwrap_or_default();+            let id = EVAL_ID.fetch_add(1, Ordering::SeqCst);+            EVAL_PENDING.lock().unwrap().push_back((id, code));+            json_resp(&json!({ "id": id.to_string() }).to_string())+        }+        (Method::Get, "/eval/pending") => {+            let next = EVAL_PENDING.lock().unwrap().pop_front();+            match next {+                Some((id, code)) => json_resp(&json!({ "id": id.to_string(), "code": code }).to_string()),+                None => json_resp("{}"),+            }+        }+        (Method::Get, "/console") => {+            let log = CONSOLE.lock().unwrap().clone().unwrap_or_default();+            json_resp(&json!({ "lines": log }).to_string())+        }+        (Method::Post, "/console") => {+            if let Ok(v) = serde_json::from_str::<Value>(body) {+                let line = format!(+                    "[{}] {}",+                    v.get("level").and_then(|l| l.as_str()).unwrap_or("log"),+                    v.get("msg").and_then(|m| m.as_str()).unwrap_or("")+                );+                if let Some(buf) = CONSOLE.lock().unwrap().as_mut() {+                    buf.push(line);+                    let len = buf.len();+                    if len > 500 {+                        buf.drain(0..len - 500);+                    }+                }+            }+            json_resp("{\"ok\":true}")+        }+        (Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),+        // /eval/:id  and  /eval/:id/result+        _ if url.starts_with("/eval/") => {+            let rest = &url["/eval/".len()..];+            if let Some(id_str) = rest.strip_suffix("/result") {+                if matches!(method, Method::Post) {+                    if let (Ok(id), Ok(v)) = (id_str.parse::<u64>(), serde_json::from_str::<Value>(body)) {+                        if let Some(map) = EVAL_RESULTS.lock().unwrap().as_mut() {+                            map.insert(id, v);+                        }+                    }+                    return json_resp("{\"ok\":true}");+                }+            }+            // GET /eval/:id → poll result+            if let Ok(id) = rest.parse::<u64>() {+                let map = EVAL_RESULTS.lock().unwrap();+                if let Some(r) = map.as_ref().and_then(|m| m.get(&id)) {+                    return json_resp(&json!({ "done": true, "result": r }).to_string());+                }+                return json_resp("{\"done\":false}");+            }+            not_found()+        }+        _ => not_found(),+    }+}++fn header(ct: &str) -> Header {+    Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()+}+fn html(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(s).with_header(header("text/html; charset=utf-8"))+}+fn svg(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(s).with_header(header("image/svg+xml"))+}+fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(s).with_header(header("application/json"))+}+fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(header("application/json"))+}+fn json_err(code: u16, msg: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    let body = json!({ "ok": false, "error": msg }).to_string();+    Response::from_string(body).with_status_code(code).with_header(header("application/json"))+}++/// Load a .kicad_sym from disk, render it via service-kicad, set app state.+pub fn load_symbol_into_state(file: &std::path::Path, name: Option<&str>) -> Result<()> {+    let content = std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;+    let symbol_name = name+        .map(String::from)+        .or_else(|| crate::first_symbol_name(&content))+        .unwrap_or_else(|| "symbol".to_string());+    let st = build_symbol_state(&content, &symbol_name, &symbol_name, "", "", "", "")?;+    set_state(st);+    Ok(())+}
src/sym_gen.rsadded+494
@@ -0,0 +1,494 @@+//! `.kicad_sym` generator — faithful Rust port of the Node `lib/sym-gen.js`+//! `generateICSym`. Category A (IC): rectangle body, pins on sides, group+//! labels. Output is byte-for-byte identical to the Node generator (verified+//! by the golden-file parity test in `tests/`).+//!+//! No gallia, no Node. (Category B / discrete baseline fetch — `symGet` — is+//! ported separately and routes through service-kicad.)++// ── Pin angle constants ──+const ANGLE_LEFT_SIDE: i32 = 0; // pin extends right from tip → left side of body+const ANGLE_RIGHT_SIDE: i32 = 180;+const ANGLE_BOTTOM_SIDE: i32 = 90;+const ANGLE_TOP_SIDE: i32 = 270;++const PIN_LENGTH: f64 = 2.54;+const PIN_PITCH: f64 = 2.54; // within a group+const GROUP_GAP: f64 = 3.81; // between groups+const LABEL_OFFSET: f64 = 2.0; // group label above first pin in group+const BODY_TOP_MARGIN: f64 = 2.54;+const BODY_BOTTOM_MARGIN: f64 = 2.54;+const MIN_BODY_WIDTH: f64 = 20.0;+const BODY_PADDING: f64 = 4.0;++const FS_PIN_NAME: f64 = 1.27;+const FS_GROUP_LABEL: f64 = 0.762;++#[derive(Clone)]+pub struct Pin {+    pub name: String,+    pub number: String,+    pub ptype: String, // input | output | power_in | bidirectional | tri_state | …+    pub group: Option<String>,+    pub side: Option<String>, // explicit override: left|right|top|bottom+}++pub struct IcInput {+    pub symbol_name: String,+    pub manufacturer: String,+    pub package: String,+    pub description: String,+    pub datasheet_url: String,+    pub reference_prefix: String,+    pub pins: Vec<Pin>,+    pub pin_layout: String, // "left-right" | "all-sides"+    pub chip_name_centered: bool,+    /// Reference designator placement (see `designator_at` for the position set).+    pub ref_pos: String,+    /// Value (chip name) placement (same position set as ref_pos).+    pub value_pos: String,+    /// distributor part numbers, insertion-ordered: (distributor, pn)+    pub distributor_pn: Vec<(String, String)>,+}++#[derive(Clone)]+struct SideGroup {+    group_name: String,+    pins: Vec<Pin>,+}++#[derive(Clone)]+struct PosPin {+    name: String,+    number: String,+    ptype: String,+    x: f64,+    y: f64,+}++#[derive(Clone)]+struct Label {+    name: String,+    x: f64,+    y: f64,+}++/// JS Number.toFixed(2) — round-half-away-from-zero, 2 decimals, "-0.00" → "0.00".+fn fx(v: f64) -> String {+    // JS toFixed rounds half away from zero; Rust's {:.2} rounds half-to-even.+    // Re-round explicitly to match JS for the rare exact-half case.+    let scaled = v * 100.0;+    let rounded = if scaled >= 0.0 { (scaled + 0.5).floor() } else { (scaled - 0.5).ceil() };+    let r = rounded / 100.0;+    let s = format!("{:.2}", r);+    if s == "-0.00" { "0.00".to_string() } else { s }+}++fn is_ground_name(name: &str) -> bool {+    let n = name.to_uppercase();+    n.starts_with("GND") || n.starts_with("VSS") || n.starts_with("AGND") || n.starts_with("DGND") || n.starts_with("EP")+}++fn layout_vertical(side_groups: &[SideGroup]) -> (Vec<PosPin>, Vec<Label>, f64) {+    let mut pins = Vec::new();+    let mut labels = Vec::new();+    let mut y = 0.0_f64;+    for (i, sg) in side_groups.iter().enumerate() {+        if i > 0 {+            y -= GROUP_GAP;+        }+        let label_y = y;+        y -= LABEL_OFFSET;+        for pin in &sg.pins {+            pins.push(PosPin { name: pin.name.clone(), number: pin.number.clone(), ptype: pin.ptype.clone(), x: 0.0, y });+            y -= PIN_PITCH;+        }+        if sg.group_name != "_ungrouped" {+            labels.push(Label { name: sg.group_name.clone(), x: 0.0, y: label_y });+        }+    }+    (pins, labels, y.abs())+}++fn layout_horizontal(side_groups: &[SideGroup], start_x: f64) -> (Vec<PosPin>, Vec<Label>, f64) {+    let mut pins = Vec::new();+    let mut labels = Vec::new();+    let mut x = start_x;+    for (i, sg) in side_groups.iter().enumerate() {+        if i > 0 {+            x += GROUP_GAP;+        }+        let label_x = x;+        if sg.group_name != "_ungrouped" {+            x += LABEL_OFFSET;+        }+        for pin in &sg.pins {+            pins.push(PosPin { name: pin.name.clone(), number: pin.number.clone(), ptype: pin.ptype.clone(), x, y: 0.0 });+            x += PIN_PITCH;+        }+        if sg.group_name != "_ungrouped" {+            labels.push(Label { name: sg.group_name.clone(), x: label_x, y: 0.0 });+        }+    }+    (pins, labels, x - start_x)+}++/// Where the symbol's content actually extends past each body edge, so "out"+/// designators clear the pins/labels on that side (auto-overlap-avoidance).+struct Clear {+    top: f64,    // y for out-top (above any top pins)+    bot: f64,    // y for out-bot (below any bottom pins)+    left: f64,   // x for out-left (left of left pin numbers)+    right: f64,  // x for out-right (right of right pin numbers)+}++/// Position a designator (Reference or Value) relative to the body box, using+/// `clr` for the "out" positions so they don't collide with pins.+/// Positions: out-top / in-top / out-bot / in-bot / out-left / in-left /+/// out-right / in-right / center. Returns (x, y, justify-suffix).+fn designator_at(pos: &str, bl: f64, br: f64, bt: f64, bb: f64, clr: &Clear) -> (f64, f64, &'static str) {+    let cy = (bt + bb) / 2.0;+    match pos {+        "center" => (0.0, cy, ""),+        "in-top" => (0.0, bt - 2.2, ""),+        "out-bot" | "out-bottom" => (0.0, clr.bot, ""),+        "in-bot" | "in-bottom" => (0.0, bb + 2.2, ""),+        "out-left" => (clr.left, cy, " (justify right)"),+        "in-left" => (bl + 2.0, cy, " (justify left)"),+        "out-right" => (clr.right, cy, " (justify left)"),+        "in-right" => (br - 2.0, cy, " (justify right)"),+        // legacy corner positions+        "top-left" => (bl + 2.0, bt - 1.8, " (justify left)"),+        "bottom-left" => (bl + 2.0, bb + 1.8, " (justify left)"),+        _ => (0.0, clr.top, ""), // out-top default+    }+}++pub fn generate_ic_sym(input: &IcInput) -> String {+    let pin_layout = if input.pin_layout.is_empty() { "left-right" } else { input.pin_layout.as_str() };++    // Group pins by group name, preserving first-seen order.+    let mut groups: Vec<(String, Vec<Pin>)> = Vec::new();+    for pin in &input.pins {+        let g = pin.group.clone().unwrap_or_else(|| "_ungrouped".to_string());+        if let Some(entry) = groups.iter_mut().find(|(n, _)| *n == g) {+            entry.1.push(pin.clone());+        } else {+            groups.push((g, vec![pin.clone()]));+        }+    }++    let mut left: Vec<SideGroup> = Vec::new();+    let mut right: Vec<SideGroup> = Vec::new();+    let mut top: Vec<SideGroup> = Vec::new();+    let mut bottom: Vec<SideGroup> = Vec::new();++    for (group_name, group_pins) in &groups {+        // Partition the group's pins by their EXPLICIT side first, so a group+        // (incl. the flat "_ungrouped" group) can span sides — e.g. flat layout+        // splitting pins half-left / half-right. Pins with no explicit side fall+        // through to the type-based auto-placement below.+        for side in ["left", "right", "top", "bottom"] {+            let pins: Vec<Pin> = group_pins.iter().filter(|p| p.side.as_deref() == Some(side)).cloned().collect();+            if pins.is_empty() {+                continue;+            }+            let sg = SideGroup { group_name: group_name.clone(), pins };+            match side {+                "left" => left.push(sg),+                "right" => right.push(sg),+                "top" => top.push(sg),+                "bottom" => bottom.push(sg),+                _ => {}+            }+        }+        let group_pins: Vec<Pin> = group_pins.iter().filter(|p| p.side.is_none()).cloned().collect();+        if !group_pins.is_empty() {+            let sg = SideGroup { group_name: group_name.clone(), pins: group_pins.clone() };+            let has_inputs = group_pins.iter().any(|p| p.ptype == "input" || p.ptype == "bidirectional");+            let has_power = group_pins.iter().any(|p| p.ptype == "power_in" || p.ptype == "power_out");+            let has_outputs = group_pins.iter().any(|p| p.ptype == "output" || p.ptype == "tri_state");+            let is_ground = group_pins.iter().all(|p| p.ptype == "power_in" && is_ground_name(&p.name));++            if pin_layout == "all-sides" {+                if is_ground {+                    bottom.push(sg);+                } else if has_power && !has_inputs && !has_outputs {+                    top.push(sg);+                } else if has_outputs && !has_inputs {+                    right.push(sg);+                } else {+                    left.push(sg);+                }+            } else {+                if is_ground {+                    left.push(sg);+                } else if has_outputs && !has_inputs {+                    right.push(sg);+                } else if has_power && !has_inputs {+                    right.push(sg);+                } else {+                    left.push(sg);+                }+            }+        }+    }++    let (left_pins, left_labels, _lh) = layout_vertical(&left);+    let (right_pins, right_labels, _rh) = layout_vertical(&right);++    // Body width from longest pin names (left-right) OR top/bottom pin count.+    let longest_left = left_pins.iter().map(|p| p.name.chars().count()).max().unwrap_or(0) as f64;+    let longest_right = right_pins.iter().map(|p| p.name.chars().count()).max().unwrap_or(0) as f64;+    let name_width = (longest_left + longest_right) * 1.0 + BODY_PADDING * 2.0;++    let top_pin_count: usize = top.iter().map(|g| g.pins.len()).sum();+    let bottom_pin_count: usize = bottom.iter().map(|g| g.pins.len()).sum();+    let top_group_gaps = (top.len().saturating_sub(1)) as f64 * GROUP_GAP;+    let bottom_group_gaps = (bottom.len().saturating_sub(1)) as f64 * GROUP_GAP;+    let top_label_offsets = top.iter().filter(|g| g.group_name != "_ungrouped").count() as f64 * LABEL_OFFSET;+    let bottom_label_offsets = bottom.iter().filter(|g| g.group_name != "_ungrouped").count() as f64 * LABEL_OFFSET;+    let top_width = top_pin_count as f64 * PIN_PITCH + top_group_gaps + top_label_offsets + BODY_PADDING * 2.0;+    let bottom_width = bottom_pin_count as f64 * PIN_PITCH + bottom_group_gaps + bottom_label_offsets + BODY_PADDING * 2.0;++    let body_width = name_width.max(MIN_BODY_WIDTH).max(top_width).max(bottom_width);+    let half_width = body_width / 2.0;++    let body_left = -(half_width / 2.54).ceil() * 2.54;+    let body_right = (half_width / 2.54).ceil() * 2.54;++    let (_td_pins, _td_labels, top_total) = layout_horizontal(&top, 0.0);+    let (_bd_pins, _bd_labels, bottom_total) = layout_horizontal(&bottom, 0.0);+    let top_start_x = -top_total / 2.0;+    let bottom_start_x = -bottom_total / 2.0;+    let (top_pins, top_labels, _tw) = layout_horizontal(&top, top_start_x);+    let (bottom_pins, bottom_labels, _bw) = layout_horizontal(&bottom, bottom_start_x);++    let mut all_label_ys: Vec<f64> = Vec::new();+    all_label_ys.extend(left_labels.iter().map(|l| l.y));+    all_label_ys.extend(right_labels.iter().map(|l| l.y));+    let top_label_y = all_label_ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);+    let top_label_y = if all_label_ys.is_empty() { 0.0 } else { top_label_y };++    let mut top_pin_clearance = 0.0_f64;+    let mut bottom_pin_clearance = 0.0_f64;+    if !top_pins.is_empty() {+        let longest_top_name = top_pins.iter().map(|p| p.name.chars().count()).max().unwrap_or(0) as f64;+        let longest_top_label = top_labels.iter().map(|l| l.name.chars().count()).max().unwrap_or(0) as f64;+        top_pin_clearance = (longest_top_name * FS_PIN_NAME * 0.7).max(longest_top_label * FS_GROUP_LABEL * 0.7) + 2.0;+    }+    if !bottom_pins.is_empty() {+        let longest_bottom_name = bottom_pins.iter().map(|p| p.name.chars().count()).max().unwrap_or(0) as f64;+        let longest_bottom_label = bottom_labels.iter().map(|l| l.name.chars().count()).max().unwrap_or(0) as f64;+        bottom_pin_clearance = (longest_bottom_name * FS_PIN_NAME * 0.7).max(longest_bottom_label * FS_GROUP_LABEL * 0.7) + 2.5;+    }++    let body_top = top_label_y + BODY_TOP_MARGIN + top_pin_clearance;++    let mut all_pin_ys: Vec<f64> = Vec::new();+    all_pin_ys.extend(left_pins.iter().map(|p| p.y));+    all_pin_ys.extend(right_pins.iter().map(|p| p.y));+    let lowest_pin_y = if all_pin_ys.is_empty() { -10.0 } else { all_pin_ys.iter().cloned().fold(f64::INFINITY, f64::min) };+    let body_bottom = lowest_pin_y - BODY_BOTTOM_MARGIN - bottom_pin_clearance;++    let mut lines: Vec<String> = Vec::new();+    lines.push("(kicad_symbol_lib".to_string());+    lines.push("  (version 20231120)".to_string());+    lines.push("  (generator \"adom\")".to_string());+    lines.push(format!("  (symbol \"{}\"", input.symbol_name));+    lines.push("    (pin_names (offset 1.016))".to_string());+    lines.push("    (exclude_from_sim no)".to_string());+    lines.push("    (in_bom yes)".to_string());+    lines.push("    (on_board yes)".to_string());++    // Auto-avoidance: push "out" designators beyond whatever's actually on each+    // side (top/bottom pins in square layouts; left/right pin numbers).+    let longest_left_num = left_pins.iter().map(|p| p.number.chars().count()).max().unwrap_or(0) as f64;+    let longest_right_num = right_pins.iter().map(|p| p.number.chars().count()).max().unwrap_or(0) as f64;+    let clr = Clear {+        top: if top_pins.is_empty() { body_top + 2.54 } else { body_top + PIN_LENGTH + 6.0 },+        bot: if bottom_pins.is_empty() { body_bottom - 2.54 } else { body_bottom - PIN_LENGTH - 6.0 },+        left: body_left - PIN_LENGTH - longest_left_num * 0.8 - 2.5,+        right: body_right + PIN_LENGTH + longest_right_num * 0.8 + 2.5,+    };++    lines.push(format!("    (property \"Reference\" \"{}\"", input.reference_prefix));+    {+        let pos = if input.ref_pos.is_empty() { "out-top" } else { input.ref_pos.as_str() };+        let (rx, ry, just) = designator_at(pos, body_left, body_right, body_top, body_bottom, &clr);+        lines.push(format!("      (at {} {} 0)", fx(rx), fx(ry)));+        lines.push(format!("      (effects (font (size 1.27 1.27)){just})"));+    }+    lines.push("    )".to_string());++    lines.push(format!("    (property \"Value\" \"{}\"", input.symbol_name));+    {+        // "none" hides the Value (e.g. the chip name is laser-marked on the 3D+        // chip instead). KiCad requires the property to exist, so emit it hidden.+        if input.value_pos == "none" {+            let (vx, vy, _) = designator_at("out-bot", body_left, body_right, body_top, body_bottom, &clr);+            lines.push(format!("      (at {} {} 0)", fx(vx), fx(vy)));+            lines.push("      (effects (font (size 1.27 1.27)) hide)".to_string());+        } else {+            let pos = if input.value_pos.is_empty() { "out-bot" } else { input.value_pos.as_str() };+            let (vx, vy, just) = designator_at(pos, body_left, body_right, body_top, body_bottom, &clr);+            lines.push(format!("      (at {} {} 0)", fx(vx), fx(vy)));+            lines.push(format!("      (effects (font (size 1.27 1.27)){just})"));+        }+    }+    lines.push("    )".to_string());++    for (prop, val) in [+        ("Footprint", input.package.as_str()),+        ("Datasheet", input.datasheet_url.as_str()),+        ("Description", input.description.as_str()),+        ("Manufacturer", input.manufacturer.as_str()),+    ] {+        lines.push(format!("    (property \"{prop}\" \"{val}\""));+        lines.push("      (at 0 0 0)".to_string());+        lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());+        lines.push("    )".to_string());+    }++    for (dist, pn) in &input.distributor_pn {+        let mut label = String::new();+        let mut chars = dist.chars();+        if let Some(c) = chars.next() {+            label.extend(c.to_uppercase());+            label.push_str(chars.as_str());+        }+        lines.push(format!("    (property \"{label}\" \"{pn}\""));+        lines.push("      (at 0 0 0)".to_string());+        lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());+        lines.push("    )".to_string());+    }++    // Graphics sub-symbol (_0_1)+    lines.push(format!("    (symbol \"{}_0_1\"", input.symbol_name));+    lines.push(format!("      (rectangle (start {} {}) (end {} {})", fx(body_left), fx(body_top), fx(body_right), fx(body_bottom)));+    lines.push("        (stroke (width 0.254) (type default))".to_string());+    lines.push("        (fill (type background))".to_string());+    lines.push("      )".to_string());++    for label in &left_labels {+        lines.push(format!("      (text \"{}\" (at {} {} 0)", label.name, fx(body_left + 1.02), fx(label.y)));+        lines.push("        (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify left))".to_string());+        lines.push("      )".to_string());+    }+    for label in &right_labels {+        lines.push(format!("      (text \"{}\" (at {} {} 0)", label.name, fx(body_right - 1.02), fx(label.y)));+        lines.push("        (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify right))".to_string());+        lines.push("      )".to_string());+    }+    for label in &top_labels {+        lines.push(format!("      (text \"{}\" (at {} {} 900)", label.name, fx(label.x), fx(body_top - 1.02)));+        lines.push("        (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify right))".to_string());+        lines.push("      )".to_string());+    }+    for label in &bottom_labels {+        lines.push(format!("      (text \"{}\" (at {} {} 900)", label.name, fx(label.x), fx(body_bottom + 1.02)));+        lines.push("        (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify left))".to_string());+        lines.push("      )".to_string());+    }+    if input.chip_name_centered {+        let center_y = fx((body_top + body_bottom) / 2.0);+        lines.push(format!("      (text \"{}\" (at 0 {} 0)", input.symbol_name, center_y));+        lines.push("        (effects (font (size 1.27 1.27) (color 100 100 100 1)) (justify center))".to_string());+        lines.push("      )".to_string());+    }+    lines.push("    )".to_string());++    // Pins sub-symbol (_1_1)+    lines.push(format!("    (symbol \"{}_1_1\"", input.symbol_name));+    for pin in &left_pins {+        let tip_x = body_left - PIN_LENGTH;+        lines.push(format!("      (pin {} line (at {} {} {}) (length 2.54) (name \"{}\") (number \"{}\"))", pin.ptype, fx(tip_x), fx(pin.y), ANGLE_LEFT_SIDE, pin.name, pin.number));+    }+    for pin in &right_pins {+        let tip_x = body_right + PIN_LENGTH;+        lines.push(format!("      (pin {} line (at {} {} {}) (length 2.54) (name \"{}\") (number \"{}\"))", pin.ptype, fx(tip_x), fx(pin.y), ANGLE_RIGHT_SIDE, pin.name, pin.number));+    }+    if !top_pins.is_empty() {+        let top_pin_entry = body_top - 0.1;+        for pin in &top_pins {+            let tip_y = top_pin_entry + PIN_LENGTH;+            lines.push(format!("      (pin {} line (at {} {} {}) (length 2.54) (name \"{}\") (number \"{}\"))", pin.ptype, fx(pin.x), fx(tip_y), ANGLE_TOP_SIDE, pin.name, pin.number));+        }+    }+    if !bottom_pins.is_empty() {+        let bottom_pin_entry = body_bottom + 0.1;+        for pin in &bottom_pins {+            let tip_y = bottom_pin_entry - PIN_LENGTH;+            lines.push(format!("      (pin {} line (at {} {} {}) (length 2.54) (name \"{}\") (number \"{}\"))", pin.ptype, fx(pin.x), fx(tip_y), ANGLE_BOTTOM_SIDE, pin.name, pin.number));+        }+    }++    lines.push("    )".to_string());+    lines.push("  )".to_string());+    lines.push(")".to_string());+    lines.push(String::new());++    lines.join("\n")+}++#[cfg(test)]+mod tests {+    use super::*;+    fn pin(name: &str, number: &str, ptype: &str, group: &str) -> Pin {+        Pin { name: name.into(), number: number.into(), ptype: ptype.into(), group: Some(group.into()), side: None }+    }+    #[test]+    fn tst_ic8_matches_golden() {+        let input = IcInput {+            symbol_name: "TST-IC8".into(),+            manufacturer: "Adom Test".into(),+            package: "SOIC-8".into(),+            description: "Parity-harness test IC".into(),+            datasheet_url: "https://example.com/ds.pdf".into(),+            reference_prefix: "U".into(),+            pins: vec![+                pin("VCC", "8", "power_in", "Power"),+                pin("GND", "4", "power_in", "Power"),+                pin("IN+", "1", "input", "Inputs"),+                pin("IN-", "2", "input", "Inputs"),+                pin("OUT", "6", "output", "Outputs"),+                pin("EN", "3", "input", "Ctrl"),+            ],+            pin_layout: "left-right".into(),+            chip_name_centered: false,+            ref_pos: "top".into(),+            value_pos: "out-bot".into(),+            distributor_pn: vec![],+        };+        bless_eq(generate_ic_sym(&input), "tests/golden/TST-IC8.kicad_sym");+    }+    /// Regression check: compare to the golden file, or rewrite it when BLESS=1.+    fn bless_eq(got: String, path: &str) {+        if std::env::var("BLESS").is_ok() {+            std::fs::write(path, &got).unwrap();+            return;+        }+        let golden = std::fs::read_to_string(path).unwrap_or_default();+        assert_eq!(got, golden, "generator output drifted from {path} (run BLESS=1 cargo test to update)");+    }+    #[test]+    fn tst_all_matches_golden() {+        let p = |name:&str,number:&str,ptype:&str,group:&str| Pin{name:name.into(),number:number.into(),ptype:ptype.into(),group:Some(group.into()),side:None};+        let input = IcInput {+            symbol_name: "TST-ALL".into(), manufacturer: "Adom Test".into(), package: "QFN-16".into(),+            description: "all-sides coverage".into(), datasheet_url: "https://x/y.pdf".into(),+            reference_prefix: "U".into(),+            pins: vec![+                p("V3V3","16","power_in","Power"),+                p("GND","8","power_in","Gnd"),+                p("SDA","1","bidirectional","IO"),+                p("SCL","2","bidirectional","IO"),+                p("TX","5","output","Out"),+            ],+            pin_layout: "all-sides".into(), chip_name_centered: false,+            ref_pos: "out-top".into(), value_pos: "out-bot".into(),+            distributor_pn: vec![("mouser".into(),"MOU-123".into())],+        };+        bless_eq(generate_ic_sym(&input), "tests/golden/TST-ALL.kicad_sym");+    }+}
src/theme.rsadded+270
@@ -0,0 +1,270 @@+//! SVG theming for symbols + footprints.+//!+//! Symbol theming ports the regex-replacement pass from+//! gallia/symbol-creator/lib/svg-theme.js applyDarkTheme() into Rust.+//! It transforms KiCad CLI's default-themed symbol SVG into the Adom+//! dark palette (canvas #0d1117, body gradient, white pin labels,+//! amber group labels). The `<style>` body is wrapped in CDATA so the+//! output is valid standalone XML — applyDarkTheme as written embeds a+//! literal `<g class="stroked-text">` inside a CSS comment, which trips+//! strict XML parsers when SVG is loaded via <object> or as a wiki+//! asset.+//!+//! Footprint theming keeps kicad-cli's native KiCad palette (the same+//! red/yellow/magenta the FpView app shows) and just paints them on+//! the canonical FpView dark canvas with a 20-unit PCB-style grid+//! (rgba(255,255,255,0.03) lines), mirroring+//! gallia/viewer/kicad-footprint-viewer.js generateFootprintViewer().++use regex::Regex;++const KICAD_BODY_STROKE: &str = "#840000";+const KICAD_BODY_FILL: &str = "#ffffc2";+const KICAD_TEXT_STROKE: &str = "#A90000";+const KICAD_HIDDEN_TEXT: &str = "#006464";++// Adom dark theme target colors.+const CANVAS_BG: &str = "#0d1117";+const BODY_STROKE: &str = "#30363d";+const BODY_FILL: &str = "#1a2332";+const BODY_FILL_GRAD: &str = "#141c27";+const PIN_WIRE: &str = "#4a5568";+const PIN_NAME: &str = "#ffffff";+const REF_TEXT: &str = "#484f58";+const PIN_DOT: &str = "#4a5568";++/// Apply the Adom dark theme to a KiCad-CLI-exported symbol SVG.+pub fn apply_dark_theme_to_symbol(raw: &str) -> String {+    let mut s: String = raw+        .trim_start_matches('\u{feff}')+        .replace("\r\n", "\n");++    // Strip XML decl and DOCTYPE.+    s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);+    s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);+    s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);+    s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);+    let s = s.trim();++    // Replace KiCad's default white background rect (if present) — older+    // KiCad outputs may include one, newer ones don't. We also unconditionally+    // inject a viewBox-sized canvas <rect> below so the symbol always has+    // an opaque Adom-bg behind it.+    let bg_rect_re = Regex::new(r#"(<rect[^>]*fill=")(?i)white("[^>]*>)"#).unwrap();+    let s = bg_rect_re.replace(s, format!("${{1}}{}${{2}}", CANVAS_BG)).to_string();++    // Pull viewBox so we know how big to make the canvas bg rect.+    let (vb_x, vb_y, vb_w, vb_h) =+        match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)+            .unwrap()+            .captures(&s)+        {+            Some(c) => (+                c[1].parse::<f64>().unwrap_or(0.0),+                c[2].parse::<f64>().unwrap_or(0.0),+                c[3].parse::<f64>().unwrap_or(100.0),+                c[4].parse::<f64>().unwrap_or(100.0),+            ),+            None => (0.0, 0.0, 100.0, 100.0),+        };+    // Canvas: solid Adom dark + the same atmosphere SymView's+    // .canvas-wrap div adds via CSS — radial teal glow at center ++    // 20-unit PCB grid. Baked into the SVG so the thumbnail looks+    // identical to SymView when shown standalone (no HTML wrapper).+    let cx = vb_x + vb_w / 2.0;+    let cy = vb_y + vb_h / 2.0;+    let glow_r = (vb_w.max(vb_h)) * 0.7;+    let canvas_bg_rect = format!(+r##"<defs>+  <radialGradient id="adom-canvas-glow" cx="{cx}" cy="{cy}" r="{r}" gradientUnits="userSpaceOnUse">+    <stop offset="0%"  stop-color="#00b8b0" stop-opacity="0.06"/>+    <stop offset="70%" stop-color="#00b8b0" stop-opacity="0"/>+  </radialGradient>+  <pattern id="adom-canvas-grid" x="0" y="0" width="2.54" height="2.54" patternUnits="userSpaceOnUse">+    <line x1="0" y1="0" x2="2.54" y2="0" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>+    <line x1="0" y1="0" x2="0" y2="2.54" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>+  </pattern>+</defs>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{bg}"/>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-grid)"/>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-glow)"/>"##,+        x = vb_x, y = vb_y, w = vb_w, h = vb_h,+        cx = cx, cy = cy, r = glow_r, bg = CANVAS_BG,+    );++    // Inject a <defs> + <style> right after the opening <svg ...>.+    // CDATA-wrap the style body so the literal `<g class=...>` inside the+    // CSS comment doesn't trip strict XML parsers.+    let defs_and_style = format!(+        r#"+<defs>+  <linearGradient id="adom-body-grad" x1="0" y1="0" x2="0" y2="1">+    <stop offset="0%" stop-color="{body_fill}"/>+    <stop offset="100%" stop-color="{body_fill_grad}"/>+  </linearGradient>+</defs>+<style><![CDATA[+  /* Stroked text groups (KiCad renders text as paths in <g class="stroked-text">) */+  g.stroked-text path {{+    stroke: {pin_name} !important;+    fill: {pin_name} !important;+  }}+  text[opacity="0"] {{+    fill: {pin_name} !important;+  }}+  .pin-highlight path {{+    stroke: #00e6dc !important;+  }}+  .pin-highlight g.stroked-text path {{+    stroke: #00e6dc !important;+  }}+  .group-highlight g.stroked-text path {{+    stroke: #FFD180 !important;+  }}+]]></style>"#,+        body_fill = BODY_FILL,+        body_fill_grad = BODY_FILL_GRAD,+        pin_name = PIN_NAME,+    );++    let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();+    let s = svg_open+        .replace(s.as_ref(), format!("${{1}}\n{}\n{}", defs_and_style, canvas_bg_rect))+        .to_string();++    // Direct color replacements — CASE-INSENSITIVE on the hex digits+    // because kicad-cli emits both #840000 and #FFFFC2 (uppercase) and+    // the gallia source pattern uses /gi. Use Regex with `(?i)` for the+    // hex tail; literal prefix stays case-sensitive.+    let s = ci_replace_all(&s, &[+        (format!("stroke:{}", KICAD_BODY_STROKE),      format!("stroke:{}", PIN_WIRE)),+        (format!("stroke=\"{}\"", KICAD_BODY_STROKE),  format!("stroke=\"{}\"", PIN_WIRE)),+        (format!("fill:{}", KICAD_BODY_STROKE),        format!("fill:{}", PIN_DOT)),+        (format!("fill=\"{}\"", KICAD_BODY_STROKE),    format!("fill=\"{}\"", PIN_DOT)),+        (format!("fill:{}", KICAD_BODY_FILL),          "fill:url(#adom-body-grad)".to_string()),+        (format!("fill=\"{}\"", KICAD_BODY_FILL),      "fill=\"url(#adom-body-grad)\"".to_string()),+        (format!("stroke:{}", KICAD_TEXT_STROKE),      format!("stroke:{}", PIN_NAME)),+        (format!("stroke=\"{}\"", KICAD_TEXT_STROKE),  format!("stroke=\"{}\"", PIN_NAME)),+        (format!("fill:{}", KICAD_TEXT_STROKE),        format!("fill:{}", PIN_NAME)),+        (format!("fill=\"{}\"", KICAD_TEXT_STROKE),    format!("fill=\"{}\"", PIN_NAME)),+        (format!("stroke:{}", KICAD_HIDDEN_TEXT),      format!("stroke:{}", REF_TEXT)),+        (format!("fill:{}", KICAD_HIDDEN_TEXT),        format!("fill:{}", REF_TEXT)),+    ]);+    // rgb(...) form for the body fill — kicad-cli sometimes uses rgb()+    // notation for the same #FFFFC2 yellow.+    let rgb_body = Regex::new(r#"fill="rgb\(\s*255\s*,\s*255\s*,\s*194\s*\)""#).unwrap();+    let s = rgb_body.replace_all(&s, "fill=\"url(#adom-body-grad)\"").to_string();++    // Strip the root <g> default fill:#000000 so symbol body shapes don't+    // come out solid black on the dark canvas.+    let root_g_black = Regex::new(r#"(<g\s+style="fill:#000000[^"]*")"#).unwrap();+    let s = root_g_black+        .replace(&s, |c: &regex::Captures| {+            c[1].replace("fill:#000000", "fill:none")+        })+        .to_string();++    s+}++/// Re-theme a KiCad-CLI footprint SVG to match the FpView app:+///   - dark canvas #0d1117 + 20-unit PCB-style grid behind the geometry+///   - strip invisible REF**/value text + stroked-text groups (else they+///     inflate the viewBox)+///   - keep kicad-cli's native pad/silk/courtyard colors as-is, just+///     painted on the dark grid+pub fn theme_footprint_svg(raw: &str) -> String {+    let mut s = raw.replace("\r\n", "\n");+    s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);+    s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);+    s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);+    s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);+    s = strip_all_matches(&s, r#"<text[^>]*opacity="0"[^<]*</text>"#);+    s = strip_all_dotall(&s, r#"<g class="stroked-text">.*?</g>"#);+    let s = s.trim();++    // Pull the SVG's viewBox so the bg rect covers the entire canvas.+    let vb = match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)+        .unwrap()+        .captures(s)+    {+        Some(c) => (+            c[1].parse::<f64>().unwrap_or(-50.0),+            c[2].parse::<f64>().unwrap_or(-50.0),+            c[3].parse::<f64>().unwrap_or(100.0),+            c[4].parse::<f64>().unwrap_or(100.0),+        ),+        None => (-50.0, -50.0, 100.0, 100.0),+    };++    let defs = r#"+<defs>+  <pattern id="adom-pcb-grid" x="0" y="0" width="1" height="1" patternUnits="userSpaceOnUse">+    <line x1="0" y1="0" x2="1" y2="0" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>+    <line x1="0" y1="0" x2="0" y2="1" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>+  </pattern>+</defs>"#;+    let bg = format!(+        "<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"{CANVAS_BG}\"/>\n<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"url(#adom-pcb-grid)\"/>",+        x = vb.0, y = vb.1, w = vb.2, h = vb.3,+    );++    let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();+    let s = svg_open+        .replace(s, format!("${{1}}\n{}\n{}", defs, bg))+        .to_string();++    // Strip any KiCad-CLI default white-ish page-background rect that+    // would render in front of our dark canvas.+    let white_bg_rect = Regex::new(+        r#"<rect[^>]*fill="(?i)#?(?:f{2,6}|white)"[^>]*/?>"#,+    ).unwrap();+    let s = white_bg_rect.replace_all(&s, "").to_string();++    s+}++fn strip_first_match(s: &str, pat: &str) -> String {+    Regex::new(pat).unwrap().replace(s, "").to_string()+}+fn strip_all_matches(s: &str, pat: &str) -> String {+    Regex::new(pat).unwrap().replace_all(s, "").to_string()+}+fn strip_all_dotall(s: &str, pat: &str) -> String {+    Regex::new(&format!(r"(?s){}", pat))+        .unwrap()+        .replace_all(s, "")+        .to_string()+}+fn simple_replace_all(s: &str, pairs: &[(String, String)]) -> String {+    let mut out = s.to_string();+    for (from, to) in pairs {+        out = out.replace(from, to);+    }+    out+}++/// Case-insensitive replace_all on each (from, to) pair. The literal+/// prefix (`fill:`, `stroke="`) stays case-sensitive; only the hex tail+/// after the `#` is matched insensitively. This matches the JS source+/// `/.../gi` semantics from gallia/symbol-creator/lib/svg-theme.js.+fn ci_replace_all(s: &str, pairs: &[(String, String)]) -> String {+    let mut out = s.to_string();+    for (from, to) in pairs {+        // Split prefix-up-to-and-including-#  vs. hex-tail at the first '#'+        // so we can keep the prefix literal and apply (?i) to the hex.+        let pat = if let Some(hash_idx) = from.find('#') {+            let prefix = regex::escape(&from[..hash_idx + 1]);+            let hex = regex::escape(&from[hash_idx + 1..]);+            format!("{prefix}(?i){hex}")+        } else {+            regex::escape(from)+        };+        let re = match Regex::new(&pat) {+            Ok(r) => r,+            Err(_) => continue,+        };+        out = re.replace_all(&out, to.as_str()).to_string();+    }+    out+}