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.7
8 files changed
+202−89
README.md+22−12@@ -22,22 +22,22 @@ stays a lean Rust client and does the alpha-keying locally. |---|---| | `<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-named[-icon].png` | part number **lasered** onto the chip top in the clean line-font (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` | **11 vector outline styles** via OCCT hidden-line removal | | `<MPN>-thumbs.json` | self-describing manifest (palette, files, full-fidelity tool) | ## The part number, lasered onto the chip (no other EDA tool does this) -chip-thumbnailer **embosses the manufacturer part number as real raised-geometry on the chip's top face**. It is not a 2D label pasted over a render,-it is a true 3D laser-mark that renders perspective-correctly from any angle-**and travels with the STEP into KiCad**. It comes name-marked on the vector-outlines too, at 180 (reads upright to the camera) or a right-angle 90, each-scaled to fit the chip's bounding box.+chip-thumbnailer **draws the manufacturer part number onto the chip's top+face** in the same crisp single-stroke line-font the vector outlines use. It is+not a flat label pasted over the render: the text is projected onto the top+plane through the live 3D camera, so it lies flat on the chip and follows the+isometric perspective. A clean engraved/laser look, not a noisy 3D extrusion.+It comes name-marked on the vector outlines too, at 180 (reads upright to the+camera) or a right-angle 90, each scaled to fit the chip's bounding box. -+ ## 11 outline styles @@ -66,6 +66,16 @@ chip, so it is usually the one to pick for a long part number or a narrow chip:  +## Works across every package type++The same one STEP in, full-family out works on whatever the part is, an RF+module with a shield can, a fine-pitch TQFP, a leadless QFN, a mechanical+USB-C connector, or a tiny LGA sensor. The geometry comes straight from the+manufacturer's STEP, so the icon is the real chip and the part number lands on+its actual top face every time.+++ ## The web app ```bash@@ -98,9 +108,9 @@ callers; `once`'s JSON inlines the manifest + a `_hints` block. 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.+- `POST /thumbnail` -> the shaded orientation PNGs, plus the clean-font+ name-laser when called with `?name=` (the part number drawn on the chip top).+- `POST /outline` -> the 11 styled vector outlines (OCCT HLR), plain + name-marked. The transparent icons are alpha-keyed from the shaded renders in pure Rust (no Python, no extra deps). The chosen up-axis (`info.json`
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-chip-thumbnailer",- "version": "1.1.6",+ "version": "1.1.7", "type": "app", "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 11 vector outline styles, in a live web app + CLI.", "tags": [
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-chip-thumbnailer",- "version": "1.1.6",+ "version": "1.1.7", "type": "app", "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 11 vector outline styles, in a live web app + CLI.", "dependencies": {},
screenshots/across-chip-types.pngadded⋯ 1 unchanged line ⋯
screenshots/name-laser.png⋯ 1 unchanged line ⋯
src/render_3d.rs+116−71@@ -38,14 +38,14 @@ 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();- match 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)) {+ // The transparent icon is rendered on a green chroma-key and+ // alpha-keyed (a gray-bg key ate part of the gray/black chip).+ if let Err(e) = render_icon_green(chip, &step, orient) { if first_err.is_none() { first_err = Some(anyhow!(- "transparent icon keying failed for {} ({}): {e}",+ "transparent icon (green key) failed for {} ({}): {e}", chip.mpn, orient.json_key() ));@@ -72,7 +72,9 @@ pub fn render(chip: &ChipPaths) -> Result<()> { // 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);+ if let Err(e) = render_family_extras(chip, &step) {+ eprintln!("warn: chip-icon family extras failed for {}: {e:#}", chip.mpn);+ } match first_err { Some(e) => Err(e),@@ -170,31 +172,30 @@ fn write_outlines( 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+/// Name-lasered iso + transparent named-icon, the part number drawn onto the+/// chip top in the clean single-stroke Hershey OUTLINE font (the same line-font+/// the vector outlines use) rather than a noisy 3D-extruded emboss. `tag` names+/// the output set ("named" = 180 upright default, "named90" = the -90 right+/// angle). Renders on a green chroma-key so the transparent icon keys cleanly+/// (chip + light text are far from green), then composites onto the dark canvas.+/// Writes: <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.+ // service_thumbnail_named reads the STEP from a path, so stage the bytes.+ let tmp_step = chip.dir.join(format!(".named-src-{}.step", chip.mpn));+ std::fs::write(&tmp_step, step).with_context(|| format!("write {}", tmp_step.display()))?;+ let green = chip.dir.join(format!(".green-{tag}-{}.png", chip.mpn));+ let res = service_thumbnail_named(+ &tmp_step, &green, axis, 320, 240, &chip.mpn, "00ff00", &chip.mpn, rot,+ );+ let _ = std::fs::remove_file(&tmp_step);+ res?;+ if let Ok((w, h, rgba)) = key_green_rgba(&green) { let icon = chip.dir.join(format!("{}-3d-iso-{tag}-icon.png", chip.mpn));- let _ = write_transparent_icon(&iso, &icon);+ let _ = write_rgba_png(&icon, w, h, &rgba);+ let disp = chip.dir.join(format!("{}-3d-iso-{tag}.png", chip.mpn));+ let _ = write_rgba_on_dark(&disp, w, h, &rgba); }+ let _ = std::fs::remove_file(&green); Ok(()) } @@ -283,11 +284,11 @@ pub fn render_orient_size( .clone(); let out = chip.three_d_thumb_path(orient, size); let (w, h) = size.dims();- service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn)+ 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).+ // The transparent icon (Md) is rendered on green + alpha-keyed. if size == ThreeDSize::Md {- write_transparent_icon(&out, &chip.three_d_icon_path(orient))+ render_icon_green(chip, &step, orient) .with_context(|| format!("transparent icon {} ({})", chip.mpn, orient.json_key()))?; } ensure_features(&step, chip);@@ -314,6 +315,7 @@ fn ensure_features(step: &Path, chip: &ChipPaths) { /// POST a STEP to service-step2glb's /thumbnail, handling both the legacy /// synchronous (200 → PNG) and the current async (202 → job → poll) shapes, /// then write the PNG to `out`.+#[allow(clippy::too_many_arguments)] fn service_thumbnail( step: &Path, out: &Path,@@ -321,11 +323,36 @@ fn service_thumbnail( w: u32, h: u32, mpn: &str,+ bg: &str,+) -> Result<()> {+ service_thumbnail_named(step, out, up_axis, w, h, mpn, bg, "", 180)+}++/// Like `service_thumbnail`, but optionally lasers `name` onto the chip top in+/// the clean single-stroke Hershey outline font (the same line-font the vector+/// outlines use) at in-plane rotation `name_rot`. Empty `name` = plain render.+#[allow(clippy::too_many_arguments)]+fn service_thumbnail_named(+ step: &Path,+ out: &Path,+ up_axis: &str,+ w: u32,+ h: u32,+ mpn: &str,+ bg: &str,+ name: &str,+ name_rot: u32, ) -> Result<()> { let svc = service_url(); let body = std::fs::read(step).with_context(|| format!("read {}", step.display()))?; let job_name = format!("thumbnail-iso-{up_axis}-{mpn}-for-chip-thumbnailer");- let url = format!("{svc}/thumbnail?pose=iso&kind=step&upAxis={up_axis}&width={w}&height={h}");+ let mut url = format!("{svc}/thumbnail?pose=iso&kind=step&upAxis={up_axis}&width={w}&height={h}");+ if !bg.is_empty() {+ url.push_str(&format!("&bg={bg}"));+ }+ if !name.is_empty() {+ url.push_str(&format!("&name={name}&rot={name_rot}"));+ } let resp = ureq::post(&url) .set("Content-Type", "application/step")@@ -387,18 +414,25 @@ 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;+/// Render one orientation on a pure-green chroma-key background and alpha-key+/// the green out, writing the transparent icon. The chip is gray/black, far from+/// green, so this keeps the whole chip, unlike keying a gray background (which+/// ate chip faces that happened to match the bg shade).+fn render_icon_green(chip: &ChipPaths, step: &Path, orient: ThreeDOrientation) -> Result<()> {+ let green = chip.dir.join(format!(".green-{}-{}.png", chip.mpn, orient.json_key()));+ service_thumbnail(step, &green, orient.step2glb_arg(), 320, 240, &chip.mpn, "00ff00")?;+ let (w, h, rgba) = key_green_rgba(&green)?;+ let _ = std::fs::remove_file(&green);+ write_rgba_png(&chip.three_d_icon_path(orient), w, h, &rgba)+} +/// Alpha-key a pure-green-background PNG. greenness = G - max(R,B): ~0 on the+/// gray/black chip (opaque), high on the green background (transparent). Spill+/// suppression pulls G down to max(R,B) so anti-aliased edges have no green+/// fringe. Returns (width, height, RGBA bytes).+fn key_green_rgba(green_png: &Path) -> Result<(usize, usize, Vec<u8>)> { let decoder = png::Decoder::new(- std::fs::File::open(opaque).with_context(|| format!("open {}", opaque.display()))?,+ std::fs::File::open(green_png).with_context(|| format!("open {}", green_png.display()))?, ); let mut reader = decoder.read_info().context("read png header")?; let (width, height) = {@@ -414,47 +448,58 @@ fn write_transparent_icon(opaque: &Path, out: &Path) -> Result<()> { 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;+ const LOW: f64 = 30.0; // greenness below this -> fully opaque (chip)+ const HIGH: f64 = 95.0; // greenness above this -> fully transparent (bg) 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 i = (row * width + col) * ch;+ let (r, g, b) = (bytes[i] as f64, bytes[i + 1] as f64, bytes[i + 2] as f64);+ let maxrb = r.max(b);+ let greenness = g - maxrb;+ let alpha = ((1.0 - ((greenness - LOW) / (HIGH - LOW)).clamp(0.0, 1.0)) * 255.0)+ .round() as u8;+ let g2 = g.min(maxrb); // spill suppression 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] = r as u8;+ rgba[o + 1] = g2 as u8;+ rgba[o + 2] = b as u8; rgba[o + 3] = alpha; } }+ Ok((width, height, rgba))+} +fn write_rgba_png(out: &Path, w: usize, h: usize, rgba: &[u8]) -> Result<()> {+ use std::io::BufWriter; 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);+ let mut enc = png::Encoder::new(BufWriter::new(file), w as u32, h 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")?;+ writer.write_image_data(rgba).context("write png data")?;+ Ok(())+}++/// Composite an RGBA buffer over the Adom dark canvas (#0d1117) -> opaque RGB+/// PNG, for the shaded display variants (named.png etc.).+fn write_rgba_on_dark(out: &Path, w: usize, h: usize, rgba: &[u8]) -> Result<()> {+ use std::io::BufWriter;+ const BG: [f64; 3] = [13.0, 17.0, 23.0]; // #0d1117+ let mut rgb = vec![0u8; w * h * 3];+ for px in 0..(w * h) {+ let a = rgba[px * 4 + 3] as f64 / 255.0;+ for c in 0..3 {+ rgb[px * 3 + c] =+ (rgba[px * 4 + c] as f64 * a + BG[c] * (1.0 - a)).round() as u8;+ }+ }+ let file = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;+ let mut enc = png::Encoder::new(BufWriter::new(file), w as u32, h as u32);+ enc.set_color(png::ColorType::Rgb);+ enc.set_depth(png::BitDepth::Eight);+ let mut writer = enc.write_header().context("write png header")?;+ writer.write_image_data(&rgb).context("write png data")?; Ok(()) }
src/serve.rs+1−1@@ -744,7 +744,7 @@ fn sanitize_stem(name: &str) -> String { fn short_orient(o: ThreeDOrientation) -> &'static str { match o {- ThreeDOrientation::AsIs => "as-is",+ ThreeDOrientation::AsIs => "as-is (Z-up)", ThreeDOrientation::ZDown => "Z-down", ThreeDOrientation::YUp => "Y-up", ThreeDOrientation::YDown => "Y-down",
src/ui/app.html+61−3@@ -208,6 +208,23 @@ 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; }++ /* click-to-copy: any [data-copy] copies its value; brief toast confirms */+ [data-copy] { cursor: pointer; }+ [data-copy]:hover { color: var(--accent-bright); }+ .subject-name[data-copy]:hover { text-decoration: underline dotted; }+ .copybtn { display: inline-flex; align-items: center; justify-content: center;+ width: 22px; height: 22px; border: 1px solid var(--line); border-radius: 6px;+ background: var(--panel-2); color: var(--muted); cursor: pointer; flex: none;+ transition: all .15s ease; }+ .copybtn:hover { border-color: var(--accent); color: var(--accent-bright); }+ .copybtn svg { width: 13px; height: 13px; }+ #toast { position: fixed; z-index: 99999; left: 50%; bottom: 28px; transform: translateX(-50%) translateY(8px);+ background: #0a0e14; color: var(--text); border: 1px solid var(--accent); border-radius: 10px;+ padding: 9px 15px; font-size: 13px; box-shadow: 0 12px 40px rgba(0,0,0,.55);+ opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease; }+ #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }+ #toast b { color: var(--accent-bright); } </style> </head> <body>@@ -225,7 +242,10 @@ </div> <div class="vsep"></div> <div class="subject">- <span class="subject-name" id="subjName">no chip loaded</span>+ <span class="subject-name" id="subjName" data-tooltip="Click to copy the chip name">no chip loaded</span>+ <button class="copybtn" id="copyName" data-tooltip="Copy the chip name to the clipboard" style="display:none">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>+ </button> <span class="subject-meta" id="subjMeta"></span> </div> <div class="actions">@@ -275,6 +295,7 @@ <div id="drop"><div class="card">Drop the STEP file to render</div></div> <div id="tip"></div>+<div id="toast"></div> <script> // ── console forwarding (app-creator §8) ───────────────────────────────@@ -347,6 +368,39 @@ function kb(bytes) { const $ = s => document.querySelector(s); const content = $("#content"); +// ── click-to-copy: any [data-copy] copies its value to the clipboard ───+let toastTimer = null;+function toast(label, value) {+ const t = $("#toast");+ t.innerHTML = label + ' <b>' + esc(value) + '</b>';+ t.classList.add("show");+ clearTimeout(toastTimer);+ toastTimer = setTimeout(() => t.classList.remove("show"), 1800);+}+function copyText(value, label) {+ // Synchronous textarea+execCommand first — works in webviews / unfocused+ // contexts where the async Clipboard API hangs or is blocked.+ try {+ const ta = document.createElement("textarea");+ ta.value = value; ta.style.position = "fixed"; ta.style.opacity = "0";+ document.body.appendChild(ta); ta.focus(); ta.select();+ document.execCommand("copy"); ta.remove();+ } catch (e) {}+ // Best-effort modern API too (non-blocking, ignore failure).+ if (navigator.clipboard) { try { navigator.clipboard.writeText(value).catch(() => {}); } catch (e) {} }+ toast(label || "Copied", value);+}+document.addEventListener("click", e => {+ const el = e.target.closest("[data-copy]");+ if (!el) return;+ const v = el.getAttribute("data-copy");+ if (v) copyText(v, el.getAttribute("data-copy-label") || "Copied");+});+$("#copyName").addEventListener("click", () => {+ const n = $("#subjName").getAttribute("data-copy");+ if (n) copyText(n, "Copied chip");+});+ // ── upload + library wiring ──────────────────────────────────────────── $("#uploadBtn").addEventListener("click", () => $("#file").click()); $("#file").addEventListener("change", e => { if (e.target.files[0]) upload(e.target.files[0]); });@@ -426,7 +480,11 @@ async function poll() { // 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)" : "");+ const sn = $("#subjName");+ sn.textContent = s.name + (s.source === "upload" ? " (uploaded)" : "");+ sn.setAttribute("data-copy", s.name);+ sn.setAttribute("data-copy-label", "Copied chip");+ $("#copyName").style.display = "inline-flex"; $("#subjMeta").textContent = s.done + " / " + renderable + " icons" + (s.running ? "" : " · done"); const prog = $("#prog"); prog.style.display = "flex";@@ -512,7 +570,7 @@ function updateCell(slot) { ? '<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="fn" data-copy="' + esc(slot.file) + '" data-copy-label="Copied" data-tooltip="' + esc(slot.file) + ' (click to copy)">' + esc(slot.file) + '</div>' + '<div class="meta">' + '<b>' + (slot.format || "") + '</b><span class="sep">·</span>' + '<span class="mono">' + dims + '</span><span class="sep">·</span>' +