app
Adom Chip Thumbnailer
Public Made by Adomby adom
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 11 vector outline styles, in a live web app + CLI.
← Commit history
Publish 1.1.0
18 files changed
+2074−377
.gitignore+2Cargo.lock+103−1Cargo.toml+10−2README.md+82−39SKILL.md+76−245adom-chip-thumbnailer.pngpackage.json+3−3page.json+6−4screenshots/hero.pngsrc/cli.rs+44−33src/install.rs+1−1src/library.rs+34src/main.rs+3−3src/queue.rs+9−39src/render_3d.rs+288−7src/serve.rs+852src/ui/app.html+555src/ui/icon.svg+6.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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>