app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
← 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
15 files changed
+1318−186
Cargo.lock+1−1Cargo.toml+1−1README.md+55−37SKILL.md+26VERSION+1−1docs/screenshot-viewer.pngpackage.json+1−1screenshots/app-3d-iso.pngscreenshots/app-chip-rail.pngscreenshots/app-label-placement.pngscreenshots/app-outline-in-body.pngsrc/app.html+683−114src/main.rs+11−5src/render.rs+98−12src/server.rs+441−14Cargo.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. -+ ## 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).++++**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.++++**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.+++ ## 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. ++ 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.-+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.  -### 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`.-+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 self-describing sidecar  ## 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,'&').replace(/</g,'<').replace(/>/g,'>')} // ── 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: ®ex::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()