← Commit history

v1.1.0: live viewer — layout studio (always open), chip-library rail, label auto-place + manual drag, undo, smoother zoom; browsing no longer mutates the library; refreshed README + app screenshots

John Lauer ·c5559bc6fa ·3mo ago ·parent 87a321f
15 files changed +1318−186
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-symbol"-version = "0.1.8"+version = "1.1.0" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-symbol"-version = "0.1.8"+version = "1.1.0" edition = "2021" description = "Create and preview KiCad/Fusion 360 schematic symbols with interactive viewer" 
README.md+55−37
@@ -3,9 +3,9 @@ Standalone Rust CLI + live app for **KiCad schematic symbols**. Generates `.kicad_sym` files natively in Rust, renders them as gorgeous dark-themed SVGs through the shared **service-kicad** container, and serves an AI-drivable live-viewer. No Node, no gallia, no local KiCad install.+viewer with a full layout studio. No Node, no gallia, no local KiCad install. -![A symbol in the Adom symbol viewer](docs/screenshot-viewer.png)+![The adom-symbol live viewer — an ADC symbol with its 3D-chip outline lasered into the body, beside the docked Layout studio](docs/screenshot-viewer.png)  ## What it does @@ -18,6 +18,11 @@ viewer. No Node, no gallia, no local KiCad install.   **inlines** the part's 3D-chip **vector outline** (with the part number lasered   on) into the body centre as real `<polyline>`s — crisp in every renderer   (browser, librsvg thumbnailer, anything), not a rasterisable `<image>`.+- **Browse + lay out** a whole chip-fetcher library in the **live viewer**: a+  left chip-library rail (with project filters), a docked **Layout studio** for+  the source / pin arrangement / designator placement / 3D-chip style, drag-to-+  place labels, one-step **Undo**, and **Send to** KiCad / Fusion 360 / Altium /+  OrCAD. - **Emit an outline contract** — alongside `<mpn>-symbol.svg`, `render` writes   `<mpn>-symbol-outline.mm.svg` + `<mpn>-symbol-outline.json` so **adom-lbr** can   draw the exact same chip outline onto the EAGLE/Fusion 360 symbol with **zero@@ -26,6 +31,37 @@ viewer. No Node, no gallia, no local KiCad install.   server (`GET /state` poller + `POST /eval` channel), so the AI can push a new   symbol, hot-patch the UI, or read the console in real time. +## The live viewer++`adom-symbol serve` opens an interactive, AI-drivable viewer over the whole+chip-fetcher library. The **Layout studio is always open** on the right — it's+where you shape the symbol — and a collapsible chip-library rail sits on the+left.++**Browse the library.** Every chip in the library root, with chip-fetcher's+project filters; ↑/↓ to step through, and each chip auto-saves the look you give+it (per-symbol; your last-used choices fill in untouched chips).++![The chip-library rail (74 chips) on the left, a symbol with its shaded 3D chip in the body, and the docked Layout studio on the right](screenshots/app-chip-rail.png)++**Shape it in the Layout studio.** Pick the **source** (auto-grouped from ds2sf,+or any manufacturer / Altium / Fusion / KiCad library variant), the pin+**layout** (left-right or all-sides) and grouping, the **3D-chip** overlay style+(off / shaded iso / thin / bold / blue / teal / dark) and size, where the chip+name shows (lasered on the chip, in the symbol, both, or hidden), and then+**Send to** a desktop EDA tool.++![The shaded photoreal "iso" 3D chip composited into the symbol body](screenshots/app-3d-iso.png)++**Place the labels — auto or by hand.** The Reference (`U?`/`Y?`) and chip-name+designators don't have to overlap the body or pins. **Auto-place** centres both+clear of everything; flip on **Manual** and each label gets a drag-outline you+grab and drop exactly where you want — it re-renders live and auto-saves to that+chip. Any change (layout, 3D look, size, auto-place, a label drag) steps back+with **↶ Undo** or Ctrl/⌘+Z.++![Manual label mode on a crystal — the Reference (Y?) and chip name (Crystal_GND24) each wrapped in a drag-outline you position by hand](screenshots/app-label-placement.png)+ ## Install  ```@@ -58,9 +94,9 @@ renders outside the container. |---|---| | `create --file <json> [--out <dir>]` | Generate `.kicad_sym` + SVG + viewer HTML from pin JSON | | `preview --file <kicad_sym> [--out <dir>]` | Render an existing symbol to a themed viewer |-| `render --file <kicad_sym> --out <svg> [--iso <png>]` | Render to a themed thumbnail SVG; auto-composites the 3D-chip outline + emits the outline contract |+| `render --file <kicad_sym> --out <svg> [--iso <png>] [--chip-size large\|medium\|small]` | Render to a themed thumbnail SVG; auto-composites the 3D-chip outline + emits the outline contract | | `embed --file <kicad_sym> [--out <html>]` | Export a self-contained interactive HTML viewer (pan/zoom + pin info) |-| `serve [--port 8781] [--file <kicad_sym>]` | Run the live AI-drivable app server |+| `serve [--port 8781] [--file <kicad_sym>]` | Run the live AI-drivable viewer + layout studio |  ## 3D-chip outline contract (for adom-lbr) @@ -69,6 +105,8 @@ The default `render` composites the part's **3D-chip vector outline** (white body — sized to **0.62×** the body's smaller side, fitted from the source `320×320` outline canvas and centred on the body rectangle. +![A symbol rendered with its 3D chip outline inlined into the body as crisp vector, the part number lasered on the top face](screenshots/app-outline-in-body.png)+ Because that exact placement was previously re-guessed downstream (adom-lbr fit the outline to ~0.38× of a different reference frame, so it drifted ~30% small), `render` now **exposes the placement** so it can be transcribed 1:1. Next to@@ -102,31 +140,20 @@ eagle_y    = center_y − (py − 160) · scale             # (note the Y flip) `center_x/center_y` (and the Y-down `symbol.svg`-frame equivalents) are written verbatim into the sidecar, so a consumer never has to reconstruct them. -## Screenshots+### The contract, proven -### Default symbol render — inline-vector 3D outline + lasered part number-The BQ25798 QFN's 3D outline inlined into the symbol body as crisp vector, with-the part number lasered on the top face.-![adom-symbol render of the BQ25798 QFN with the heavy 3D outline inlined into the body](screenshots/outline-symbol.png)+The emitted `.mm.svg` (recoloured magenta) placed over the live `-symbol.svg` —+dead-centre in the body, framed by the real pins, tracing the chip body, side+pads and lasered name. Zero drift. -### The outline contract lands exactly where adom-symbol draws it-The emitted `.mm.svg` (shown here recoloured magenta) placed over the live-`-symbol.svg` — it sits dead-centre in the body, framed by the real pins, tracing-the chip body, side pads and lasered name. Zero drift. ![Magenta outline contract centered in the symbol body, framed by the pins](screenshots/outline-alignment.png) -### Real EAGLE `.lbr` output — contract vs the old guess-adom-lbr drawing the outline as layer-94 wires: **left** transcribed 1:1 from the-contract (exact 0.62 fit, centred); **right** the legacy guess — too small *and*-shoved off-centre, because it fit the source's ink bbox instead of the canvas.-Both round-tripped back through `adom-sfconvert`.-![Side-by-side EAGLE symbols: the contract fit centred vs the smaller, off-centre legacy guess](screenshots/outline-lbr-contract-vs-legacy.png)+Real EAGLE `.lbr` output: **left** transcribed 1:1 from the contract (exact 0.62+fit, centred); **right** the legacy guess — too small *and* off-centre, because+it fit the source's ink bbox instead of the canvas. -### The lite `.mm.svg` outline-Decimated to a few hundred segments for small libraries, in symbol mm-space.-![The standalone lite outline polylines on a dark canvas](screenshots/outline-mm-lite.png)+![Side-by-side EAGLE symbols: the contract fit centred vs the smaller, off-centre legacy guess](screenshots/outline-lbr-contract-vs-legacy.png) -### The self-describing sidecar ![The outline-contract JSON with placement in both coordinate frames](screenshots/outline-contract-json.png)  ## Architecture@@ -139,6 +166,8 @@ KiCad locally. The live app server (`src/server.rs`) follows the Adom app-creator contract: `GET /state`, `POST /load`, `POST /eval` + `GET /eval/pending` + `POST /eval/:id/result`, `GET/POST /console`, `POST /shutdown`, `GET /favicon.svg`, and best-effort HD port registration.+Per-symbol layout (including dragged label positions) persists to each chip's+`info.json` and bakes into its `.kicad_sym` on save.  ## Build from source @@ -157,27 +186,16 @@ cp target/release/adom-symbol ~/.local/bin/ The only runtime dependency is network access to a service-kicad endpoint (`KICAD_SERVICE_API`, defaults to the shared container). -## Contribute back via pull request--adom-symbol is public, source included. To propose a change:--1. `adompkg source adom-symbol` to get the repo.-2. Make your change and run `cargo test --release` (keep the golden tests green).-3. Open a pull request against the wiki page for `adom-symbol`.--> **Note:** real PR support on wiki.adom.inc is in progress. Until it lands,-> send patches or a branch link to the maintainers and they will apply it.- ## Wiki -https://wiki.adom.inc/apps/adom-symbol+https://wiki.adom.inc/adom/adom-symbol  ## Contributing & forking — open source under the Adom org -**adom-symbol** is **open source** under the **adom** org on the [Adom Wiki](https://wiki.adom.inc/adom/adom-symbol). The full source lives in this page's repo — open the **Files** tab, or `adompkg install adom/adom-symbol` and edit the package. Third-party developers are welcome to modify it and contribute back.+**adom-symbol** is **open source** under the **adom** org on the [Adom Wiki](https://wiki.adom.inc/adom/adom-symbol). The full source lives in this page's repo (public) — open the **Files** tab, or `adompkg source adom-symbol`. Third-party developers are welcome to modify it and contribute back.  **Modify it**-- Get the source from the Files tab (or `adompkg install adom/adom-symbol`), make your changes, and rebuild.+- Get the source from the Files tab (or `adompkg source adom-symbol`), make your changes, run `cargo test --release` (keep the golden tests green), and rebuild.  **Contribute back to Adom — two paths** 1. **Pull request (preferred).** The wiki runs a PR-per-page flow: propose your change on this page and the maintainer reviews + merges it — no fork needed.
SKILL.md+26
@@ -154,6 +154,28 @@ The service enforces all branding rules (dark theme, Adom purple group labels, g  For demo/tour workflow (NE555 and S32K344 samples, region zoom commands, what to point out): see [`viewer-and-delivery-reference.md`](viewer-and-delivery-reference.md) § Sample Symbols & SymView Tour. +## Live viewer & layout studio (`adom-symbol serve`)++`adom-symbol serve` opens an AI-drivable viewer over a whole chip-fetcher+library. The **Layout studio is always open** (right side); a chip-library rail+(with project filters, ↑/↓ nav) is on the left. From the studio you can:++- pick the **source** (auto-grouped ds2sf, or any manufacturer / Altium / Fusion+  / KiCad library variant), the pin **layout** (left-right / all-sides) + grouping;+- choose the **3D-chip** overlay style (off / shaded iso / thin / bold / blue /+  teal / dark) and size, and where the chip **name** shows (on the chip, in the+  symbol, both, or hidden);+- place the **Reference + chip-name designators**: **Auto-place** centres both+  clear of the body and pins; **Manual** gives each label a drag-outline you drop+  exactly where you want — it re-renders live and auto-saves per-chip. **↶ Undo**+  (or Ctrl/⌘+Z) steps back any change;+- **Send to** desktop KiCad / Fusion 360 / Altium / OrCAD.++Per-symbol layout (incl. dragged label positions) persists to each chip's+`info.json` and bakes into its `.kicad_sym` on save. **Reset** un-pins a chip and+restores the vendor original. Browsing/viewing a chip never mutates it — only a+deliberate edit saves.+ ## Symbol Folder Structure  Every symbol gets a dedicated folder under the project schematics directory:@@ -243,6 +265,10 @@ Coordinate system (Y-up), pin angle table (0/90/180/270), and pin electrical typ  Group pins by function with **7.62mm (3 grid-step) gaps between groups**. +By default `render` composites the **white 3D-chip outline + lasered name** into+the symbol body (and emits the outline contract adom-lbr consumes). The why ++mechanics: [`docs/default-symbol-3d-outline.md`](docs/default-symbol-3d-outline.md).+ **Pin placement depends on the `pinLayout` preference and optional per-pin `side` overrides:**  #### Explicit Pin Side Override
VERSION+1−1
@@ -1 +1 @@-1.0.9+1.1.0
docs/screenshot-viewer.png
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-symbol",-  "version": "1.0.9",+  "version": "1.1.0",   "type": "app",   "description": "KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360",   "dependencies": {
screenshots/app-3d-iso.pngadded
⋯ 1 unchanged line ⋯
screenshots/app-chip-rail.pngadded
⋯ 1 unchanged line ⋯
screenshots/app-label-placement.pngadded
⋯ 1 unchanged line ⋯
screenshots/app-outline-in-body.pngadded
⋯ 1 unchanged line ⋯
src/app.html+683−114
@@ -27,6 +27,7 @@     .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)}+    .tbtn:disabled{opacity:.38;cursor:not-allowed;pointer-events:none}     .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}@@ -49,37 +50,36 @@     @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}}+    /* Layout panel: a DOCKED right sidebar (a flex item in .bodyrow) — it reserves+       real space, so fit()/center() centre the symbol in the ACTUAL stage instead+       of under a floaty. Toggling it hides it and the stage reclaims the width. */+    #studio{order:2;flex:0 0 auto;width:min(86vw,300px);align-self:stretch;+      overflow-y:auto;overflow-x:hidden;box-sizing:border-box;+      background:var(--panel);border-left:1px solid var(--border);padding:11px 13px;+      display:flex;flex-direction:column;gap:9px;z-index:1}+    /* Sleek, subtle scrollbars for the docked panels (replaces the chunky default). */+    #studio::-webkit-scrollbar,#chiplist::-webkit-scrollbar{width:9px;height:9px}+    #studio::-webkit-scrollbar-track,#chiplist::-webkit-scrollbar-track{background:transparent}+    #studio::-webkit-scrollbar-thumb,#chiplist::-webkit-scrollbar-thumb{background:rgba(255,255,255,.13);border-radius:7px;border:2px solid transparent;background-clip:padding-box}+    #studio::-webkit-scrollbar-thumb:hover,#chiplist::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.26);background-clip:padding-box}+    #studio,#chiplist{scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.18) transparent}     #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 .studio-head{display:flex;align-items:center;gap:8px;padding:0 0 8px;margin-bottom:2px;+      border-bottom:1px solid var(--border);position:sticky;top:-11px;background:var(--panel);z-index:2}+    #studio .studio-head .grip{display:none}   /* docked, not draggable → no grip */+    #studio .studio-head .studio-title{flex:1;font-size:12px;font-weight:700;color:var(--text)}+    #studio .studio-head .shbtn{width:20px;height:20px;display:grid;place-items:center;border:0;background:transparent;+      color:var(--muted);cursor:pointer;border-radius:4px;font-size:13px}+    #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;flex-wrap:wrap;min-width:0}     #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} }+    @media (max-width:560px){ #studio{width:min(92vw,260px)} #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:@@ -90,8 +90,16 @@     #stage.drag{cursor:grabbing}     #content{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}     #content svg{display:block}+    /* The symbol's own glyphs/paths are decorative — ALL interaction is via the+       .hit rects. Without this, apply3d() re-appends text groups above the hit+       rects, so the cursor over a pin showed the stage's grab hand (not pointer)+       and the text intercepted/blocked hover. */+    #content svg text, #content svg g.stroked-text{pointer-events:none}     .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}+    /* Hit-boxes debug overlay: teal = NUMBER hover box, magenta = NAME hover box. */+    .hit.show{stroke-width:.12;vector-effect:non-scaling-stroke}+    .hit.show.hit-num{fill:rgba(0,230,220,.16);stroke:#00e6dc}+    .hit.show.hit-name{fill:rgba(255,43,214,.15);stroke:#ff2bd6}     #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) */@@ -140,6 +148,67 @@     #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}+    /* ── left chip-library switcher (in-flow: pushes the layout, no overlay) ── */+    .bodyrow{display:flex;flex:1;min-height:0;min-width:0}+    main{min-width:0}+    #chiprail{display:flex;align-items:stretch;flex:0 0 auto;border-right:1px solid var(--border);background:var(--panel)}+    #chiprail.collapsed{align-items:center}+    #chiprailToggle{display:none;align-items:center;gap:7px;background:var(--panel2);color:var(--muted);border:0;border-right:1px solid var(--border);padding:11px 8px;cursor:pointer;font-size:11.5px;font-weight:700;writing-mode:vertical-rl;letter-spacing:.6px;height:100%}+    #chiprailToggle:hover{color:#fff;background:#20283a}+    #chiprailToggle .cr-arrow{writing-mode:horizontal-tb}+    #chiprail.collapsed #chiprailToggle{display:flex}+    #chiprail.collapsed #chiplistWrap{display:none}+    #chiplistWrap{width:236px;background:var(--panel);display:flex;flex-direction:column}+    .cr-head{display:flex;align-items:center;justify-content:space-between;padding:9px 12px;border-bottom:1px solid var(--border);font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.4px;text-transform:uppercase}+    #chiprailClose{background:transparent;border:0;color:var(--muted);font-size:16px;cursor:pointer;padding:0 4px;line-height:1}+    #chiprailClose:hover{color:#fff}+    .cr-project{margin:7px 10px 4px;padding:5px 8px;background:#0a0e14;color:var(--text);border:1px solid var(--border);border-radius:7px;font-size:11.5px;cursor:pointer}+    .cr-project:hover{border-color:var(--accent)}+    #chiplist{flex:1;overflow:auto;outline:none}+    .cr-item{padding:7px 12px;cursor:pointer;font-size:12.5px;color:var(--text2,#aeb6c2);border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+    .cr-item:hover{background:rgba(0,184,177,.10);color:#fff}+    .cr-item.cur{background:linear-gradient(90deg,rgba(0,184,177,.20),transparent);border-left-color:var(--accent);color:#fff;font-weight:600}+    .cr-hint{padding:7px 12px;border-top:1px solid var(--border);font-size:10px;color:var(--muted2,#6e7886)}+    /* ── busy spinner (shown while a layout change re-renders) ── */+    #busy{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);z-index:9998;display:none;align-items:center;gap:8px;background:rgba(22,27,34,.96);border:1px solid rgba(0,184,177,.35);border-radius:999px;padding:7px 14px;color:var(--text);font-size:12px;font-weight:600;box-shadow:0 10px 32px rgba(0,0,0,.5)}+    #busy.show{display:flex}+    #busy .spin{width:13px;height:13px;border-radius:50%;border:2px solid rgba(0,184,177,.3);border-top-color:var(--accent2,#00e6dc);animation:busyspin .7s linear infinite}+    @keyframes busyspin{to{transform:rotate(360deg)}}+    /* LIGHT, non-blocking spinner positioned exactly where the 3D chip outline+       renders (body centre). Represents ONLY the 3D outline (re)loading — it does+       not dim or block the rest of the app. */+    #chip3dspin{position:absolute;display:none;width:30px;height:30px;margin:-15px 0 0 -15px;border-radius:50%;+      border:2.5px solid rgba(0,230,220,.20);border-top-color:var(--accent2,#00e6dc);+      animation:busyspin .8s linear infinite;z-index:4;pointer-events:none;opacity:.85}+    #chip3dspin.show{display:block}+    /* Draggable Reference / chip-name (Value) field handles — shown only in+       manual-fields mode. Positioned in stage-local px (pan baked in), like the+       3D spinner. The user drags them to place each designator on the symbol. */+    /* Field drag handle = a TRANSPARENT outline box drawn exactly AROUND the+       label, so the word (Y? / Crystal_GND24) is never covered — you grab the+       word itself. The grip tab hangs OUTSIDE the box (above-left), so it can't+       touch the text either. */+    .fldh{position:absolute;display:none;z-index:6;box-sizing:border-box;+      border:1.5px dashed rgba(42,240,230,.85);border-radius:5px;background:rgba(0,230,220,.05);+      cursor:grab;touch-action:none}+    .fldh.show{display:block}+    .fldh.dragging{cursor:grabbing;background:rgba(0,230,220,.16);border-style:solid}+    .fldh .grip{position:absolute;left:-1.5px;top:-1.5px;transform:translateY(-100%);+      background:linear-gradient(180deg,#00c2bb,#00a39c);color:#04201f;border:1px solid #2af0e6;+      border-radius:6px 6px 6px 0;padding:1px 6px;font-size:9.5px;font-weight:700;letter-spacing:.3px;+      white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.45);user-select:none}+    body.manualfields #content svg{cursor:default}+    /* ── save LED + regen spinner ── */+    .savled{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;color:var(--muted);padding:0 4px}+    .savled .led{width:8px;height:8px;border-radius:50%;background:#6e7886;box-shadow:0 0 0 transparent;transition:.18s}+    .savled.saved{color:#3fb950}.savled.saved .led{background:#3fb950;box-shadow:0 0 8px rgba(63,185,80,.6)}+    .savled.unsaved{color:#e3b341}.savled.unsaved .led{background:#e3b341;box-shadow:0 0 8px rgba(227,179,65,.6);animation:ledpulse 1.1s ease-in-out infinite}+    .savled.saving{color:var(--accent2,#00e6dc)}.savled.saving .led{background:var(--accent2,#00e6dc);box-shadow:0 0 8px rgba(0,230,220,.6)}+    @keyframes ledpulse{50%{opacity:.45}}+    .rgnchk{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--muted);cursor:pointer;user-select:none}+    .rgnchk input{accent-color:var(--accent2,#00e6dc);cursor:pointer}+    #regenBtn.spinning{color:var(--accent2,#00e6dc);border-color:rgba(0,230,220,.45)}+    #regenBtn.spinning::before{content:"";display:inline-block;width:11px;height:11px;margin-right:5px;border-radius:50%;border:2px solid rgba(0,230,220,.3);border-top-color:var(--accent2,#00e6dc);animation:busyspin .7s linear infinite;vertical-align:-2px}   </style> </head> <body>@@ -161,18 +230,24 @@       <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>+      <button class="tbtn" id="hitsb" data-tooltip="Toggle the pin hover hit-boxes: TEAL = the box around each pin NUMBER, MAGENTA = the box around each pin NAME. These are exactly the areas that trigger the hover tooltip.">⊡ Hit boxes</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>+      <button class="tbtn" id="studioBtn" data-tooltip="Layout — choose the source (auto-grouped / manufacturer / ds2sf), pin arrangement, grouping, reference position, and the 3D chip overlay.">⚙ Layout</button>+      <span class="savled saved" id="savled" data-tooltip="All changes saved"><span class="led"></span><span id="savledT">Saved</span></span>       <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> +  <!-- Body row (flex): left chip-library rail · main stage · docked Layout sidebar.+       All three reserve real space, so fit()/center() centre the symbol in the+       actual stage. #toast/#busy are position:fixed so they're unaffected here. -->+  <div class="bodyrow">+   <div id="studio" hidden>     <div class="studio-head" id="studioHead">       <span class="grip">⠿</span>-      <span class="studio-title">Layout studio</span>+      <span class="studio-title">Layout</span>       <button class="shbtn" id="studioCollapse" data-tooltip="Collapse / expand">▾</button>       <button class="shbtn" id="studioClose" data-tooltip="Close (reopen from ⚙ Layout)">✕</button>     </div>@@ -216,6 +291,13 @@         <button data-v="out">Out</button><button data-v="in">In</button>       </div>     </div>+    <div class="srow">+      <span class="slab" data-tooltip="The Reference (U?/Y?) and chip-name labels. Auto-place positions both clear of the body and every pin. Manual lets you drag each one exactly where you want it on the symbol — it auto-saves to THIS chip.">Labels</span>+      <div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">+        <button class="tbtn" id="autoFields" data-tooltip="Auto-place the Reference + chip-name clear of the body and all pins (centred above / below). Saves to this symbol.">✦ Auto-place</button>+        <button class="tbtn" id="manualFields" data-tooltip="Manual mode: drag the Reference + chip-name handles to position them exactly. Auto-saves to this chip. Click again to finish.">✥ Manual</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">@@ -230,6 +312,21 @@       <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">+      <span class="slab" data-tooltip="Size of the 3D chip in the body. Large is the standard size; pick Medium or Small when the chip crowds the pin names.">Chip size</span>+      <div class="seg" id="segSize">+        <button data-v="large">Large</button>+        <button data-v="medium">Medium</button>+        <button data-v="small">Small</button>+      </div>+    </div>+    <div class="srow">+      <span class="slab" data-tooltip="Regenerate THIS chip's vector 3D outline from its STEP (one OCCT pass via chip-thumbnailer). 'Regen now' forces it immediately; the checkbox auto-regens each time you switch to this chip. Off by default so browsing doesn't hammer the shared service-step2glb / chip-thumbnailer.">3D outline</span>+      <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;flex:1 1 auto">+        <button class="tbtn" id="regenBtn" data-tooltip="Force-regenerate THIS chip's vector outline now (one OCCT pass via chip-thumbnailer).">⟳ Regen now</button>+        <label class="rgnchk" data-tooltip="Sticky, off by default. When ON, regenerate just THIS chip's outline each time you load/switch to it — so it's fresh from the STEP."><input type="checkbox" id="regenOnSwitch"><span>auto on switch</span></label>+      </div>+    </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">@@ -239,23 +336,42 @@         <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 class="srow" style="justify-content:flex-end;flex-wrap:wrap;gap:6px">+      <button class="tbtn" id="undoBtn" disabled data-tooltip="Undo the last change to THIS symbol — layout, 3D look, chip size, auto-place, or a label drag. Also Ctrl/⌘+Z. Steps back one edit at a time.">↶ Undo</button>+      <button class="tbtn" id="applyAll" data-tooltip="Saves THIS chip's current Layout + 3D look as the per-chip look for every chip in the list right now — that's the current project filter ('All chips' = the whole library; pick a project first to scope it). It only writes each chip's info.json (no re-fetch, no regen), overrides their own saved look, and asks you to confirm with the exact count. Undo any chip afterward with Reset.">Apply to all</button>+      <button class="tbtn" id="studioReset" data-tooltip="Un-pin THIS symbol's saved look so it follows the default again. Leaves other chips + your sticky default alone.">Reset</button>     </div>+    <div class="srow" style="justify-content:flex-start"><span class="slab" id="stickyNote" data-tooltip="Each symbol REMEMBERS its own look once you change it — returning won't override it. Untouched symbols follow your last-used (sticky) choices. 'Apply to all' pushes the current look onto every chip; 'Reset' un-pins this one so it follows the default again.">per-symbol · sticky fills blanks</span></div>   </div>   <div id="toast"></div> +  <!-- Left chip-library switcher. Collapsed rail by default; expand to arrow-nav+       the whole chip-fetcher library. Switching auto-saves the current chip. -->+  <div id="chiprail" class="collapsed">+    <button id="chiprailToggle" title="Chip library — expand to switch chips (↑/↓)"><span class="cr-arrow">▸</span><span class="cr-lab">Chips</span></button>+    <div id="chiplistWrap">+      <div class="cr-head"><span id="cr-count">Library</span><button id="chiprailClose" title="Collapse">‹</button></div>+      <select id="cr-project" class="cr-project" autocomplete="off" title="Filter by chip-fetcher project"><option value="">All chips</option></select>+      <div id="chiplist" tabindex="0"></div>+      <div class="cr-hint">↑ / ↓ to switch · auto-saves on leave</div>+    </div>+  </div>+   <main>     <div id="stage">       <div id="content"></div>+      <div id="chip3dspin" aria-label="3D chip outline loading"></div>+      <div id="fldRef" class="fldh" title="Drag the Reference (U?/Y?) wherever you want it"><span class="grip">✥ Ref</span></div>+      <div id="fldVal" class="fldh" title="Drag the chip name (Value) wherever you want it"><span class="grip">✥ Name</span></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>+  </div>    <footer id="foot" style="display:none"></footer>    <div id="tip"></div>+  <div id="busy"><span class="spin"></span>rendering…</div>   <div id="dtt"></div>    <script>@@ -277,12 +393,198 @@   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;+  let baseW=1, baseH=1, baseX=0, baseY=0, fittedSymbol=null;   // viewBox origin+size (mm) + the chip we last fit+  // The zoom readout is RELATIVE TO FIT: "100%" = the symbol fit nicely to the+  // window (the natural full view). A symbol's true physical size (mm at screen+  // DPI) is tiny on a big monitor, so an honest scale*100 read ~250%+ at fit and+  // felt wrong. fitScale captures the fit() scale so 100% means "fit to window".+  let fitScale=1;+  let userAdjusted=false;   // true once the user zooms/pans → don't auto-refit on resize    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()}+  // Zoom by RESIZING the SVG (it re-renders its vectors crisply at any scale).+  // Pan is the only CSS transform (translate never rasterizes). This is why the+  // drawing stays sharp when you zoom instead of upscaling a 100% bitmap.+  function applyT(){+    if(svgEl){svgEl.style.width=(baseW*MM*scale)+'px';svgEl.style.height=(baseH*MM*scale)+'px';}+    stage.style.transform=`translate(${panX}px,${panY}px)`;+    document.getElementById('zlbl').textContent=Math.round(scale/(fitScale||1)*100)+'%';+    placeChip3dSpin();   // keep the 3D-chip spinner pinned to the body centre through pan/zoom+    placeFieldHandles(); // keep the draggable label handles pinned to their designators+  }+  // The 3D-chip outline spinner sits at the symbol-body centre (where the 3D chip+  // renders) and tracks pan/zoom. Light + non-blocking (pointer-events:none).+  function chip3dCenter(){+    if(!window._body||!offset)return null;+    const b=window._body, ux=offset.x+(b.x1+b.x2)/2, uy=offset.y-(b.y1+b.y2)/2;+    return {x:panX+(ux-baseX)*MM*scale, y:panY+(uy-baseY)*MM*scale};+  }+  function placeChip3dSpin(){const el=document.getElementById('chip3dspin');if(!el||!el.classList.contains('show'))return;const c=chip3dCenter();if(c){el.style.left=c.x+'px';el.style.top=c.y+'px';}}+  function showChip3dSpin(on){const el=document.getElementById('chip3dspin');if(!el)return;el.classList.toggle('show',!!on);placeChip3dSpin();}++  // ── draggable Reference / chip-name field handles ──────────────────────────+  // The designator positions in /state.refField/valueField are KiCad symbol-local+  // mm (Y-up). Map them to stage-local px exactly like the 3D spinner, so a handle+  // sits right on its label; the inverse map turns a drag back into mm to save.+  function symField2Screen(fx,fy){+    if(!offset)return null;+    const ux=offset.x+fx, uy=offset.y-fy;+    return {x:panX+(ux-baseX)*MM*scale, y:panY+(uy-baseY)*MM*scale};+  }+  function screen2symField(sx,sy){+    if(!offset)return null;+    const ux=(sx-panX)/(MM*scale)+baseX, uy=(sy-panY)/(MM*scale)+baseY;+    return {x:ux-offset.x, y:offset.y-uy};+  }+  let manualFields=false, fieldDragging=false;+  const FLD_PAD=4;   // px of breathing room around the label inside the outline box+  // Find the rendered <text> nearest a field's anchor (stage-local px) and return+  // its on-screen box (centre + size). The Reference/Value are anchored exactly at+  // refField/valueField, so the nearest text IS that label — we draw the outline+  // around the REAL word (measured), never a fixed guess that could cover it.+  function nearestFieldText(ax,ay,want){+    if(!svgEl)return null;+    const sr=stageWrap.getBoundingClientRect();+    const wantT=(want||'').trim();+    let best=null,bestD=1e18, match=null,matchD=1e18;+    svgEl.querySelectorAll('text').forEach(t=>{+      const r=t.getBoundingClientRect(); if(!r.width||!r.height)return;+      const cx=(r.left+r.right)/2-sr.left, cy=(r.top+r.bottom)/2-sr.top;+      const d=(cx-ax)*(cx-ax)+(cy-ay)*(cy-ay);+      const box={cx,cy,w:r.width,h:r.height};+      if(d<bestD){bestD=d;best=box;}+      if(wantT && (t.textContent||'').trim()===wantT && d<matchD){matchD=d;match=box;}+    });+    // PREFER the <text> whose content is EXACTLY this designator (so we outline the+    // right word and never latch onto a nearby pin name); fall back to nearest only+    // if it's close to the anchor.+    if(match)return match;+    return (best && bestD < 70*70) ? best : null;+  }+  function positionFieldBox(el,field,want){+    const a=symField2Screen(field.x,field.y);+    const t=a?nearestFieldText(a.x,a.y,want):null;+    if(!t){el.classList.remove('show');return;}+    el.style.left=(t.cx-t.w/2-FLD_PAD)+'px'; el.style.top=(t.cy-t.h/2-FLD_PAD)+'px';+    el.style.width=(t.w+FLD_PAD*2)+'px'; el.style.height=(t.h+FLD_PAD*2)+'px';+    el.classList.add('show');+  }+  function placeFieldHandles(){+    const rf=document.getElementById('fldRef'), vf=document.getElementById('fldVal');+    if(!rf||!vf)return;+    const s=window._lastState||{};+    if(!manualFields||!offset||fieldDragging){ if(!fieldDragging){rf.classList.remove('show');vf.classList.remove('show');} return; }+    if(s.refField)positionFieldBox(rf,s.refField,s.refText); else rf.classList.remove('show');+    // The chip-name box only shows when the Value is actually drawn in the symbol.+    const valHidden=(s.valuePos==='none')||!s.valueField;+    if(!valHidden)positionFieldBox(vf,s.valueField,s.valueText); else vf.classList.remove('show');+  }+  // A field move is a real per-symbol edit: re-render + mark dirty so it SAVES to+  // this chip's info.json — but it is NOT written to the global sticky prefs.+  async function moveField(patch){+    setBusy(true);+    try{ await fetch('relayout',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }+    catch(e){ setBusy(false); }+    lastHash=''; markDirty();+    poll();   // refresh _lastState now so a rapid 2nd drag snapshots the up-to-date override+  }+  function makeFieldDraggable(el,kind){+    let sx=0,sy=0,ox=0,oy=0;+    el.addEventListener('pointerdown',e=>{+      if(!manualFields)return; e.preventDefault(); e.stopPropagation();+      pushUndo();+      fieldDragging=true; el.classList.add('dragging');+      try{el.setPointerCapture(e.pointerId);}catch(_){}+      sx=e.clientX; sy=e.clientY; ox=parseFloat(el.style.left)||0; oy=parseFloat(el.style.top)||0;+    });+    el.addEventListener('pointermove',e=>{+      if(!fieldDragging)return;+      el.style.left=(ox+(e.clientX-sx))+'px'; el.style.top=(oy+(e.clientY-sy))+'px';+    });+    const end=e=>{+      if(!fieldDragging)return; fieldDragging=false; el.classList.remove('dragging');+      try{el.releasePointerCapture(e.pointerId);}catch(_){}+      // The box is centred on the label, so its CENTRE is the label position.+      const cx=(parseFloat(el.style.left)||0)+el.offsetWidth/2;+      const cy=(parseFloat(el.style.top)||0)+el.offsetHeight/2;+      const mm=screen2symField(cx,cy);+      if(!mm)return;+      if(kind==='ref') moveField({refX:+mm.x.toFixed(3), refY:+mm.y.toFixed(3)});+      else moveField({valueX:+mm.x.toFixed(3), valueY:+mm.y.toFixed(3)});+    };+    el.addEventListener('pointerup',end);+    el.addEventListener('pointercancel',end);+  }+  makeFieldDraggable(document.getElementById('fldRef'),'ref');+  makeFieldDraggable(document.getElementById('fldVal'),'value');+  function setManualFields(on){+    manualFields=!!on;+    document.body.classList.toggle('manualfields',manualFields);+    document.getElementById('manualFields').classList.toggle('active',manualFields);+    placeFieldHandles();+    if(manualFields)showToast('Manual labels: drag the outlined Ref / Name where you want it','ok');+  }+  document.getElementById('manualFields').onclick=()=>setManualFields(!manualFields);+  document.getElementById('autoFields').onclick=async()=>{+    pushUndo();+    setBusy(true);+    try{ await fetch('autofields',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); }+    catch(e){ setBusy(false); }+    lastHash=''; markDirty();+    showToast('✦ Auto-placed labels clear of the pins','ok');+  };++  // ── Undo ────────────────────────────────────────────────────────────────+  // A per-symbol stack of layout snapshots taken BEFORE each user edit, so any+  // change — a Layout toggle, 3D look, chip size, auto-place, or a label drag —+  // can be stepped back (button + Ctrl/⌘+Z). Cleared when you switch chips.+  let undoStack=[];+  function captureState(){+    const s=window._lastState||{};+    if(!s.symbolName)return null;+    return {symbol:s.symbolName, source:s.source, pinLayout:s.pinLayout, grouped:s.grouped,+      refPos:s.refPos, valuePos:s.valuePos, laserName:s.laserName, threeD:s.threeD, chipSize:chip3dSize,+      refOverride:s.refOverride||null, valueOverride:s.valueOverride||null};+  }+  function updateUndo(){const b=document.getElementById('undoBtn');if(b)b.disabled=undoStack.length===0;}+  function pushUndo(){+    const snap=captureState(); if(!snap)return;+    const last=undoStack[undoStack.length-1];+    if(last && JSON.stringify(last)===JSON.stringify(snap))return;   // skip no-ops+    undoStack.push(snap); if(undoStack.length>60)undoStack.shift(); updateUndo();+  }+  function clearUndo(){undoStack=[];updateUndo();}+  async function restoreSnapshot(s){+    chip3dSize=s.chipSize||chip3dSize; stickyChipSize=chip3dSize;+    document.querySelectorAll('#segSize button').forEach(b=>b.classList.toggle('on',b.dataset.v===chip3dSize));+    // refPos/valuePos restore the preset (and clear any override server-side); a+    // remembered override is then re-applied via refX/refY — handler order makes+    // that land on the exact saved spot.+    const patch={source:s.source, pinLayout:s.pinLayout, grouped:s.grouped, laserName:s.laserName,+      threeD:s.threeD, refPos:s.refPos, valuePos:s.valuePos};+    if(s.refOverride){patch.refX=s.refOverride[0];patch.refY=s.refOverride[1];}+    if(s.valueOverride){patch.valueX=s.valueOverride[0];patch.valueY=s.valueOverride[1];}+    setBusy(true);+    try{ await fetch('relayout',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }+    catch(e){ setBusy(false); }+    lastHash=''; markDirty();+  }+  async function doUndo(){+    if(!undoStack.length)return;+    const snap=undoStack.pop(); updateUndo();+    await restoreSnapshot(snap);+    showToast('↶ Undone','ok');+  }+  document.getElementById('undoBtn').onclick=doUndo;+  document.addEventListener('keydown',e=>{+    if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&(e.key==='z'||e.key==='Z')){+      if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA')return;+      e.preventDefault(); doUndo();+    }+  });+  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;userAdjusted=true;applyT()}+  function center(){if(!svgEl)return;const w=stageWrap.clientWidth,h=stageWrap.clientHeight;const sw=baseW*MM*scale,sh=baseH*MM*scale;panX=(w-sw)/2;panY=(h-sh)/2;applyT()}+  function fit(){if(!svgEl)return;const w=stageWrap.clientWidth,h=stageWrap.clientHeight;const sw=baseW*MM,sh=baseH*MM;scale=Math.max(.05,Math.min(Math.min(w/sw,h/sh)*0.86,60));fitScale=scale;userAdjusted=false;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)};@@ -292,12 +594,27 @@   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});+  // Smooth, delta-PROPORTIONAL zoom. A fixed factor per event makes the+  // trackpad leap: one gentle two-finger nudge fires a burst of tiny wheel+  // events and a fixed 1.12× compounds many times. Instead derive the factor+  // from how much was actually scrolled (exp → smooth + reversible), normalise+  // across device units, and clamp a single event so a chunky mouse notch+  // can't jump while a feather trackpad touch stays barely-there.+  const ZOOM_K=0.0022;   // sensitivity: lower = gentler+  stageWrap.addEventListener('wheel',e=>{+    e.preventDefault();+    let d=e.deltaY;+    if(e.deltaMode===1)d*=16;                  // lines → px+    else if(e.deltaMode===2)d*=stageWrap.clientHeight; // pages → px+    d=Math.max(-50,Math.min(50,d));            // cap one event's contribution+    const r=stageWrap.getBoundingClientRect();+    zoomAt(e.clientX-r.left,e.clientY-r.top,Math.exp(-d*ZOOM_K));+  },{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`}+    if(dragging){panX=pStartX+(e.clientX-dsx);panY=pStartY+(e.clientY-dsy);userAdjusted=true;applyT()}+    if(svgEl&&offset){const r=stageWrap.getBoundingClientRect();const ux=baseX+(e.clientX-r.left-panX)/scale/MM, uy=baseY+(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')}); @@ -311,7 +628,7 @@   },{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();}+    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);userAdjusted=true;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=>{@@ -345,7 +662,7 @@     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 mark(p){clearHL();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@@ -353,16 +670,30 @@   // 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()}+  // Pair each invisible <text opacity="0"> with its visible <g class="stroked-text">+  // glyphs. They're NOT always adjacent siblings: ROTATED (top/bottom) pin text is+  // wrapped in a rotate <g>, so the stroked-text group is the wrapper's next+  // sibling, not the text's. So we walk the SVG in document order and pair each+  // pending invisible text with the next stroked-text group. (nextElementSibling+  // silently dropped every vertical pin → no hit box, no highlight.)+  function collectGlyphLabels(){+    const out=[]; let pending=null;+    if(!svgEl)return out;+    svgEl.querySelectorAll('text, g.stroked-text').forEach(n=>{+      if(n.classList&&n.classList.contains('stroked-text')){+        if(pending){const k=norm(pending.textContent);if(k)out.push({k,group:n});pending=null;}+      }else if(n.getAttribute&&n.getAttribute('opacity')==='0'){+        pending=n;+      }+    });+    return out;+  }   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;+    collectGlyphLabels().forEach(({k,group})=>{       if(!textMap.has(k))textMap.set(k,[]);-      textMap.get(k).push(g);+      textMap.get(k).push(group);     });     if(offset){       const paths=[...svgEl.querySelectorAll('path')];@@ -386,10 +717,11 @@   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).+  // ONE continuous tight box per pin = the UNION of its NUMBER glyph box, its+  // NAME glyph box, and the wire between them. Hugs the actual text (only as tall+  // as the label), spans number→name with NO gaps (so the cursor never crosses+  // dead space → no flicker), and never reaches into empty body. Hover is+  // instant (no open delay). Boxes double as nearest-pin tap targets on touch.   let pinZones=[];   function buildHits(){     if(!svgEl||!offset)return;@@ -398,41 +730,63 @@     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.27;                       // half-thickness across the wire (~pitch*0.95, like the original)-    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;-      // Reach all the way to the body centre so the zone covers the WHOLE pin-      // name (long names like P0.04/GRTC_CLKOUT32K), not just the first 7mm.-      const nameDepth=(horiz?bw:bh)*0.5;-      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;}++    // Catalogue every rendered label's box via the document-order pairing (works+    // for rotated top/bottom pins too). getBBox() is in SVG user units = our+    // overlay space (the stroked-text paths are absolute root coords).+    const labels=[];+    collectGlyphLabels().forEach(({k,group})=>{+      let box;try{box=group.getBBox();}catch(e){return;}+      if(!box||!box.width)return;+      labels.push({k,box,cx:box.x+box.width/2,cy:box.y+box.height/2,used:false});+    });+    // Nearest unused label whose string matches `k` to the anchor (handles+    // duplicate names like multiple GND by proximity to each pin).+    function pick(k,ax,ay){+      k=norm(k);let best=null,bd=1e9;+      for(const l of labels){if(l.used||l.k!==k)continue;const d=Math.hypot(l.cx-ax,l.cy-ay);if(d<bd){bd=d;best=l;}}+      if(best)best.used=true;return best;+    }+    let hideT=null;+    // One tight box hugging the NUMBER glyphs + one hugging the NAME glyphs, per+    // pin. Both fire the same pin's tooltip; moving between a pin's two boxes is+    // flicker-free (shared hide grace, cleared on enter). `kind` colours them in+    // the Hit-boxes overlay so you can SEE exactly what's hover-able.+    function addBox(box,p,idx,kind){+      const pad=0.45,x=box.x-pad,y=box.y-pad,w=box.width+pad*2,h=box.height+pad*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.setAttribute('class','hit hit-'+kind+(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('mouseenter',ev=>{if(sticky)return;clearTimeout(hideT);showPin(p,ev.clientX,ev.clientY,false);mark(p)});       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('mouseleave',()=>{if(sticky)return;clearTimeout(hideT);hideT=setTimeout(()=>{tip.classList.remove('show');unmark()},45)});       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);+    }+    pins.forEach((p,idx)=>{+      const tipX=offset.x+p.x, tipY=offset.y-p.y;const L=p.length||2.54;+      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 nl=pick(p.number, tipX+dx*L*0.5, tipY+dy*L*0.5-(dx!==0?0.9:0));   // number: outside, over the wire+      const ml=pick(p.name, tipX+dx*(L+2), tipY+dy*(L+2));                    // name: inside the body+      if(nl)addBox(nl.box,p,idx,'num');+      if(ml)addBox(ml.box,p,idx,'name');+      // Discrete parts hide pin name + number → no glyphs at all. Fall back to a+      // box over the wire so the pin is still hoverable/tappable.+      if(!nl&&!ml){+        const ex=tipX+dx*L,ey=tipY+dy*L,HT=1.0;+        addBox({x:Math.min(tipX,ex)-(dx===0?HT:0.3),y:Math.min(tipY,ey)-(dy===0?HT:0.3),+                width:Math.abs(ex-tipX)+(dx===0?HT*2:0.6),height:Math.abs(ey-tipY)+(dy===0?HT*2:0.6)},p,idx,'num');+      }     });   }   // 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;+    const ux=baseX+(clientX-r.left-panX)/scale/MM, uy=baseY+(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));@@ -467,11 +821,21 @@   }    // ── state ── lightweight poll; heavy SVG + 3D assets fetched on demand ──-  let lastRev=-1, cur3dMode=null, cur3dAsset='', cur3dLaser=null;+  let lastRev=-1, cur3dMode=null, cur3dAsset='', cur3dLaser=null, lastSymbol=null;   async function poll(){     try{       const s=await (await fetch('state',{cache:'no-store'})).json();setConn(true);       window._lastState=s;+      // Reflect the server's regen status on the Regen button spinner.+      document.getElementById('regenBtn').classList.toggle('spinning',!!s.regen);+      // A new chip just loaded → it's clean, and we auto-regen its 3D assets.+      // Also reconcile the rail to server truth (unless a nav is mid-debounce).+      if(s.symbolName && s.symbolName!==lastSymbol){+        lastSymbol=s.symbolName; loadedMpn=s.symbolName; setSave('saved'); if(regenOnSwitch)scheduleRegen();+        clearUndo();          // a fresh chip starts with an empty undo history+        setManualFields(false);   // and out of manual-label mode (no ghost handles on the new chip)+        if(typeof navT!=='undefined' && !navT && chips.length && !chips.some(c=>c.current&&c.mpn===s.symbolName)) setCurrent(s.symbolName);+      }       const has=!!s.symbolName;       document.getElementById('empty').style.display=has?'none':'';       document.getElementById('sName').textContent=has?s.symbolName:'—';@@ -485,6 +849,7 @@       }       apply3d(s);       maybeApplyPrefs(s);+      showChip3dSpin(!!s.regen);   // 3D-outline spinner: ONLY while the outline is (re)generating     }catch(e){setConn(false)}   }   function renderSvg(s, svg){@@ -495,59 +860,205 @@     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')}+      if(vb.length===4){baseX=vb[0]||0;baseY=vb[1]||0;baseW=vb[2]||1;baseH=vb[3]||1;svgEl.removeAttribute('width');svgEl.removeAttribute('height')}       buildHits();       buildMaps();-      requestAnimationFrame(fit);+      // Fit only when the CHIP changed; a layout/3D re-render of the same chip+      // keeps your current zoom + pan so the view doesn't jump under you.+      if(s.symbolName!==fittedSymbol){fittedSymbol=s.symbolName;requestAnimationFrame(fit);}+      else{applyT();}     }+    setBusy(false);   // the re-render landed → drop the spinner   }   setInterval(poll,1000);poll();-  new ResizeObserver(()=>{if(svgEl)fit()}).observe(stageWrap);+  new ResizeObserver(()=>{if(svgEl){if(userAdjusted)applyT();else fit();}}).observe(stageWrap);++  // ── left chip-library switcher ──+  // Lists every chip in the chip-fetcher library root; the launched chip is+  // auto-selected. Collapsed by default; expand to ↑/↓ between chips. Switching+  // auto-saves the chip you're leaving (server side, in /load-chip).+  // `chips` = the whole library; `projects` mirrors chip-fetcher's groupings, so+  // the dropdown offers a global "All chips" list + the same per-project filters.+  // `shown` is the currently-filtered list the ↑/↓ keys + clicks operate on.+  let chips=[], projects={}, projFilter='', shown=[], chipIdx=-1;+  const rail=document.getElementById('chiprail'), chiplist=document.getElementById('chiplist'), projSel=document.getElementById('cr-project');+  function applyFilter(){+    shown = (projFilter && projects[projFilter])+      ? chips.filter(c=>(projects[projFilter].mpns||[]).includes(c.mpn))+      : chips.slice();+    chipIdx = shown.findIndex(c=>c.current);+  }+  function renderChips(){+    applyFilter();+    chiplist.innerHTML=shown.map(c=>`<div class="cr-item${c.current?' cur':''}" data-mpn="${esc(c.mpn)}">${esc(c.mpn)}</div>`).join('')+      || '<div class="cr-hint" style="border:0">no chips in this project</div>';+    document.getElementById('cr-count').textContent=(projFilter||'Library')+' · '+shown.length;+    // Make the bulk button's SCOPE explicit: how many chips + which set.+    const ab=document.getElementById('applyAll');+    if(ab)ab.textContent = projFilter ? `Apply to ${shown.length} in "${projFilter}"` : `Apply to all ${shown.length}`;+    const cur=chiplist.querySelector('.cr-item.cur'); if(cur)cur.scrollIntoView({block:'nearest'});+  }+  function renderProjectOptions(){+    // Only projects that actually contain a chip present in THIS library (mirrors+    // chip-fetcher showing just the relevant project groupings).+    const names=Object.keys(projects)+      .map(n=>[n,(projects[n].mpns||[]).filter(m=>chips.some(c=>c.mpn===m)).length])+      .filter(([,cnt])=>cnt>0)+      .sort((a,b)=>a[0].toLowerCase().localeCompare(b[0].toLowerCase()));+    projSel.innerHTML='<option value="">All chips ('+chips.length+')</option>'++      names.map(([n,cnt])=>`<option value="${esc(n)}">${esc(n)} (${cnt})</option>`).join('');+    // If the active filter no longer matches anything, fall back to All.+    if(projFilter && !names.some(([n])=>n===projFilter))projFilter='';+    projSel.value=projFilter;+  }+  async function loadChips(){+    try{+      const [cj,pj]=await Promise.all([+        fetch('api/chips',{cache:'no-store'}).then(r=>r.json()),+        fetch('api/projects',{cache:'no-store'}).then(r=>r.json()).catch(()=>({projects:{}})),+      ]);+      chips=cj.chips||[]; projects=pj.projects||{};+      renderProjectOptions(); renderChips();+    }catch(e){}+  }+  let loadedMpn=null, navT=null;+  function setCurrent(mpn){chips.forEach(c=>c.current=(c.mpn===mpn));renderChips();}+  async function loadChipMpn(mpn){+    if(!mpn||mpn===loadedMpn)return;+    const leaving=loadedMpn;+    // Leaving a chip: flush any pending edit so a DELIBERATE look is kept (its LED+    // toast confirms it). A chip you only viewed stays clean → server won't pin it.+    clearTimeout(saveT);+    if(leaving && (savled.classList.contains('unsaved')||savled.classList.contains('saving'))) await doSave(leaving);+    loadedMpn=mpn;+    // Clear the previous chip's undo history + exit manual mode NOW (not later in+    // poll()) so a Ctrl+Z in the gap before /state refreshes can't apply a stale+    // layout to the freshly-loaded chip.+    clearUndo(); setManualFields(false);+    // No swap-spinner: the symbol (with its already-composited 3D outline) is what+    // we're loading, and it's fast. We render it the moment /load-chip returns+    // instead of waiting for the 1s poll tick, so switching feels instant. The 3D+    // spinner only appears when the outline is actually (re)generating (s.regen).+    try{+      await fetch('load-chip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mpn,chipSize:chip3dSize})});+      poll();   // pull the new symbol + SVG right now — don't wait for the tick+    }+    catch(e){ loadedMpn=leaving; }+    setSave('saved');   // freshly-loaded chip is clean+  }+  // Highlight instantly; debounce the heavy service-kicad load so ↑/↓ browsing+  // only loads the chip you LAND on (not one per keypress).+  function selectChip(mpn){ if(!mpn)return; setCurrent(mpn); clearTimeout(navT); navT=setTimeout(()=>{navT=null;loadChipMpn(mpn);},280); }+  function switchByOffset(d){+    if(!shown.length)return;+    let i = chipIdx<0 ? (d>0?0:shown.length-1) : chipIdx+d;   // first ↓→top, first ↑→bottom+    i=Math.max(0,Math.min(shown.length-1,i));+    selectChip(shown[i].mpn);+  }+  chiplist.addEventListener('click',e=>{const it=e.target.closest('.cr-item');if(it)selectChip(it.dataset.mpn)});+  projSel.addEventListener('change',()=>{projFilter=projSel.value;renderChips();chiplist.focus();});+  function setRail(open){ rail.classList.toggle('collapsed',!open); try{localStorage.setItem('adomSymChipRail',open?'1':'0')}catch(e){} if(open){loadChips();chiplist.focus();} }+  document.getElementById('chiprailToggle').onclick=()=>setRail(true);+  document.getElementById('chiprailClose').onclick=()=>setRail(false);+  (function(){let p=null;try{p=localStorage.getItem('adomSymChipRail')}catch(e){} setRail(p==='1');})();   // collapsed by default+  // ↑/↓ navigate the FILTERED chip list whenever the rail is expanded.+  document.addEventListener('keydown',e=>{+    if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT'||rail.classList.contains('collapsed'))return;+    if(e.key==='ArrowDown'){e.preventDefault();switchByOffset(1);}+    else if(e.key==='ArrowUp'){e.preventDefault();switchByOffset(-1);}+  });+  // Flush the current chip's edits if the window is closed/navigated away — but+  // ONLY if there's a GENUINE unsaved edit. Firing this unconditionally re-saved+  // (= pinned) whatever chip was showing on every reload/navigate, silently+  // mutating the library even when nothing was changed. (Root-caused 2026-06-29.)+  window.addEventListener('beforeunload',()=>{+    if(!(savled.classList.contains('unsaved')||savled.classList.contains('saving')))return;+    try{navigator.sendBeacon('save',new Blob([JSON.stringify({chipSize:chip3dSize})],{type:'application/json'}));}catch(e){}+  });+  loadChips();   // populate immediately (the launched chip is flagged current)    // ── 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'};-  function setStudioOpen(open){studio.hidden=!open;document.getElementById('studioBtn').classList.toggle('active',open);try{localStorage.setItem('adomSymStudioOpen',open?'1':'0')}catch(e){}}+  function setStudioOpen(open){studio.hidden=!open;document.getElementById('studioBtn').classList.toggle('active',open);try{localStorage.setItem('adomSymStudioOpen',open?'1':'0')}catch(e){};requestAnimationFrame(()=>{if(svgEl&&!userAdjusted)fit();});}   document.getElementById('studioBtn').onclick=()=>setStudioOpen(studio.hidden);-  // On a wide laptop/desktop the Layout studio floats OPEN by default (luxurious-  // screen real-estate); on a phone it stays closed. An explicit open/close sticks.-  (function(){var pref=null;try{pref=localStorage.getItem('adomSymStudioOpen')}catch(e){}-    setStudioOpen(pref===null ? window.innerWidth>=1024 : pref==='1');})();-  // ── 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=()=>setStudioOpen(false);-    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();-  })();+  // The Layout studio is where you DO everything, so it's ALWAYS open on load —+  // no screen-width gate, no stale stored "closed". You can still close it for+  // more room during a session; the next load brings it back.+  setStudioOpen(true);+  // ── docked Layout sidebar: close + collapse (no drag; it reserves real space) ──+  document.getElementById('studioClose').onclick=()=>setStudioOpen(false);+  document.getElementById('studioCollapse').onclick=()=>{const c=studio.classList.toggle('collapsed');document.getElementById('studioCollapse').textContent=c?'▸':'▾'};+  document.getElementById('studioHead').addEventListener('dblclick',e=>{if(!e.target.closest('.shbtn'))document.getElementById('studioCollapse').click()});   // sticky studio preferences (per browser)   const PREF_KEY='adomSymbolStudio3'; // bumped: new defaults are bold 3D chip + on-chip laser name   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){+  // Auto-save on every tweak (debounced): persist the edited symbol + re-render+  // the library thumbnail so chip-fetcher reloads it instantly and adom-lbr reads+  // the latest. `remember=false` (prefs re-application) does NOT trigger a save.+  // Save-state LED: green=saved, amber(pulsing)=unsaved, teal=saving. A toast is+  // shown ONLY when we save because you're LEAVING a chip (so you know it landed+  // even though that chip's LED is gone); in-page edits just move the LED.+  const savled=document.getElementById('savled'), savledT=document.getElementById('savledT');+  function setSave(state){+    savled.className='savled '+state;+    savledT.textContent={saved:'Saved',unsaved:'Unsaved',saving:'Saving…'}[state]||'';+    savled.setAttribute('data-tooltip',{saved:'All changes saved',unsaved:'You have unsaved layout changes',saving:'Saving your changes…'}[state]||'');+  }+  let saveT=null, saveSeq=0;+  function markDirty(){ setSave('unsaved'); scheduleSave(); }+  function scheduleSave(){ clearTimeout(saveT); saveT=setTimeout(()=>doSave(),500); }+  async function doSave(toastMpn){+    const seq=++saveSeq; setSave('saving');+    try{+      await fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({chipSize:chip3dSize})});+      if(seq===saveSeq)setSave('saved');+      if(toastMpn)showToast('✓ Saved '+toastMpn,'ok');+      return true;+    }catch(e){ if(seq===saveSeq)setSave('unsaved'); return false; }+  }+  // ── auto-regen the 3D chip assets (outlines + iso) via chip-thumbnailer ──+  // It's multi-second per chip, so debounce: arrow-browsing many chips only+  // regenerates the one you settle on. The ⟳ Regen button forces it; the button+  // spins (driven off /state.regen) until the regen finishes.+  let regenT=null;+  function scheduleRegen(){ clearTimeout(regenT); regenT=setTimeout(()=>doRegen(),800); }+  async function doRegen(){+    document.getElementById('regenBtn').classList.add('spinning');+    try{ await fetch('regen',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); }+    catch(e){ document.getElementById('regenBtn').classList.remove('spinning'); showToast('⚠ Regen failed to start','err'); }+  }+  document.getElementById('regenBtn').onclick=()=>doRegen();+  // `heavy` (default true) = the change re-renders the symbol via service-kicad,+  // so show the spinner until renderSvg clears it. 3D/size toggles pass heavy=false+  // (instant, client-side) so no spinner flashes.+  const busyEl=document.getElementById('busy'); let busySafety=null;+  function setBusy(on){ busyEl.classList.toggle('show',on); if(on){clearTimeout(busySafety);busySafety=setTimeout(()=>busyEl.classList.remove('show'),6000);} }+  async function relayout(patch,remember=true,heavy=true){     relaying=true;+    if(heavy)setBusy(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)}+    catch(e){console.warn('relayout failed',e);setBusy(false);_studioSig=null;}   // force re-sync so a failed change doesn't latch the wrong button     lastHash='';                       // force the next poll to re-render+    if(remember)markDirty();           // a real user edit → mark unsaved + persist     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;+    // Chip size is per-symbol when saved, else the sticky default.+    chip3dSize = s.savedLayout ? (s.chipSize||stickyChipSize) : stickyChipSize;+    document.querySelectorAll('#segSize button').forEach(b=>b.classList.toggle('on',b.dataset.v===chip3dSize));+    if(window._lastState)apply3d(window._lastState);+    // A DELIBERATELY-SET (saved) symbol must NOT be overridden by the sticky+    // default — its own choice wins. Sticky only fills in untouched symbols.+    if(s.savedLayout) return;     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;@@ -555,17 +1066,46 @@     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+    if(Object.keys(patch).length) relayout(patch,false);   // remember=false → doesn't pin   }+  // "Reset to default" = UN-PIN this symbol (drop its saved prefs) and revert it+  // to the current default look. Leaves the global sticky + other chips alone.   document.getElementById('studioReset').onclick=()=>{-    savePrefs({});+    pushUndo();+    fetch('unpin',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>{});     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-    nameMode='chip';+    prefsAppliedFor=s.symbolName||'__reset__';+    nameMode='chip'; chip3dSize=stickyChipSize;+    document.querySelectorAll('#segSize button').forEach(b=>b.classList.toggle('on',b.dataset.v===chip3dSize));     relayout({source:src,pinLayout:'left-right',grouped:true,refPos:'out-top',valuePos:'none',threeD:'bold',laserName:true},false);+    showToast('↺ Reset — this chip now follows the default','ok');   };-  function seg(id,onClick){document.getElementById(id).addEventListener('click',e=>{const b=e.target.closest('button[data-v]');if(b)onClick(b.dataset.v)})}+  // Bulk: push this look onto the chips CURRENTLY SHOWN (respects the project+  // filter), overriding their saved look. Explicit scope + count in the confirm.+  document.getElementById('applyAll').onclick=async()=>{+    const mpns=shown.map(c=>c.mpn);+    if(!mpns.length){showToast('No chips in the current list','warn');return;}+    const scope = projFilter ? `the ${mpns.length} chips in project "${projFilter}"` : `ALL ${mpns.length} chips in the library`;+    if(!confirm(`Apply this chip's current Layout + 3D look to ${scope}?\n\n• Writes each chip's saved look (info.json) — no re-fetch/regen\n• Overrides any look you set per-chip\n• Undo a chip later with Reset`))return;+    showToast('Applying to '+mpns.length+' chips…','warn');+    try{const r=await (await fetch('apply-all',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({chipSize:chip3dSize,mpns})})).json();+      showToast('✓ Applied to '+(r.count||0)+' chips','ok');+    }catch(e){showToast('⚠ Apply-to-all failed','err');}+  };+  // Optimistic: highlight the clicked option INSTANTLY (before the server round+  // trip) so the panel feels responsive; syncStudio later confirms from /state.+  // `optimistic=false` for momentary actions (Send) that aren't a persistent state.+  function seg(id,onClick,optimistic){+    if(optimistic===undefined)optimistic=true;+    document.getElementById(id).addEventListener('click',e=>{+      const b=e.target.closest('button[data-v]');if(!b)return;+      // Only state-changing segments (optimistic) snapshot for Undo; momentary+      // actions like "Send to" (optimistic=false) don't.+      if(optimistic){pushUndo();b.parentNode.querySelectorAll('button[data-v]').forEach(x=>x.classList.toggle('on',x===b));}+      onClick(b.dataset.v);+    });+  }   seg('segSource',v=>relayout({source:v}));   seg('segLayout',v=>relayout({pinLayout:v}));   seg('segGroups',v=>relayout({grouped:v==='1'}));@@ -583,7 +1123,21 @@     const showSym=(v==='symbol'||v==='both');     relayout({ valuePos: showSym?posOf(valSide,valIO):'none', laserName: (v==='chip'||v==='both') });   });-  seg('seg3d',v=>relayout({threeD:v}));+  seg('seg3d',v=>relayout({threeD:v},true,false));   // 3D toggle is instant (client overlay) — no spinner+  // Chip-size is a client-only display choice (no re-render): Large = standard,+  // Medium/Small shrink the overlay when it crowds the pin names.+  const SIZE_F={large:1,medium:0.78,small:0.58};+  // stickyChipSize = the global default; chip3dSize = the CURRENT symbol's size+  // (its own when the chip is saved, else the sticky). Changing size updates both+  // and saves to the symbol (markDirty).+  let stickyChipSize='large';try{stickyChipSize=localStorage.getItem('adomSymChipSize')||'large'}catch(e){}+  let chip3dSize=stickyChipSize;+  // Sticky "regen on switch" — OFF by default so casual browsing doesn't hammer+  // the shared service-step2glb / chip-thumbnailer. Only when ON do we auto-regen+  // the showing chip's vector outline as you switch chips.+  let regenOnSwitch=false;try{regenOnSwitch=localStorage.getItem('adomSymRegenOnSwitch')==='1'}catch(e){}+  (function(){const cb=document.getElementById('regenOnSwitch');if(cb){cb.checked=regenOnSwitch;cb.addEventListener('change',()=>{regenOnSwitch=cb.checked;try{localStorage.setItem('adomSymRegenOnSwitch',regenOnSwitch?'1':'0')}catch(e){};if(regenOnSwitch)doRegen();});}})();+  seg('segSize',v=>{chip3dSize=v;stickyChipSize=v;try{localStorage.setItem('adomSymChipSize',v)}catch(e){};if(window._lastState)apply3d(window._lastState);markDirty()});   // 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)}@@ -597,7 +1151,7 @@       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')}-  });+  },false);   // Send is a momentary action, not a persistent state — no optimistic latch   // 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){@@ -617,17 +1171,31 @@   }   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'];+  let _studioSig=null, _studioOptSig=null;   function syncStudio(s){-    // build the Source segment from available sources+    // Only touch the panel when something ACTUALLY changed. Re-running every 1s+    // poll churned the DOM and — while the server was still rendering your last+    // click — kept reverting the button to the old value. Gating on a signature+    // means your optimistic highlight stays put until the server confirms it.+    const sig=JSON.stringify([s.sources,s.source,s.sourceLabels,s.pinLayout,s.grouped,s.refPos,s.valuePos,s.laserName,s.threeD,s.threeDModes,s.threeDAsset,chip3dSize]);+    if(sig===_studioSig)return;+    _studioSig=sig;     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)+    // Only REBUILD the Source/3D button DOM when the available OPTIONS (or labels)+    // change — not on every value change — so an in-progress optimistic click on+    // these segments isn't wiped by an unrelated re-sync.+    const optSig=JSON.stringify([avail,lbl,s.threeDModes]);     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('');+    if(optSig!==_studioOptSig){+      _studioOptSig=optSig;+      ss.innerHTML=avail.map(v=>`<button data-v="${v}">${lbl[v]||SRC_LABEL[v]||v}</button>`).join('')||'<button data-v="manufacturer">Manufacturer</button>';+      document.getElementById('seg3d').innerHTML=avail3d.map(k=>`<button data-v="${k}">${TD_LABEL[k]||k}</button>`).join('');+    }+    const setOn=(id,val)=>document.querySelectorAll('#'+id+' button').forEach(b=>b.classList.toggle('on',b.dataset.v===String(val)));+    // live preview of the current 3D asset (verifies the path: if this shows, the overlay will too)     // 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='';}@@ -642,6 +1210,7 @@     nameMode = s.laserName ? (hiddenName?'chip':'both') : (hiddenName?'none':'symbol');     setOn('segName', nameMode);     setOn('seg3d',s.threeD||'off');+    setOn('segSize',chip3dSize);     // 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');@@ -654,7 +1223,7 @@     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||'');+    const laserKey=(s.laserName?'1':'0')+'|'+(s.symbolName||'')+'|'+chip3dSize;     if(mode===cur3dMode && cur3dAsset===asset && laserKey===cur3dLaser && (mode==='off'||present)) return;     cur3dMode=mode; cur3dAsset=asset; cur3dLaser=laserKey;     if(present)present.remove();@@ -665,7 +1234,7 @@     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 sz=Math.min(w,h)*(mode==='iso'?0.72:0.74)*(SIZE_F[chip3dSize]||1);     const NS='http://www.w3.org/2000/svg';     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);
src/main.rs+11−5
@@ -68,6 +68,9 @@ enum Cmd {         /// `<mpn>-3d-iso.png`.         #[arg(long)]         iso: Option<PathBuf>,+        /// 3D-chip size in the body: large (default, the historical size) | medium | small.+        #[arg(long, default_value = "large")]+        chip_size: String,     },     /// Render a ds2sf `<mpn>-symbol.extracted.json` to a themed thumbnail SVG     /// (auto-grouped via our generator). Used by chip-fetcher for the ds2sf@@ -196,9 +199,10 @@ fn main() { fn run() -> Result<()> {     match Cli::parse().command {         Cmd::Create { file, out } => cmd_create(file, out),-        Cmd::Render { file, out, iso } => {+        Cmd::Render { file, out, iso, chip_size } => {             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 frac = render::chip_size_frac(&chip_size);             let mut svg = render::render_symbol_svg(&content, &name)?;             // The DEFAULT symbol layout shows the 3D chip as a WHITE vector OUTLINE             // with the chip name lasered on, composited into the body — matching the@@ -212,7 +216,7 @@ fn run() -> Result<()> {             let mut composited = false;             let mut outline_note = String::new();             if let Some(p) = overlay.as_ref() {-                match render::composite_iso_into_symbol(&svg, &content, &name, p) {+                match render::composite_iso_into_symbol(&svg, &content, &name, p, frac) {                     Ok(s) => { svg = s; composited = true; }                     Err(e) => eprintln!("WARN: 3D-chip composite skipped: {e}"),                 }@@ -226,7 +230,7 @@ fn run() -> Result<()> {                     let mm_name = format!("{stem}-outline.mm.svg");                     let json_name = format!("{stem}-outline.json");                     match fs::read_to_string(p).map_err(anyhow::Error::from).and_then(|ov| {-                        render::build_outline_contract(&svg, &content, &ov, p, &mm_name)+                        render::build_outline_contract(&svg, &content, &ov, p, &mm_name, frac)                     }) {                         Ok((mm_svg, json, segs)) => {                             fs::write(dir.join(&mm_name), &mm_svg)@@ -249,9 +253,11 @@ fn run() -> Result<()> {         Cmd::Ui { action, args, port } => cmd_ui(action, args, port),         Cmd::Embed { file, name, out } => cmd_embed(file, name, out),         Cmd::Preview { file, name, out } => cmd_preview(file, name, out),-        Cmd::Serve { port, file, name } => {+        Cmd::Serve { port, file, name: _ } => {             if let Some(f) = file {-                server::load_symbol_into_state(&f, name.as_deref())?;+                // Full chip load (sources + 3D assets + library root for the+                // switcher) when the file lives in a chip-fetcher library layout.+                server::load_chip_from_file(&f)?;             }             server::run(port)         }
src/render.rs+98−12
@@ -95,13 +95,15 @@ fn build_schematic(name: &str, block: &str) -> String {  /// 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();+    // The value span `"(?:[^"\\]|\\.)*"` crosses KiCad-escaped quotes (\") so a+    // designator with a quote in its value still matches.+    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();+    let re = Regex::new(&format!(r#"\(property "{name}" "((?:[^"\\]|\\.)*)""#)).unwrap();     re.captures(block).map(|c| c[1].to_string()) } @@ -135,6 +137,37 @@ fn prop_justify(block: &str, name: &str) -> String {     re.captures(block).map(|c| format!(" {}", &c[1])).unwrap_or_default() } +/// The current `(at x y)` of the Reference/Value designator for `symbol_name`,+/// in KiCad symbol-local mm (Y-up). Powers the app's draggable field handles.+pub fn field_at(kicad_sym: &str, symbol_name: &str, field: &str) -> Option<(f64, f64)> {+    let block = extract_symbol_block(kicad_sym, symbol_name)?;+    prop_at(&block, field)+}++/// The string VALUE of a named property (e.g. Reference prefix "U", Value name)+/// for `symbol_name` — lets the app match the exact on-screen <text> for a field.+pub fn field_val(kicad_sym: &str, symbol_name: &str, field: &str) -> Option<String> {+    let block = extract_symbol_block(kicad_sym, symbol_name)?;+    prop_val(&block, field)+}++/// Rewrite a designator's position: replace the X Y of `(property "<field>" "…"+/// (at X Y A) …)` with `x y`, KEEPING the rotation angle A. Used to bake a+/// user-dragged / auto-placed Reference or Value position into the symbol so it+/// renders there and ships to KiCad/EAGLE exactly as positioned. Idempotent and+/// source-agnostic (works on a generated OR a vendor symbol). Untouched if the+/// field isn't found.+pub fn set_field_pos(kicad_sym: &str, field: &str, x: f64, y: f64) -> String {+    let re = Regex::new(&format!(+        r#"(\(property "{field}" "(?:[^"\\]|\\.)*"\s*\(at )(-?[\d.]+) (-?[\d.]+)( [-\d.]+\))"#+    ))+    .unwrap();+    re.replace(kicad_sym, |c: &regex::Captures| {+        format!("{}{:.3} {:.3}{}", &c[1], x, y, &c[4])+    })+    .into_owned()+}+ /// Composite the 3D-chip overlay into the symbol body centre — the "default /// symbol with the 3D chip in it" the chip-fetcher dashboard card shows, centred /// in the symbol's largest body rectangle and sized to ~62% of it so pin@@ -146,13 +179,13 @@ fn prop_justify(block: &str, name: &str) -> String { /// EVERY renderer — browser, librsvg thumbnailer, anything. (Wrapping the SVG in /// an `<image data:svg+xml>` rasterised it in non-browser renderers.) For a /// **raster** overlay (`--iso` PNG) we fall back to an `<image>` element.-pub fn composite_iso_into_symbol(svg: &str, block: &str, _name: &str, iso_path: &std::path::Path) -> Result<String> {+pub fn composite_iso_into_symbol(svg: &str, block: &str, _name: &str, iso_path: &std::path::Path, frac: f64) -> Result<String> {     let is_svg = iso_path.extension().and_then(|e| e.to_str()) == Some("svg");      let markup = if is_svg {         let overlay = std::fs::read_to_string(iso_path)             .with_context(|| format!("reading outline {}", iso_path.display()))?;-        inline_outline_markup(block, &overlay)+        inline_outline_markup(block, &overlay, frac)             .ok_or_else(|| anyhow!("no body geometry / outline strokes to inline"))?     } else {         let bytes = std::fs::read(iso_path)@@ -167,7 +200,7 @@ pub fn composite_iso_into_symbol(svg: &str, block: &str, _name: &str, iso_path:                 None => return Err(anyhow!("no body geometry to place iso")),             },         };-        let (w, h) = (bw * OUTLINE_BODY_FRAC, bh * OUTLINE_BODY_FRAC);+        let (w, h) = (bw * frac, bh * frac);         let (x, y) = (cx - w / 2.0, cy - h / 2.0);         format!(             "<image x=\"{x:.3}\" y=\"{y:.3}\" width=\"{w:.3}\" height=\"{h:.3}\" \@@ -191,8 +224,8 @@ pub fn composite_iso_into_symbol(svg: &str, block: &str, _name: &str, iso_path: /// places a chip-thumbnailer outline SVG into the symbol body at FULL resolution, /// in the `<mpn>-symbol.svg` frame, faithful to each layer's stroke style. This /// is the crisp-vector twin of the lite `.mm.svg` the outline contract emits.-fn inline_outline_markup(block: &str, overlay_svg: &str) -> Option<String> {-    let place = outline_placement(block, overlay_svg)?;+fn inline_outline_markup(block: &str, overlay_svg: &str, frac: f64) -> Option<String> {+    let place = outline_placement(block, overlay_svg, frac)?;     let elems = parse_poly_elems(overlay_svg);     if elems.is_empty() {         return None;@@ -246,6 +279,8 @@ pub struct OutlinePlacement {     pub source_viewbox: [f64; 4],     /// mm per source unit. `min(body_w, body_h) * OUTLINE_BODY_FRAC / canvas`.     pub scale: f64,+    /// The body-fraction the outline was sized to (Large 0.62 / Medium / Small).+    pub frac: f64,     /// Body rectangle in `.kicad_sym` symbol-local mm (Y-up): [left, bottom, right, top].     pub body_rect: [f64; 4],     /// Outline centre in `.kicad_sym` / EAGLE frame: mm, **Y-up**, origin at the@@ -267,10 +302,59 @@ impl OutlinePlacement {     } } +/// The white-native outline asset to composite into a saved `<mpn>-symbol.svg`+/// (thickness ladder, then teal) — mirrors the CLI `render` default so auto-saved+/// thumbnails match the canonical look. None if no outline has been generated.+pub fn default_white_outline_in(dir: &std::path::Path, mpn: &str) -> Option<std::path::PathBuf> {+    for style in ["heavy", "dark", "hairline", "teal"] {+        let p = dir.join(format!("{mpn}-3d-outline-{style}-named.svg"));+        if p.is_file() {+            return Some(p);+        }+    }+    None+}++/// Render a chip's `.kicad_sym` to `<mpn>-symbol.svg` in `dir`, compositing the+/// 3D-chip outline at body-fraction `frac`, and write the outline contract+/// (`.mm.svg` + `.json`) alongside. This is the shared write path used by both the+/// `render` CLI and adom-symbol's auto-save, so the on-disk thumbnail the rest of+/// the pipeline (chip-fetcher, adom-lbr) reads always matches the app.+pub fn render_into_library(kicad_sym: &str, render_name: &str, mpn: &str, dir: &std::path::Path, frac: f64) -> Result<()> {+    let mut svg = render_symbol_svg(kicad_sym, render_name)?;+    if let Some(p) = default_white_outline_in(dir, mpn) {+        if let Ok(ov) = std::fs::read_to_string(&p) {+            if let Ok(s) = composite_iso_into_symbol(&svg, kicad_sym, render_name, &p, frac) {+                svg = s;+            }+            let mm_name = format!("{mpn}-symbol-outline.mm.svg");+            if let Ok((mm, json, _)) = build_outline_contract(&svg, kicad_sym, &ov, &p, &mm_name, frac) {+                let _ = std::fs::write(dir.join(&mm_name), mm);+                let _ = std::fs::write(dir.join(format!("{mpn}-symbol-outline.json")), json);+            }+        }+    }+    std::fs::write(dir.join(format!("{mpn}-symbol.svg")), &svg)+        .with_context(|| format!("writing {mpn}-symbol.svg"))?;+    Ok(())+}++/// Map a chip-size choice to the body-fraction the outline fills. Large is the+/// historical default (0.62); Medium/Small shrink it for parts where the 3D chip+/// crowded the pin names. Unknown → Large.+pub fn chip_size_frac(size: &str) -> f64 {+    match size {+        "small" => OUTLINE_BODY_FRAC * 0.58,   // ~0.36+        "medium" => OUTLINE_BODY_FRAC * 0.78,  // ~0.48+        _ => OUTLINE_BODY_FRAC,                 // large = current+    }+}+ /// Compute the outline placement for a symbol block + a source outline SVG. /// `outline_svg` only needs its viewBox/size; pass the same file that gets-/// composited so the contract matches the pixels exactly.-pub fn outline_placement(block: &str, outline_svg: &str) -> Option<OutlinePlacement> {+/// composited so the contract matches the pixels exactly. `frac` is the+/// body-fraction (see [`chip_size_frac`]).+pub fn outline_placement(block: &str, outline_svg: &str, frac: f64) -> Option<OutlinePlacement> {     const PX: f64 = 100.0;     const PY: f64 = 100.0;     let (cx_k, cy_k, bw, bh, rect) = match largest_body_rect(block) {@@ -285,7 +369,7 @@ pub fn outline_placement(block: &str, outline_svg: &str) -> Option<OutlinePlacem         }     };     let vb = parse_viewbox(outline_svg)?;-    let (w, h) = (bw * OUTLINE_BODY_FRAC, bh * OUTLINE_BODY_FRAC);+    let (w, h) = (bw * frac, bh * frac);     // preserveAspectRatio=meet: the WHOLE source canvas fits, smaller ratio wins.     let scale = (w / vb[2]).min(h / vb[3]);     if !(scale.is_finite() && scale > 0.0) {@@ -294,6 +378,7 @@ pub fn outline_placement(block: &str, outline_svg: &str) -> Option<OutlinePlacem     Some(OutlinePlacement {         source_viewbox: vb,         scale,+        frac,         body_rect: rect,         center_kicad: (cx_k, cy_k),         center_svg: (PX + cx_k, PY - cy_k),@@ -440,8 +525,9 @@ pub fn build_outline_contract(     overlay_svg: &str,     overlay_path: &std::path::Path,     mm_svg_name: &str,+    frac: f64, ) -> Result<(String, String, usize)> {-    let place = outline_placement(block, overlay_svg)+    let place = outline_placement(block, overlay_svg, frac)         .ok_or_else(|| anyhow!("no body geometry / outline viewBox to place outline"))?;     let elems = parse_poly_elems(overlay_svg);     if elems.is_empty() {@@ -537,7 +623,7 @@ pub fn build_outline_contract(         "source": overlay_path.file_name().and_then(|s| s.to_str()).unwrap_or(""),         "source_viewbox": place.source_viewbox,         "default_variant": variant,-        "body_fraction": OUTLINE_BODY_FRAC,+        "body_fraction": round4(place.frac),         "segments": segs,         "preserve_aspect_ratio": "xMidYMid meet (the SQUARE source canvas fits the body box — not the ink bbox)",         // The frame adom-lbr wants: KiCad/EAGLE symbol-local mm, Y-UP, origin at
src/server.rs+441−14
@@ -38,6 +38,16 @@ pub struct AppState {     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)+    pub saved_layout: bool,   // this chip has per-symbol prefs saved (sticky must NOT override)+    pub chip_size: String,    // 3D-chip size: large|medium|small (per-symbol when saved)+    // Effective Reference/Value designator positions (KiCad symbol-local mm, Y-up)+    // as actually rendered — the app uses these to place the draggable field handles.+    pub ref_field: Option<(f64, f64)>,+    pub value_field: Option<(f64, f64)>,+    // The exact rendered text of each designator (e.g. "U?", "AD7124-8") so the+    // app can match the precise on-screen <text> for the drag box (not a pin name).+    pub ref_text: String,+    pub value_text: String, }  /// Map a source slug to a friendly label. Delegates to the SHARED provenance@@ -77,8 +87,20 @@ struct Model {     value_pos: String,     laser_name: bool,     three_d: String,       // "off" | "iso" | outline style+    chip_size: String,     // 3D-chip size: large|medium|small+    saved: bool,           // per-symbol prefs were loaded from info.json (sticky must not override)+    // Custom Reference/Value designator positions (KiCad symbol-local mm, Y-up).+    // Some = the user dragged it / auto-placed it → overrides the discrete refPos.+    // Cleared when a discrete refPos/valuePos preset is picked.+    ref_x: Option<f64>,+    ref_y: Option<f64>,+    value_x: Option<f64>,+    value_y: Option<f64>, } static MODEL: Mutex<Option<Model>> = Mutex::new(None);+// The chip-fetcher library root (folder of <MPN>/ dirs) — powers the left+// chip-library switcher. Set when a chip is loaded from a library folder.+static LIBRARY_ROOT: Mutex<Option<String>> = 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);@@ -110,6 +132,10 @@ pub fn build_symbol_state(         hide_pin_names: meta.hide_pin_names,         body_rect: meta.body_rect,         kicad_sym: kicad_sym.to_string(),+        ref_field: crate::render::field_at(kicad_sym, render_name, "Reference"),+        value_field: crate::render::field_at(kicad_sym, render_name, "Value"),+        ref_text: crate::render::field_val(kicad_sym, render_name, "Reference").map(|p| format!("{p}?")).unwrap_or_default(),+        value_text: render_name.to_string(),         ..Default::default()     }) }@@ -124,6 +150,9 @@ 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);+// True while a chip-thumbnailer regen (3D outlines + iso) is running for the+// loaded chip — surfaced in /state so the app spins the ⟳ Regen button.+static REGEN_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);  pub fn set_state(s: AppState) {     REV.fetch_add(1, Ordering::SeqCst);@@ -131,6 +160,17 @@ pub fn set_state(s: AppState) { }  fn state_json() -> String {+    // Pre-read the raw field overrides (MODEL) BEFORE locking STATE, so we never+    // nest the two locks (avoids any lock-ordering hazard with save/regen paths).+    let (ref_ovr, val_ovr): (Option<[f64; 2]>, Option<[f64; 2]>) = MODEL+        .lock()+        .unwrap()+        .as_ref()+        .map(|m| (+            match (m.ref_x, m.ref_y) { (Some(x), Some(y)) => Some([x, y]), _ => None },+            match (m.value_x, m.value_y) { (Some(x), Some(y)) => Some([x, y]), _ => None },+        ))+        .unwrap_or((None, None));     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@@ -153,6 +193,7 @@ fn state_json() -> String {             .unwrap_or_default();             json!({                 "rev": REV.load(Ordering::SeqCst),+                "regen": REGEN_RUNNING.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,@@ -161,6 +202,15 @@ fn state_json() -> String {                 "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,+                "savedLayout": s.saved_layout, "chipSize": s.chip_size,+                // Effective designator positions (KiCad mm, Y-up) for the draggable handles.+                "refField": s.ref_field.map(|(x, y)| json!({ "x": x, "y": y })),+                "valueField": s.value_field.map(|(x, y)| json!({ "x": x, "y": y })),+                "refText": s.ref_text, "valueText": s.value_text,+                // Raw custom overrides (null = following the discrete preset) — lets+                // the client's Undo restore the exact preset-vs-dragged state.+                "refOverride": ref_ovr,+                "valueOverride": val_ovr,                 "threeDModes": modes,                 // only the CURRENT mode's asset (sync, reliable) — not all 6.                 "threeDAsset": cur_asset,@@ -230,6 +280,10 @@ fn load_3d_assets(dir: &str, mpn: &str, axis: &str) -> std::collections::HashMap /// 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) {+    // Remember the library root (this chip's parent folder) for the switcher.+    if let Some(parent) = std::path::Path::new(dir).parent() {+        *LIBRARY_ROOT.lock().unwrap() = Some(parent.to_string_lossy().to_string());+    }     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@@ -243,7 +297,9 @@ fn load_from_dir(dir: &str, mpn: &str, init_source: &str) {                 continue;             }             let method = &name[prefix.len()..name.len() - suffix.len()];-            if !method.is_empty() && !method.contains('.') {+            // `vendor`/`prev` are auto-save BACKUPS, not provenance sources — don't+            // list them in the Source carat.+            if !method.is_empty() && !method.contains('.') && method != "vendor" && method != "prev" {                 variants.push(method.to_string());             }         }@@ -254,13 +310,20 @@ fn load_from_dir(dir: &str, mpn: &str, init_source: &str) {         .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 {+    let default_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(),     };+    // Per-symbol saved prefs (info.json → "adom_symbol") WIN over the sticky+    // defaults. If present, this chip was deliberately set by the user, so we+    // restore exactly those and flag `saved` so the client won't re-apply sticky.+    let saved = read_saved_prefs(dir, mpn);+    let g = |k: &str, d: &str| saved.as_ref().and_then(|v| v.get(k)).and_then(|x| x.as_str()).map(String::from).unwrap_or_else(|| d.to_string());+    let gb = |k: &str, d: bool| saved.as_ref().and_then(|v| v.get(k)).and_then(|x| x.as_bool()).unwrap_or(d);+    let gn = |k: &str| saved.as_ref().and_then(|v| v.get(k)).and_then(|x| x.as_f64());     *MODEL.lock().unwrap() = Some(Model {         dir: dir.to_string(),         mpn: mpn.to_string(),@@ -269,18 +332,242 @@ fn load_from_dir(dir: &str, mpn: &str, init_source: &str) {         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: "none".to_string(),   // name lives ON the chip by default, not in the symbol-        laser_name: true,                // default: laser-mark the chip name onto the 3D package-        three_d: "bold".to_string(),     // default: the BOLD vector-outline chip in the body centre+        source: g("source", &default_source),+        pin_layout: g("pinLayout", "left-right"),+        grouped: gb("grouped", true),+        ref_pos: g("refPos", "out-top"),+        value_pos: g("valuePos", "none"),   // name lives ON the chip by default+        laser_name: gb("laserName", true),+        three_d: g("threeD", "bold"),+        chip_size: g("chipSize", "large"),+        saved: saved.is_some(),+        ref_x: gn("refX"),+        ref_y: gn("refY"),+        value_x: gn("valueX"),+        value_y: gn("valueY"),     });     regenerate(); } +/// Read this chip's per-symbol prefs block (`info.json` → `adom_symbol`), if any.+fn read_saved_prefs(dir: &str, _mpn: &str) -> Option<Value> {+    let v: Value = serde_json::from_str(&read_file(dir, "info.json")?).ok()?;+    v.get("adom_symbol").filter(|x| x.is_object()).cloned()+}++/// Persist this chip's per-symbol prefs into `info.json` → `adom_symbol`, merging+/// with the rest of info.json. This is what makes a deliberately-set symbol stick.+fn write_saved_prefs(dir: &str, prefs: Value) {+    let path = std::path::Path::new(dir).join("info.json");+    let mut root: Value = std::fs::read_to_string(&path).ok()+        .and_then(|s| serde_json::from_str(&s).ok())+        .unwrap_or_else(|| json!({}));+    if !root.is_object() { root = json!({}); }+    root["adom_symbol"] = prefs;+    if let Ok(s) = serde_json::to_string_pretty(&root) {+        let _ = std::fs::write(&path, s);+    }+}++fn library_root() -> Option<String> {+    LIBRARY_ROOT.lock().unwrap().clone()+}++/// Every chip in the library root (a `<MPN>/<MPN>.kicad_sym` folder), sorted, with+/// the currently-loaded one flagged. Powers the left chip-library switcher.+fn list_chips() -> Vec<(String, bool)> {+    let root = match library_root() {+        Some(r) => r,+        None => return vec![],+    };+    let cur = MODEL.lock().unwrap().as_ref().map(|m| m.mpn.clone()).unwrap_or_default();+    let mut out = Vec::new();+    if let Ok(rd) = std::fs::read_dir(&root) {+        for e in rd.flatten() {+            if !e.path().is_dir() {+                continue;+            }+            let name = e.file_name().to_string_lossy().to_string();+            if e.path().join(format!("{name}.kicad_sym")).is_file() {+                let is_cur = name == cur;+                out.push((name, is_cur));+            }+        }+    }+    out.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));+    out+}++/// Persist the current symbol back to its library folder so chip-fetcher and+/// adom-lbr see the latest: write the canonical `<mpn>.kicad_sym` (backing up the+/// original vendor file once to `<mpn>.vendor.kicad_sym`) and re-render+/// `<mpn>-symbol.svg` + the outline contract at the chosen chip size. Always+/// re-renders the thumbnail (so a size change persists) even when the symbol+/// itself is unchanged. Returns true if there was a chip to save.+fn save_current(chip_size: &str) -> bool {+    let (dir, mpn) = match MODEL.lock().unwrap().as_ref() {+        Some(m) => (m.dir.clone(), m.mpn.clone()),+        None => return false,+    };+    if dir.is_empty() || mpn.is_empty() {+        return false;+    }+    let ks = match STATE.lock().unwrap().as_ref() {+        Some(s) => s.kicad_sym.clone(),+        None => return false,+    };+    if ks.trim().is_empty() {+        return false;+    }+    let dirp = std::path::Path::new(&dir);+    let canon = dirp.join(format!("{mpn}.kicad_sym"));+    let existing = std::fs::read_to_string(&canon).unwrap_or_default();+    if existing.trim() != ks.trim() {+        if !existing.is_empty() {+            // Backups live in a hidden `.bak/` subdir so they never get picked up+            // as `<mpn>.<method>.kicad_sym` source variants (which cluttered the+            // Source carat). `.vendor` = the ORIGINAL, kept once; `.prev` = the+            // immediately-previous canonical, a one-step undo so a later edit can't+            // unrecoverably clobber an earlier good one.+            let bak = dirp.join(".bak");+            let _ = std::fs::create_dir_all(&bak);+            let vendor = bak.join(format!("{mpn}.vendor.kicad_sym"));+            if !vendor.exists() {+                let _ = std::fs::write(&vendor, &existing);+            }+            let _ = std::fs::write(bak.join(format!("{mpn}.prev.kicad_sym")), &existing);+        }+        let _ = std::fs::write(&canon, &ks);+    }+    let render_name = crate::first_symbol_name(&ks).unwrap_or_else(|| mpn.clone());+    let frac = crate::render::chip_size_frac(chip_size);+    let _ = crate::render::render_into_library(&ks, &render_name, &mpn, dirp, frac);+    // Persist the per-symbol prefs so this deliberate look STICKS — on the next+    // load/nav-back the sticky default won't override it (saved wins).+    if let Some(m) = MODEL.lock().unwrap().as_mut() {+        m.chip_size = chip_size.to_string();+        m.saved = true;+        write_saved_prefs(&dir, json!({+            "source": m.source, "pinLayout": m.pin_layout, "grouped": m.grouped,+            "refPos": m.ref_pos, "valuePos": m.value_pos, "laserName": m.laser_name,+            "threeD": m.three_d, "chipSize": chip_size,+            // Custom (dragged / auto-placed) designator positions, KiCad mm Y-up.+            "refX": m.ref_x, "refY": m.ref_y, "valueX": m.value_x, "valueY": m.value_y,+        }));+    }+    true+}++/// Remove this chip's per-symbol prefs (info.json → `adom_symbol`) so it goes back+/// to FOLLOWING the sticky default. Backs the kicad_sym change is NOT undone here;+/// this only un-pins the layout/display prefs. Returns true if it had any.+fn unpin_current() -> bool {+    let (dir, mpn) = match MODEL.lock().unwrap().as_ref() { Some(m) => (m.dir.clone(), m.mpn.clone()), None => return false };+    if dir.is_empty() { return false; }+    let dirp = std::path::Path::new(&dir);+    let mut had = false;+    // 1) Drop this chip's per-symbol prefs block from info.json.+    let path = dirp.join("info.json");+    if let Ok(s) = std::fs::read_to_string(&path) {+        if let Ok(mut root) = serde_json::from_str::<Value>(&s) {+            had = root.get("adom_symbol").is_some();+            if let Some(obj) = root.as_object_mut() { obj.remove("adom_symbol"); }+            if let Ok(out) = serde_json::to_string_pretty(&root) { let _ = std::fs::write(&path, out); }+        }+    }+    // 2) Restore the pristine vendor .kicad_sym if a backup exists — so a label+    //    that was dragged/auto-placed and SAVED (baked into the canonical file) is+    //    fully reverted, not just un-pinned. The .vendor backup is written once on+    //    the first save (save_current), so it's the true original.+    let vendor = dirp.join(".bak").join(format!("{mpn}.vendor.kicad_sym"));+    if vendor.exists() {+        if let Ok(orig) = std::fs::read_to_string(&vendor) {+            if std::fs::write(dirp.join(format!("{mpn}.kicad_sym")), orig).is_ok() { had = true; }+        }+    }+    // 3) Clear in-memory: no saved prefs, no custom field overrides.+    if let Some(m) = MODEL.lock().unwrap().as_mut() {+        m.saved = false;+        m.ref_x = None; m.ref_y = None; m.value_x = None; m.value_y = None;+    }+    had+}++/// Write the given prefs into chips' info.json (bulk "Apply to all"). When `only`+/// is Some, restrict to exactly those MPNs (the filtered/shown set); when None,+/// every chip in the library root. Only touches info.json (no re-render) — the new+/// look applies as each chip loads. Returns how many chips were written.+fn apply_prefs_to_all(prefs: &Value, only: Option<&[String]>) -> usize {+    let root = match library_root() { Some(r) => r, None => return 0 };+    let mut n = 0;+    if let Ok(rd) = std::fs::read_dir(&root) {+        for e in rd.flatten() {+            if !e.path().is_dir() { continue; }+            let name = e.file_name().to_string_lossy().to_string();+            if let Some(list) = only { if !list.iter().any(|m| m == &name) { continue; } }+            if e.path().join(format!("{name}.kicad_sym")).is_file() {+                write_saved_prefs(&e.path().to_string_lossy(), prefs.clone());+                n += 1;+            }+        }+    }+    n+}++/// Regenerate a chip's 3D assets (outlines + iso, forced) via chip-thumbnailer,+/// then refresh the in-memory 3D assets so the live app shows the fresh outline,+/// and bump REV so the poller re-applies them. chip-thumbnailer resolves the chip+/// via the library root it reads from the SAME `CHIP_FETCHER_LIB` this process+/// inherited. Runs on a background thread (it's multi-second per chip).+fn run_regen(dir: &str, mpn: &str, axis: &str) {+    let _ = std::process::Command::new("adom-chip-thumbnailer")+        .args(["rerender", mpn, "--kind", "outline"])+        .output();   // LIGHT: just the composited vector outline, not the iso family+    let assets = load_3d_assets(dir, mpn, axis);+    // The user may have switched chips while this ran — only apply the refreshed+    // assets if we're STILL on this chip, else we'd corrupt the new chip's view.+    let still_current = MODEL.lock().unwrap().as_ref().map(|m| m.mpn == mpn).unwrap_or(false);+    if still_current {+        if let Some(st) = STATE.lock().unwrap().as_mut() {+            st.three_d_assets = assets;+        }+        REV.fetch_add(1, Ordering::SeqCst);+    }+}++/// Load a chip given a `.kicad_sym` path. If it sits in a chip-fetcher library+/// layout (`<root>/<mpn>/<mpn>.kicad_sym`) we load the FULL model (sources + 3D+/// assets) and register the library root for the switcher; otherwise we fall back+/// to a plain render of just that file.+pub fn load_chip_from_file(file: &std::path::Path) -> Result<()> {+    let mpn = file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();+    let dir = file.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();+    if !mpn.is_empty()+        && !dir.is_empty()+        && std::path::Path::new(&dir).join(format!("{mpn}.kicad_sym")).is_file()+    {+        load_from_dir(&dir, &mpn, "auto");+        Ok(())+    } else {+        load_symbol_into_state(file, None)+    }+}+ /// Regenerate the AppState from the current Model (source + options).+/// Bake the model's custom Reference/Value positions into a kicad_sym, if any.+/// No-op when neither override is set (the discrete refPos/valuePos placement,+/// or the vendor file's own positions, stands). Source-agnostic.+fn apply_field_overrides(ks: String, m: &Model) -> String {+    let mut out = ks;+    if let (Some(x), Some(y)) = (m.ref_x, m.ref_y) {+        out = crate::render::set_field_pos(&out, "Reference", x, y);+    }+    if let (Some(x), Some(y)) = (m.value_x, m.value_y) {+        out = crate::render::set_field_pos(&out, "Value", x, y);+    }+    out+}+ fn regenerate() {     let m = match MODEL.lock().unwrap().clone() {         Some(m) => m,@@ -337,12 +624,12 @@ fn regenerate() {             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, "", "", "", "")?+            build_symbol_state(&apply_field_overrides(ks, &m), &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, "", "", "", "")?+            build_symbol_state(&apply_field_overrides(ks, &m), &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))@@ -355,7 +642,7 @@ fn regenerate() {                 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 ks = apply_field_overrides(crate::sym_gen::generate_ic_sym(&input), &m);             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() {@@ -380,6 +667,8 @@ fn regenerate() {         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();+        st.saved_layout = m.saved;+        st.chip_size = m.chip_size.clone();         Ok(st)     })(); @@ -510,6 +799,94 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec             }             json_resp("{\"ok\":true}")         }+        (Method::Get, "/api/projects") => {+            // Mirror chip-fetcher's projects (same on-disk store) so the chip+            // switcher can offer the same global + per-project filtered lists.+            let home = std::env::var("HOME").unwrap_or_default();+            let path = std::path::Path::new(&home).join(".config/adom-chip-fetcher/projects.json");+            let body = std::fs::read_to_string(&path).unwrap_or_else(|_| "{\"projects\":{}}".to_string());+            json_resp(&body)+        }+        (Method::Get, "/api/chips") => {+            let arr: Vec<Value> = list_chips().into_iter().map(|(m, c)| json!({"mpn": m, "current": c})).collect();+            let cur = MODEL.lock().unwrap().as_ref().map(|m| m.mpn.clone()).unwrap_or_default();+            json_resp(&json!({"chips": arr, "current": cur}).to_string())+        }+        (Method::Post, "/load-chip") => {+            let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);+            let mpn = v.get("mpn").and_then(|x| x.as_str()).unwrap_or("").to_string();+            // Deliberately NO auto-save here. The client flushes any unsaved edit+            // (POST /save) on the chip you're LEAVING before it calls /load-chip, so+            // a deliberate look is still persisted. Calling save_current() on every+            // switch re-wrote info.json (+ the kicad_sym, + re-rendered the SVG) for+            // every chip you merely BROWSED past — silently pinning the whole library+            // to whatever the sticky look happened to be. A chip you only viewed must+            // stay unpinned and keep following the sticky default. (Root-caused 2026-06-29.)+            match library_root() {+                Some(root) if !mpn.is_empty() => {+                    let dir = std::path::Path::new(&root).join(&mpn);+                    if dir.join(format!("{mpn}.kicad_sym")).is_file() {+                        load_from_dir(&dir.to_string_lossy(), &mpn, "auto");+                        json_resp("{\"ok\":true}")+                    } else {+                        json_err(404, "chip not found in library")+                    }+                }+                _ => json_err(404, "no library root / mpn"),+            }+        }+        (Method::Post, "/save") => {+            let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);+            let chip_size = v.get("chipSize").and_then(|x| x.as_str()).unwrap_or("large");+            let saved = save_current(chip_size);+            json_resp(&json!({"ok": true, "saved": saved}).to_string())+        }+        (Method::Post, "/apply-all") => {+            // Bulk: push the CURRENT layout/display prefs onto every chip in the+            // library (the deliberate "make everything bold" path — overrides even+            // per-symbol pins, which is what the user wants for a bulk change).+            let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);+            let chip_size = v.get("chipSize").and_then(|x| x.as_str()).unwrap_or("large");+            let prefs = MODEL.lock().unwrap().as_ref().map(|m| json!({+                "source": m.source, "pinLayout": m.pin_layout, "grouped": m.grouped,+                "refPos": m.ref_pos, "valuePos": m.value_pos, "laserName": m.laser_name,+                "threeD": m.three_d, "chipSize": chip_size,+            }));+            // Optional explicit scope (the filtered/shown chips). Absent = whole library.+            let only: Option<Vec<String>> = v.get("mpns").and_then(|x| x.as_array())+                .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect());+            let n = match prefs { Some(p) => apply_prefs_to_all(&p, only.as_deref()), None => 0 };+            // The current chip is now "saved" too.+            if let Some(m) = MODEL.lock().unwrap().as_mut() { m.saved = true; m.chip_size = chip_size.to_string(); }+            json_resp(&json!({"ok": true, "count": n}).to_string())+        }+        (Method::Post, "/unpin") => {+            // Drop this chip's per-symbol prefs + restore the vendor original, then+            // re-render so the reverted symbol shows immediately.+            let had = unpin_current();+            regenerate();+            json_resp(&json!({"ok": true, "had": had}).to_string())+        }+        (Method::Post, "/regen") => {+            // Kick chip-thumbnailer to regenerate this chip's 3D outlines + iso in+            // a background thread; /state.regen drives the app's spinner.+            let started = {+                match MODEL.lock().unwrap().as_ref() {+                    Some(m) if !m.dir.is_empty() && !m.mpn.is_empty() => {+                        if !REGEN_RUNNING.swap(true, Ordering::SeqCst) {+                            let (dir, mpn, axis) = (m.dir.clone(), m.mpn.clone(), m.mpn_axis.clone());+                            std::thread::spawn(move || {+                                run_regen(&dir, &mpn, &axis);+                                REGEN_RUNNING.store(false, Ordering::SeqCst);+                            });+                        }+                        true+                    }+                    _ => false,+                }+            };+            json_resp(&format!("{{\"ok\":{started}}}"))+        }         (Method::Post, "/deliver") => {             let target = serde_json::from_str::<Value>(body)                 .ok()@@ -528,10 +905,16 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec             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);+                    let gn = |k: &str| v.get(k).and_then(|x| x.as_f64());                     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; }+                    // A discrete refPos/valuePos preset CLEARS any custom (dragged)+                    // position for that field — the preset wins, as the user just chose it.+                    if let Some(s) = g("refPos") { m.ref_pos = s; m.ref_x = None; m.ref_y = None; non_3d_change = true; }+                    if let Some(s) = g("valuePos") { m.value_pos = s; m.value_x = None; m.value_y = None; non_3d_change = true; }+                    // Custom (manual-drag) positions, KiCad symbol-local mm, Y-up.+                    if let (Some(x), Some(y)) = (gn("refX"), gn("refY")) { m.ref_x = Some(x); m.ref_y = Some(y); non_3d_change = true; }+                    if let (Some(x), Some(y)) = (gn("valueX"), gn("valueY")) { m.value_x = Some(x); m.value_y = Some(y); 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); }@@ -553,6 +936,50 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec             }             json_resp("{\"ok\":true}")         }+        (Method::Post, "/autofields") => {+            // Auto-place the Reference + Value designators CLEAR of the body and+            // every pin: Reference centred just above the top extent, Value just+            // below the bottom extent. Baked source-agnostically via the override.+            let placed = {+                let st = STATE.lock().unwrap();+                st.as_ref().and_then(|s| {+                    // Seed the extents from the body rect when present; otherwise+                    // (polyline-only / discrete symbols with no rect) fall back to+                    // the pin envelope so auto-place NEVER dead-ends.+                    let mut max_y = f64::NEG_INFINITY;+                    let mut min_y = f64::INFINITY;+                    let mut cx = 0.0;+                    if let Some(b) = s.body_rect.as_ref() {+                        max_y = b.y1.max(b.y2); min_y = b.y1.min(b.y2);+                        cx = (b.x1 + b.x2) / 2.0;+                    }+                    if !s.pins.is_empty() {+                        let (mut sx, mut have_x) = (0.0, false);+                        for p in &s.pins {+                            if p.y > max_y { max_y = p.y; }+                            if p.y < min_y { min_y = p.y; }+                            sx += p.x; have_x = true;+                        }+                        if s.body_rect.is_none() && have_x { cx = sx / s.pins.len() as f64; }+                    }+                    if max_y.is_finite() && min_y.is_finite() {+                        const M: f64 = 2.54; // clearance above/below the outermost feature+                        Some((cx, max_y + M, cx, min_y - M))+                    } else { None }+                })+            };+            match placed {+                Some((rx, ry, vx, vy)) => {+                    if let Some(m) = MODEL.lock().unwrap().as_mut() {+                        m.ref_x = Some(rx); m.ref_y = Some(ry);+                        m.value_x = Some(vx); m.value_y = Some(vy);+                    }+                    regenerate();+                    json_resp(&json!({"ok": true, "ref": [rx, ry], "value": [vx, vy]}).to_string())+                }+                None => json_err(400, "no body geometry to auto-place from"),+            }+        }         (Method::Post, "/eval") => {             let code = serde_json::from_str::<Value>(body)                 .ok()