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