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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
//! Per-chip thumbnail manifest. Written as `<MPN>-thumbs.json` next to
//! the source files after every render. Self-describing — downstream
//! apps and AI callers should be able to consume the manifest alone
//! to know:
//!
//! 1. What thumbnail files exist + their dimensions / sizes
//! 2. What palette colors are baked in (so the host UI can match)
//! 3. Which "full-fidelity" app to launch when the user wants more
//! than the thumbnail (adom-step for 3D, SymView for symbol,
//! FpView for footprint)
//! 4. Inline hints — short imperative sentences the AI can echo to
//! the user without re-deriving them
use crate::library::{ChipPaths, ThreeDOrientation, ThreeDSize};
use anyhow::Result;
use serde::Serialize;
use std::path::PathBuf;
const VERSION: &str = env!("CARGO_PKG_VERSION");
// All manifest types serialize to camelCase JSON. This matches the
// rest of the Adom CLI ecosystem (step2glb's `sizeBytes` /
// `durationMs` / `srcKind`, chip-fetcher's `infoJson`, etc.) so
// downstream apps can use one casing convention everywhere. Rust
// fields stay snake_case (the language norm); the wire format is
// camelCase via serde rename.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub mpn: String,
pub generated_by: String,
pub generated_by_version: &'static str,
pub generated_at_iso: String,
pub palette: Palette,
pub thumbnails: Thumbnails,
pub hints: Vec<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Palette {
// Symbol (gallia/symbol-creator/lib/svg-theme.js THEME_COLORS)
pub canvas_bg: &'static str,
pub body_fill: &'static str,
pub body_fill_grad: &'static str,
pub body_stroke: &'static str,
pub pin_wire: &'static str,
pub pin_name: &'static str,
pub pin_number: &'static str,
pub group_label: &'static str,
pub group_label_hover: &'static str,
// Adom brand accent (gallia/viewer/adom-theme.js THEME)
pub accent_teal: &'static str,
pub accent_teal_bright: &'static str,
// Footprint (gallia/viewer/kicad-footprint-viewer.js)
pub pad_fill: &'static str,
pub pad_stroke: &'static str,
pub pad_number: &'static str,
pub courtyard: &'static str,
pub silkscreen: &'static str,
pub fab_outline: &'static str,
// Canvas atmosphere (baked into themed SVGs by chip-thumbnailer)
pub grid_line: &'static str,
pub glow: &'static str,
}
impl Palette {
pub fn canonical() -> Self {
Self {
canvas_bg: "#0d1117",
body_fill: "#1a2332",
body_fill_grad: "#141c27",
body_stroke: "#30363d",
pin_wire: "#4a5568",
pin_name: "#ffffff",
pin_number: "#8b949e",
group_label: "#E6B450",
group_label_hover: "#FFD180",
accent_teal: "#00b8b0",
accent_teal_bright: "#00e6dc",
pad_fill: "#C83434",
pad_stroke: "#ef5350",
pad_number: "#ffffff",
courtyard: "#FF26E2",
silkscreen: "#00cccc",
fab_outline: "#6e7681",
grid_line: "rgba(255,255,255,0.025)",
glow: "rgba(0,184,176,0.06)",
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Thumbnails {
pub symbol: Option<SvgEntry>,
pub footprint: Option<SvgEntry>,
#[serde(rename = "threeD")]
pub three_d: Option<ThreeD>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SvgEntry {
pub format: &'static str, // "svg"
pub path: String, // relative to chip dir
pub size_bytes: u64,
pub full_fidelity: FullFidelity,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreeD {
pub format: &'static str, // "png"
pub pose: &'static str, // "iso" (default; iso2/top/front/side available)
/// Back-compat alias: the `asIs` orientation's PNG sizes. Older
/// consumers (chip-thumbnailer 0.2.x) that look up
/// `thumbnails.threeD.iso.md.path` keep working.
pub iso: PngSizes,
/// All six face-up orientation variants of the same chip. chip-
/// thumbnailer is intentionally neutral about which is "correct" —
/// every variant gets rendered, and downstream tooling
/// (chipsmith, chipfit, etc.) picks the right one per chip.
/// See gallia/skills/up-axis-conventions for the math.
pub orientations: Orientations,
/// Source-STEP bounding box in millimeters (canonical Adom unit per
/// gallia/skills/electrical-engineering §1a). `bbox_mils` carries the
/// same value pre-converted so downstream UIs can render mm + mils
/// side-by-side without recomputing (the human-ui-patterns §4
/// multi-unit rule).
pub bbox_mm: Option<[[f64; 2]; 3]>,
pub bbox_mils: Option<[[f64; 2]; 3]>,
pub full_fidelity: FullFidelity,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Orientations {
/// Identity. Source +Z up. STEP / KiCad / Solidworks canonical.
pub as_is: OrientationVariant,
/// `R_x(π)`. Source -Z up.
pub z_down: OrientationVariant,
/// `R_x(+π/2)`. Source +Y up. Canonical Y-up (glTF / three.js /
/// Blender) — Bosch sensors, Espressif modules, Nordic WLCSPs.
pub y_up: OrientationVariant,
/// `R_x(-π/2)`. Source -Y up. Inverted Y-up — WAGO connectors.
pub y_down: OrientationVariant,
/// `R_y(-π/2)`. Source +X up.
pub x_up: OrientationVariant,
/// `R_y(+π/2)`. Source -X up.
pub x_down: OrientationVariant,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OrientationVariant {
/// Human-readable label; UIs should use this in radio button text.
pub label: &'static str,
/// Math notation describing the rotation applied (or "identity").
pub rotation: &'static str,
/// PNG sizes — sm, md, lg.
pub sizes: PngSizes,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PngSizes {
pub sm: Option<PngEntry>,
pub md: Option<PngEntry>,
pub lg: Option<PngEntry>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PngEntry {
pub path: String,
pub width: u32,
pub height: u32,
pub size_bytes: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FullFidelity {
/// Adom tool that renders this kind interactively at full fidelity.
pub tool: &'static str,
/// Concrete command the user (or an AI agent) can run.
pub command: String,
/// One-line description of what extra functionality the full-fidelity
/// viewer provides over the thumbnail.
pub description: &'static str,
}
pub fn build(chip: &ChipPaths) -> Manifest {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| iso8601(d.as_secs()))
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());
Manifest {
mpn: chip.mpn.clone(),
generated_by: "chip-thumbnailer".into(),
generated_by_version: VERSION,
generated_at_iso: now,
palette: Palette::canonical(),
thumbnails: Thumbnails {
symbol: svg_entry(&chip.symbol_thumb(), &chip.dir, FullFidelity {
tool: "symbol-creator (SymView)",
command: format!(
"POST http://127.0.0.1:8781/sym/create with this chip's pin metadata, then open <MPN>-viewer.html. Or for an existing .kicad_sym, run the gallia generateBrandedViewer pipeline (gallia/symbol-creator/lib/viewer-gen.js)."
),
description: "Interactive SymView — pin hover tooltips, group highlights, click-to-pin sticky info, datasheet link, zoom/fit controls.",
}),
footprint: svg_entry(&chip.footprint_thumb(), &chip.dir, FullFidelity {
tool: "FpView (gallia/viewer/kicad-footprint-viewer.js)",
command: "Run generateFootprintViewer(<MPN>.kicad_mod) to produce <MPN>-fpview.html — wraps the SVG with toolbar, layer toggle, measure tool, pad tooltips, solder-blob preview.".into(),
description: "Interactive FpView — pad hover tooltips with signal + dimensions, layer toggle (silk / fab / courtyard / solder), Fusion-style measure tool, solder-blob jet-placement preview.",
}),
three_d: three_d_entry(chip),
},
hints: build_hints(chip),
}
}
fn svg_entry(path: &PathBuf, _chip_dir: &PathBuf, full_fidelity: FullFidelity) -> Option<SvgEntry> {
if !path.is_file() {
return None;
}
let size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
Some(SvgEntry {
format: "svg",
path: path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
size_bytes,
full_fidelity,
})
}
fn three_d_entry(chip: &ChipPaths) -> Option<ThreeD> {
// Walk every (orientation, size) and collect what landed on disk.
let as_is = collect_sizes_for(chip, ThreeDOrientation::AsIs);
let z_down = collect_sizes_for(chip, ThreeDOrientation::ZDown);
let y_up = collect_sizes_for(chip, ThreeDOrientation::YUp);
let y_down = collect_sizes_for(chip, ThreeDOrientation::YDown);
let x_up = collect_sizes_for(chip, ThreeDOrientation::XUp);
let x_down = collect_sizes_for(chip, ThreeDOrientation::XDown);
let any = as_is.has_any() || z_down.has_any() || y_up.has_any()
|| y_down.has_any() || x_up.has_any() || x_down.has_any();
if !any {
return None;
}
// Pull bbox from the sidecar features.json (auto-populated by
// render_3d::render_orient_size on first render).
let (bbox_mm, bbox_mils) = read_bbox_for(chip);
Some(ThreeD {
format: "png",
pose: "iso",
iso: as_is.clone(), // back-compat alias for 0.2.x consumers
orientations: Orientations {
as_is: OrientationVariant { label: ThreeDOrientation::AsIs.label(), rotation: "identity", sizes: as_is },
z_down: OrientationVariant { label: ThreeDOrientation::ZDown.label(), rotation: "R_x(π)", sizes: z_down },
y_up: OrientationVariant { label: ThreeDOrientation::YUp.label(), rotation: "R_x(+π/2)", sizes: y_up },
y_down: OrientationVariant { label: ThreeDOrientation::YDown.label(), rotation: "R_x(-π/2)", sizes: y_down },
x_up: OrientationVariant { label: ThreeDOrientation::XUp.label(), rotation: "R_y(-π/2)", sizes: x_up },
x_down: OrientationVariant { label: ThreeDOrientation::XDown.label(), rotation: "R_y(+π/2)", sizes: x_down },
},
bbox_mm,
bbox_mils,
full_fidelity: FullFidelity {
tool: "adom-step",
command: format!("adom-step view library/{}/{}.step", chip.mpn, chip.mpn),
description: "Interactive STEP viewer — Fusion 360-feel components browser, smart-pick measure tool (mm + mils + angle), hover-inspect, geometry-based pin/contact detection (LGA, BGA, QFN, SOIC, through-hole). Backed by service-step2glb's same OCCT pipeline.",
},
})
}
fn collect_sizes_for(chip: &ChipPaths, orient: ThreeDOrientation) -> PngSizes {
let mut s = PngSizes { sm: None, md: None, lg: None };
for size in ThreeDSize::all() {
let path = chip.three_d_thumb_path(orient, size);
if !path.is_file() {
continue;
}
let (w, h) = size.dims();
let entry = Some(PngEntry {
path: path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
width: w,
height: h,
size_bytes: std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0),
});
match size {
ThreeDSize::Sm => s.sm = entry,
ThreeDSize::Md => s.md = entry,
ThreeDSize::Lg => s.lg = entry,
}
}
s
}
impl PngSizes {
fn has_any(&self) -> bool {
self.sm.is_some() || self.md.is_some() || self.lg.is_some()
}
}
impl Clone for PngSizes {
fn clone(&self) -> Self {
PngSizes {
sm: self.sm.clone(),
md: self.md.clone(),
lg: self.lg.clone(),
}
}
}
impl Clone for PngEntry {
fn clone(&self) -> Self {
PngEntry {
path: self.path.clone(),
width: self.width,
height: self.height,
size_bytes: self.size_bytes,
}
}
}
/// Look for a `<MPN>-features.json` sidecar (left by `step2glb features`)
/// and pull the chip's bbox in mm. Convert to mils for the multi-unit
/// display rule (1 mm = 39.3700787 mils, per electrical-engineering §1a).
fn read_bbox_for(chip: &ChipPaths) -> (Option<[[f64; 2]; 3]>, Option<[[f64; 2]; 3]>) {
let path = chip.dir.join(format!("{}-features.json", chip.mpn));
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return (None, None),
};
let v: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => return (None, None),
};
let bb = match v.get("bbox_mm") {
Some(b) => b,
None => return (None, None),
};
let pair = |key: &str| -> Option<[f64; 2]> {
let arr = bb.get(key)?.as_array()?;
Some([arr.get(0)?.as_f64()?, arr.get(1)?.as_f64()?])
};
let (Some(x), Some(y), Some(z)) = (pair("x"), pair("y"), pair("z")) else {
return (None, None);
};
let mm = [x, y, z];
let to_mils = |v: f64| (v * 39.370_078_7 * 100.0).round() / 100.0;
let mils = [
[to_mils(x[0]), to_mils(x[1])],
[to_mils(y[0]), to_mils(y[1])],
[to_mils(z[0]), to_mils(z[1])],
];
(Some(mm), Some(mils))
}
fn build_hints(chip: &ChipPaths) -> Vec<String> {
let mut hints = vec![
"These thumbnails are sized for index / card / tooltip views. When the user clicks on one to see the full version, hand off to the matching full-fidelity tool listed in `thumbnails.<kind>.full_fidelity`.".into(),
"Vector thumbnails (symbol, footprint) scale infinitely — render at any size with no quality loss. The 3D PNG comes in three preset sizes (sm/md/lg); pick by the consuming UI's container width.".into(),
"All thumbnails share the canonical Adom dark palette declared in `palette` — match your surrounding UI to it (e.g. card bg `palette.canvas_bg`, accent links `palette.accent_teal_bright`) so thumbnails don't visually clash.".into(),
"The dark canvas + 2.54 mm PCB grid + faint teal radial glow are baked into the SVGs (so they look correct standalone). If your container already provides those (e.g. SymView), don't double up.".into(),
];
if chip.step.is_some() {
hints.push(format!(
"For 3D inspection: `adom-step view library/{}/{}.step` opens the source STEP in an interactive viewer with measure tool + components browser.",
chip.mpn, chip.mpn
));
}
if chip.kicad_sym.is_some() {
hints.push(format!(
"For the interactive symbol with pin tooltips and group highlights, point a SymView at library/{}/{}.kicad_sym.",
chip.mpn, chip.mpn
));
}
if chip.kicad_mod.is_some() {
hints.push(format!(
"For the FpView footprint viewer (measure, pad tooltips, solder-blob jet preview), point at library/{}/{}.kicad_mod.",
chip.mpn, chip.mpn
));
}
hints.push(
"If a thumbnail entry is `null` in this manifest, the source file (.kicad_sym / .kicad_mod / .step) wasn't present in the chip dir at render time. chip-fetcher should populate it.".into(),
);
hints
}
pub fn write(chip: &ChipPaths) -> Result<PathBuf> {
let manifest = build(chip);
let path = chip.dir.join(format!("{}-thumbs.json", chip.mpn));
let pretty = serde_json::to_vec_pretty(&manifest)?;
std::fs::write(&path, pretty)?;
Ok(path)
}
fn iso8601(secs_since_epoch: u64) -> String {
// Lightweight UTC ISO-8601 formatter (no chrono dep). Enough precision
// for "when was this rendered".
let s = secs_since_epoch;
let days = (s / 86400) as i64;
let mut secs_of_day = (s % 86400) as i64;
let hour = secs_of_day / 3600;
secs_of_day -= hour * 3600;
let minute = secs_of_day / 60;
let second = secs_of_day - minute * 60;
let (year, month, day) = days_to_ymd(days + 719468); // shift epoch
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hour, minute, second
)
}
fn days_to_ymd(z: i64) -> (i64, u32, u32) {
// Howard Hinnant's date algorithm. Input: days since 0000-03-01.
let era = if z >= 0 { z / 146097 } else { (z - 146096) / 146097 };
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}