← Commit history
.gitignore+2
@@ -1,3 +1,5 @@ target/ *.swp .DS_Store+# built binary copy at repo root (a release asset, not source)+/adom-chip-thumbnailer
Cargo.lock+103−1
@@ -2,16 +2,25 @@ # It is not intended for manual editing. version = 4 +[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"+ [[package]] name = "adom-chip-thumbnailer"-version = "0.5.1"+version = "0.6.0" dependencies = [  "anyhow",+ "base64",  "clap",  "clap_complete",+ "png",  "regex",  "serde",  "serde_json",+ "tiny_http",  "ureq", ] @@ -80,12 +89,24 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"+ [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]]+name = "bitflags"+version = "1.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"+ [[package]] name = "cc" version = "1.2.61"@@ -102,6 +123,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"+ [[package]] name = "clap" version = "4.6.1"@@ -157,6 +184,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]+ [[package]] name = "displaydoc" version = "0.2.5"@@ -168,12 +204,31 @@ dependencies = [  "syn", ] +[[package]]+name = "fdeflate"+version = "0.3.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"+dependencies = [+ "simd-adler32",+]+ [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]+ [[package]] name = "form_urlencoded" version = "1.2.2"@@ -200,6 +255,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"+ [[package]] name = "icu_collections" version = "2.2.0"@@ -339,6 +400,16 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]+ [[package]] name = "once_cell" version = "1.21.4"@@ -357,6 +428,19 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]]+name = "png"+version = "0.17.16"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"+dependencies = [+ "bitflags",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide",+]+ [[package]] name = "potential_utf" version = "0.1.5"@@ -511,6 +595,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"+ [[package]] name = "smallvec" version = "1.15.1"@@ -557,6 +647,18 @@ dependencies = [  "syn", ] +[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]+ [[package]] name = "tinystr" version = "0.8.3"
Cargo.toml+10−2
@@ -1,8 +1,8 @@ [package] name = "adom-chip-thumbnailer"-version = "0.5.1"+version = "0.6.0" edition = "2021"-description = "Renders symbol / footprint / 3D thumbnails for chip-fetcher library entries (Adom-themed SVGs + OCCT-direct PNGs via service-step2glb)."+description = "Chip icon factory: renders the full 3D chip-icon family (orientations, transparent icons, name-lasered + vector outlines) from a STEP via service-step2glb OCCT. Live web app + CLI." license = "MIT"  [[bin]]@@ -19,6 +19,14 @@ regex = "1" # Wiki-fetch SKILL.md at install time. No fallback to embedded content # is allowed (see gallia/skills/tool-publisher/SKILL.md "install rule"). ureq = { version = "2", default-features = false, features = ["tls"] }+# Web app (`chip-thumbnailer serve`): tiny embedded HTTP server + PNG+# introspection (real pixel dims + true alpha-transparency detection for+# the per-icon metadata captions).+tiny_http = "0.12"+png = "0.17"+# Decode the base64 named-STEP / named-iso payloads from the service+# /name-emboss endpoint.+base64 = "0.22"  [profile.release] opt-level = "z"
README.md+82−39
@@ -1,62 +1,105 @@ # chip-thumbnailer -Renders symbol / footprint / 3D thumbnails for every chip in the-[chip-fetcher](https://github.com/adom-inc/chip-fetcher) library.--- **Symbol** → `<MPN>-symbol.svg` — Adom dark theme via the same-  `applyDarkTheme()` palette gallia/symbol-creator uses (ported to Rust).-- **Footprint** → `<MPN>-footprint.svg` — kicad-cli's native KiCad-  palette painted on the canonical FpView dark canvas + 20-unit PCB-  grid.-- **3D** → `<MPN>-3d-iso.png` — 320×240 OCCT-direct render via-  `step2glb thumbnail` → `service-step2glb` POST `/thumbnail` (no-  pyrender; just OCCT V3d offscreen).--## Install+The **chip-icon factory**. Give it a STEP file and it renders the+complete family of chip icons, in real time, from one source: shaded 3D+orientations, transparent icons, name-lasered variants, and a gallery of+vector outline styles. CLI + a live web app.++It is the single producer of every chip icon the rest of the Adom EDA+stack consumes (adom-symbol's centre overlay, library cards, wiki chip+pages). The heavy OCCT work runs on **service-step2glb**; chip-thumbnailer+stays a lean Rust client and does the alpha-keying locally.++> Symbol and footprint thumbnails are **not** this tool's job, those+> belong to **adom-symbol** and **adom-footprint**. chip-thumbnailer+> renders the *chip* (its 3D body) in every icon form.++## The icon family (from one STEP)++| Output | What it is |+|---|---|+| `<MPN>-3d-iso[-<axis>][-<size>].png` | shaded 3D render, 6 face-up orientations x sm/md/lg |+| `<MPN>-3d-iso[-<axis>]-icon.png` | the same, alpha-keyed to a **transparent** background |+| `<MPN>-3d-iso-named[-icon].png` | part number **lasered** onto the chip top (180, reads right-side-up) |+| `<MPN>-3d-iso-named90[-icon].png` | the name at a right angle (90), for chips taller than wide |+| `<MPN>-named.step` | the embossed STEP, so the mark travels to KiCad too |+| `<MPN>-3d-outline-<style>[-named].svg` | **16 vector outline styles** via OCCT hidden-line removal |+| `<MPN>-thumbs.json` | self-describing manifest (palette, files, full-fidelity tool) |++The 16 outline styles span three axes: **colour** (white / teal / blue /+purple / amber / green / pink), **thickness** (hairline to heavy, plus+dark "ink on paper"), and **approach** (solid / dashed / neon / x-ray /+blueprint / ink-on-paper). Each comes plain and name-marked.++## The web app  ```bash-curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-thumbnailer/chip-thumbnailer \-  -o /tmp/chip-thumbnailer && chmod +x /tmp/chip-thumbnailer && \-  sudo install -m 0755 /tmp/chip-thumbnailer /usr/local/bin/chip-thumbnailer && \-  chip-thumbnailer install+chip-thumbnailer serve            # run the HTTP server+chip-thumbnailer app              # open it in a Hydrogen webview tab ``` -## Usage+Drop a STEP file (or pick a library chip) and watch every icon fill into+its dashed box in real time, each captioned with file name, format, pixel+size, KB, and true alpha transparency. A Regenerate button re-runs the+whole family; the orientations stay up top while the outline + name-laser+galleries collapse.++## CLI  ```bash-chip-thumbnailer once VL53L8CX             # render all three thumbnails-chip-thumbnailer scan                      # process every .thumb-pending marker-chip-thumbnailer status ADS131M04IPBSR     # what's there, what's missing-chip-thumbnailer health                    # probe deps+chip-thumbnailer once <MPN>        # render the full family for one chip + manifest+chip-thumbnailer scan              # process every .thumb-pending marker in the library+chip-thumbnailer status <MPN>      # what's there, what's missing+chip-thumbnailer rerender <MPN>    # re-render (--kind 3d)+chip-thumbnailer serve | app       # the web app+chip-thumbnailer health | config   # probe deps / print paths ``` -Outputs land in `chip-fetcher/library/<MPN>/<MPN>-{symbol,footprint,3d-iso}.{svg,png}`.+Every command prints `OK:` lines for humans and a final JSON line for AI+callers; `once`'s JSON inlines the manifest + a `_hints` block. -## Build+## Architecture++chip-thumbnailer calls **service-step2glb** for the OCCT-heavy work and+keeps itself dependency-light:++- `POST /thumbnail` -> the shaded orientation PNGs.+- `POST /outline` -> the 16 styled vector outlines (OCCT HLR).+- `POST /name-emboss` -> the name-lasered STEP + rendered iso.++The transparent icons are alpha-keyed from the shaded renders in pure+Rust (no Python, no extra deps). The chosen up-axis (`info.json`+`chosen_up_axis`) is honoured so Y-up parts pose correctly.++## Install  ```bash-cargo build --release+adom-wiki pkg install adom/adom-chip-thumbnailer+chip-thumbnailer install        # fetch SKILL.md + bash completions from the wiki ``` -Run as `target/release/chip-thumbnailer`. No system deps for build;-runtime needs `kicad-cli` (10.0.x), `step2glb` (0.6.2+), and the-shared `service-step2glb` reachable.+`chip-thumbnailer install` fetches `SKILL.md` fresh from+[wiki.adom.inc](https://wiki.adom.inc/adom/adom-chip-thumbnailer) at+install time (no `include_str!`, per the no-fallback install rule). -## Contributing & forking — open source under the Adom org+## Build -**chip-thumbnailer** is **open source** under the **adom** org on the [Adom Wiki](https://wiki.adom.inc/adom/adom-chip-thumbnailer). The full source lives in this page's repo — open the **Files** tab, or `adompkg install adom/adom-chip-thumbnailer` and edit the package. Third-party developers are welcome to modify it and contribute back.+```bash+cargo build --release            # run as target/release/adom-chip-thumbnailer+``` -**Modify it**-- Get the source from the Files tab (or `adompkg install adom/adom-chip-thumbnailer`), make your changes, and rebuild.+No system deps to build. At runtime it needs `service-step2glb` reachable+(for the OCCT renders); the transparent-icon keying is self-contained. -**Contribute back to Adom — two paths**-1. **Pull request (preferred).** The wiki runs a PR-per-page flow: propose your change on this page and the maintainer reviews + merges it — no fork needed.-2. **Fork + breadcrumb.** Prefer your own fork or add-on? Publish it as your own wiki page, then drop a **breadcrumb on _this_ page** pointing at your fork so it's discoverable:-   - Read existing trails: `GET https://wiki.adom.inc/api/v1/pages/adom-chip-thumbnailer/breadcrumbs`-   - Attach a breadcrumb from your fork's page targeting this anchor (a short label + your slug).+## Open source under the Adom org -   The breadcrumb leaves a **linked path back here**, so **Adom's AI can auto-discover your fork** and surface it to users — the trail is followable both ways. Your fork stays yours; the original stays discoverable.+**chip-thumbnailer** is **open source** (MIT) under the **adom** org on the+[Adom Wiki](https://wiki.adom.inc/adom/adom-chip-thumbnailer). The source+lives in the page's repo (Files tab) and on+[github.com/adom-inc/chip-thumbnailer](https://github.com/adom-inc/chip-thumbnailer).+Contribute via the wiki's PR-per-page flow, or fork and drop a breadcrumb+on this page so Adom's AI can discover your fork.  ## License -**MIT** — © 2026 Adom Industries Inc. Open source; see [LICENSE](LICENSE). Modify, fork, and redistribute freely.+**MIT** - (c) 2026 Adom Industries Inc. See [LICENSE](LICENSE).
SKILL.md+76−245
@@ -1,276 +1,107 @@ --- name: chip-thumbnailer-description: "Render symbol / footprint / 3D thumbnails for chip-fetcher library entries — Adom-themed SVGs (symbol via gallia/symbol-creator/svg-theme + footprint via FpView dark canvas + PCB grid) and OCCT-direct PNGs in three sizes (sm/md/lg) via service-step2glb /thumbnail. Writes a self-describing <MPN>-thumbs.json manifest per chip listing every file + the canonical Adom palette + which app to launch for full-fidelity inspection. Use when the user wants thumbnails for chips, wants to populate wiki chip pages, batch-renders the chip-fetcher library, or asks 'render thumbnails / make thumbnails / chip thumbs / preview a chip / generate symbol+footprint+3d previews'. Triggers: chip thumbnail, chip thumbs, render chip, generate chip preview, symbol thumbnail, footprint thumbnail, 3d thumbnail, preview chip in library, batch render chips, .thumb-pending, chip-thumbs.json, chip wiki hero, library card, chip-thumbnailer."+description: "The chip-icon factory. From a single STEP file it renders the complete family of CHIP icons (the 3D body in every form): 6 face-up shaded orientations x sm/md/lg, transparent alpha-keyed icons, the part-number LASERED onto the chip top (180 reads upright + a 90 right-angle variant), the embossed .step that travels to KiCad, and 16 vector OUTLINE styles via OCCT hidden-line removal (colours white/teal/blue/purple/amber/green/pink, thicknesses hairline..heavy, approaches solid/dashed/neon/x-ray/blueprint/ink-on-paper). Ships a live web app (chip-thumbnailer serve|app) that fills each icon into a dashed box in real time with per-icon size/format/KB/transparency, plus a CLI (once/scan/status/rerender). The heavy OCCT work runs on service-step2glb; chip-thumbnailer is a lean Rust client + does alpha-keying locally. NOT symbol/footprint thumbnails (those are adom-symbol / adom-footprint). Triggers: chip icon, chip thumbnail, chip thumbs, render chip, chip 3d icon, transparent chip icon, name on chip, laser part number, chip outline, vector outline, chip-thumbnailer, .thumb-pending, chip-thumbs.json, chip wiki hero, library card, regenerate chip icons."+user-invocable: true --- -# chip-thumbnailer+# chip-thumbnailer, the chip-icon factory -Renders three thumbnail kinds per chip in the chip-fetcher library and-drops them next to the source files, plus a manifest:+Give it a STEP file, get the complete family of **chip** icons, the 3D+body rendered in every form a downstream surface needs. It is the single+producer of the icons adom-symbol's centre overlay, library cards, and+wiki chip pages consume. -```-library/<MPN>/<MPN>-symbol.svg        Adom-dark-themed KiCad symbol-library/<MPN>/<MPN>-footprint.svg     FpView-styled footprint-library/<MPN>/<MPN>-3d-iso-sm.png     160 × 120 — icon / list view-library/<MPN>/<MPN>-3d-iso.png        320 × 240 — card / index (canonical, back-compat)-library/<MPN>/<MPN>-3d-iso-lg.png     800 × 600 — wiki hero / detail page-library/<MPN>/<MPN>-thumbs.json       self-describing manifest (READ THIS)-```--**Always read `<MPN>-thumbs.json` before consuming thumbnails.** It-declares which files actually exist for this chip (sources may be-incomplete), the exact palette colors used (so you can match your-container UI), and which Adom app to launch when the user clicks-through for full-fidelity inspection.--## Sizes (3D PNG)--| Suffix | Dimensions | Typical use | Approx file size |-|---|---|---|---|-| `-sm.png` | 160 × 120 | icon, list-view, inline preview | 1–4 KB |-| `.png` (no suffix) | 320 × 240 | card / library-index thumbnail (canonical) | 4–12 KB |-| `-lg.png` | 800 × 600 | wiki hero, detail page, fallback hero | 12–35 KB |--Symbol + footprint are SVG — vectors scale infinitely, one file each.--## Palette (canonical, baked into every output)--Sourced from `gallia/symbol-creator/lib/svg-theme.js` `THEME_COLORS` +-`gallia/viewer/adom-theme.js` `THEME` + `gallia/viewer/kicad-footprint-viewer.js`.--| Token | Hex | Purpose |-|---|---|---|-| `canvas_bg` | `#0d1117` | dark page background, behind everything |-| `body_fill` | `#1a2332` | symbol body fill (top of vertical gradient) |-| `body_fill_grad` | `#141c27` | symbol body fill (bottom of gradient) |-| `body_stroke` | `#30363d` | symbol body outline |-| `pin_wire` | `#4a5568` | symbol pin wires + outline strokes |-| `pin_name` | `#ffffff` | symbol pin name labels |-| `pin_number` | `#8b949e` | symbol pin number labels (muted gray) |-| `group_label` | `#E6B450` | symbol pin-group section label (warm amber) |-| `group_label_hover` | `#FFD180` | group label on hover (lighter amber) |-| `accent_teal` | `#00b8b0` | Adom brand teal — link / accent |-| `accent_teal_bright` | `#00e6dc` | hover / focus highlight |-| `pad_fill` | `#C83434` | footprint pad fill (KiCad F.Cu red) |-| `pad_stroke` | `#ef5350` | footprint pad outline |-| `pad_number` | `#ffffff` | pad number label inside the pad |-| `courtyard` | `#FF26E2` | footprint courtyard (magenta dashed) |-| `silkscreen` | `#00cccc` | footprint silkscreen (cyan) |-| `fab_outline` | `#6e7681` | footprint F.Fab outline (subtle gray) |-| `grid_line` | `rgba(255,255,255,0.025)` | 2.54 mm PCB-style grid behind the symbol/footprint |-| `glow` | `rgba(0,184,176,0.06)` | radial teal atmosphere centered on the chip |--The dark canvas + PCB grid + radial teal glow are **baked into every-themed SVG** so the thumbnail looks correct standalone. If your host-container (e.g. SymView) already provides these via CSS, the SVG's-own atmosphere will simply layer on top — usually fine, occasionally-double up. In that case, strip the `<defs>` `adom-canvas-*` IDs from-the SVG.--## Manifest schema (`<MPN>-thumbs.json`)--```json-{-  "mpn": "VL53L8CX",-  "generated_by": "chip-thumbnailer",-  "generated_by_version": "0.4.1",-  "generated_at_iso": "2026-05-05T16:57:00Z",--  "palette": { /* every color above as { token: hex } */ },+> Symbol (`.kicad_sym`) and footprint (`.kicad_mod`) thumbnails are **not**+> this tool's job, they belong to **adom-symbol** and **adom-footprint**.+> This renders the chip. -  "thumbnails": {-    "symbol": {-      "format": "svg",-      "path": "VL53L8CX-symbol.svg",-      "size_bytes": 84023,-      "full_fidelity": {-        "tool": "symbol-creator (SymView)",-        "command": "POST http://127.0.0.1:8781/sym/create … or open <MPN>-viewer.html",-        "description": "Interactive SymView — pin tooltips, group highlights, click-to-pin info, datasheet link, zoom/fit."-      }-    },-    "footprint": {-      "format": "svg",-      "path": "VL53L8CX-footprint.svg",-      "size_bytes": 11852,-      "full_fidelity": {-        "tool": "FpView (gallia/viewer/kicad-footprint-viewer.js)",-        "command": "generateFootprintViewer(<MPN>.kicad_mod) → <MPN>-fpview.html",-        "description": "Interactive FpView — pad tooltips with signal + dimensions, layer toggle, measure tool, solder-blob jet preview."-      }-    },-    "threeD": {-      "format": "png",-      "pose": "iso",-      "iso": {-        "sm": { "path": "VL53L8CX-3d-iso-sm.png", "width": 160, "height": 120, "sizeBytes": 1835 },-        "md": { "path": "VL53L8CX-3d-iso.png",    "width": 320, "height": 240, "sizeBytes": 4091 },-        "lg": { "path": "VL53L8CX-3d-iso-lg.png", "width": 800, "height": 600, "sizeBytes": 12637 }-      },-      "orientations": {-        "asIs": {-          "label": "As-is (Z-up — STEP canonical)",-          "description": "File rendered exactly as authored. STEP is Z-up canonically (ISO 10303-21 / KiCad / Solidworks / FreeCAD / AutoCAD), so this is the right answer for ~99% of chips.",-          "sizes": { "sm": { /* … */ }, "md": { /* … */ }, "lg": { /* … */ } }-        },-        "yUp": {-          "label": "Force Y-up source (fallback)",-          "description": "Source pre-rotated -90° about X. Use only when as-is renders the chip tipped on its side — happens with rare third-party exports authored Y-up (Maya / Blender / three.js convention).",-          "sizes": { "sm": { /* … */ }, "md": { /* … */ }, "lg": { /* … */ } }-        }-      },-      "inferredSourceUpAxis": "z",-      "bboxMm": [[-3.2, 3.2], [0.0, 1.13], [-3.25, 3.25]],-      "bboxMils": [[-126.0, 126.0], [0.0, 44.49], [-127.95, 127.95]],-      "full_fidelity": {-        "tool": "adom-step",-        "command": "adom-step view library/<MPN>/<MPN>.step",-        "description": "Interactive STEP viewer with measure tool, scene-graph navigation, geometric pin/pad detection."-      }-    }-  },+## What it makes (written next to the source `<MPN>.step`) -  "hints": [ "…" ]-} ```+<MPN>-3d-iso[-<axis>][-<size>].png     shaded render, 6 orientations x sm/md/lg+<MPN>-3d-iso[-<axis>]-icon.png         transparent (alpha-keyed) icon  <- adom-symbol reads this+<MPN>-3d-iso-named[-icon].png          part number lasered on top, 180 (reads upright)+<MPN>-3d-iso-named90[-icon].png        the name at a right angle, 90 (taller/wider fit)+<MPN>-named.step                       embossed STEP (the mark travels to KiCad)+<MPN>-3d-outline-<style>[-named].svg   16 OCCT-HLR vector outline styles, plain + name-marked+<MPN>-thumbs.json                      self-describing manifest (READ THIS first)+``` -Any kind whose source file (`.kicad_sym` / `.kicad_mod` / `.step`)-isn't in the chip dir at render time will be `null` in the manifest.--## 3D orientation: as-is is canonical Z-up--STEP is Z-up by convention (ISO 10303-21 / KiCad / Solidworks /-FreeCAD / AutoCAD all author Z-up), so the **as-is** render IS-already Z-up in our pipeline — that's the right answer for ~99% of-chips. Use `library/<MPN>/<MPN>-3d-iso.png` (the unsuffixed file)-in every default surface.--The **Y-up fallback** (`<MPN>-3d-iso-yup.png`) exists for the-genuine subset of manufacturer STEPs that author Y-up (Bosch BME688-/ BMI270, TI INA226, Nordic nRF52840-CKAA, Espressif ESP32-S3-WROOM-modules — verified in chip-fetcher's library). The fallback applies-`R_x(+π/2)` server-side (canonical glTF / three.js / Blender / Adom-convention) so the chip lands upright.--The manifest's `inferredSourceUpAxis` field is a bbox-aspect heuristic-(`"z"` / `"y"` / `"unknown"`); use it as a hint when building an-orientation picker UI, but the user always wins.--**For anyone implementing or modifying a Y-up ↔ Z-up transform-elsewhere in the Adom stack**, read-[`gallia/skills/up-axis-conventions/SKILL.md`](https://github.com/adom-inc/gallia/blob/main/skills/up-axis-conventions/SKILL.md)-first. It captures the correct rotation sign (`R_x(+π/2)`, always-positive), reference implementations across Babylon.js / three.js /-OCCT (Python) / glTF, and the testing trap that lets sign errors-slip past visual review on Z-up sources. chip-thumbnailer 0.4.0 →-0.4.1 itself shipped a sign error caught only when the user pushed-back on the rotation correctness — the gallia skill exists so-nobody else hits it.--## How AI agents should use this--When the user asks anything like *"show me VL53L8CX"* / *"open this-chip"* / *"preview ADS131M04IPBSR"*:+**Axes** (orientation token): none = `asIs` (Z-up canonical, ~80% of+chips), `yup`/`ydown`/`xup`/`xdown`/`zdown` for the rest. The+chosen-up-axis from chip-fetcher's `info.json` is honoured for the+single-pose outline + name-laser set. **Sizes**: `-sm` 160x120,+none = `md` 320x240 (canonical), `-lg` 800x600. -1. `cat library/<MPN>/<MPN>-thumbs.json` — read the manifest first.-2. Use the right thumbnail for the surface:-   - List / index card → `.thumbnails.3d.iso.md.path` (320×240 PNG) +-     `.thumbnails.symbol.path` (svg)-   - Tooltip / inline preview → `.thumbnails.3d.iso.sm.path`-   - Wiki hero / detail page → `.thumbnails.3d.iso.lg.path`-3. When the user wants more than the thumbnail (clicks through, asks-   to inspect, asks to measure), launch the full-fidelity app named-   in `.thumbnails.<kind>.full_fidelity.tool` via the suggested-   `.command`.-4. If your UI has its own background, adopt `.palette.canvas_bg` so-   thumbnails sit cleanly. If your UI has accents, use-   `.palette.accent_teal` (Adom brand teal) so it looks consistent.-5. Watch the `_hints` array in `chip-thumbnailer once`'s JSON output-   for next-step suggestions specific to the chip you just rendered.+**16 outline styles**, three axes of variety:+- colour: `white`(dark) / `teal` / `blue` / `purple` / `amber` / `green` / `pink`+- thickness: `hairline` / `dark`(medium) / `heavy`, plus dark-ink `thin` / `bold` / `ink-heavy`+- approach: solid / `dashed` / `neon` (glow) / `xray` (hidden edges) / `blueprint` -## CLI+## The web app  ```bash-chip-thumbnailer once <MPN>             # render all kinds + all 3 sizes; write manifest-chip-thumbnailer scan                   # process every .thumb-pending in the library-chip-thumbnailer status <MPN>           # which thumbs/sources/manifest exist-chip-thumbnailer rerender <MPN>         # re-render all (or --kind sym|fp|3d to limit)-chip-thumbnailer touch-pending <MPN>    # mark a chip as out-of-date-chip-thumbnailer health                 # probe kicad-cli + step2glb + service-step2glb-chip-thumbnailer config                 # print library root + dependency paths+chip-thumbnailer serve            # HTTP server (default :8794)+chip-thumbnailer app              # open in a Hydrogen webview tab ``` -Every command emits `OK: …` lines for humans and a final JSON line-for AI / scripted callers. The JSON of `once` includes the-just-written manifest inline + a `_hints` block with concrete next-steps so a single command-and-parse gives the AI everything it-needs.+POST a STEP (or pick a library chip) and every icon fills into a dashed+box in real time, captioned with file name, format, pixel size, KB, and+true alpha transparency. Regenerate button; orientations stay up top, the+outline + name-laser galleries collapse. Every action is curl-drivable+(`GET /api/state`, `POST /api/generate|/api/regenerate`). -## Install+## CLI  ```bash-curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-thumbnailer/chip-thumbnailer \-  -o /tmp/chip-thumbnailer && chmod +x /tmp/chip-thumbnailer && \-  sudo install -m 0755 /tmp/chip-thumbnailer /usr/local/bin/chip-thumbnailer && \-  chip-thumbnailer install+chip-thumbnailer once <MPN>        # render the full family + write the manifest+chip-thumbnailer scan              # process every .thumb-pending in the library+chip-thumbnailer status <MPN>      # which icons/sources/manifest exist+chip-thumbnailer rerender <MPN>    # re-render (--kind 3d is the only kind now)+chip-thumbnailer serve | app       # the web app+chip-thumbnailer health | config   # probe service-step2glb / print paths ``` -`chip-thumbnailer install` fetches `SKILL.md` fresh from the wiki at-install time — no `include_str!` of SKILL.md in the binary, per the-canonical no-fallback rule in-`gallia/skills/tool-publisher/SKILL.md`. On any wiki error the-install exits non-zero with a clear hint.+`once` prints `OK:` lines plus a final JSON line that inlines the+`<MPN>-thumbs.json` manifest and a `_hints` block of next steps. -## Pipeline (per chip)+## Architecture, lean client + service -1. **Symbol** — `kicad-cli sym export svg` → port of gallia's-   `applyDarkTheme()` regex pass (case-insensitive on hex tails to-   match `#FFFFC2` and `#ffffc2` both — the JS source uses `/.../gi`).-   `<style>` block wrapped in `<![CDATA[…]]>` so the literal-   `<g class="stroked-text">` in the CSS comment doesn't trip strict-   XML parsers when the SVG is loaded via `<object>` or as a wiki-   asset. Canvas bg + 2.54 mm PCB grid + radial teal glow injected-   right after `<svg>` so the SVG looks correct standalone.+The OCCT-heavy work runs on **service-step2glb** (the server never blocks+on OCCT; jobs run in isolated subprocesses, `202 + job_id`, poll+`/jobs/{id}` then `/jobs/{id}/result`): -2. **Footprint** — stage `.kicad_mod` into a temp `.pretty/`,-   `kicad-cli fp export svg --footprint <name>`, strip-   `<title>`/`<desc>`/`opacity="0"` text + `<g class="stroked-text">`-   groups (KiCad inflates the viewBox with these), inject FpView's-   dark canvas + PCB grid background, drop KiCad's white page bg.-   Pad / silk / courtyard colors stay as kicad-cli's native palette-   (which already matches FpView).+| call | produces |+|---|---|+| `POST /thumbnail?upAxis=<a>` | one shaded orientation PNG |+| `POST /outline?upAxis=<a>&name=&suffix=&rot=` | `{style: svg}` for the 16 styles (OCCT HLR) |+| `POST /name-emboss?upAxis=<a>&name=&rot=` | embossed STEP + rendered iso (base64) | -3. **3D** — `step2glb thumbnail <MPN>.step --pose iso --width N-   --height N` for each of sm (160×120), md (320×240), lg (800×600).-   step2glb POSTs to `service-step2glb` 0.4.x `/thumbnail` which-   renders server-side via OCCT V3d offscreen + Xvfb (no pyrender,-   no per-render Hydrogen browser tab).+`rot` = text orientation: `180` (default, reads upright to the iso+camera), `270` (a right angle, the `named90` set). The text is scaled to+the chip's top-face bounding box at whichever rotation. -## Failure handling+chip-thumbnailer does the **transparent-icon alpha-key in pure Rust**+(median the four shaded-render corners as the background, feather to alpha+by RGB distance, T0=16/T1=44), so there are no Python/numpy deps in the+client. -If a render fails, chip-thumbnailer:-- Writes `library/<MPN>/.thumb-errors.json` with which kind failed-  and why.-- **Leaves the `.thumb-pending` marker in place** so the next `scan`-  retries.-- Still writes the manifest — but with `null` for kinds that didn't-  render, so downstream apps know what's available.+## How AI agents should use this -The marker is only cleared when ALL three kinds (or all the sizes-that were attempted) render successfully.+When the user wants to show / preview a chip:+1. `cat library/<MPN>/<MPN>-thumbs.json` first, it lists what exists.+2. Pick the icon for the surface: list/card -> `-3d-iso.png` (md) or the+   transparent `-3d-iso-icon.png`; tooltip -> `-sm`; hero -> `-lg`.+3. Match your UI to `palette.canvas_bg` (#0d1117) + `palette.accent_teal`.+4. A `null` entry means the source `.step` was missing at render time.  ## Related -- **chip-fetcher** writes the source files (`.kicad_sym`,-  `.kicad_mod`, `.step`) and touches `.thumb-pending` after each-  successful import.-- **service-step2glb** v0.4.x provides the OCCT-direct `/thumbnail`-  endpoint (replaced the prior pyrender stack).-- **step2glb** (Rust client) wraps `/thumbnail` for the 3D path.-- **gallia/symbol-creator** owns the canonical symbol dark-theme-  palette; chip-thumbnailer ports the regex pass into Rust to avoid-  a node shell-out at render time.-- **gallia/viewer/kicad-footprint-viewer.js** owns the FpView-  footprint styling.-- **adom-step** is the full-fidelity 3D viewer to launch when the-  user wants more than the thumbnail.+- **chip-fetcher** writes `<MPN>.step` + touches `.thumb-pending`; it now+  calls `chip-thumbnailer once` for the full family (it no longer shells+  its own OCCT scripts).+- **service-step2glb** owns the OCCT endpoints (`/thumbnail`, `/outline`,+  `/name-emboss`).+- **adom-symbol** consumes the transparent `-3d-iso-icon.png` + the+  outline/named SVGs as its centre overlay (it does NOT make them).+- **adom-step** is the full-fidelity 3D viewer to launch on click-through.
adom-chip-thumbnailer.png
⋯ 1 unchanged line ⋯
package.json+3−3
@@ -1,9 +1,9 @@ {   "slug": "adom-chip-thumbnailer",-  "version": "1.0.2",+  "version": "1.1.0",   "type": "app",-  "description": "Renders crisp PNG thumbnails of a chip's 3D model (6 orientations x 3 sizes) from its STEP file, server-side, for library cards and previews.",-  "tags": ["thumbnail", "3d", "step", "chip", "render", "eda"],+  "description": "The chip-icon factory: from a STEP file it renders the complete family of chip icons, shaded 3D orientations, transparent icons, name-lasered variants, and 16 vector outline styles, in a live web app + CLI.",+  "tags": ["chip", "icon", "thumbnail", "3d", "step", "outline", "render", "eda"],   "hero": { "type": "image", "path": "adom-chip-thumbnailer.png" },   "dependencies": {},   "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },
page.json+6−4
@@ -1,14 +1,16 @@ {   "slug": "adom-chip-thumbnailer",-  "version": "1.0.2",+  "version": "1.1.0",   "type": "app",-  "description": "Renders crisp PNG thumbnails of a chip's 3D model (6 orientations x 3 sizes) from its STEP file, server-side, for library cards and previews.",+  "description": "The chip-icon factory: from a STEP file it renders the complete family of chip icons, shaded 3D orientations, transparent icons, name-lasered variants, and 16 vector outline styles, in a live web app + CLI.",   "dependencies": {},   "tags": [+    "chip",+    "icon",     "thumbnail",     "3d",     "step",-    "chip",+    "outline",     "render",     "eda"   ],@@ -19,7 +21,7 @@   "needs_sudo": false,   "hero": {     "type": "image",-    "path": "adom-chip-thumbnailer-hero-v5.png"+    "path": "adom-chip-thumbnailer.png"   },   "visibility": "public",   "title": "Adom Chip Thumbnailer",
screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
src/cli.rs+44−33
@@ -1,7 +1,7 @@ use crate::library; use crate::queue; use crate::ui::{err, hint, ok, warn, CYAN, DIM, RESET};-use crate::{install, manifest, note, render_3d, render_fp, render_sym};+use crate::{install, manifest, note, render_3d}; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use std::process::Command as ProcCommand; @@ -43,6 +43,18 @@ pub enum Command {     Install,     /// Print shell completions to stdout: `chip-thumbnailer completions bash`     Completions(CompletionsArgs),+    /// Run the web app: a live dashboard that renders every icon for a STEP+    /// file in real-time, with per-icon size / format / transparency.+    Serve(ServeArgs),+    /// Open the web app in a Hydrogen webview tab (starts `serve` if needed).+    App(ServeArgs),+}++#[derive(Args, Debug)]+pub struct ServeArgs {+    /// Port to bind the web app on.+    #[arg(long, env = "CHIP_THUMBNAILER_PORT", default_value = "8794")]+    pub port: u16, }  #[derive(Args, Debug)]@@ -100,16 +112,13 @@ pub fn once_cmd(args: OnceArgs) {         Ok(report) => {             if report.all_ok() {                 ok(format!(-                    "{CYAN}{}{RESET} — sym/fp/3d all rendered, .thumb-pending cleared",+                    "{CYAN}{}{RESET} — 3D orientations + transparent icons rendered, .thumb-pending cleared",                     args.mpn                 ));             } else {                 warn(format!(-                    "{CYAN}{}{RESET} — partial render ({} sym, {} fp, {} 3d). See .thumb-errors.json",+                    "{CYAN}{}{RESET} — 3D render FAILED. See .thumb-errors.json",                     args.mpn,-                    if report.sym_ok { "ok" } else { "FAIL" },-                    if report.fp_ok { "ok" } else { "FAIL" },-                    if report.three_d_ok { "ok" } else { "FAIL" },                 ));             } @@ -132,8 +141,8 @@ pub fn once_cmd(args: OnceArgs) {                         c.mpn, c.mpn                     ));                     next_steps.push(format!(-                        "Open all thumbnails as VS Code tabs: adom-vscode open library/{}/{}-symbol.svg && adom-vscode open library/{}/{}-footprint.svg && adom-vscode open library/{}/{}-3d-iso.png",-                        c.mpn, c.mpn, c.mpn, c.mpn, c.mpn, c.mpn+                        "Open the chip icons as VS Code tabs: adom-vscode open library/{}/{}-3d-iso.png && adom-vscode open library/{}/{}-3d-iso-icon.png",+                        c.mpn, c.mpn, c.mpn, c.mpn                     ));                 }             }@@ -143,7 +152,7 @@ pub fn once_cmd(args: OnceArgs) {                     args.mpn                 ));                 next_steps.push(format!(-                    "Retry just the failed kinds: chip-thumbnailer rerender {} --kind sym|fp|3d",+                    "Retry the render: chip-thumbnailer rerender {} --kind 3d",                     args.mpn                 ));             }@@ -153,8 +162,6 @@ pub fn once_cmd(args: OnceArgs) {                 serde_json::to_string(&serde_json::json!({                     "ok": report.all_ok(),                     "mpn": report.mpn,-                    "sym": report.sym_ok,-                    "fp": report.fp_ok,                     "3d": report.three_d_ok,                     "errors": report.errors,                     "manifest": m,@@ -232,31 +239,17 @@ pub fn rerender_cmd(args: RerenderArgs) {             std::process::exit(1);         }     };-    let want_all = args.kind == "all";+    // chip-thumbnailer renders chip icons only; `3d` (the 3D orientations ++    // transparent icons) is the sole kind. `all` is kept as an alias.     let mut had_failure = false;--    if want_all || args.kind == "sym" {-        match render_sym::render(&chip) {-            Ok(_) => ok(format!("sym  → {}", chip.symbol_thumb().display())),-            Err(e) => { err(format!("sym failed: {e}")); had_failure = true; }-        }-    }-    if want_all || args.kind == "fp" {-        match render_fp::render(&chip) {-            Ok(_) => ok(format!("fp   → {}", chip.footprint_thumb().display())),-            Err(e) => { err(format!("fp failed: {e}")); had_failure = true; }-        }-    }-    if want_all || args.kind == "3d" {-        match render_3d::render(&chip) {-            Ok(_) => ok(format!("3d   → {}", chip.three_d_thumb().display())),-            Err(e) => { err(format!("3d failed: {e}")); had_failure = true; }-        }-    }-    if !["all", "sym", "fp", "3d"].contains(&args.kind.as_str()) {-        err(format!("unknown --kind `{}`; want all|sym|fp|3d", args.kind));+    if !["all", "3d"].contains(&args.kind.as_str()) {+        err(format!("unknown --kind `{}`; chip-thumbnailer renders chip icons, so the only kind is `3d` (or `all`)", args.kind));         std::process::exit(1);     }+    match render_3d::render(&chip) {+        Ok(_) => ok(format!("3d   → {}", chip.three_d_thumb().display())),+        Err(e) => { err(format!("3d failed: {e}")); had_failure = true; }+    }     // Refresh the manifest so file lists, palette, version stay in sync     // with what's on disk — same contract as `once`.     match manifest::write(&chip) {@@ -325,6 +318,24 @@ pub fn completions_cmd(args: CompletionsArgs) {     clap_complete::generate(shell, &mut cmd, "chip-thumbnailer", &mut std::io::stdout()); } +pub fn serve_cmd(args: ServeArgs) {+    if let Err(e) = crate::serve::run(args.port) {+        err(format!("serve failed: {e}"));+        std::process::exit(1);+    }+}++pub fn app_cmd(args: ServeArgs) {+    match crate::serve::open_in_hydrogen(args.port) {+        Ok(_) => {}+        Err(e) => {+            err(format!("could not open app: {e}"));+            hint("Is the server running? Try `chip-thumbnailer serve` in another shell.");+            std::process::exit(1);+        }+    }+}+ fn which_or(name: &str) -> String {     ProcCommand::new("which")         .arg(name)
src/install.rs+1−1
@@ -11,7 +11,7 @@ use std::path::PathBuf; use std::time::Duration;  const SKILL_URL: &str =-    "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-thumbnailer/SKILL.md";+    "https://wiki.adom.inc/api/pages/adom/adom-chip-thumbnailer/files/SKILL.md";  fn fetch_skill_md() -> ! {     panic!("never called — fetch_skill_md returns into main; here for type-only docs");
src/library.rs+34
@@ -61,6 +61,31 @@ impl ChipPaths {         })     } +    /// Build a ChipPaths for an arbitrary working directory (NOT under the+    /// chip-fetcher library root). Used by `chip-thumbnailer serve` when a+    /// user uploads a lone STEP file: we stage it in a per-session scratch+    /// dir as `<name>.step` and render every 3D orientation into that dir.+    /// Symbol / footprint stay `None` unless a matching `.kicad_sym` /+    /// `.kicad_mod` was staged alongside it.+    pub fn for_dir(dir: PathBuf, mpn: &str) -> Self {+        let pick = |ext: &str| {+            let p = dir.join(format!("{mpn}.{ext}"));+            if p.is_file() {+                Some(p)+            } else {+                None+            }+        };+        Self {+            mpn: mpn.to_string(),+            kicad_sym: pick("kicad_sym"),+            kicad_mod: pick("kicad_mod"),+            step: pick("step").or_else(|| pick("stp")),+            thumb_pending: false,+            dir,+        }+    }+     pub fn symbol_thumb(&self) -> PathBuf {         self.dir.join(format!("{}-symbol.svg", self.mpn))     }@@ -84,6 +109,15 @@ impl ChipPaths {         self.dir             .join(format!("{}-3d-iso{osuffix}{ssuffix}.png", self.mpn))     }+    /// Transparent-background icon for one orientation (Md size, alpha-keyed+    /// from the shaded iso). asIs keeps the canonical `<MPN>-3d-iso-icon.png`+    /// name adom-symbol reads first; others get the orientation token.+    pub fn three_d_icon_path(&self, orient: ThreeDOrientation) -> PathBuf {+        let osuffix = orient.file_suffix();+        self.dir+            .join(format!("{}-3d-iso{osuffix}-icon.png", self.mpn))+    }+     /// Iterator over all three rendered 3D sizes (as-is orientation).     pub fn three_d_thumbs(&self) -> Vec<(ThreeDSize, PathBuf)> {         ThreeDSize::all()
src/main.rs+3−3
@@ -4,9 +4,7 @@ mod library; mod manifest; mod queue; mod render_3d;-mod render_fp;-mod render_sym;-mod theme;+mod serve; mod ui;  use clap::Parser;@@ -23,5 +21,7 @@ fn main() {         cli::Command::TouchPending(a) => cli::touch_pending_cmd(a),         cli::Command::Install => cli::install_cmd(),         cli::Command::Completions(a) => cli::completions_cmd(a),+        cli::Command::Serve(a) => cli::serve_cmd(a),+        cli::Command::App(a) => cli::app_cmd(a),     } }
src/queue.rs+9−39
@@ -1,24 +1,26 @@-//! Pipeline runner: render all three thumbnails for one MPN, write a-//! `.thumb-errors.json` sidecar on partial failure, clear the-//! `.thumb-pending` marker only when ALL three succeed.+//! Pipeline runner: render the chip's 3D thumbnails + transparent icons for one+//! MPN, write a `.thumb-errors.json` sidecar on failure, clear the+//! `.thumb-pending` marker on success.+//!+//! chip-thumbnailer renders CHIP icons (the 3D body), NOT schematic symbols or+//! PCB footprints. Symbol/footprint thumbnails are owned by adom-symbol and+//! adom-footprint; chip-fetcher shells those directly.  use crate::library::{self, ChipPaths}; use crate::ui::{ok, warn};-use crate::{manifest, render_3d, render_fp, render_sym};+use crate::{manifest, render_3d}; use anyhow::Result;  #[derive(Debug, Default)] pub struct RenderReport {     pub mpn: String,-    pub sym_ok: bool,-    pub fp_ok: bool,     pub three_d_ok: bool,     pub errors: Vec<(&'static str, String)>, }  impl RenderReport {     pub fn all_ok(&self) -> bool {-        self.sym_ok && self.fp_ok && self.three_d_ok+        self.three_d_ok     } } @@ -29,38 +31,6 @@ pub fn render_one(mpn: &str) -> Result<RenderReport> {         ..Default::default()     }; -    if chip.kicad_sym.is_some() {-        match render_sym::render(&chip) {-            Ok(_) => {-                report.sym_ok = true;-                ok(format!("sym  → {}", chip.symbol_thumb().display()));-            }-            Err(e) => {-                report.errors.push(("sym", format!("{e}")));-                warn(format!("sym  failed: {e}"));-            }-        }-    } else {-        report.errors.push(("sym", "no .kicad_sym in chip dir".into()));-        warn("sym  skipped: no .kicad_sym");-    }--    if chip.kicad_mod.is_some() {-        match render_fp::render(&chip) {-            Ok(_) => {-                report.fp_ok = true;-                ok(format!("fp   → {}", chip.footprint_thumb().display()));-            }-            Err(e) => {-                report.errors.push(("fp", format!("{e}")));-                warn(format!("fp   failed: {e}"));-            }-        }-    } else {-        report.errors.push(("fp", "no .kicad_mod in chip dir".into()));-        warn("fp   skipped: no .kicad_mod");-    }-     if chip.step.is_some() {         match render_3d::render(&chip) {             Ok(_) => {
src/render_3d.rs+288−7
@@ -38,14 +38,29 @@ pub fn render(chip: &ChipPaths) -> Result<()> {     for orient in ThreeDOrientation::all() {         let out = chip.three_d_thumb_path(orient, ThreeDSize::Md);         let (w, h) = ThreeDSize::Md.dims();-        if let Err(e) = service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn) {+        match service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn) {+            Ok(_) => {+                // Derive the transparent-background icon (alpha-keyed from the+                // shaded iso) so adom-symbol's centre overlay has no slate box.+                if let Err(e) = write_transparent_icon(&out, &chip.three_d_icon_path(orient)) {+                    if first_err.is_none() {+                        first_err = Some(anyhow!(+                            "transparent icon keying failed for {} ({}): {e}",+                            chip.mpn,+                            orient.json_key()+                        ));+                    }+                }+            }             // Keep rendering the other orientations; report the first failure.-            if first_err.is_none() {-                first_err = Some(anyhow!(-                    "step2glb service render failed for {} ({}): {e}",-                    chip.mpn,-                    orient.json_key()-                ));+            Err(e) => {+                if first_err.is_none() {+                    first_err = Some(anyhow!(+                        "step2glb service render failed for {} ({}): {e}",+                        chip.mpn,+                        orient.json_key()+                    ));+                }             }         }     }@@ -53,12 +68,202 @@ pub fn render(chip: &ChipPaths) -> Result<()> {     // Lazily populate <MPN>-features.json (manifest pulls bbox from it).     ensure_features(&step, chip); +    // Full chip-icon family: vector outlines + name-lasered icons via the+    // service's OCCT endpoints (the service owns OCCT; we stay a lean client).+    // Best-effort: a service hiccup here must not fail the core orientation+    // renders that already landed.+    let _ = render_family_extras(chip, &step);+     match first_err {         Some(e) => Err(e),         None => Ok(()),     } } +/// The vector outlines + name-laser are a single-pose set. chip-fetcher records+/// the picked up-axis in `info.json.chosen_up_axis`; we honor it so Y-up chips+/// (Bosch / Espressif / Nordic WLCSP) pose correctly. Default = Z-up canonical.+fn chosen_orientation(chip: &ChipPaths) -> ThreeDOrientation {+    let tok = std::fs::read_to_string(chip.dir.join("info.json"))+        .ok()+        .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();+    match tok.to_lowercase().as_str() {+        "y" | "yup" | "+y" => ThreeDOrientation::YUp,+        "-y" | "ydown" => ThreeDOrientation::YDown,+        "x" | "xup" | "+x" => ThreeDOrientation::XUp,+        "-x" | "xdown" => ThreeDOrientation::XDown,+        "-z" | "zdown" => ThreeDOrientation::ZDown,+        _ => ThreeDOrientation::AsIs,+    }+}++/// Generate the vector-outline SVGs + name-lasered icons for the chip by+/// calling service-step2glb's /outline and /name-emboss. Writes:+///   <MPN>-3d-outline-<style>.svg          (5 plain styles)+///   <MPN>-named.step                      (embossed STEP, for KiCad too)+///   <MPN>-3d-iso-named.png                (opaque name-lasered iso)+///   <MPN>-3d-iso-named-icon.png           (transparent name-lasered icon)+///   <MPN>-3d-outline-<style>-named.svg    (5 name-marked styles)+pub fn render_family_extras(chip: &ChipPaths, step: &Path) -> Result<()> {+    let step_bytes = std::fs::read(step).with_context(|| format!("read {}", step.display()))?;+    let orient = chosen_orientation(chip);+    let axis = orient.step2glb_arg(); // "asIs" / "yUp" / ...++    // Plain vector outlines (no text, so rotation is irrelevant).+    write_outlines(chip, &step_bytes, axis, None, "", 180)?;+    // Name-lasered set, two text orientations the user can pick from:+    //   180  → reads right-side-up to the iso camera (the default `-named`)+    //   270  → a right angle to that, for chips taller than wide (`-named90`)+    if write_named(chip, &step_bytes, axis, 180, "named").is_ok() {+        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named", 180);+    }+    if write_named(chip, &step_bytes, axis, 270, "named90").is_ok() {+        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named90", 270);+    }++    // The canonical `<MPN>-3d-iso-icon.png` (no axis suffix) follows the chosen+    // axis, matching chip-fetcher's convention (adom-symbol reads that name).+    if orient != ThreeDOrientation::AsIs {+        let src = chip.three_d_icon_path(orient);+        if src.is_file() {+            let _ = std::fs::copy(&src, chip.three_d_icon_path(ThreeDOrientation::AsIs));+        }+    }+    Ok(())+}++/// Vector outline SVGs (5 styles) via service /outline. `suffix` e.g. "-named".+fn write_outlines(+    chip: &ChipPaths,+    step: &[u8],+    axis: &str,+    name: Option<&str>,+    suffix: &str,+    rot: u32,+) -> Result<()> {+    let mut q = format!("/outline?upAxis={axis}");+    if let Some(n) = name {+        q.push_str(&format!("&name={n}&suffix={suffix}&rot={rot}"));+    }+    let v = service_job_json(&q, step, &format!("outline-{}{}", chip.mpn, suffix))?;+    let outlines = v+        .get("outlines")+        .and_then(|o| o.as_object())+        .ok_or_else(|| anyhow!("/outline result had no outlines"))?;+    // The service already bakes `suffix` into the returned style key+    // (e.g. "blueprint-named"), so the filename must NOT re-append it.+    for (style, svg) in outlines {+        if let Some(s) = svg.as_str() {+            let path = chip+                .dir+                .join(format!("{}-3d-outline-{style}.svg", chip.mpn));+            std::fs::write(&path, s).with_context(|| format!("write {}", path.display()))?;+        }+    }+    Ok(())+}++/// Name-lasered STEP + iso + transparent named-icon via service /name-emboss,+/// at text rotation `rot`. `tag` names the output set ("named" = 180 default,+/// "named90" = the 270 right-angle option). Writes:+///   <MPN>-<tag>.step  <MPN>-3d-iso-<tag>.png  <MPN>-3d-iso-<tag>-icon.png+fn write_named(chip: &ChipPaths, step: &[u8], axis: &str, rot: u32, tag: &str) -> Result<()> {+    use base64::Engine;+    let q = format!("/name-emboss?upAxis={axis}&name={}&rot={rot}", chip.mpn);+    let v = service_job_json(&q, step, &format!("emboss-{}-{tag}", chip.mpn))?;+    let b64 = base64::engine::general_purpose::STANDARD;+    if let Some(s) = v.get("named_step_b64").and_then(|s| s.as_str()) {+        let bytes = b64.decode(s).context("decode named_step_b64")?;+        std::fs::write(chip.dir.join(format!("{}-{tag}.step", chip.mpn)), &bytes)?;+    }+    if let Some(s) = v+        .get("named_iso_png_b64")+        .and_then(|s| s.as_str())+        .filter(|s| !s.is_empty())+    {+        let bytes = b64.decode(s).context("decode named_iso_png_b64")?;+        let iso = chip.dir.join(format!("{}-3d-iso-{tag}.png", chip.mpn));+        std::fs::write(&iso, &bytes)?;+        // Key the opaque named iso to the transparent named icon adom-symbol reads.+        let icon = chip.dir.join(format!("{}-3d-iso-{tag}-icon.png", chip.mpn));+        let _ = write_transparent_icon(&iso, &icon);+    }+    Ok(())+}++/// POST a STEP to a service JSON endpoint (sync 200 or async 202 + job) and+/// return the parsed result JSON.+fn service_job_json(path_and_query: &str, step: &[u8], job_name: &str) -> Result<serde_json::Value> {+    let svc = service_url();+    let url = format!("{svc}{path_and_query}");+    let resp = ureq::post(&url)+        .set("Content-Type", "application/step")+        .set("X-Client", "chip-thumbnailer/chip-fetcher")+        .set("X-Job-Name", job_name)+        .timeout(Duration::from_secs(180))+        .send_bytes(step)+        .map_err(|e| anyhow!("POST {url}: {e}"))?;+    match resp.status() {+        200 => {+            let t = resp.into_string().context("read 200 body")?;+            Ok(serde_json::from_str(&t).context("parse result json")?)+        }+        202 => {+            let t = resp.into_string().context("read 202 body")?;+            let v: serde_json::Value = serde_json::from_str(&t).context("parse 202 json")?;+            let jid = v+                .get("job_id")+                .and_then(|s| s.as_str())+                .ok_or_else(|| anyhow!("202 response had no job_id"))?+                .to_string();+            poll_job_json(&svc, &jid)+        }+        code => Err(anyhow!("service returned HTTP {code}")),+    }+}++fn poll_job_json(svc: &str, jid: &str) -> Result<serde_json::Value> {+    for _ in 0..45 {+        if let Ok(r) = ureq::get(&format!("{svc}/jobs/{jid}"))+            .set("X-Client", "chip-thumbnailer/chip-fetcher")+            .set("X-Job-Name", "poll")+            .timeout(Duration::from_secs(20))+            .call()+        {+            let v: serde_json::Value = r+                .into_string()+                .ok()+                .and_then(|t| serde_json::from_str(&t).ok())+                .unwrap_or(serde_json::Value::Null);+            match v.get("status").and_then(|s| s.as_str()) {+                Some("complete") | Some("done") | Some("succeeded") => {+                    let res = ureq::get(&format!("{svc}/jobs/{jid}/result"))+                        .set("X-Client", "chip-thumbnailer/chip-fetcher")+                        .set("X-Job-Name", "result")+                        .timeout(Duration::from_secs(60))+                        .call()+                        .map_err(|e| anyhow!("GET job result: {e}"))?;+                    let t = res.into_string().context("read job result")?;+                    return Ok(serde_json::from_str(&t).context("parse job result json")?);+                }+                Some("failed") | Some("error") => {+                    let msg = v.get("error").and_then(|s| s.as_str()).unwrap_or("unknown");+                    return Err(anyhow!("service job failed: {msg}"));+                }+                _ => {}+            }+        }+        std::thread::sleep(Duration::from_secs(2));+    }+    Err(anyhow!("service job {jid} timed out"))+}+ /// Back-compat single-size render at the AsIs orientation. pub fn render_size(chip: &ChipPaths, size: ThreeDSize) -> Result<()> {     render_orient_size(chip, ThreeDOrientation::AsIs, size)@@ -79,6 +284,11 @@ pub fn render_orient_size(     let (w, h) = size.dims();     service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn)         .with_context(|| format!("3D render {} ({}/{})", chip.mpn, orient.json_key(), size.label()))?;+    // The transparent icon is keyed from the Md shaded iso (the displayed size).+    if size == ThreeDSize::Md {+        write_transparent_icon(&out, &chip.three_d_icon_path(orient))+            .with_context(|| format!("transparent icon {} ({})", chip.mpn, orient.json_key()))?;+    }     ensure_features(&step, chip);     Ok(()) }@@ -176,6 +386,77 @@ fn poll_and_write(svc: &str, job_id: &str, out: &Path) -> Result<()> {     Err(anyhow!("service job {job_id} timed out")) } +/// Make a transparent-background icon from a shaded iso PNG. The service+/// renders the chip on a uniform slate background; we sample that colour from+/// the four corners (median, robust to a stray pixel) and feather it to+/// transparent by RGB distance, keeping clean anti-aliased chip edges. This is+/// a pure-Rust port of chip-fetcher's `occt-iso.py` (T0=16, T1=44) so the+/// output matches byte-for-byte what shipped before — chip-thumbnailer now owns+/// it instead of chip-fetcher shelling Python+numpy+Pillow.+fn write_transparent_icon(opaque: &Path, out: &Path) -> Result<()> {+    use std::io::BufWriter;++    let decoder = png::Decoder::new(+        std::fs::File::open(opaque).with_context(|| format!("open {}", opaque.display()))?,+    );+    let mut reader = decoder.read_info().context("read png header")?;+    let (width, height) = {+        let info = reader.info();+        (info.width as usize, info.height as usize)+    };+    let mut buf = vec![0u8; reader.output_buffer_size()];+    let frame = reader.next_frame(&mut buf).context("decode png frame")?;+    let bytes = &buf[..frame.buffer_size()];+    let ch = match reader.output_color_type().0 {+        png::ColorType::Rgb => 3usize,+        png::ColorType::Rgba => 4usize,+        other => return Err(anyhow!("unexpected iso PNG color type {other:?}")),+    };++    let rgb = |col: usize, row: usize| -> [f64; 3] {+        let i = (row * width + col) * ch;+        [bytes[i] as f64, bytes[i + 1] as f64, bytes[i + 2] as f64]+    };+    // Background = per-channel median of the four corners (inset 2px).+    let corners = [+        rgb(2, 2),+        rgb(width - 3, 2),+        rgb(2, height - 3),+        rgb(width - 3, height - 3),+    ];+    let median4 = |c: usize| {+        let mut v = [corners[0][c], corners[1][c], corners[2][c], corners[3][c]];+        v.sort_by(|a, b| a.partial_cmp(b).unwrap());+        (v[1] + v[2]) / 2.0+    };+    let bg = [median4(0), median4(1), median4(2)];++    const T0: f64 = 16.0;+    const T1: f64 = 44.0;+    let mut rgba = vec![0u8; width * height * 4];+    for row in 0..height {+        for col in 0..width {+            let p = rgb(col, row);+            let dist =+                ((p[0] - bg[0]).powi(2) + (p[1] - bg[1]).powi(2) + (p[2] - bg[2]).powi(2)).sqrt();+            let alpha = (((dist - T0) / (T1 - T0)).clamp(0.0, 1.0) * 255.0).round() as u8;+            let o = (row * width + col) * 4;+            rgba[o] = p[0] as u8;+            rgba[o + 1] = p[1] as u8;+            rgba[o + 2] = p[2] as u8;+            rgba[o + 3] = alpha;+        }+    }++    let file = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;+    let mut enc = png::Encoder::new(BufWriter::new(file), width as u32, height as u32);+    enc.set_color(png::ColorType::Rgba);+    enc.set_depth(png::BitDepth::Eight);+    let mut writer = enc.write_header().context("write png header")?;+    writer.write_image_data(&rgba).context("write png data")?;+    Ok(())+}+ fn write_reader(resp: ureq::Response, out: &Path) -> Result<()> {     let mut reader = resp.into_reader();     let mut f = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;
src/serve.rsadded+852
@@ -0,0 +1,852 @@+//! chip-thumbnailer web app: a Hydrogen-webview dashboard that takes a STEP+//! file and renders every icon (symbol, footprint, and all six 3D face-up+//! orientations at sm/md/lg) in real-time. The UI lays out a dashed-box+//! placeholder for each upcoming icon and pops the rendered thumbnail into+//! its box the moment it lands, with a per-icon caption: file name, format,+//! pixel dimensions, KB, and whether it carries real alpha transparency.+//!+//! Follows the Adom app-creator conventions: server is the single source of+//! truth (the UI only polls `GET /api/state` and POSTs commands), every+//! action is curl-drivable, relative URLs throughout, brand-compliant UI,+//! monochrome favicon, console forwarding, graceful `/shutdown`.++use crate::library::{self, ChipPaths, ThreeDOrientation, ThreeDSize};+use crate::render_3d;+use anyhow::{anyhow, Context, Result};+use std::io::Read;+use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::Mutex;+use tiny_http::{Header, Method, Response, Server};++const INDEX_HTML: &str = include_str!("ui/app.html");+const FAVICON_SVG: &str = include_str!("ui/icon.svg");++// Monotonic generation counter. Each `generate` / `regenerate` bumps it; the+// background render thread checks its captured generation before every state+// write and bails the instant a newer run supersedes it (so a Regenerate+// click never lets a stale thread clobber the fresh grid).+static GEN: AtomicU64 = AtomicU64::new(0);++static STATE: Mutex<Option<Session>> = Mutex::new(None);++// Bounded ring buffer of forwarded browser console lines (app-creator §8).+static CONSOLE: Mutex<Vec<String>> = Mutex::new(Vec::new());++#[derive(Clone)]+struct Session {+    generation: u64,+    name: String,+    source: &'static str, // "upload" | "library"+    dir: PathBuf,+    started: u64,+    running: bool,+    slots: Vec<Slot>,+}++#[derive(Clone)]+struct Slot {+    id: String,+    kind: &'static str, // "symbol" | "footprint" | "3d"+    label: String,      // short caption shown under the box+    group: String,      // group header the box sits under+    status: String,     // pending | rendering | done | failed | skipped+    file: Option<String>,+    format: Option<String>,+    width: Option<u32>,+    height: Option<u32>,+    size_bytes: Option<u64>,+    transparent: Option<bool>,+    light_tile: Option<bool>,+    error: Option<String>,+}++/// What the render thread actually does for one slot. chip-thumbnailer renders+/// the chip body (3D orientations); the transparent icon is keyed as a side+/// effect of the Md render, so it has no Job of its own.+#[derive(Clone)]+enum Job {+    ThreeD(ThreeDOrientation, ThreeDSize),+}++struct Plan {+    id: String,+    job: Job,+    out: PathBuf,+}++fn now_epoch() -> u64 {+    std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_secs())+        .unwrap_or(0)+}++// ────────────────────────────────────────────────────────────────────────+// HTTP server+// ────────────────────────────────────────────────────────────────────────++pub fn run(port: u16) -> Result<()> {+    let addr = format!("0.0.0.0:{port}");+    let server = Server::http(&addr).map_err(|e| anyhow!("bind {addr}: {e}"))?;+    println!("adom-chip-thumbnailer web app listening on http://{addr}");+    println!("open it in Hydrogen: adom-chip-thumbnailer app --port {port}");+    println!("or visit: {}", proxy_url_for_port(port));++    for mut request in server.incoming_requests() {+        // Strip a Coder proxy prefix (e.g. /proxy/8794/) so routes match+        // whether we're hit directly or through the wiki proxy.+        let raw_url = request.url().to_string();+        let url = strip_proxy_prefix(&raw_url);+        let path = url.split('?').next().unwrap_or("").to_string();+        let method = request.method().clone();++        let response = match (&method, path.as_str()) {+            (Method::Get, "/") | (Method::Get, "/index.html") => html_response(INDEX_HTML),+            (Method::Get, "/favicon.svg") | (Method::Get, "/favicon.ico") => Response::from_string(+                FAVICON_SVG.to_string(),+            )+            .with_header(hdr("Content-Type", "image/svg+xml")),++            (Method::Get, "/api/state") => json_response(state_json()),+            (Method::Get, "/api/library") => json_response(library_json()),++            (Method::Post, "/api/generate") => {+                let filename = header_value(&request, "X-Filename");+                let ctype = header_value(&request, "Content-Type").unwrap_or_default();+                let mut body: Vec<u8> = Vec::new();+                let _ = request.as_reader().read_to_end(&mut body);+                handle_generate(ctype, filename, body)+            }+            (Method::Post, "/api/regenerate") => handle_regenerate(),++            (Method::Get, p) if p.starts_with("/thumb/") => serve_thumb(&p["/thumb/".len()..]),++            (Method::Post, "/console") => {+                let mut body = String::new();+                let _ = request.as_reader().read_to_string(&mut body);+                push_console(&body);+                json_response("{\"ok\":true}".into())+            }+            (Method::Get, "/console") => json_response(console_json()),++            (Method::Post, "/shutdown") => {+                let _ = json_response("{\"ok\":true}".into());+                println!("shutdown requested — exiting");+                let _ = request.respond(json_response("{\"ok\":true}".into()));+                std::process::exit(0);+            }++            _ => not_found(),+        };++        let _ = request.respond(response);+    }+    Ok(())+}++// ────────────────────────────────────────────────────────────────────────+// command handlers+// ────────────────────────────────────────────────────────────────────────++fn handle_generate(+    ctype: String,+    filename: Option<String>,+    body: Vec<u8>,+) -> Response<std::io::Cursor<Vec<u8>>> {+    // JSON body => library pick: {"mpn":"..."}.+    if ctype.contains("application/json") {+        let v: serde_json::Value = match serde_json::from_slice(&body) {+            Ok(v) => v,+            Err(e) => return error_response(400, format!("{{\"error\":\"bad json: {e}\"}}")),+        };+        let mpn = v.get("mpn").and_then(|s| s.as_str()).unwrap_or("");+        if mpn.is_empty() {+            return error_response(400, "{\"error\":\"missing mpn\"}".into());+        }+        return match ChipPaths::for_mpn(mpn) {+            Ok(chip) => {+                let dir = chip.dir.clone();+                start_generation("library", mpn.to_string(), dir, chip);+                json_response(state_json())+            }+            Err(e) => error_response(404, format!("{{\"error\":\"{e}\"}}")),+        };+    }++    // Otherwise: a raw STEP upload.+    if body.is_empty() {+        return error_response(400, "{\"error\":\"empty body — POST a STEP file\"}".into());+    }+    if !looks_like_step(&body) {+        return error_response(+            400,+            "{\"error\":\"that does not look like a STEP file (no ISO-10303-21 header)\"}".into(),+        );+    }+    let stem = sanitize_stem(filename.as_deref().unwrap_or("upload"));+    let dir = sessions_root().join(&stem);+    if let Err(e) = std::fs::create_dir_all(&dir) {+        return error_response(500, format!("{{\"error\":\"mkdir: {e}\"}}"));+    }+    // Clear any stale thumbnails from a previous run of the same file.+    if let Ok(entries) = std::fs::read_dir(&dir) {+        for e in entries.flatten() {+            let p = e.path();+            let keep = p+                .file_name()+                .and_then(|n| n.to_str())+                .map(|n| n.ends_with(".step"))+                .unwrap_or(false);+            if !keep {+                let _ = std::fs::remove_file(&p);+            }+        }+    }+    let step_path = dir.join(format!("{stem}.step"));+    if let Err(e) = std::fs::write(&step_path, &body) {+        return error_response(500, format!("{{\"error\":\"write step: {e}\"}}"));+    }+    let chip = ChipPaths::for_dir(dir.clone(), &stem);+    start_generation("upload", stem, dir, chip);+    json_response(state_json())+}++fn handle_regenerate() -> Response<std::io::Cursor<Vec<u8>>> {+    let (name, dir) = {+        let g = STATE.lock().unwrap();+        match g.as_ref() {+            Some(s) => (s.name.clone(), s.dir.clone()),+            None => return error_response(409, "{\"error\":\"nothing to regenerate yet\"}".into()),+        }+    };+    // ChipPaths::for_dir works for both upload + library dirs (same layout).+    let chip = ChipPaths::for_dir(dir.clone(), &name);+    let source = if dir.starts_with(library::root()) {+        "library"+    } else {+        "upload"+    };+    start_generation(source, name, dir, chip);+    json_response(state_json())+}++// ────────────────────────────────────────────────────────────────────────+// generation: build slots, spawn the render thread+// ────────────────────────────────────────────────────────────────────────++fn start_generation(source: &'static str, name: String, dir: PathBuf, chip: ChipPaths) {+    let generation = GEN.fetch_add(1, Ordering::SeqCst) + 1;+    let (slots, plans) = build_slots_and_plans(&chip);++    {+        let mut g = STATE.lock().unwrap();+        *g = Some(Session {+            generation,+            name,+            source,+            dir,+            started: now_epoch(),+            running: !plans.is_empty(),+            slots,+        });+    }++    if plans.is_empty() {+        return;+    }+    std::thread::spawn(move || render_loop(generation, chip, plans));+}++/// Build the display slots (the whole chip-icon family) and the render plan.+/// chip-thumbnailer renders the chip body, so every slot here is a CHIP icon:+/// the shaded 3D orientations and their transparent alpha-keyed icons. The+/// name-lasered + vector-outline variants (produced by service-step2glb) are+/// shown when they already exist in the chip dir.+fn build_slots_and_plans(chip: &ChipPaths) -> (Vec<Slot>, Vec<Plan>) {+    let mut slots = Vec::new();+    let mut plans = Vec::new();+    let have_step = chip.step.is_some();++    // 3D: six face-up orientations. Under each orientation: the transparent+    // icon (the headline asset adom-symbol consumes), then the shaded md / sm /+    // lg renders. Rendered md-first so the icon + canonical view fill first.+    for orient in ThreeDOrientation::all() {+        let group = format!("Chip {}", short_orient(orient));++        // Transparent icon (keyed from the Md shaded render; no own job).+        let icon_id = format!("icon-{}", orient.json_key());+        if have_step {+            slots.push(Slot {+                group: group.clone(),+                ..pending_slot(&icon_id, "icon", &group, "transparent icon")+            });+        } else {+            slots.push(skipped_slot(&icon_id, "icon", &group, "transparent icon", "no .step in the source"));+        }++        for size in [ThreeDSize::Md, ThreeDSize::Sm, ThreeDSize::Lg] {+            let id = format!("3d-{}-{}", orient.json_key(), size.label());+            let label = format!("shaded · {}", size.label());+            let out = chip.three_d_thumb_path(orient, size);+            if have_step {+                slots.push(Slot {+                    group: group.clone(),+                    ..pending_slot(&id, "3d", &group, &label)+                });+                plans.push(Plan { id, job: Job::ThreeD(orient, size), out });+            } else {+                slots.push(skipped_slot(&id, "3d", &group, &label, "no .step in the source"));+            }+        }+    }++    // Display any name-lasered + vector-outline icons already produced for this+    // chip by service-step2glb (read-only tiles, with provenance).+    push_existing_assets(chip, &mut slots);++    (slots, plans)+}++/// Add read-only display slots for name-lasered + vector-outline icons that+/// exist in the chip dir (produced by service-step2glb's emboss/HLR pass).+fn push_existing_assets(chip: &ChipPaths, slots: &mut Vec<Slot>) {+    let named = [+        ("named-icon", format!("{}-3d-iso-named-icon.png", chip.mpn), "name-lasered icon (180)"),+        ("named", format!("{}-3d-iso-named.png", chip.mpn), "name-lasered (180, opaque)"),+        ("named90-icon", format!("{}-3d-iso-named90-icon.png", chip.mpn), "name-lasered icon (90)"),+        ("named90", format!("{}-3d-iso-named90.png", chip.mpn), "name-lasered (90, opaque)"),+    ];+    for (id, file, label) in named {+        if chip.dir.join(&file).is_file() {+            let mut s = pending_slot(id, "named", "Name-lasered", label);+            s.status = "done".into();+            s.file = Some(file.clone());+            if let Some(m) = inspect(&chip.dir.join(&file)) {+                s.format = Some(m.format); s.width = Some(m.width); s.height = Some(m.height);+                s.size_bytes = Some(m.size_bytes); s.transparent = Some(m.transparent); s.light_tile = Some(m.light_tile);+            }+            slots.push(s);+        }+    }+    // Vector outlines: <mpn>-3d-outline-<style>[-named].svg+    let mut outlines: Vec<String> = Vec::new();+    if let Ok(rd) = std::fs::read_dir(&chip.dir) {+        let prefix = format!("{}-3d-outline-", chip.mpn);+        for e in rd.flatten() {+            let name = e.file_name().to_string_lossy().to_string();+            if name.starts_with(&prefix) && name.ends_with(".svg") {+                outlines.push(name);+            }+        }+    }+    outlines.sort();+    for file in outlines {+        let style = file+            .trim_start_matches(&format!("{}-3d-outline-", chip.mpn))+            .trim_end_matches(".svg")+            .to_string();+        let mut s = pending_slot(&format!("outline-{style}"), "outline", "Vector outlines", &style);+        s.status = "done".into();+        s.file = Some(file.clone());+        if let Some(m) = inspect(&chip.dir.join(&file)) {+            s.format = Some(m.format); s.width = Some(m.width); s.height = Some(m.height);+            s.size_bytes = Some(m.size_bytes); s.transparent = Some(m.transparent); s.light_tile = Some(m.light_tile);+        }+        slots.push(s);+    }+}++fn render_loop(generation: u64, chip: ChipPaths, plans: Vec<Plan>) {+    for plan in plans {+        // Bail if a newer generation superseded us.+        if !is_current(generation) {+            return;+        }+        if !set_status(generation, &plan.id, "rendering") {+            return;+        }++        let Job::ThreeD(o, s) = plan.job;+        let result = render_3d::render_orient_size(&chip, o, s);++        if !is_current(generation) {+            return;+        }++        match result {+            Ok(_) => {+                let meta = inspect(&plan.out);+                finish_slot(generation, &plan.id, &plan.out, meta);+                // The Md render also keys the transparent icon — finish its slot.+                if s == ThreeDSize::Md {+                    let icon_out = chip.three_d_icon_path(o);+                    let icon_meta = inspect(&icon_out);+                    finish_slot(generation, &format!("icon-{}", o.json_key()), &icon_out, icon_meta);+                }+            }+            Err(e) => {+                fail_slot(generation, &plan.id, format!("{e}"));+                if s == ThreeDSize::Md {+                    fail_slot(generation, &format!("icon-{}", o.json_key()), "shaded render failed".into());+                }+            }+        }+    }++    // Generate the vector-outline + name-lasered family (service OCCT), then+    // surface the produced files as new slots so they appear in the grid.+    if is_current(generation) {+        if let Some(step) = chip.step.as_ref() {+            let _ = render_3d::render_family_extras(&chip, step);+            if is_current(generation) {+                append_family_slots(generation, &chip);+            }+        }+    }++    // Mark the session done.+    let mut g = STATE.lock().unwrap();+    if let Some(s) = g.as_mut() {+        if s.generation == generation {+            s.running = false;+        }+    }+}++/// After the family extras are generated, append display slots for the new+/// outline / name-lasered files (skipping ids already present).+fn append_family_slots(generation: u64, chip: &ChipPaths) {+    let mut extra: Vec<Slot> = Vec::new();+    push_existing_assets(chip, &mut extra);+    let mut g = STATE.lock().unwrap();+    let Some(s) = g.as_mut() else { return };+    if s.generation != generation {+        return;+    }+    let have: std::collections::HashSet<String> = s.slots.iter().map(|x| x.id.clone()).collect();+    for slot in extra {+        if !have.contains(&slot.id) {+            s.slots.push(slot);+        }+    }+}++// ── slot helpers ────────────────────────────────────────────────────────++fn pending_slot(id: &str, kind: &'static str, group: &str, label: &str) -> Slot {+    Slot {+        id: id.into(),+        kind,+        label: label.into(),+        group: group.into(),+        status: "pending".into(),+        file: None,+        format: None,+        width: None,+        height: None,+        size_bytes: None,+        transparent: None,+        light_tile: None,+        error: None,+    }+}++fn skipped_slot(id: &str, kind: &'static str, group: &str, label: &str, why: &str) -> Slot {+    Slot {+        status: "skipped".into(),+        error: Some(why.into()),+        ..pending_slot(id, kind, group, label)+    }+}++fn is_current(generation: u64) -> bool {+    STATE+        .lock()+        .unwrap()+        .as_ref()+        .map(|s| s.generation == generation)+        .unwrap_or(false)+}++fn set_status(generation: u64, id: &str, status: &str) -> bool {+    let mut g = STATE.lock().unwrap();+    let Some(s) = g.as_mut() else { return false };+    if s.generation != generation {+        return false;+    }+    if let Some(slot) = s.slots.iter_mut().find(|x| x.id == id) {+        slot.status = status.into();+    }+    true+}++fn finish_slot(generation: u64, id: &str, out: &Path, meta: Option<ImgMeta>) {+    let mut g = STATE.lock().unwrap();+    let Some(s) = g.as_mut() else { return };+    if s.generation != generation {+        return;+    }+    if let Some(slot) = s.slots.iter_mut().find(|x| x.id == id) {+        slot.status = "done".into();+        slot.file = out+            .file_name()+            .map(|n| n.to_string_lossy().to_string());+        if let Some(m) = meta {+            slot.format = Some(m.format);+            slot.width = Some(m.width);+            slot.height = Some(m.height);+            slot.size_bytes = Some(m.size_bytes);+            slot.transparent = Some(m.transparent);+            slot.light_tile = Some(m.light_tile);+        }+    }+}++fn fail_slot(generation: u64, id: &str, err: String) {+    let mut g = STATE.lock().unwrap();+    let Some(s) = g.as_mut() else { return };+    if s.generation != generation {+        return;+    }+    if let Some(slot) = s.slots.iter_mut().find(|x| x.id == id) {+        slot.status = "failed".into();+        slot.error = Some(err);+    }+}++// ────────────────────────────────────────────────────────────────────────+// image introspection — real dims + true alpha transparency+// ────────────────────────────────────────────────────────────────────────++struct ImgMeta {+    format: String,+    width: u32,+    height: u32,+    size_bytes: u64,+    transparent: bool,+    // Vector outlines drawn in the dark theme color (#0d1117 lines, the+    // "ink on paper" styles thin/bold) are invisible on a dark tile, so the+    // UI puts them on a light tile instead.+    light_tile: bool,+}++fn inspect(path: &Path) -> Option<ImgMeta> {+    let size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);+    let ext = path+        .extension()+        .and_then(|e| e.to_str())+        .unwrap_or("")+        .to_ascii_lowercase();++    if ext == "svg" {+        let txt = std::fs::read_to_string(path).ok()?;+        let (width, height) = svg_dims(&txt);+        // An SVG is opaque only if it paints a full-canvas background rect; the+        // vector outlines paint no background, so they are transparent. (The+        // old heuristic wrongly keyed on the presence of #0d1117 anywhere,+        // which mislabeled the dark-LINE styles as opaque.)+        let transparent = !has_full_canvas_rect(&txt, width, height);+        // Dark-line styles (thin/bold draw strokes in #0d1117 / #191919) are+        // invisible on a dark tile — flag them so the UI shows a light tile.+        let light_tile = transparent+            && (txt.contains("stroke=\"#0d1117\"") || txt.contains("stroke=\"#191919\""));+        return Some(ImgMeta {+            format: "SVG".into(),+            width,+            height,+            size_bytes,+            transparent,+            light_tile,+        });+    }++    // PNG: decode the header for true dimensions, then scan the alpha+    // channel for any sub-255 pixel so "transparent" reflects reality, not+    // just "has an alpha channel".+    let file = std::fs::File::open(path).ok()?;+    let decoder = png::Decoder::new(file);+    let mut reader = decoder.read_info().ok()?;+    let (width, height, has_trns) = {+        let info = reader.info();+        (info.width, info.height, info.trns.is_some())+    };+    let mut buf = vec![0u8; reader.output_buffer_size()];+    let frame = reader.next_frame(&mut buf).ok()?;+    let bytes = &buf[..frame.buffer_size()];+    let (out_color, _bd) = reader.output_color_type();+    let transparent = match out_color {+        png::ColorType::Rgba => bytes.chunks_exact(4).any(|p| p[3] < 255),+        png::ColorType::GrayscaleAlpha => bytes.chunks_exact(2).any(|p| p[1] < 255),+        _ => has_trns,+    };+    Some(ImgMeta {+        format: "PNG".into(),+        width,+        height,+        size_bytes,+        transparent,+        light_tile: false,+    })+}++/// True if the SVG paints a rectangle covering the whole canvas (an opaque+/// background). Used to tell a baked-background SVG from a bare line drawing.+fn has_full_canvas_rect(txt: &str, w: u32, h: u32) -> bool {+    // Look for a <rect> sized to 100% or the exact viewBox dims with a fill.+    let wh = format!("width=\"{w}\"");+    let hh = format!("height=\"{h}\"");+    txt.contains("<rect")+        && (txt.contains("width=\"100%\"")+            || (txt.contains(&wh) && txt.contains(&hh)))+        && txt.contains("fill=\"#")+}++fn svg_dims(txt: &str) -> (u32, u32) {+    // Prefer viewBox; fall back to width/height attributes.+    if let Some(c) = regex_once(r#"viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)"#, txt)+    {+        return (c.0.round() as u32, c.1.round() as u32);+    }+    let w = regex_one(r#"width\s*=\s*["']([\d.]+)"#, txt).unwrap_or(0.0);+    let h = regex_one(r#"height\s*=\s*["']([\d.]+)"#, txt).unwrap_or(0.0);+    (w.round() as u32, h.round() as u32)+}++fn regex_once(pat: &str, txt: &str) -> Option<(f64, f64)> {+    let re = regex::Regex::new(pat).ok()?;+    let c = re.captures(txt)?;+    Some((c.get(1)?.as_str().parse().ok()?, c.get(2)?.as_str().parse().ok()?))+}+fn regex_one(pat: &str, txt: &str) -> Option<f64> {+    let re = regex::Regex::new(pat).ok()?;+    let c = re.captures(txt)?;+    c.get(1)?.as_str().parse().ok()+}++// ────────────────────────────────────────────────────────────────────────+// JSON serialization (hand-rolled so we don't have to derive Serialize on+// the state types, which carry non-serde enums)+// ────────────────────────────────────────────────────────────────────────++fn state_json() -> String {+    let g = STATE.lock().unwrap();+    match g.as_ref() {+        None => "{\"hasSession\":false}".into(),+        Some(s) => {+            let slots: Vec<serde_json::Value> = s+                .slots+                .iter()+                .map(|x| {+                    serde_json::json!({+                        "id": x.id,+                        "kind": x.kind,+                        "label": x.label,+                        "group": x.group,+                        "status": x.status,+                        "file": x.file,+                        "format": x.format,+                        "width": x.width,+                        "height": x.height,+                        "sizeBytes": x.size_bytes,+                        "transparent": x.transparent,+                        "lightTile": x.light_tile,+                        "error": x.error,+                    })+                })+                .collect();+            serde_json::json!({+                "hasSession": true,+                "name": s.name,+                "source": s.source,+                "running": s.running,+                "started": s.started,+                "total": s.slots.len(),+                "done": s.slots.iter().filter(|x| x.status == "done").count(),+                "failed": s.slots.iter().filter(|x| x.status == "failed").count(),+                "slots": slots,+            })+            .to_string()+        }+    }+}++fn library_json() -> String {+    let mut entries = Vec::new();+    if let Ok(mpns) = library::list_mpns() {+        for mpn in mpns {+            if let Ok(chip) = ChipPaths::for_mpn(&mpn) {+                if chip.step.is_some() {+                    entries.push(serde_json::json!({+                        "mpn": mpn,+                        "hasSym": chip.kicad_sym.is_some(),+                        "hasMod": chip.kicad_mod.is_some(),+                    }));+                }+            }+        }+    }+    serde_json::json!({ "chips": entries }).to_string()+}++// ────────────────────────────────────────────────────────────────────────+// thumb serving + small helpers+// ────────────────────────────────────────────────────────────────────────++fn serve_thumb(name: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    // Defend against path traversal — basenames only.+    if name.is_empty() || name.contains('/') || name.contains("..") {+        return not_found();+    }+    let dir = {+        let g = STATE.lock().unwrap();+        match g.as_ref() {+            Some(s) => s.dir.clone(),+            None => return not_found(),+        }+    };+    let path = dir.join(name);+    let ct = match path.extension().and_then(|e| e.to_str()) {+        Some("svg") => "image/svg+xml",+        Some("png") => "image/png",+        _ => "application/octet-stream",+    };+    match std::fs::read(&path) {+        Ok(bytes) => Response::from_data(bytes)+            .with_header(hdr("Content-Type", ct))+            .with_header(hdr("Cache-Control", "no-store")),+        Err(_) => not_found(),+    }+}++fn looks_like_step(body: &[u8]) -> bool {+    let head_len = body.len().min(512);+    let head = String::from_utf8_lossy(&body[..head_len]);+    head.contains("ISO-10303-21") || head.contains("ISO-10303")+}++fn sanitize_stem(name: &str) -> String {+    let stem = Path::new(name)+        .file_stem()+        .and_then(|s| s.to_str())+        .unwrap_or("upload");+    let cleaned: String = stem+        .chars()+        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })+        .collect();+    let cleaned = cleaned.trim_matches('_').to_string();+    if cleaned.is_empty() {+        "upload".into()+    } else {+        cleaned+    }+}++fn short_orient(o: ThreeDOrientation) -> &'static str {+    match o {+        ThreeDOrientation::AsIs => "as-is",+        ThreeDOrientation::ZDown => "Z-down",+        ThreeDOrientation::YUp => "Y-up",+        ThreeDOrientation::YDown => "Y-down",+        ThreeDOrientation::XUp => "X-up",+        ThreeDOrientation::XDown => "X-down",+    }+}++fn sessions_root() -> PathBuf {+    if let Ok(p) = std::env::var("CHIP_THUMBNAILER_SESSIONS") {+        return PathBuf::from(p);+    }+    std::env::temp_dir().join("chip-thumbnailer-sessions")+}++fn push_console(line: &str) {+    let mut c = CONSOLE.lock().unwrap();+    c.push(line.to_string());+    let len = c.len();+    if len > 500 {+        c.drain(0..len - 500);+    }+}+fn console_json() -> String {+    let c = CONSOLE.lock().unwrap();+    serde_json::json!({ "lines": *c }).to_string()+}++fn header_value(req: &tiny_http::Request, field: &str) -> Option<String> {+    req.headers()+        .iter()+        .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(field))+        .map(|h| h.value.as_str().to_string())+}++fn strip_proxy_prefix(url: &str) -> String {+    // Turn "/proxy/8794/api/state" into "/api/state".+    if let Some(rest) = url.strip_prefix("/proxy/") {+        if let Some(slash) = rest.find('/') {+            return rest[slash..].to_string();+        }+    }+    url.to_string()+}++fn hdr(name: &str, value: &str) -> Header {+    Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()+}+fn html_response(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body.to_string())+        .with_header(hdr("Content-Type", "text/html; charset=utf-8"))+}+fn json_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body).with_header(hdr("Content-Type", "application/json"))+}+fn error_response(code: u16, body: String) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body)+        .with_status_code(code)+        .with_header(hdr("Content-Type", "application/json"))+}+fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string("not found")+        .with_status_code(404)+        .with_header(hdr("Content-Type", "text/plain"))+}++// ────────────────────────────────────────────────────────────────────────+// proxy URL + Hydrogen tab open+// ────────────────────────────────────────────────────────────────────────++fn proxy_url_for_port(port: u16) -> String {+    if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {+        return format!("https://{host}/proxy/{port}/");+    }+    if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {+        return format!("https://{slug}.adom.cloud/proxy/{port}/");+    }+    if let Ok(tpl) = std::env::var("VSCODE_PROXY_URI") {+        if tpl.contains("{{port}}") {+            return tpl.replace("{{port}}", &port.to_string());+        }+    }+    format!("http://127.0.0.1:{port}/")+}++pub fn open_in_hydrogen(port: u16) -> Result<()> {+    let url = proxy_url_for_port(port);+    println!("opening {url} in Hydrogen webview");+    let status = std::process::Command::new("adom-cli")+        .args([+            "hydrogen",+            "webview",+            "open-or-refresh",+            "--url",+            &url,+            "--name",+            "Chip Thumbnailer",+        ])+        .status()+        .context("running adom-cli hydrogen webview open-or-refresh")?;+    if !status.success() {+        anyhow::bail!("adom-cli webview open returned non-zero exit");+    }+    Ok(())+}
src/ui/app.htmladded+555
@@ -0,0 +1,555 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8" />+<meta name="viewport" content="width=device-width, initial-scale=1" />+<title>Chip Thumbnailer</title>+<link rel="icon" href="favicon.svg" type="image/svg+xml" />+<style>+  /* Brand fonts: per-weight WOFF2 from adom.inc (the `/fonts/<name>.css`+     aggregate paths 404, so @font-face the real woff2 files directly). */+  @font-face { font-family: "Familjen Grotesk";+    src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2") format("woff2");+    font-weight: 700; font-display: swap; }+  @font-face { font-family: "Familjen Grotesk";+    src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2") format("woff2");+    font-weight: 500; font-display: swap; }+  @font-face { font-family: "Satoshi";+    src: url("https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2") format("woff2");+    font-weight: 400; font-display: swap; }+  @font-face { font-family: "Satoshi";+    src: url("https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2") format("woff2");+    font-weight: 500; font-display: swap; }+  @font-face { font-family: "JetBrains Mono";+    src: url("https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2") format("woff2");+    font-weight: 400; font-display: swap; }++  :root {+    --bg: #0d1117;+    --panel: #161b22;+    --panel-2: #1c2230;+    --line: #283040;+    --line-soft: #222a37;+    --text: #e6edf3;+    --muted: #8b949e;+    --muted-2: #6e7886;+    --accent: #00b8b0;+    --accent-bright: #00e6dc;+    --green: #3fb950;+    --red: #f85149;+    --amber: #e3b341;+  }+  * { box-sizing: border-box; }+  html, body { margin: 0; height: 100%; }+  body {+    background:+      radial-gradient(1100px 700px at 88% -12%, rgba(0,184,176,.10), transparent 60%),+      radial-gradient(900px 700px at -8% 112%, rgba(140,107,247,.08), transparent 55%),+      var(--bg);+    color: var(--text);+    font-family: "Satoshi", system-ui, sans-serif;+    font-size: 14px;+    -webkit-font-smoothing: antialiased;+  }+  a { color: var(--accent-bright); }++  /* canonical single-row Adom app header (adom-app-header skill, 44px) */+  .app-header { display: flex; align-items: center; gap: 12px; height: 44px;+    padding: 0 16px; border-bottom: 1px solid var(--line-soft); }+  .app-header .brand { display: flex; align-items: center; gap: 8px; cursor: default;+    font-family: "Familjen Grotesk", sans-serif; font-weight: 600; outline: none; }+  .app-header .brand .logo { width: 16px; height: 16px; color: var(--accent); flex: none; }+  .app-header .brand .app-name { color: var(--accent-bright); font-size: 14px; }+  .app-header .brand .mid { color: var(--muted-2); }+  .app-header .brand .sub { color: var(--muted); font-size: 13px; font-weight: 500; }+  .vsep { width: 1px; height: 20px; background: var(--line); flex: none; }+  .subject { display: flex; align-items: baseline; gap: 8px; min-width: 0; }+  .subject-name { color: var(--text); font-weight: 600; font-size: 13.5px;+    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42vw; }+  .subject-meta { color: var(--muted); font-family: "JetBrains Mono", monospace; font-size: 11.5px; flex: none; }+  .app-header .actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }+  .icon-btn { display: inline-flex; align-items: center; justify-content: center;+    width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 8px;+    background: var(--panel-2); color: var(--text); cursor: pointer; transition: all .15s ease; }+  .icon-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-bright); }+  .icon-btn:disabled { opacity: .4; cursor: not-allowed; }+  .icon-btn svg { width: 16px; height: 16px; }++  /* app-info popover (hover the brand block) */+  #popover { position: fixed; z-index: 99999; min-width: 240px; max-width: 300px;+    background: #0a0e14; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;+    box-shadow: 0 16px 50px rgba(0,0,0,.55); opacity: 0; pointer-events: none;+    transition: opacity .18s ease; font-size: 12.5px; line-height: 1.6; }+  #popover.show { opacity: 1; pointer-events: auto; }+  #popover .pv-title { font-family: "Familjen Grotesk", sans-serif; font-weight: 600; color: var(--text); }+  #popover .pv-desc { color: var(--muted); margin: 4px 0 9px; }+  #popover .pv-row { color: var(--muted); display: flex; justify-content: space-between; gap: 14px; }++  .wrap { padding: 16px 22px 60px; max-width: 94rem; margin: 0 auto; }+  .prov { color: var(--muted-2); font-size: 11.5px; font-style: italic; margin: 0 2px 14px; }++  /* toolbar */+  .toolbar {+    display: flex; flex-wrap: wrap; align-items: center; gap: 10px;+    padding: 14px 16px; margin-bottom: 18px;+    background: var(--panel); border: 1px solid var(--line); border-radius: 12px;+  }+  .btn {+    display: inline-flex; align-items: center; gap: 8px;+    font-family: "Satoshi", sans-serif; font-weight: 500; font-size: 13.5px;+    color: var(--text); background: var(--panel-2);+    border: 1px solid var(--line); border-radius: 8px;+    padding: 9px 14px; cursor: pointer; transition: all .15s ease;+  }+  .btn:hover { border-color: var(--accent); background: #20283a; }+  .btn:disabled { opacity: .4; cursor: not-allowed; }+  .btn.primary { background: rgba(0,184,176,.14); border-color: rgba(0,184,176,.5); color: var(--accent-bright); }+  .btn.primary:hover { background: rgba(0,184,176,.22); }+  .btn svg { width: 16px; height: 16px; }+  select.btn { appearance: none; padding-right: 30px;+    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%238b949e' stroke-width='2'><path d='M2 4l4 4 4-4'/></svg>");+    background-repeat: no-repeat; background-position: right 11px center; }+  .spacer { flex: 1 1 auto; }++  .progress { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; }+  .bar { width: 150px; height: 6px; border-radius: 4px; background: var(--panel-2); overflow: hidden; }+  .bar > i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--accent), var(--accent-bright)); transition: width .35s ease; }+  .mono { font-family: "JetBrains Mono", monospace; }++  /* dropzone overlay */+  #drop { position: fixed; inset: 0; z-index: 50; display: none;+    background: rgba(5,8,12,.82); backdrop-filter: blur(3px);+    align-items: center; justify-content: center; }+  #drop.show { display: flex; }+  #drop .card {+    border: 2px dashed var(--accent); border-radius: 18px;+    padding: 60px 90px; text-align: center; color: var(--accent-bright);+    font-family: "Familjen Grotesk", sans-serif; font-size: 22px; font-weight: 700;+    background: rgba(0,184,176,.06);+  }++  /* empty state */+  .empty { text-align: center; color: var(--muted); padding: 70px 20px; }+  .empty .big { font-family: "Familjen Grotesk", sans-serif; font-weight: 700; font-size: 19px; color: var(--text); margin-bottom: 8px; }++  /* groups + grid */+  .group { margin: 22px 0 6px; }+  .group h3 {+    font-family: "Familjen Grotesk", sans-serif; font-weight: 700;+    font-size: 13px; letter-spacing: .4px; text-transform: none;+    color: var(--muted); margin: 0 0 12px; display: flex; align-items: center; gap: 9px;+    cursor: pointer; user-select: none;+  }+  .group h3:hover { color: var(--text); }+  .group h3 .chev { width: 12px; height: 12px; flex: none; transition: transform .15s ease; color: var(--muted-2); }+  .group.collapsed h3 .chev { transform: rotate(-90deg); }+  .group h3 .gcount { color: var(--muted-2); font-weight: 500; }+  .group h3 .rule { flex: 1; height: 1px; background: var(--line-soft); }+  .group.collapsed .grid { display: none; }++  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); gap: 16px; }++  .cell { display: flex; flex-direction: column; gap: 8px; }+  .box {+    position: relative; aspect-ratio: 4 / 3; border-radius: 11px;+    display: flex; align-items: center; justify-content: center;+    overflow: hidden;+  }+  /* pending = dashed placeholder */+  .box.pending { border: 1.6px dashed var(--line); background: rgba(255,255,255,.012); }+  .box.rendering { border: 1.6px dashed var(--accent); background: rgba(0,184,176,.05); }+  .box.done { border: 1px solid var(--line); background: #0a0e14; }+  .box.failed { border: 1.6px dashed rgba(248,81,73,.6); background: rgba(248,81,73,.05); }+  .box.skipped { border: 1.4px dashed var(--line-soft); background: rgba(255,255,255,.008); opacity: .55; }++  .box .ph { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--muted-2); font-size: 11.5px; }+  .box.rendering .ph { color: var(--accent-bright); }+  .box.failed .ph { color: var(--red); }++  /* checkerboard shows true transparency */+  .box img { max-width: 100%; max-height: 100%; display: block; }+  .box.transparent {+    background-color: #0a0e14;+    background-image:+      linear-gradient(45deg, #161b22 25%, transparent 25%),+      linear-gradient(-45deg, #161b22 25%, transparent 25%),+      linear-gradient(45deg, transparent 75%, #161b22 75%),+      linear-gradient(-45deg, transparent 75%, #161b22 75%);+    background-size: 16px 16px;+    background-position: 0 0, 0 8px, 8px -8px, -8px 0;+  }+  /* Dark-ink outline styles (thin / bold draw #0d1117 lines) are invisible on a+     dark tile, so they sit on a light "paper" tile, the way that style is meant+     to read. Padding keeps the line art off the edges. */+  .box.lighttile { background: #e9edf2; padding: 8px; }+  .box.lighttile img { mix-blend-mode: normal; }++  .spinner { width: 20px; height: 20px; border-radius: 50%;+    border: 2.4px solid rgba(0,184,176,.25); border-top-color: var(--accent-bright);+    animation: spin .8s linear infinite; }+  @keyframes spin { to { transform: rotate(360deg); } }+  .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .5; }++  .cap { font-size: 11.5px; line-height: 1.45; }+  .cap .fn { font-family: "JetBrains Mono", monospace; font-size: 11px; color: var(--text);+    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+  .cap .meta { color: var(--muted); margin-top: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }+  .cap .meta .sep { color: var(--muted-2); }+  .tag { display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px; border-radius: 20px; font-size: 10.5px; }+  .tag.tp { color: var(--accent-bright); background: rgba(0,184,176,.12); }+  .tag.op { color: var(--muted); background: rgba(255,255,255,.05); }+  .tag.tp .d { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-bright); }+  .tag.op .d { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }+  .lbl { color: var(--muted-2); font-size: 11px; }++  /* tooltip (app-creator §7c: 500ms hover-delay, body-appended) */+  #tip { position: fixed; z-index: 99999; max-width: 280px; pointer-events: none;+    background: #0a0e14; color: var(--text); border: 1px solid var(--line);+    border-radius: 8px; padding: 8px 11px; font-size: 12px; line-height: 1.5;+    box-shadow: 0 12px 40px rgba(0,0,0,.5); opacity: 0; transition: opacity .18s ease; }+  #tip.show { opacity: 1; }+</style>+</head>+<body>+<header class="app-header">+  <div class="brand" id="brandBlock" data-tooltip-target="app-info" tabindex="0">+    <svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">+      <rect x="3" y="3" width="7.5" height="7.5" rx="1.4"/>+      <rect x="13.5" y="3" width="7.5" height="7.5" rx="1.4" stroke-dasharray="2 2"/>+      <rect x="3" y="13.5" width="7.5" height="7.5" rx="1.4" stroke-dasharray="2 2"/>+      <path d="M14.2 17.25h6M17.2 14.25v6"/>+    </svg>+    <span class="app-name">Chip Thumbnailer</span>+    <span class="mid">·</span>+    <span class="sub">chip icon factory</span>+  </div>+  <div class="vsep"></div>+  <div class="subject">+    <span class="subject-name" id="subjName">no chip loaded</span>+    <span class="subject-meta" id="subjMeta"></span>+  </div>+  <div class="actions">+    <button class="icon-btn" id="regenBtn" disabled data-tooltip="Regenerate: throw away every rendered icon and render them all again from the same STEP. Use this if a render looked wrong or the source changed.">+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></svg>+    </button>+  </div>+</header>++<div id="popover">+  <div class="pv-title">Chip Thumbnailer <span style="color:var(--muted);font-weight:400">v0.6.0</span></div>+  <div class="pv-desc">Renders the full chip-icon family from a STEP: 3D orientations, transparent icons, name-lasered + vector outlines.</div>+  <div class="pv-row"><span>Engine</span><span>OCCT via service-step2glb</span></div>+  <div class="pv-row"><span>Wiki</span><a href="https://wiki.adom.inc/adom/adom-chip-thumbnailer" target="_blank" rel="noopener">wiki.adom.inc</a></div>+</div>++<div class="wrap">+  <div class="toolbar">+    <button class="btn primary" id="uploadBtn" data-tooltip="Pick a STEP (.step / .stp) file from your machine. The server stages it and renders every chip icon it can produce, popping each thumbnail into its dashed box the instant it is ready.">+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/></svg>+      Upload STEP+    </button>+    <input type="file" id="file" accept=".step,.stp,.STEP,.STP" style="display:none" />++    <span class="lbl">or</span>+    <select class="btn" id="libSel" data-tooltip="Pick a chip already in the chip-fetcher library and render its full icon family from the stored STEP.">+      <option value="">a library chip…</option>+    </select>++    <div class="spacer"></div>++    <div class="progress" id="prog" style="display:none" data-tooltip="How many chip icons have finished rendering out of the total this STEP can produce.">+      <div class="bar"><i id="barFill"></i></div>+      <span class="mono" id="progText">0 / 0</span>+    </div>+  </div>++  <div class="prov">Icons rendered by chip-thumbnailer (OCCT via service-step2glb). Not extracted from the datasheet.</div>++  <div id="content">+    <div class="empty">+      <div class="big">No chip loaded yet</div>+      <div>Drop a STEP file anywhere on this window, click <b>Upload STEP</b>, or pick a library chip.</div>+    </div>+  </div>+</div>++<div id="drop"><div class="card">Drop the STEP file to render</div></div>+<div id="tip"></div>++<script>+// ── console forwarding (app-creator §8) ───────────────────────────────+(function () {+  function fwd(level, args) {+    try {+      fetch("console", { method: "POST", headers: { "Content-Type": "text/plain" },+        body: "[" + level + "] " + Array.from(args).map(a => {+          try { return typeof a === "string" ? a : JSON.stringify(a); } catch { return String(a); }+        }).join(" ") });+    } catch (e) {}+  }+  ["log", "warn", "error", "info"].forEach(function (l) {+    const o = console[l].bind(console);+    console[l] = function () { fwd(l, arguments); o.apply(console, arguments); };+  });+  window.addEventListener("error", e => fwd("onerror", [e.message + " @ " + e.filename + ":" + e.lineno]));+  window.addEventListener("unhandledrejection", e => fwd("rejection", [String(e.reason)]));+})();++// ── tooltips: 500ms hover delay, body-appended ─────────────────────────+(function () {+  const tip = document.getElementById("tip");+  let timer = null, cur = null;+  function show(el) {+    const text = el.getAttribute("data-tooltip"); if (!text) return;+    tip.textContent = text;+    const r = el.getBoundingClientRect();+    tip.style.left = Math.min(r.left, window.innerWidth - 300) + "px";+    tip.style.top = (r.bottom + 8) + "px";+    tip.classList.add("show");+  }+  document.addEventListener("mouseover", e => {+    const el = e.target.closest("[data-tooltip]"); if (!el || el === cur) return;+    cur = el; clearTimeout(timer); timer = setTimeout(() => show(el), 600);+  });+  document.addEventListener("mouseout", e => {+    const el = e.target.closest("[data-tooltip]"); if (!el) return;+    cur = null; clearTimeout(timer); tip.classList.remove("show");+  });+  window.addEventListener("scroll", () => { tip.classList.remove("show"); }, true);+})();++// ── app-info popover on the brand block (600ms delay, 220ms grace) ─────+(function () {+  const brand = document.getElementById("brandBlock");+  const pop = document.getElementById("popover");+  if (!brand || !pop) return;+  let showT = null, hideT = null;+  function show() {+    clearTimeout(hideT);+    const r = brand.getBoundingClientRect();+    pop.style.left = r.left + "px";+    pop.style.top = (r.bottom + 8) + "px";+    pop.classList.add("show");+  }+  function scheduleHide() { hideT = setTimeout(() => pop.classList.remove("show"), 220); }+  brand.addEventListener("mouseenter", () => { clearTimeout(hideT); showT = setTimeout(show, 600); });+  brand.addEventListener("mouseleave", () => { clearTimeout(showT); scheduleHide(); });+  pop.addEventListener("mouseenter", () => clearTimeout(hideT));+  pop.addEventListener("mouseleave", scheduleHide);+})();++// ── helpers ────────────────────────────────────────────────────────────+function kb(bytes) {+  if (bytes == null) return "";+  if (bytes < 1024) return bytes + " B";+  return (bytes / 1024).toFixed(bytes < 102400 ? 1 : 0) + " KB";+}+const $ = s => document.querySelector(s);+const content = $("#content");++// ── upload + library wiring ────────────────────────────────────────────+$("#uploadBtn").addEventListener("click", () => $("#file").click());+$("#file").addEventListener("change", e => { if (e.target.files[0]) upload(e.target.files[0]); });+$("#regenBtn").addEventListener("click", () => post("regenerate"));+$("#libSel").addEventListener("change", e => {+  if (e.target.value) generateLibrary(e.target.value);+});++async function upload(file) {+  console.log("uploading " + file.name + " (" + kb(file.size) + ")");+  const buf = await file.arrayBuffer();+  try {+    const r = await fetch("api/generate", {+      method: "POST",+      headers: { "Content-Type": "application/step", "X-Filename": file.name },+      body: buf,+    });+    if (!r.ok) { const j = await r.json().catch(() => ({})); alert(j.error || ("upload failed: " + r.status)); return; }+    poll();+  } catch (e) { console.error("upload error", e); }+  $("#file").value = "";+}+async function generateLibrary(mpn) {+  try {+    const r = await fetch("api/generate", {+      method: "POST", headers: { "Content-Type": "application/json" },+      body: JSON.stringify({ mpn }),+    });+    if (!r.ok) { const j = await r.json().catch(() => ({})); alert(j.error || "failed"); return; }+    poll();+  } catch (e) { console.error(e); }+}+async function post(path) {+  try { await fetch("api/" + path, { method: "POST" }); poll(); }+  catch (e) { console.error(e); }+}++// ── drag + drop the whole window ───────────────────────────────────────+const drop = $("#drop");+let dragDepth = 0;+window.addEventListener("dragenter", e => { e.preventDefault(); dragDepth++; drop.classList.add("show"); });+window.addEventListener("dragover", e => e.preventDefault());+window.addEventListener("dragleave", e => { dragDepth--; if (dragDepth <= 0) drop.classList.remove("show"); });+window.addEventListener("drop", e => {+  e.preventDefault(); dragDepth = 0; drop.classList.remove("show");+  const f = e.dataTransfer.files[0]; if (f) upload(f);+});++// ── library dropdown ───────────────────────────────────────────────────+async function loadLibrary() {+  try {+    const r = await fetch("api/library"); const j = await r.json();+    const sel = $("#libSel");+    (j.chips || []).forEach(c => {+      const o = document.createElement("option");+      o.value = c.mpn;+      o.textContent = c.mpn + (c.hasSym && c.hasMod ? "  (sym+fp)" : "");+      sel.appendChild(o);+    });+  } catch (e) {}+}++// ── state polling + grid rendering ─────────────────────────────────────+let lastSig = "";        // structural signature (ids) to know when to rebuild+let lastStarted = 0;     // cache-bust key for <img> after a regenerate+let lastRunning = true;  // poll fast while a render is in flight, slow when idle+const cellEls = {};      // id -> { box, capEls... }++async function poll() {+  let s;+  try { s = await (await fetch("api/state")).json(); }+  catch (e) { return; }++  if (!s.hasSession) { lastRunning = false; return; }+  lastRunning = !!s.running;+  $("#regenBtn").disabled = false;++  // subject zone (what's loaded right now) + progress+  const renderable = s.slots.filter(x => x.status !== "skipped").length;+  $("#subjName").textContent = s.name + (s.source === "upload" ? " (uploaded)" : "");+  $("#subjMeta").textContent = s.done + " / " + renderable + " icons" + (s.running ? "" : " · done");+  const prog = $("#prog");+  prog.style.display = "flex";+  $("#progText").textContent = s.done + " / " + renderable + (s.running ? "" : "  done");+  $("#barFill").style.width = renderable ? (100 * s.done / renderable) + "%" : "0%";++  const sig = s.started + "|" + s.slots.map(x => x.id).join(",");+  if (sig !== lastSig) { rebuild(s); lastSig = sig; lastStarted = s.started; }+  else { s.slots.forEach(updateCell); }+}++function rebuild(s) {+  for (const k in cellEls) delete cellEls[k];+  content.innerHTML = "";+  // group slots preserving order+  const groups = [];+  const byGroup = {};+  s.slots.forEach(slot => {+    if (!byGroup[slot.group]) { byGroup[slot.group] = []; groups.push(slot.group); }+    byGroup[slot.group].push(slot);+  });+  groups.forEach(g => {+    const wrap = document.createElement("div"); wrap.className = "group";+    if (isCollapsed(g)) wrap.classList.add("collapsed");+    const h = document.createElement("h3");+    h.innerHTML =+      '<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' ++      '<span class="gname">' + esc(g) + '</span>' ++      '<span class="gcount">' + byGroup[g].length + '</span>' ++      '<span class="rule"></span>';+    h.addEventListener("click", () => {+      groupCollapsed[g] = !wrap.classList.contains("collapsed") ? true : false;+      wrap.classList.toggle("collapsed");+    });+    wrap.appendChild(h);+    const grid = document.createElement("div"); grid.className = "grid";+    byGroup[g].forEach(slot => grid.appendChild(makeCell(slot)));+    wrap.appendChild(grid);+    content.appendChild(wrap);+  });+}++// Collapse the big secondary sections (vector outlines + name-lasered) by+// default so the chip orientations + transparent icons stay at the top. The+// user's per-group toggle persists across re-renders via groupCollapsed.+const groupCollapsed = {};+function defaultCollapsed(name) { return /outline|name-lasered/i.test(name); }+function isCollapsed(name) { return name in groupCollapsed ? groupCollapsed[name] : defaultCollapsed(name); }++function makeCell(slot) {+  const cell = document.createElement("div"); cell.className = "cell";+  const box = document.createElement("div"); box.className = "box";+  const cap = document.createElement("div"); cap.className = "cap";+  cell.appendChild(box); cell.appendChild(cap);+  cellEls[slot.id] = { box, cap, img: null, loadedFile: null };+  updateCell(slot);+  return cell;+}++function updateCell(slot) {+  const c = cellEls[slot.id]; if (!c) return;+  // Only touch the DOM when this cell's render-relevant data actually changed.+  // Finished cells then stay put, so text stays selectable and the grid is not+  // rewritten on every poll tick.+  const sig = [slot.status, slot.file, slot.format, slot.width, slot.height,+    slot.sizeBytes, slot.transparent, slot.lightTile, slot.error, slot.label, lastStarted].join("|");+  if (c.sig === sig) return;+  c.sig = sig;+  const { box, cap } = c;+  box.className = "box " + slot.status ++    (slot.lightTile ? " lighttile" : (slot.transparent ? " transparent" : ""));++  if (slot.status === "done" && slot.file) {+    const url = "thumb/" + encodeURIComponent(slot.file) + "?s=" + lastStarted;+    if (c.loadedFile !== url) {+      box.innerHTML = "";+      const img = document.createElement("img");+      img.src = url; img.alt = slot.label; img.loading = "lazy";+      box.appendChild(img); c.img = img; c.loadedFile = url;+    }+    const dims = slot.width && slot.height ? slot.width + "×" + slot.height : "vector";+    const tp = slot.transparent+      ? '<span class="tag tp"><span class="d"></span>transparent</span>'+      : '<span class="tag op"><span class="d"></span>opaque</span>';+    cap.innerHTML =+      '<div class="fn" data-tooltip="' + esc(slot.file) + '">' + esc(slot.file) + '</div>' ++      '<div class="meta">' ++        '<b>' + (slot.format || "") + '</b><span class="sep">·</span>' ++        '<span class="mono">' + dims + '</span><span class="sep">·</span>' ++        '<span class="mono">' + kb(slot.sizeBytes) + '</span>' ++        tp ++      '</div>';+  } else if (slot.status === "rendering") {+    box.innerHTML = '<div class="ph"><div class="spinner"></div>rendering…</div>';+    cap.innerHTML = '<div class="lbl">' + esc(slot.label) + '</div>';+    c.loadedFile = null;+  } else if (slot.status === "failed") {+    box.innerHTML = '<div class="ph" data-tooltip="' + esc(slot.error || "render failed") + '">' ++      '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16.5v.01"/></svg>failed</div>';+    cap.innerHTML = '<div class="lbl">' + esc(slot.label) + '</div>';+    c.loadedFile = null;+  } else if (slot.status === "skipped") {+    box.innerHTML = '<div class="ph" data-tooltip="' + esc(slot.error || "not available") + '">n/a</div>';+    cap.innerHTML = '<div class="lbl">' + esc(slot.label) + '</div>';+    c.loadedFile = null;+  } else {+    // pending+    box.innerHTML = '<div class="ph"><div class="dot"></div>queued</div>';+    cap.innerHTML = '<div class="lbl">' + esc(slot.label) + '</div>';+    c.loadedFile = null;+  }+}++function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, m => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[m])); }++loadLibrary();+// Poll fast (500ms) while rendering, slow (2.5s) when idle. The cell-signature+// guard means idle polls do not touch the DOM, so nothing flickers and text+// stays selectable.+function pollLoop() {+  poll().finally(() => setTimeout(pollLoop, lastRunning ? 500 : 2500));+}+pollLoop();+</script>+</body>+</html>
src/ui/icon.svgadded+6
@@ -0,0 +1,6 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">+  <rect x="3" y="3" width="7.5" height="7.5" rx="1.4"/>+  <rect x="13.5" y="3" width="7.5" height="7.5" rx="1.4" stroke-dasharray="2 2"/>+  <rect x="3" y="13.5" width="7.5" height="7.5" rx="1.4" stroke-dasharray="2 2"/>+  <path d="M14.2 17.25h6M17.2 14.25v6"/>+</svg>