← Commit history
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. -![3D laser-mark of the part number on the chip, plus the name-marked vector outline](screenshots/name-laser.png)+![Clean single-stroke laser-mark of the part number on the chip, plus the name-marked vector outline](screenshots/name-laser.png)  ## 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:  ![Name-marked outlines at 90, the part number fits inside the chip](screenshots/named90-outlines.png) +## 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.++![chip-thumbnailer across package types: RF module, TQFP, QFN, USB-C connector, LGA sensor](screenshots/across-chip-types.png)+ ## 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>' +