app
[DEPRECATED] Chip Fetcher → adom-chip-fetcher
Public Made by Adomby adom
DEPRECATED — use adom/adom-chip-fetcher. No longer maintained.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
// Thumbnail generation by shelling to the canonical `chip-thumbnailer` CLI.
//
// chip-thumbnailer is the single source of truth for rendering the three
// per-chip artifacts under library/<MPN>/ — keeping the rendering logic
// (KiCad-CLI invocations, SVG dark theming, OCCT iso-pose render) in one
// place across every Adom tool that needs them.
//
// Output paths (canonical):
// <MPN>-symbol.svg
// <MPN>-footprint.svg
// <MPN>-3d-iso-sm.png (160×120)
// <MPN>-3d-iso.png (320×240) — primary, used by the dashboard
// <MPN>-3d-iso-lg.png (800×600) — used for full-screen pup tab on click
//
// chip-thumbnailer is idempotent: it skips artifacts whose source hasn't
// changed since the last render.
use anyhow::{Context, Result};
use std::process::Command;
use crate::library;
const CHIP_THUMBNAILER_BIN: &str = "adom-chip-thumbnailer";
const PENDING_MARKER: &str = ".thumb-pending";
pub struct GenReport {
pub mpn: String,
pub generated: Vec<&'static str>,
pub skipped: Vec<&'static str>,
pub errors: Vec<(String, String)>,
}
/// Render thumbnails for a single chip. `force = true` touches the
/// pending-marker first so chip-thumbnailer re-renders even if the output
/// is newer than the source.
pub fn generate_for(mpn: &str, force: bool) -> Result<GenReport> {
let dir = library::root().join(mpn);
if !dir.exists() {
anyhow::bail!("library/{mpn}/ does not exist");
}
if force {
let _ = std::fs::write(dir.join(PENDING_MARKER), b"");
}
let out = Command::new(CHIP_THUMBNAILER_BIN)
.arg("once")
.arg(mpn)
.output()
.context("running chip-thumbnailer once — install with `chip-thumbnailer install`")?;
let mut report = GenReport {
mpn: mpn.to_string(),
generated: Vec::new(),
skipped: Vec::new(),
errors: Vec::new(),
};
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
// Parse chip-thumbnailer's `OK: sym → ...` / `ERROR: 3d → ...` lines so
// chip-fetcher can show the same per-kind summary it always has.
for line in stdout.lines().chain(stderr.lines()) {
let l = strip_ansi(line);
if let Some(rest) = l.strip_prefix("OK: ") {
if rest.starts_with("sym ") || rest.starts_with("sym\t") || rest.starts_with("sym→") || rest.starts_with("sym ") {
report.generated.push("symbol");
} else if rest.starts_with("fp ") || rest.starts_with("fp\t") || rest.starts_with("fp→") || rest.starts_with("fp ") {
report.generated.push("footprint");
} else if rest.starts_with("3d ") || rest.starts_with("3d\t") || rest.starts_with("3d→") || rest.starts_with("3d ") {
report.generated.push("chip3d");
}
} else if let Some(rest) = l.strip_prefix("ERROR: ") {
// Best-effort kind parsing
let kind = if rest.starts_with("sym") { "symbol" }
else if rest.starts_with("fp") { "footprint" }
else if rest.starts_with("3d") { "chip3d" }
else { "render" };
report.errors.push((kind.into(), rest.trim().to_string()));
}
}
if !out.status.success() && report.errors.is_empty() {
report.errors.push(("setup".into(), format!("chip-thumbnailer exited {:?}: {}", out.status.code(), stderr.trim())));
}
// Dedup the kinds in case parsing matched twice
report.generated.sort_unstable();
report.generated.dedup();
// Cross-app: also render the ds2sf source-variant thumbnails (best-effort).
generate_ds2sf_variants(&dir, mpn);
Ok(report)
}
/// Render the ds2sf datasheet-extracted symbol/footprint to their own variant
/// thumbnails (`<mpn>-symbol.ds2sf.svg`, `<mpn>-footprint.ds2sf.svg`) so the
/// provenance carat shows a ds2sf option distinct from the vendor download.
/// Best-effort — skipped silently if the tools or the ds2sf extraction are absent.
fn generate_ds2sf_variants(dir: &std::path::Path, mpn: &str) {
// The canonical SYM/FP tile must show the ACTUAL vendor download (the source
// the carat labels "SnapEDA"/etc.), not the ds2sf datasheet synthesis.
// chip-thumbnailer prefers the ds2sf "authoritative" SVG for the footprint
// (it predates a working service-kicad path), which made the vendor tile show
// "ds2sf SYNTHESIZED". Re-render the canonical tiles from the real vendor
// files via adom-symbol/adom-footprint (service-kicad), overwriting that.
let canon_mod = dir.join(format!("{mpn}.kicad_mod"));
if canon_mod.is_file() {
let _ = Command::new("adom-footprint")
.args(["render", "--file"]).arg(&canon_mod)
.arg("--out").arg(dir.join(format!("{mpn}-footprint.svg")))
.output();
}
let sym_json = dir.join(format!("{mpn}-symbol.extracted.json"));
if sym_json.is_file() {
let out = dir.join(format!("{mpn}-symbol.ds2sf.svg"));
let _ = Command::new("adom-symbol")
.args(["render-ds2sf", "--file"])
.arg(&sym_json).arg("--out").arg(&out)
.output();
}
let fp_json = dir.join(format!("{mpn}-footprint.extracted.json"));
if fp_json.is_file() {
let out = dir.join(format!("{mpn}-footprint.ds2sf.svg"));
let _ = Command::new("adom-footprint")
.args(["render-ds2sf", "--file"])
.arg(&fp_json).arg("--out").arg(&out)
.output();
}
// Black-&-white vector OUTLINE icons (OCCT HLR) at the chosen orientation.
let step = dir.join(format!("{mpn}.step"));
if step.is_file() {
let axis = std::fs::read_to_string(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_else(|| "z".to_string());
let _ = Command::new("python3")
.arg("/home/adom/project/chip-fetcher/scripts/occt-outline.py")
.arg(&step).arg(dir).arg(mpn).arg(&axis)
.output();
// Transparent-background iso icon (chroma-key the shaded iso for the
// chosen axis) so the symbol-centre overlay has no ugly box.
let iso_in = match axis.as_str() {
"y" | "yup" => format!("{mpn}-3d-iso-yup.png"),
"x" | "xup" => format!("{mpn}-3d-iso-xup.png"),
"-x" | "xdown" => format!("{mpn}-3d-iso-xdown.png"),
"-y" | "ydown" => format!("{mpn}-3d-iso-ydown.png"),
"-z" | "zdown" => format!("{mpn}-3d-iso-zdown.png"),
_ => format!("{mpn}-3d-iso.png"),
};
let iso_path = dir.join(&iso_in);
if iso_path.is_file() {
let _ = Command::new("python3")
.arg("/home/adom/project/chip-fetcher/scripts/occt-iso.py")
.arg(&iso_path)
.arg(dir.join(format!("{mpn}-3d-iso-icon.png")))
.output();
}
// Name-marked 3D: emboss the part number on the chip's top face (real
// STEP geometry) and render the iso + flat-top outline variants, so the
// "laser the name on the chip" mode has perspective-correct assets that
// match what ships to KiCad. Best-effort; skipped if OCCT isn't present.
generate_named_assets(dir, mpn, &axis);
}
// Canonical SYMBOL render — done LAST, AFTER the transparent iso icon exists.
// John's rule: show adom-symbol's DEFAULT symbol with the iso 3D chip
// composited into the body. The order matters: rendering the symbol BEFORE
// the `-3d-iso-icon.png` (transparent) is generated bakes in the shaded
// `-3d-iso.png` instead — a gray box behind the chip. Generating the icon
// first (above) guarantees the symbol gets the clean transparent overlay.
let canon_sym = dir.join(format!("{mpn}.kicad_sym"));
if canon_sym.is_file() {
let mut cmd = Command::new("adom-symbol");
cmd.args(["render", "--file"]).arg(&canon_sym)
.arg("--out").arg(dir.join(format!("{mpn}-symbol.svg")));
let iso_icon = dir.join(format!("{mpn}-3d-iso-icon.png"));
let iso_plain = dir.join(format!("{mpn}-3d-iso.png"));
if iso_icon.is_file() {
cmd.arg("--iso").arg(&iso_icon);
} else if iso_plain.is_file() {
cmd.arg("--iso").arg(&iso_plain);
}
let _ = cmd.output();
}
// Foreign-format libraries (EAGLE/Fusion .lbr) → convert to KiCad sym+mod via
// adom-sfconvert so each format renders its OWN geometry in the carat instead
// of falling back to the canonical KiCad render. Altium .SchLib/.PcbLib are
// OLE binary — adom-sfconvert errors honestly there, so no Altium thumb yet.
convert_foreign_libraries(dir, mpn);
// Per-source vendor variants kept by import (<mpn>.<method>.kicad_sym/.kicad_mod)
// → render each to its own thumbnail so the carat shows a preview per source.
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
for (ext, bin, kind) in [("kicad_sym", "adom-symbol", "symbol"), ("kicad_mod", "adom-footprint", "footprint")] {
let suffix = format!(".{ext}");
let prefix = format!("{mpn}.");
if name == format!("{mpn}.{ext}") || !name.ends_with(&suffix) || !name.starts_with(&prefix) {
continue;
}
let method = &name[prefix.len()..name.len() - suffix.len()];
if method.is_empty() || method.contains('.') {
continue;
}
let out = dir.join(format!("{mpn}-{kind}.{method}.svg"));
if !out.is_file() {
let _ = Command::new(bin).args(["render", "--file"]).arg(e.path()).arg("--out").arg(&out).output();
}
}
}
}
}
/// Convert foreign-format vendor libraries into KiCad sym+mod so each format
/// renders its OWN geometry. Writes `<mpn>.<method>.kicad_sym/.kicad_mod`, which
/// the per-source variant loop above then renders to `<mpn>-<kind>.<method>.svg`.
/// Idempotent + best-effort: if the converted files already exist, or the
/// converter can't handle the format (Altium OLE binary), we leave it alone.
fn convert_foreign_libraries(dir: &std::path::Path, mpn: &str) {
// EAGLE / Fusion 360 .lbr → NATIVE via adom-sfconvert (eagle.rs, pure Rust —
// no service round-trip). Emits export .kicad_sym/.kicad_mod AND the native
// SVG thumbnails (<mpn>.fusion-symbol.svg / <mpn>.fusion-footprint.svg),
// which we move to the canonical per-source names so the variant loop skips
// the service render.
let lbr = dir.join(format!("{mpn}.lbr"));
if lbr.is_file() && !dir.join(format!("{mpn}-symbol.fusion.svg")).is_file() {
let _ = Command::new("adom-sfconvert")
.arg("convert").arg(&lbr)
.arg("--out-dir").arg(dir)
.arg("--name").arg(format!("{mpn}.fusion"))
.output();
let _ = std::fs::rename(
dir.join(format!("{mpn}.fusion-symbol.svg")),
dir.join(format!("{mpn}-symbol.fusion.svg")),
);
let _ = std::fs::rename(
dir.join(format!("{mpn}.fusion-footprint.svg")),
dir.join(format!("{mpn}-footprint.fusion.svg")),
);
}
// Altium .SchLib (symbol) + .PcbLib (footprint) → NATIVE via adom-sfconvert
// (altium-schlib / altium-pcblib, pure Rust — no service round-trip). It
// emits the export .kicad_sym/.kicad_mod AND the native SVG thumbnail
// (<mpn>.altium-symbol.svg / <mpn>.altium-footprint.svg). We move those SVGs
// to chip-fetcher's canonical per-source thumbnail names so the variant
// render loop below finds them already present and skips the service render.
let schlib = dir.join(format!("{mpn}.SchLib"));
if schlib.is_file() && !dir.join(format!("{mpn}-symbol.altium.svg")).is_file() {
let _ = Command::new("adom-sfconvert")
.arg("convert").arg(&schlib)
.arg("--out-dir").arg(dir)
.arg("--name").arg(format!("{mpn}.altium"))
.output();
let _ = std::fs::rename(
dir.join(format!("{mpn}.altium-symbol.svg")),
dir.join(format!("{mpn}-symbol.altium.svg")),
);
}
let pcblib = dir.join(format!("{mpn}.PcbLib"));
if pcblib.is_file() && !dir.join(format!("{mpn}-footprint.altium.svg")).is_file() {
let _ = Command::new("adom-sfconvert")
.arg("convert").arg(&pcblib)
.arg("--out-dir").arg(dir)
.arg("--name").arg(format!("{mpn}.altium"))
.output();
let _ = std::fs::rename(
dir.join(format!("{mpn}.altium-footprint.svg")),
dir.join(format!("{mpn}-footprint.altium.svg")),
);
}
}
const SCRIPTS: &str = "/home/adom/project/chip-fetcher/scripts";
/// Emboss the part number onto the chip's 3D STEP top face and render the
/// name-marked iso + flat-top outline variants. The mark is real geometry so it
/// renders perspective-correctly AND travels with the STEP to KiCad. The shaded
/// iso uses a shallow raised relief; the line-art outline uses flat top-face
/// geometry (top edge only — no extrusion clutter). Best-effort, idempotent.
fn generate_named_assets(dir: &std::path::Path, mpn: &str, axis_raw: &str) {
let step = dir.join(format!("{mpn}.step"));
if !step.is_file() {
return;
}
// The emboss only knows the y-up / z-up top face; normalize, skip exotic axes.
let up = match axis_raw.to_lowercase().as_str() {
"y" | "yup" | "+y" => "y",
"z" | "zup" | "+z" | "" => "z",
_ => return,
};
let icon = dir.join(format!("{mpn}-3d-iso-named-icon.png"));
if icon.is_file() {
return; // already generated
}
// Optional variant second line (e.g. "QFAA-R7") from info.json, if recorded.
let variant = std::fs::read_to_string(dir.join("info.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("variant_marking").and_then(|a| a.as_str()).map(String::from))
.unwrap_or_default();
// 1. Embossed (shallow relief) STEP → for the shaded iso + KiCad delivery.
let named_step = dir.join(format!("{mpn}-named.step"));
let mut emboss = Command::new("python3");
emboss.arg(format!("{SCRIPTS}/occt-name-on-chip.py")).arg(&step).arg(up).arg(mpn).arg(&named_step);
if !variant.is_empty() { emboss.arg(&variant); }
if !matches!(emboss.output(), Ok(o) if o.status.success()) {
return;
}
// 2. Render the named STEP's iso (xvfb-backed V3d) → transparent icon.
let tmp_iso = dir.join(format!("{mpn}-3d-iso-named.png"));
let _ = Command::new("xvfb-run").args(["-a", "python3"])
.arg(format!("{SCRIPTS}/render-iso.py")).arg(&named_step).arg(&tmp_iso).arg(axis_raw)
.output();
if tmp_iso.is_file() {
let _ = Command::new("python3").arg(format!("{SCRIPTS}/occt-iso.py")).arg(&tmp_iso).arg(&icon).output();
}
// 3. Outline "-named" variants — single-stroke (centerline) part-number drawn
// on the top face (clean even for long names; no outlined-glyph clutter).
let mut ol = Command::new("python3");
ol.arg(format!("{SCRIPTS}/occt-outline.py"))
.arg(&step).arg(dir).arg(mpn).arg(axis_raw)
.args(["--suffix", "-named", "--name", mpn]);
if !variant.is_empty() { ol.args(["--line2"]).arg(&variant); }
let _ = ol.output();
}
pub fn generate_all(force: bool) -> Vec<GenReport> {
let root = library::root();
let mut reports = Vec::new();
if let Ok(entries) = std::fs::read_dir(&root) {
for entry in entries.flatten() {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let mpn = match entry.file_name().to_str() {
Some(s) => s.to_string(),
None => continue,
};
if mpn.starts_with('.') { continue; }
match generate_for(&mpn, force) {
Ok(r) => reports.push(r),
Err(e) => reports.push(GenReport {
mpn,
generated: vec![],
skipped: vec![],
errors: vec![("setup".into(), e.to_string())],
}),
}
}
}
reports
}
fn strip_ansi(s: &str) -> String {
// Drop CSI escape sequences (chip-thumbnailer color-codes its output).
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i+1] == b'[' {
i += 2;
while i < bytes.len() {
let b = bytes[i];
i += 1;
if (0x40..=0x7e).contains(&b) { break; }
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}