app
[DEPRECATED] Chip Thumbnailer → adom-chip-thumbnailer
Public Made by Adomby adom
DEPRECATED: use adom/adom-chip-thumbnailer. No longer maintained.
← Commit history
chip-thumbnailer page: hero strip, 3 detailed feature images, open-source section + Rust source
17 files changed
+2198−1
Cargo.tomladded+26@@ -0,0 +1,26 @@+[package]+name = "chip-thumbnailer"+version = "0.5.1"+edition = "2021"+description = "Renders symbol / footprint / 3D thumbnails for chip-fetcher library entries (Adom-themed SVGs + OCCT-direct PNGs via service-step2glb)."+license = "MIT"++[[bin]]+name = "chip-thumbnailer"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive", "env"] }+clap_complete = "4"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+anyhow = "1"+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"] }++[profile.release]+opt-level = "z"+lto = true+strip = true
README.md+48−1@@ -1,3 +1,50 @@ # chip-thumbnailer -Renders symbol / footprint / 3D thumbnails for every chip-fetcher library entry — Adom-themed KiCad SVGs + OCCT-direct 3D PNGs, no local KiCad or EAGLE.+**The rendering engine behind chip-fetcher's library — three crisp thumbnails per chip (symbol, footprint, 3D), Adom-themed, with no local KiCad or EAGLE.**++chip-thumbnailer walks the chip-fetcher library and renders, for every part, the three views you see on each card: an Adom-themed KiCad **symbol** SVG, a dark-grid **footprint** SVG, and an OCCT-direct **3D isometric** PNG. It runs through the shared `service-kicad` and `service-step2glb` services — no local KiCad, no local EAGLE, no gallia. Standalone Rust.++## Three thumbnails per chip++++The **symbol** is rendered from the part's `.kicad_sym` through `service-kicad`, then re-themed for the dark Adom canvas — pins grouped, labels legible, cropped tight. This is the same artwork the chip-fetcher card and the adom-symbol studio show, generated once and cached next to the source files.++++The **footprint** comes from the `.kicad_mod`, styled onto a dark PCB grid with copper, the courtyard, and the pin-1 marker — cropped to the part with no page frame or title block. chip-thumbnailer reads legacy KiCad-5 `(module …)` footprints as well as modern ones, so older vendor downloads render correctly too.++## 3D render styles++++The **3D** view is rendered OCCT-direct from the STEP model via `service-step2glb` — not a screenshot of a viewer, but a true offscreen render at the correct orientation. Beyond the shaded isometric PNG, chip-thumbnailer emits crisp **vector outline** styles (thin / bold / blueprint / teal / dark) via OCCT hidden-line removal, each optionally with the part number laser-marked onto the chip's top face. These feed the chip-fetcher carat switcher and the adom-symbol "chip on the symbol" overlay.++## How it runs++- **`once <MPN>`** — render all three thumbnails for one chip, written alongside the source files.+- **`scan`** — render every chip carrying a `.thumb-pending` marker (the batch path chip-fetcher triggers).+- **`rerender <MPN> <sym|fp|3d|all>`** — re-do just one kind.+- **`status` / `health` / `config`** — show what exists, probe `kicad-cli` + `step2glb` + `service-step2glb`, print the resolved library root.++## Open source — check it out, vibe-code it, send it back++chip-thumbnailer is MIT and **fully open**. The complete Rust source is in this page's **Files** tab — browse it, or grab `src/` + `Cargo.toml`.++1. **Build it.** `cargo build --release` drops a standalone binary (no Node, no gallia).+2. **Point your install at your checkout.** `adompkg link john/chip-thumbnailer ./chip-thumbnailer` so the `chip-thumbnailer` you run is your local copy.+3. **Vibe-code a change.** Open the folder in Claude Code and describe what you want — a new render style, a different crop, another thumbnail kind. The symbol / footprint / 3D renderers are each one small, readable module.+4. **Send it back.** Open a **pull request** on this page (the wiki reviews and merges per-repo PRs), or start a discussion thread. Good additions get folded into the shipped app.++## Part of the Adom EDA family++The rendering engine for **chip-fetcher**; produces the artwork **adom-symbol**, **adom-footprint**, and the carat switcher display. Pairs with **adom-lbr** and **ds2sf**.++## Run it++```bash+chip-thumbnailer once nRF54L15 # render symbol + footprint + 3D for one chip+chip-thumbnailer scan # render every chip marked .thumb-pending+chip-thumbnailer health # probe kicad-cli + step2glb + service-step2glb+```+MIT, contributions welcome.
docs/fp.pngadded⋯ 1 unchanged line ⋯
docs/hero-strip.pngadded⋯ 1 unchanged line ⋯
docs/styles.pngadded⋯ 1 unchanged line ⋯
docs/sym.pngadded⋯ 1 unchanged line ⋯
src/cli.rsadded+337@@ -0,0 +1,337 @@+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 clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};+use std::process::Command as ProcCommand;++#[derive(Parser, Debug)]+#[command(+ name = "chip-thumbnailer",+ version,+ about = "Render symbol/footprint/3D thumbnails for chip-fetcher library entries.",+ long_about = "\+chip-thumbnailer renders three thumbnails per chip in the chip-fetcher\n\+library and drops them next to the source files:\n \n\+ <MPN>-symbol.svg Adom-themed KiCad symbol (kicad-cli + svg-theme)\n\+ <MPN>-footprint.svg FpView-styled footprint (kicad-cli + dark grid)\n\+ <MPN>-3d-iso.png OCCT-direct render via service-step2glb /thumbnail\n\+"+)]+pub struct Cli {+ #[command(subcommand)]+ pub command: Command,+}++#[derive(Subcommand, Debug)]+pub enum Command {+ /// Render all three thumbnails for one chip (writes alongside the source files).+ Once(OnceArgs),+ /// Render every chip in the library that has a `.thumb-pending` marker.+ Scan,+ /// Show which thumbnails exist + which sources are present for one chip.+ Status(StatusArgs),+ /// Re-render only one kind for one chip (sym, fp, 3d, or all).+ Rerender(RerenderArgs),+ /// Probe local kicad-cli + step2glb + service-step2glb reachability.+ Health,+ /// Print resolved library root + step2glb binary location.+ Config,+ /// Manually mark a chip's thumbnails as out-of-date (touches `.thumb-pending`).+ TouchPending(TouchPendingArgs),+ /// Install SKILL.md + bash completions (fetches fresh SKILL.md from the wiki).+ Install,+ /// Print shell completions to stdout: `chip-thumbnailer completions bash`+ Completions(CompletionsArgs),+}++#[derive(Args, Debug)]+pub struct OnceArgs {+ pub mpn: String,+}++#[derive(Args, Debug)]+pub struct StatusArgs {+ pub mpn: String,+}++#[derive(Args, Debug)]+pub struct RerenderArgs {+ pub mpn: String,+ /// Which thumbnail to re-render. Default = all three.+ #[arg(long, default_value = "all")]+ pub kind: String,+}++#[derive(Args, Debug)]+pub struct TouchPendingArgs {+ pub mpn: String,+}++#[derive(Args, Debug)]+pub struct CompletionsArgs {+ #[arg(value_enum)]+ pub shell: ShellArg,+}++#[derive(Debug, Clone, Copy, ValueEnum)]+pub enum ShellArg {+ Bash,+ Zsh,+ Fish,+}++impl From<ShellArg> for clap_complete::Shell {+ fn from(s: ShellArg) -> Self {+ match s {+ ShellArg::Bash => clap_complete::Shell::Bash,+ ShellArg::Zsh => clap_complete::Shell::Zsh,+ ShellArg::Fish => clap_complete::Shell::Fish,+ }+ }+}++// ────────────────────────────────────────────────────────────────────────+// command implementations+// ────────────────────────────────────────────────────────────────────────++pub fn once_cmd(args: OnceArgs) {+ match queue::render_one(&args.mpn) {+ Ok(report) => {+ if report.all_ok() {+ ok(format!(+ "{CYAN}{}{RESET} — sym/fp/3d all rendered, .thumb-pending cleared",+ args.mpn+ ));+ } else {+ warn(format!(+ "{CYAN}{}{RESET} — partial render ({} sym, {} fp, {} 3d). 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" },+ ));+ }++ // Build the AI-hint payload by reading back the freshly-written+ // manifest so the JSON output of `once` is self-describing —+ // any AI agent driving chip-thumbnailer learns from one+ // command both what was rendered AND how to use it.+ let chip = library::ChipPaths::for_mpn(&args.mpn).ok();+ let m = chip.as_ref().map(manifest::build);++ let mut next_steps: Vec<String> = Vec::new();+ if report.all_ok() {+ if let Some(ref c) = chip {+ next_steps.push(format!(+ "Manifest written: cat library/{}/{}-thumbs.json",+ c.mpn, c.mpn+ ));+ next_steps.push(format!(+ "Open the 3D in interactive mode: adom-step view library/{}/{}.step",+ 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+ ));+ }+ }+ if !report.all_ok() {+ next_steps.push(format!(+ "Inspect failures: cat library/{}/.thumb-errors.json",+ args.mpn+ ));+ next_steps.push(format!(+ "Retry just the failed kinds: chip-thumbnailer rerender {} --kind sym|fp|3d",+ args.mpn+ ));+ }++ println!(+ "{}",+ 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,+ "_hints": {+ "purpose": "These thumbnails are sized for card / index / tooltip surfaces. For full-fidelity inspection, see manifest.thumbnails.<kind>.full_fidelity.tool / .command.",+ "palette": "All thumbnails use the canonical Adom palette in manifest.palette — match your surrounding UI to canvas_bg #0d1117 and accent_teal #00b8b0 to avoid clashing.",+ "sizes": "3D PNGs come in sm (160×120), md (320×240, no suffix — back-compat), lg (800×600). SVGs scale infinitely; one file each.",+ "atmosphere": "Themed SVGs bake in the dark canvas + 2.54 mm PCB grid + faint teal radial glow. If your container already provides those (e.g. SymView), don't double them.",+ "next_steps": next_steps,+ }+ })).unwrap()+ );+ if !report.all_ok() {+ std::process::exit(3);+ }+ }+ Err(e) => {+ err(format!("once failed for {}: {e}", args.mpn));+ hint("Run `chip-thumbnailer status <MPN>` to inspect the chip dir.");+ std::process::exit(1);+ }+ }+}++pub fn scan_cmd() {+ match queue::render_pending() {+ Ok(0) => {+ ok("no chips with .thumb-pending markers — nothing to do");+ }+ Ok(n) => {+ ok(format!("processed {n} pending chip(s)"));+ }+ Err(e) => {+ err(format!("scan failed: {e}"));+ std::process::exit(1);+ }+ }+}++pub fn status_cmd(args: StatusArgs) {+ let chip = match library::ChipPaths::for_mpn(&args.mpn) {+ Ok(c) => c,+ Err(e) => {+ err(format!("{e}"));+ std::process::exit(1);+ }+ };+ let yes = "✓";+ let no = "·";+ note!("{CYAN}{}{RESET}", chip.mpn);+ note!(" {DIM}dir{RESET} {}", chip.dir.display());+ note!(+ " {DIM}sources{RESET} sym={} mod={} step={}",+ if chip.kicad_sym.is_some() { yes } else { no },+ if chip.kicad_mod.is_some() { yes } else { no },+ if chip.step.is_some() { yes } else { no },+ );+ note!(+ " {DIM}thumbs{RESET} sym={} fp={} 3d={}",+ if chip.symbol_thumb().is_file() { yes } else { no },+ if chip.footprint_thumb().is_file() { yes } else { no },+ if chip.three_d_thumb().is_file() { yes } else { no },+ );+ note!(+ " {DIM}pending{RESET} {}",+ if chip.thumb_pending { "yes (.thumb-pending)" } else { "no" }+ );+}++pub fn rerender_cmd(args: RerenderArgs) {+ let chip = match library::ChipPaths::for_mpn(&args.mpn) {+ Ok(c) => c,+ Err(e) => {+ err(format!("{e}"));+ std::process::exit(1);+ }+ };+ let want_all = args.kind == "all";+ 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));+ std::process::exit(1);+ }+ // Refresh the manifest so file lists, palette, version stay in sync+ // with what's on disk — same contract as `once`.+ match manifest::write(&chip) {+ Ok(path) => ok(format!("manifest → {}", path.display())),+ Err(e) => warn(format!("manifest write failed: {e}")),+ }+ if had_failure {+ std::process::exit(3);+ }+}++pub fn health_cmd() {+ let kicad_ok = ProcCommand::new("kicad-cli").arg("version").output().is_ok();+ let step2glb_ok = ProcCommand::new("step2glb").arg("--version").output().is_ok();+ let svc_ok = ureq::get("https://step2glb-gmdoncpxdwx0.adom.cloud/health")+ .timeout(std::time::Duration::from_secs(5))+ .call()+ .is_ok();+ let lib_ok = library::root().is_dir();++ note!("{DIM}kicad-cli{RESET} {}", if kicad_ok { "ok" } else { "MISSING" });+ note!("{DIM}step2glb{RESET} {}", if step2glb_ok { "ok" } else { "MISSING" });+ note!("{DIM}service-step2glb{RESET} {}", if svc_ok { "reachable" } else { "UNREACHABLE" });+ note!("{DIM}library{RESET} {} ({})",+ library::root().display(),+ if lib_ok { "exists" } else { "MISSING" }+ );+ if !(kicad_ok && step2glb_ok && svc_ok && lib_ok) {+ std::process::exit(2);+ }+}++pub fn config_cmd() {+ note!("{DIM}library root:{RESET} {}", library::root().display());+ note!("{DIM}kicad-cli:{RESET} {}", which_or("kicad-cli"));+ note!("{DIM}step2glb:{RESET} {}", which_or("step2glb"));+ note!("{DIM}service-step2glb:{RESET} https://step2glb-gmdoncpxdwx0.adom.cloud");+}++pub fn touch_pending_cmd(args: TouchPendingArgs) {+ match library::ChipPaths::for_mpn(&args.mpn) {+ Ok(c) => match library::touch_pending(&args.mpn) {+ Ok(_) => ok(format!(+ "marked {} as pending — next `scan` will re-render",+ c.mpn+ )),+ Err(e) => {+ err(format!("{e}"));+ std::process::exit(1);+ }+ },+ Err(e) => {+ err(format!("{e}"));+ std::process::exit(1);+ }+ }+}++pub fn install_cmd() {+ install::run();+}++pub fn completions_cmd(args: CompletionsArgs) {+ let shell: clap_complete::Shell = args.shell.into();+ let mut cmd = Cli::command();+ clap_complete::generate(shell, &mut cmd, "chip-thumbnailer", &mut std::io::stdout());+}++fn which_or(name: &str) -> String {+ ProcCommand::new("which")+ .arg(name)+ .output()+ .ok()+ .filter(|o| o.status.success())+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())+ .filter(|s| !s.is_empty())+ .unwrap_or_else(|| format!("(not on PATH)"))+}
src/install.rsadded+95@@ -0,0 +1,95 @@+//! Install command — fetches SKILL.md from the Adom Wiki at install+//! time. NEVER include_str! the SKILL.md (rule from+//! gallia/skills/tool-publisher/SKILL.md "install rule"). On any wiki+//! error the install exits non-zero with a clear message; no fallback.++use crate::ui::{err, hint, ok};+use clap::CommandFactory;+use std::fs;+use std::io::Write;+use std::path::PathBuf;+use std::time::Duration;++const SKILL_URL: &str =+ "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/chip-thumbnailer/SKILL.md";++fn fetch_skill_md() -> ! {+ panic!("never called — fetch_skill_md returns into main; here for type-only docs");+}++pub fn run() {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());++ // 1. Skill file → ~/.claude/skills/chip-thumbnailer/SKILL.md+ let skill_md = match ureq::get(SKILL_URL).timeout(Duration::from_secs(10)).call() {+ Ok(r) => match r.into_string() {+ Ok(s) => s,+ Err(e) => fail(&format!("read SKILL.md body from {SKILL_URL}: {e}")),+ },+ Err(ureq::Error::Status(code, _)) => {+ fail(&format!("wiki returned HTTP {code} for {SKILL_URL}"))+ }+ Err(e) => fail(&format!("could not reach {SKILL_URL}: {e}")),+ };++ let skill_dir = PathBuf::from(&home).join(".claude/skills/chip-thumbnailer");+ if let Err(e) = fs::create_dir_all(&skill_dir) {+ fail(&format!("mkdir {}: {e}", skill_dir.display()));+ }+ let skill_path = skill_dir.join("SKILL.md");+ if let Err(e) = fs::write(&skill_path, &skill_md) {+ fail(&format!("write {}: {e}", skill_path.display()));+ }+ ok(format!(+ "skill installed at {} ({} bytes from wiki)",+ skill_path.display(),+ skill_md.len()+ ));++ // 2. Bash completions → ~/.local/share/bash-completion/completions/chip-thumbnailer+ let comp_dir = PathBuf::from(&home).join(".local/share/bash-completion/completions");+ let _ = fs::create_dir_all(&comp_dir);+ let mut cmd = crate::cli::Cli::command();+ let mut buf = Vec::new();+ clap_complete::generate(+ clap_complete::Shell::Bash,+ &mut cmd,+ "chip-thumbnailer",+ &mut buf,+ );+ let _ = fs::write(comp_dir.join("chip-thumbnailer"), &buf);+ ok(format!(+ "bash completions at {}/chip-thumbnailer",+ comp_dir.display()+ ));++ // 3. .bashrc nudge (idempotent).+ let bashrc_path = PathBuf::from(&home).join(".bashrc");+ let existing = fs::read_to_string(&bashrc_path).unwrap_or_default();+ if !existing.contains("# chip-thumbnailer completions") {+ if let Ok(mut f) = fs::OpenOptions::new().append(true).open(&bashrc_path) {+ let _ = f.write_all(+ b"\n# chip-thumbnailer completions\neval \"$(chip-thumbnailer completions bash 2>/dev/null)\"\n",+ );+ ok(".bashrc: completion hook appended (open a new shell or `source ~/.bashrc`)");+ }+ }++ println!();+ ok("chip-thumbnailer installed. Try `chip-thumbnailer health`.");+}++fn fail(msg: &str) -> ! {+ err(format!("install aborted — {msg}"));+ hint("SKILL.md is fetched from the wiki at install time (no embedded fallback).");+ hint("Check connectivity to wiki-ufypy5dpx93o.adom.cloud and retry `chip-thumbnailer install`.");+ std::process::exit(2);+}++// Keep fetch_skill_md private hint above; the trick is `fail` returns `!`+// so callers can use it in expression position. The dummy fn above keeps+// the docstring discoverable; lint silenced.+#[allow(dead_code)]+fn _doc_fetch_skill_md() {+ let _ = fetch_skill_md;+}
src/library.rsadded+296@@ -0,0 +1,296 @@+//! Locate chip-fetcher library entries on disk and inspect their files.+//!+//! chip-fetcher stores each chip at:+//! /home/adom/project/chip-fetcher/library/<MPN>/+//! with files named <MPN>.{step,kicad_sym,kicad_mod,pdf,...}.+//!+//! This module finds the library root and surfaces per-chip metadata+//! about which source files exist (so the renderers can decide which+//! thumbnails are even possible).++use anyhow::{anyhow, Result};+use std::path::{Path, PathBuf};++/// Marker file written by chip-fetcher after a successful import. Presence+/// means "thumbnails are out of date; re-render." chip-thumbnailer deletes+/// the marker after a successful render.+pub const THUMB_PENDING_MARKER: &str = ".thumb-pending";++/// Errors-from-last-render sidecar so chip-thumbnailer can resume failed+/// renders without re-running the whole pipeline.+pub const THUMB_ERRORS_FILE: &str = ".thumb-errors.json";++pub fn root() -> PathBuf {+ if let Ok(p) = std::env::var("CHIP_FETCHER_LIBRARY") {+ return PathBuf::from(p);+ }+ PathBuf::from("/home/adom/project/chip-fetcher/library")+}++#[derive(Debug, Clone)]+pub struct ChipPaths {+ pub mpn: String,+ pub dir: PathBuf,+ pub kicad_sym: Option<PathBuf>,+ pub kicad_mod: Option<PathBuf>,+ pub step: Option<PathBuf>,+ pub thumb_pending: bool,+}++impl ChipPaths {+ pub fn for_mpn(mpn: &str) -> Result<Self> {+ let dir = root().join(mpn);+ if !dir.is_dir() {+ return Err(anyhow!("chip not in library: {}", dir.display()));+ }+ let pick = |ext: &str| {+ let p = dir.join(format!("{mpn}.{ext}"));+ if p.is_file() {+ Some(p)+ } else {+ None+ }+ };+ Ok(Self {+ mpn: mpn.to_string(),+ kicad_sym: pick("kicad_sym"),+ kicad_mod: pick("kicad_mod"),+ step: pick("step").or_else(|| pick("stp")),+ thumb_pending: dir.join(THUMB_PENDING_MARKER).exists(),+ dir,+ })+ }++ pub fn symbol_thumb(&self) -> PathBuf {+ self.dir.join(format!("{}-symbol.svg", self.mpn))+ }+ pub fn footprint_thumb(&self) -> PathBuf {+ self.dir.join(format!("{}-footprint.svg", self.mpn))+ }+ /// Canonical 3D thumbnail (as-is orientation, medium / 320×240).+ /// Kept un-suffixed so back-compat consumers still find it.+ pub fn three_d_thumb(&self) -> PathBuf {+ self.three_d_thumb_path(ThreeDOrientation::AsIs, ThreeDSize::Md)+ }+ /// 3D thumbnail at a specific size, as-is orientation.+ pub fn three_d_thumb_size(&self, size: ThreeDSize) -> PathBuf {+ self.three_d_thumb_path(ThreeDOrientation::AsIs, size)+ }+ /// 3D thumbnail at a specific (orientation, size).+ /// As-is keeps the original un-suffixed name; yUp/zUp get a token.+ pub fn three_d_thumb_path(&self, orient: ThreeDOrientation, size: ThreeDSize) -> PathBuf {+ let osuffix = orient.file_suffix();+ let ssuffix = size.suffix();+ self.dir+ .join(format!("{}-3d-iso{osuffix}{ssuffix}.png", self.mpn))+ }+ /// Iterator over all three rendered 3D sizes (as-is orientation).+ pub fn three_d_thumbs(&self) -> Vec<(ThreeDSize, PathBuf)> {+ ThreeDSize::all()+ .into_iter()+ .map(|s| (s, self.three_d_thumb_size(s)))+ .collect()+ }+}++/// Up-axis interpretation applied to the source STEP before the 3D+/// render. Six face-up cube orientations cover every "which side is+/// up" question; the consumer (e.g. chip-fetcher's UI) picks the+/// right one for each chip.+///+/// Math + reference impls + per-format catalog:+/// `gallia/skills/up-axis-conventions/SKILL.md`.+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum ThreeDOrientation {+ /// Identity. Source +Z maps to render +Z. STEP / KiCad / Solidworks+ /// canonical — right for ~80% of chips.+ AsIs,+ /// `R_x(π)`. Source -Z maps to render +Z. Use when STEP was+ /// authored upside-down in Z (rare).+ ZDown,+ /// `R_x(+π/2)`. Source +Y maps to render +Z. Canonical Y-up,+ /// matches glTF / three.js / Blender / Adom-canonical-viewer+ /// (`rotateYUpToZUp` uses Math.PI/2). Right for chips authored+ /// Y-up — Bosch sensors, Espressif modules, Nordic WLCSPs, etc.+ YUp,+ /// `R_x(-π/2)`. Source -Y maps to render +Z. Inverted Y-up — a+ /// chip authored "Y-down" (pins along +Y instead of -Y). WAGO+ /// connectors are the canonical real-world example.+ YDown,+ /// `R_y(-π/2)`. Source +X maps to render +Z. Rare — some+ /// industrial parts.+ XUp,+ /// `R_y(+π/2)`. Source -X maps to render +Z.+ XDown,+}++impl ThreeDOrientation {+ pub fn all() -> [ThreeDOrientation; 6] {+ [+ ThreeDOrientation::AsIs,+ ThreeDOrientation::ZDown,+ ThreeDOrientation::YUp,+ ThreeDOrientation::YDown,+ ThreeDOrientation::XUp,+ ThreeDOrientation::XDown,+ ]+ }+ /// Filename token appended between `-3d-iso` and the size suffix.+ /// AsIs is empty (canonical un-suffixed name for back-compat).+ pub fn file_suffix(self) -> &'static str {+ match self {+ ThreeDOrientation::AsIs => "",+ ThreeDOrientation::ZDown => "-zdown",+ ThreeDOrientation::YUp => "-yup",+ ThreeDOrientation::YDown => "-ydown",+ ThreeDOrientation::XUp => "-xup",+ ThreeDOrientation::XDown => "-xdown",+ }+ }+ /// Label used in the JSON manifest.+ pub fn json_key(self) -> &'static str {+ match self {+ ThreeDOrientation::AsIs => "asIs",+ ThreeDOrientation::ZDown => "zDown",+ ThreeDOrientation::YUp => "yUp",+ ThreeDOrientation::YDown => "yDown",+ ThreeDOrientation::XUp => "xUp",+ ThreeDOrientation::XDown => "xDown",+ }+ }+ /// Value passed to `step2glb thumbnail --up-axis ...`.+ pub fn step2glb_arg(self) -> &'static str {+ match self {+ ThreeDOrientation::AsIs => "asIs",+ ThreeDOrientation::ZDown => "zDown",+ ThreeDOrientation::YUp => "yUp",+ ThreeDOrientation::YDown => "yDown",+ ThreeDOrientation::XUp => "xUp",+ ThreeDOrientation::XDown => "xDown",+ }+ }+ /// Human-readable label for picker UIs.+ pub fn label(self) -> &'static str {+ match self {+ ThreeDOrientation::AsIs => "as-is (Z-up canonical)",+ ThreeDOrientation::ZDown => "Z-down (R_x(π))",+ ThreeDOrientation::YUp => "Y-up (R_x(+π/2))",+ ThreeDOrientation::YDown => "Y-down (R_x(-π/2))",+ ThreeDOrientation::XUp => "X-up (R_y(-π/2))",+ ThreeDOrientation::XDown => "X-down (R_y(+π/2))",+ }+ }+}++/// Standard 3D thumbnail sizes. `Md` is the canonical filename+/// (no suffix) — kept for back-compat with chip-thumbnailer 0.1.x+/// and downstream consumers that expect `<MPN>-3d-iso.png`.+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum ThreeDSize {+ Sm,+ Md,+ Lg,+}++impl ThreeDSize {+ pub fn all() -> [ThreeDSize; 3] {+ [ThreeDSize::Sm, ThreeDSize::Md, ThreeDSize::Lg]+ }+ /// (width, height)+ pub fn dims(self) -> (u32, u32) {+ match self {+ ThreeDSize::Sm => (160, 120),+ ThreeDSize::Md => (320, 240),+ ThreeDSize::Lg => (800, 600),+ }+ }+ /// Filename suffix appended after `-3d-iso` and before `.png`.+ /// Md → "" (canonical, no suffix). Sm → "-sm". Lg → "-lg".+ pub fn suffix(self) -> &'static str {+ match self {+ ThreeDSize::Sm => "-sm",+ ThreeDSize::Md => "",+ ThreeDSize::Lg => "-lg",+ }+ }+ pub fn label(self) -> &'static str {+ match self {+ ThreeDSize::Sm => "sm",+ ThreeDSize::Md => "md",+ ThreeDSize::Lg => "lg",+ }+ }+ pub fn parse(s: &str) -> Option<Self> {+ match s.to_ascii_lowercase().as_str() {+ "sm" | "small" => Some(ThreeDSize::Sm),+ "md" | "medium" => Some(ThreeDSize::Md),+ "lg" | "large" => Some(ThreeDSize::Lg),+ _ => None,+ }+ }+}++/// Walk the library and return every MPN dir.+pub fn list_mpns() -> Result<Vec<String>> {+ let root = root();+ if !root.exists() {+ return Ok(vec![]);+ }+ let mut out = Vec::new();+ for entry in std::fs::read_dir(&root)? {+ let entry = entry?;+ if entry.file_type()?.is_dir() {+ if let Some(name) = entry.file_name().to_str() {+ if !name.starts_with('.') {+ out.push(name.to_string());+ }+ }+ }+ }+ out.sort();+ Ok(out)+}++pub fn pending_chips() -> Result<Vec<String>> {+ let mut out = Vec::new();+ for mpn in list_mpns()? {+ if root().join(&mpn).join(THUMB_PENDING_MARKER).exists() {+ out.push(mpn);+ }+ }+ Ok(out)+}++pub fn touch_pending(mpn: &str) -> Result<()> {+ let path = root().join(mpn).join(THUMB_PENDING_MARKER);+ std::fs::write(&path, b"").map_err(|e| anyhow!("touch {}: {e}", path.display()))+}++pub fn clear_pending(mpn: &str) -> Result<()> {+ let path = root().join(mpn).join(THUMB_PENDING_MARKER);+ if path.exists() {+ std::fs::remove_file(&path).map_err(|e| anyhow!("rm {}: {e}", path.display()))?;+ }+ Ok(())+}++pub fn write_errors(mpn: &str, kinds: &[(&str, String)]) -> Result<PathBuf> {+ let path = root().join(mpn).join(THUMB_ERRORS_FILE);+ let mut obj = serde_json::Map::new();+ for (k, v) in kinds {+ obj.insert((*k).to_string(), serde_json::Value::String(v.clone()));+ }+ let blob = serde_json::Value::Object(obj);+ std::fs::write(&path, serde_json::to_vec_pretty(&blob)?)?;+ Ok(path)+}++pub fn clear_errors(mpn: &str) {+ let _ = std::fs::remove_file(root().join(mpn).join(THUMB_ERRORS_FILE));+}++pub fn relative_to_workspace(p: &Path) -> String {+ p.strip_prefix("/home/adom/project")+ .map(|x| format!("/home/adom/project/{}", x.display()))+ .unwrap_or_else(|_| p.display().to_string())+}
src/main.rsadded+27@@ -0,0 +1,27 @@+mod cli;+mod install;+mod library;+mod manifest;+mod queue;+mod render_3d;+mod render_fp;+mod render_sym;+mod theme;+mod ui;++use clap::Parser;++fn main() {+ let args = cli::Cli::parse();+ match args.command {+ cli::Command::Once(a) => cli::once_cmd(a),+ cli::Command::Scan => cli::scan_cmd(),+ cli::Command::Status(a) => cli::status_cmd(a),+ cli::Command::Rerender(a) => cli::rerender_cmd(a),+ cli::Command::Health => cli::health_cmd(),+ cli::Command::Config => cli::config_cmd(),+ cli::Command::TouchPending(a) => cli::touch_pending_cmd(a),+ cli::Command::Install => cli::install_cmd(),+ cli::Command::Completions(a) => cli::completions_cmd(a),+ }+}
src/manifest.rsadded+435@@ -0,0 +1,435 @@+//! 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)+}
src/queue.rsadded+109@@ -0,0 +1,109 @@+//! 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.++use crate::library::{self, ChipPaths};+use crate::ui::{ok, warn};+use crate::{manifest, render_3d, render_fp, render_sym};+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+ }+}++pub fn render_one(mpn: &str) -> Result<RenderReport> {+ let chip = ChipPaths::for_mpn(mpn)?;+ let mut report = RenderReport {+ mpn: mpn.to_string(),+ ..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(_) => {+ report.three_d_ok = true;+ ok(format!("3d → {}", chip.three_d_thumb().display()));+ }+ Err(e) => {+ report.errors.push(("3d", format!("{e}")));+ warn(format!("3d failed: {e}"));+ }+ }+ } else {+ report.errors.push(("3d", "no .step in chip dir".into()));+ warn("3d skipped: no .step");+ }++ if report.all_ok() {+ library::clear_pending(mpn)?;+ library::clear_errors(mpn);+ } else if !report.errors.is_empty() {+ let kinds: Vec<(&str, String)> = report+ .errors+ .iter()+ .map(|(k, v)| (*k, v.clone()))+ .collect();+ let _ = library::write_errors(mpn, &kinds);+ }++ // Always (re)write the manifest — even on partial render, so downstream+ // apps can see what's available and what's missing.+ match manifest::write(&chip) {+ Ok(path) => ok(format!("manifest → {}", path.display())),+ Err(e) => warn(format!("manifest write failed: {e}")),+ }++ Ok(report)+}++pub fn render_pending() -> Result<usize> {+ let mut n = 0;+ for mpn in library::pending_chips()? {+ let _ = render_one(&mpn);+ n += 1;+ }+ Ok(n)+}
src/render_3d.rsadded+184@@ -0,0 +1,184 @@+//! 3D thumbnail: call service-step2glb's POST /thumbnail directly (OCCT+//! V3d offscreen). We talk to the service over HTTP rather than shelling+//! to the `step2glb` CLI, because the deployed CLI's thumbnail path does+//! NOT poll the service's async (202 + job) response — it bails with+//! "service returned HTTP 202". Calling the service directly lets us poll+//! the job to completion, which is what actually works.+//!+//! Renders the as-is + 5 other face-up orientations at the medium size the+//! dashboard displays. Manufacturer chip STEPs ship in inconsistent up-axis+//! conventions; chip-thumbnailer is intentionally neutral and renders every+//! face-up variant — downstream tools pick the right one per chip.++use crate::library::{ChipPaths, ThreeDOrientation, ThreeDSize};+use anyhow::{anyhow, Context, Result};+use std::path::Path;+use std::time::Duration;++const SERVICE_URL_DEFAULT: &str = "https://step2glb-gmdoncpxdwx0.adom.cloud";++fn service_url() -> String {+ std::env::var("STEP2GLB_SERVICE_API")+ .unwrap_or_else(|_| SERVICE_URL_DEFAULT.to_string())+ .trim_end_matches('/')+ .to_string()+}++/// Render every face-up orientation at the displayed (Md) size. This covers+/// the dashboard's Z↑/Y↑ tiles plus the 6-orientation popover. (The sm/lg+/// size variants are rendered on demand via `render_orient_size`.)+pub fn render(chip: &ChipPaths) -> Result<()> {+ let step = chip+ .step+ .as_ref()+ .ok_or_else(|| anyhow!("no .step in {}", chip.dir.display()))?+ .clone();++ let mut first_err: Option<anyhow::Error> = None;+ 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) {+ // 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()+ ));+ }+ }+ }++ // Lazily populate <MPN>-features.json (manifest pulls bbox from it).+ ensure_features(&step, chip);++ match first_err {+ Some(e) => Err(e),+ None => Ok(()),+ }+}++/// 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)+}++/// Render a single (orientation, size) to disk via the service.+pub fn render_orient_size(+ chip: &ChipPaths,+ orient: ThreeDOrientation,+ size: ThreeDSize,+) -> Result<()> {+ let step = chip+ .step+ .as_ref()+ .ok_or_else(|| anyhow!("no .step in {}", chip.dir.display()))?+ .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)+ .with_context(|| format!("3D render {} ({}/{})", chip.mpn, orient.json_key(), size.label()))?;+ ensure_features(&step, chip);+ Ok(())+}++fn ensure_features(step: &Path, chip: &ChipPaths) {+ let features_path = chip.dir.join(format!("{}-features.json", chip.mpn));+ if features_path.is_file() {+ return;+ }+ // features still goes through the CLI. Its render path can return the+ // service's async 202 (which the CLI doesn't poll) — that's a non-fatal+ // metadata miss, so capture output rather than letting a misleading+ // "feature extraction failed" line leak to the user.+ let _ = std::process::Command::new("step2glb")+ .arg("features")+ .arg(step)+ .arg("--out")+ .arg(&features_path)+ .output();+}++/// 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`.+fn service_thumbnail(+ step: &Path,+ out: &Path,+ up_axis: &str,+ w: u32,+ h: u32,+ mpn: &str,+) -> 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 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(&body)+ .map_err(|e| anyhow!("POST {url}: {e}"))?;++ match resp.status() {+ 200 => write_reader(resp, out),+ 202 => {+ let txt = resp.into_string().context("read 202 job body")?;+ let v: serde_json::Value =+ serde_json::from_str(&txt).context("parse 202 job json")?;+ let job_id = v+ .get("job_id")+ .and_then(|s| s.as_str())+ .ok_or_else(|| anyhow!("202 response had no job_id"))?+ .to_string();+ poll_and_write(&svc, &job_id, out)+ }+ code => Err(anyhow!("service returned HTTP {code}")),+ }+}++fn poll_and_write(svc: &str, job_id: &str, out: &Path) -> Result<()> {+ for _ in 0..30 {+ if let Ok(r) = ureq::get(&format!("{svc}/jobs/{job_id}"))+ .set("X-Client", "chip-thumbnailer/chip-fetcher")+ .set("X-Job-Name", "poll")+ .timeout(Duration::from_secs(15))+ .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") => {+ let res = ureq::get(&format!("{svc}/jobs/{job_id}/result"))+ .set("X-Client", "chip-thumbnailer/chip-fetcher")+ .set("X-Job-Name", "result")+ .timeout(Duration::from_secs(30))+ .call()+ .map_err(|e| anyhow!("GET job result: {e}"))?;+ return write_reader(res, out);+ }+ Some("failed") => {+ 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 {job_id} timed out"))+}++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()))?;+ std::io::copy(&mut reader, &mut f).with_context(|| format!("write {}", out.display()))?;+ Ok(())+}
src/render_fp.rsadded+94@@ -0,0 +1,94 @@+//! Footprint thumbnail: shell to local kicad-cli, then apply the+//! footprint theme (dark canvas + PCB grid behind kicad-cli's native+//! KiCad-palette geometry).+//!+//! kicad-cli's footprint SVG export expects a `.pretty/` directory and+//! `--footprint <name>` flag, *not* a single .kicad_mod file. We stage+//! a temp `.pretty/` symlink-free directory.++use crate::library::ChipPaths;+use crate::theme;+use anyhow::{anyhow, Context, Result};+use std::path::Path;+use std::process::Command;++pub fn render(chip: &ChipPaths) -> Result<()> {+ // Prefer the ds2sf-rendered authoritative footprint SVG when present+ // (validate.rs writes <MPN>-footprint.authoritative.svg via service-kicad).+ // Adom containers have no local kicad-cli, so this is the path that+ // actually works; the kicad-cli branch below is a dev-only fallback.+ let authoritative = chip+ .dir+ .join(format!("{}-footprint.authoritative.svg", chip.mpn));+ if authoritative.is_file() {+ let raw = std::fs::read_to_string(&authoritative)+ .with_context(|| format!("reading {}", authoritative.display()))?;+ // Apply the Adom footprint theme (dark canvas + PCB grid behind the+ // KiCad-palette pads), same as the kicad-cli path did — otherwise the+ // tile is a raw light render that clashes with the symbol/3D tiles.+ let themed = theme::theme_footprint_svg(&raw);+ std::fs::write(chip.footprint_thumb(), themed)+ .with_context(|| format!("writing {}", chip.footprint_thumb().display()))?;+ return Ok(());+ }++ let kicad_mod = chip+ .kicad_mod+ .as_ref()+ .ok_or_else(|| anyhow!("no .kicad_mod in {}", chip.dir.display()))?;++ let pretty = tempdir(&format!("chip-thumb-fp-{}-pretty", chip.mpn))?;+ let staged_mod = pretty.join(format!("{}.kicad_mod", chip.mpn));+ std::fs::copy(kicad_mod, &staged_mod)+ .with_context(|| format!("staging {} → {}", kicad_mod.display(), staged_mod.display()))?;++ let out_dir = tempdir(&format!("chip-thumb-fp-{}-out", chip.mpn))?;+ let status = Command::new("kicad-cli")+ .arg("fp")+ .arg("export")+ .arg("svg")+ .arg("--footprint")+ .arg(&chip.mpn)+ .arg("--output")+ .arg(&out_dir)+ .arg(&pretty)+ .status()+ .context("running kicad-cli — install KiCad to PATH if missing")?;+ if !status.success() {+ return Err(anyhow!("kicad-cli fp export svg failed for {}", chip.mpn));+ }++ let exported = pick_first_svg(&out_dir)+ .ok_or_else(|| anyhow!("kicad-cli produced no .svg in {}", out_dir.display()))?;++ let raw = std::fs::read_to_string(&exported)+ .with_context(|| format!("reading {}", exported.display()))?;+ let themed = theme::theme_footprint_svg(&raw);++ let out = chip.footprint_thumb();+ std::fs::write(&out, themed)+ .with_context(|| format!("writing {}", out.display()))?;++ let _ = std::fs::remove_dir_all(&pretty);+ let _ = std::fs::remove_dir_all(&out_dir);+ Ok(())+}++fn pick_first_svg(dir: &Path) -> Option<std::path::PathBuf> {+ if let Ok(read) = std::fs::read_dir(dir) {+ for entry in read.flatten() {+ let p = entry.path();+ if p.extension().and_then(|e| e.to_str()) == Some("svg") {+ return Some(p);+ }+ }+ }+ None+}++fn tempdir(prefix: &str) -> Result<std::path::PathBuf> {+ let base = std::env::temp_dir().join(format!("{prefix}-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&base);+ std::fs::create_dir_all(&base)?;+ Ok(base)+}
src/render_sym.rsadded+184@@ -0,0 +1,184 @@+//! Symbol thumbnail: render the `.kicad_sym` via the canonical sch-wrap path.+//!+//! Adom containers have no local `kicad-cli`, and service-kicad's symbol SVG+//! export only renders parts already IN the shared library (`:library/:name`)+//! — not an arbitrary fetched `.kicad_sym`. So we mirror what gallia symview's+//! `kicad-svg.js` does: wrap the symbol in a minimal one-instance schematic,+//! render THAT to SVG via `service-kicad sch export svg`, then crop the+//! full-page sheet down to the symbol's bounding box.+//!+//! (This is the same canonical algorithm adom-symbol uses internally. Folding+//! it behind an `adom-symbol export-svg` subcommand is a follow-up — see the+//! chip-fetcher dashboard work notes.)++use crate::library::ChipPaths;+use crate::theme;+use anyhow::{anyhow, Context, Result};+use regex::Regex;+use std::process::Command;++pub fn render(chip: &ChipPaths) -> Result<()> {+ let kicad_sym = chip+ .kicad_sym+ .as_ref()+ .ok_or_else(|| anyhow!("no .kicad_sym in {}", chip.dir.display()))?;+ let content = std::fs::read_to_string(kicad_sym)+ .with_context(|| format!("reading {}", kicad_sym.display()))?;++ let (name, block) = extract_first_symbol(&content)+ .ok_or_else(|| anyhow!("no (symbol ...) block in {}", kicad_sym.display()))?;++ let sch_path = std::env::temp_dir()+ .join(format!("chip-thumb-sym-{}-{}.kicad_sch", chip.mpn, std::process::id()));+ std::fs::write(&sch_path, build_schematic(&name, &block))+ .with_context(|| format!("writing {}", sch_path.display()))?;++ let svg_tmp = std::env::temp_dir()+ .join(format!("chip-thumb-sym-{}-{}.svg", chip.mpn, std::process::id()));+ let status = Command::new("service-kicad")+ .args(["sch", "export", "svg"])+ .arg(&sch_path)+ .arg("--out")+ .arg(&svg_tmp)+ .status()+ .context("running service-kicad — install via wiki if missing")?;+ if !status.success() {+ let _ = std::fs::remove_file(&sch_path);+ return Err(anyhow!("service-kicad sch export svg failed for {}", chip.mpn));+ }++ let svg = std::fs::read_to_string(&svg_tmp)+ .with_context(|| format!("reading {}", svg_tmp.display()))?;+ // Drop KiCad 10's schematic page background (warm-white #F5F4EF <g>) so the+ // dark canvas + teal glow + PCB grid the theme injects show through. Without+ // this, that page rect sits on top of the dark canvas and the tile renders+ // light. The symbol body/pin/text colors are KiCad's standard palette, which+ // the theme already recolors.+ let svg = svg+ .replace("fill:#F5F4EF", "fill:none")+ .replace("stroke:#F5F4EF", "stroke:none");+ // Crop to the symbol FIRST (rewrites the viewBox), then apply the Adom dark+ // theme — it sizes its canvas/glow/grid to the (now-cropped) viewBox. This+ // is the styling that makes the symbol thumbnail gorgeous and match the FP/3D+ // tiles; dropping it is what made them look like raw KiCad sheets.+ let cropped = crop_to_symbol(&svg, &block);+ let themed = theme::apply_dark_theme_to_symbol(&cropped);+ std::fs::write(chip.symbol_thumb(), themed)+ .with_context(|| format!("writing {}", chip.symbol_thumb().display()))?;++ let _ = std::fs::remove_file(&sch_path);+ let _ = std::fs::remove_file(&svg_tmp);+ Ok(())+}++/// Extract the first top-level `(symbol "NAME" ...)` block by paren balance.+fn extract_first_symbol(content: &str) -> Option<(String, String)> {+ let start = content.find("(symbol \"")?;+ let name_start = start + "(symbol \"".len();+ let name_end = content[name_start..].find('"')? + name_start;+ let name = content[name_start..name_end].to_string();++ let bytes = content.as_bytes();+ let mut depth = 0i32;+ let mut end = None;+ for (i, &b) in bytes.iter().enumerate().skip(start) {+ match b {+ b'(' => depth += 1,+ b')' => {+ depth -= 1;+ if depth == 0 {+ end = Some(i + 1);+ break;+ }+ }+ _ => {}+ }+ }+ Some((name, content[start..end?].to_string()))+}++/// Minimal one-instance schematic embedding the symbol at (100,100).+fn build_schematic(name: &str, block: &str) -> String {+ format!(+ "(kicad_sch (version 20211123) (generator eeschema)\n \+ (uuid \"00000000-0000-0000-0000-000000000001\")\n (paper \"A4\")\n \+ (lib_symbols\n {block}\n )\n \+ (symbol (lib_id \"{name}\") (at 100 100 0) (unit 1)\n \+ (in_bom yes) (on_board yes)\n \+ (uuid \"00000000-0000-0000-0000-000000000002\")\n )\n \+ (sheet_instances (path \"/\" (page \"1\")))\n)\n"+ )+}++/// Rewrite the rendered full-sheet SVG's viewBox/width/height to crop to the+/// symbol. The SVG is in mm (matching schematic coords); the symbol is placed+/// at (100,100) with KiCad's placement Y-flip. Falls back to the full sheet if+/// no geometry is found.+fn crop_to_symbol(svg: &str, block: &str) -> String {+ let (minx, maxx, miny, maxy) = match symbol_local_bbox(block) {+ Some(b) => b,+ None => return svg.to_string(),+ };+ const M: f64 = 4.0;+ const PX: f64 = 100.0;+ const PY: f64 = 100.0;+ let (sx0, sx1) = (PX + minx - M, PX + maxx + M);+ // KiCad flips Y when placing a symbol on a schematic.+ let (sy0, sy1) = (PY - maxy - M, PY - miny + M);+ let vbx = sx0.min(sx1);+ let vby = sy0.min(sy1);+ let vbw = (sx1 - sx0).abs();+ let vbh = (sy1 - sy0).abs();++ let re = Regex::new(r#"width="[^"]*mm" height="[^"]*mm" viewBox="[^"]*""#).unwrap();+ let replacement = format!(+ "width=\"{vbw:.3}mm\" height=\"{vbh:.3}mm\" viewBox=\"{vbx:.4} {vby:.4} {vbw:.4} {vbh:.4}\""+ );+ re.replace(svg, replacement.as_str()).into_owned()+}++/// Bounding box of the symbol body + pin tips in symbol-local mm.+fn symbol_local_bbox(block: &str) -> Option<(f64, f64, f64, f64)> {+ let mut xs: Vec<f64> = Vec::new();+ let mut ys: Vec<f64> = Vec::new();++ let xy = Regex::new(r"\(xy (-?[\d.]+) (-?[\d.]+)\)").unwrap();+ for c in xy.captures_iter(block) {+ if let (Ok(x), Ok(y)) = (c[1].parse(), c[2].parse()) {+ xs.push(x);+ ys.push(y);+ }+ }+ let se = Regex::new(r"\((?:start|end) (-?[\d.]+) (-?[\d.]+)\)").unwrap();+ for c in se.captures_iter(block) {+ if let (Ok(x), Ok(y)) = (c[1].parse(), c[2].parse()) {+ xs.push(x);+ ys.push(y);+ }+ }+ // Pins: (pin ... (at x y a) ... (length L) ...) — the (at) is the outer tip.+ let pin = Regex::new(r"(?s)\(pin\b.*?\(at (-?[\d.]+) (-?[\d.]+) (-?[\d.]+)\).*?\(length (-?[\d.]+)\)").unwrap();+ for c in pin.captures_iter(block) {+ if let (Ok(x), Ok(y), Ok(a), Ok(l)) = (+ c[1].parse::<f64>(),+ c[2].parse::<f64>(),+ c[3].parse::<f64>(),+ c[4].parse::<f64>(),+ ) {+ let r = a.to_radians();+ for (px, py) in [(x, y), (x + l * r.cos(), y + l * r.sin()), (x - l * r.cos(), y - l * r.sin())] {+ xs.push(px);+ ys.push(py);+ }+ }+ }++ if xs.is_empty() {+ return None;+ }+ let minx = xs.iter().cloned().fold(f64::INFINITY, f64::min);+ let maxx = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);+ let miny = ys.iter().cloned().fold(f64::INFINITY, f64::min);+ let maxy = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);+ Some((minx, maxx, miny, maxy))+}
src/theme.rsadded+270@@ -0,0 +1,270 @@+//! SVG theming for symbols + footprints.+//!+//! Symbol theming ports the regex-replacement pass from+//! gallia/symbol-creator/lib/svg-theme.js applyDarkTheme() into Rust.+//! It transforms KiCad CLI's default-themed symbol SVG into the Adom+//! dark palette (canvas #0d1117, body gradient, white pin labels,+//! amber group labels). The `<style>` body is wrapped in CDATA so the+//! output is valid standalone XML — applyDarkTheme as written embeds a+//! literal `<g class="stroked-text">` inside a CSS comment, which trips+//! strict XML parsers when SVG is loaded via <object> or as a wiki+//! asset.+//!+//! Footprint theming keeps kicad-cli's native KiCad palette (the same+//! red/yellow/magenta the FpView app shows) and just paints them on+//! the canonical FpView dark canvas with a 20-unit PCB-style grid+//! (rgba(255,255,255,0.03) lines), mirroring+//! gallia/viewer/kicad-footprint-viewer.js generateFootprintViewer().++use regex::Regex;++const KICAD_BODY_STROKE: &str = "#840000";+const KICAD_BODY_FILL: &str = "#ffffc2";+const KICAD_TEXT_STROKE: &str = "#A90000";+const KICAD_HIDDEN_TEXT: &str = "#006464";++// Adom dark theme target colors.+const CANVAS_BG: &str = "#0d1117";+const BODY_STROKE: &str = "#30363d";+const BODY_FILL: &str = "#1a2332";+const BODY_FILL_GRAD: &str = "#141c27";+const PIN_WIRE: &str = "#4a5568";+const PIN_NAME: &str = "#ffffff";+const REF_TEXT: &str = "#484f58";+const PIN_DOT: &str = "#4a5568";++/// Apply the Adom dark theme to a KiCad-CLI-exported symbol SVG.+pub fn apply_dark_theme_to_symbol(raw: &str) -> String {+ let mut s: String = raw+ .trim_start_matches('\u{feff}')+ .replace("\r\n", "\n");++ // Strip XML decl and DOCTYPE.+ s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);+ s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);+ s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);+ s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);+ let s = s.trim();++ // Replace KiCad's default white background rect (if present) — older+ // KiCad outputs may include one, newer ones don't. We also unconditionally+ // inject a viewBox-sized canvas <rect> below so the symbol always has+ // an opaque Adom-bg behind it.+ let bg_rect_re = Regex::new(r#"(<rect[^>]*fill=")(?i)white("[^>]*>)"#).unwrap();+ let s = bg_rect_re.replace(s, format!("${{1}}{}${{2}}", CANVAS_BG)).to_string();++ // Pull viewBox so we know how big to make the canvas bg rect.+ let (vb_x, vb_y, vb_w, vb_h) =+ match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)+ .unwrap()+ .captures(&s)+ {+ Some(c) => (+ c[1].parse::<f64>().unwrap_or(0.0),+ c[2].parse::<f64>().unwrap_or(0.0),+ c[3].parse::<f64>().unwrap_or(100.0),+ c[4].parse::<f64>().unwrap_or(100.0),+ ),+ None => (0.0, 0.0, 100.0, 100.0),+ };+ // Canvas: solid Adom dark + the same atmosphere SymView's+ // .canvas-wrap div adds via CSS — radial teal glow at center ++ // 20-unit PCB grid. Baked into the SVG so the thumbnail looks+ // identical to SymView when shown standalone (no HTML wrapper).+ let cx = vb_x + vb_w / 2.0;+ let cy = vb_y + vb_h / 2.0;+ let glow_r = (vb_w.max(vb_h)) * 0.7;+ let canvas_bg_rect = format!(+r##"<defs>+ <radialGradient id="adom-canvas-glow" cx="{cx}" cy="{cy}" r="{r}" gradientUnits="userSpaceOnUse">+ <stop offset="0%" stop-color="#00b8b0" stop-opacity="0.06"/>+ <stop offset="70%" stop-color="#00b8b0" stop-opacity="0"/>+ </radialGradient>+ <pattern id="adom-canvas-grid" x="0" y="0" width="2.54" height="2.54" patternUnits="userSpaceOnUse">+ <line x1="0" y1="0" x2="2.54" y2="0" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>+ <line x1="0" y1="0" x2="0" y2="2.54" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>+ </pattern>+</defs>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{bg}"/>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-grid)"/>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-glow)"/>"##,+ x = vb_x, y = vb_y, w = vb_w, h = vb_h,+ cx = cx, cy = cy, r = glow_r, bg = CANVAS_BG,+ );++ // Inject a <defs> + <style> right after the opening <svg ...>.+ // CDATA-wrap the style body so the literal `<g class=...>` inside the+ // CSS comment doesn't trip strict XML parsers.+ let defs_and_style = format!(+ r#"+<defs>+ <linearGradient id="adom-body-grad" x1="0" y1="0" x2="0" y2="1">+ <stop offset="0%" stop-color="{body_fill}"/>+ <stop offset="100%" stop-color="{body_fill_grad}"/>+ </linearGradient>+</defs>+<style><![CDATA[+ /* Stroked text groups (KiCad renders text as paths in <g class="stroked-text">) */+ g.stroked-text path {{+ stroke: {pin_name} !important;+ fill: {pin_name} !important;+ }}+ text[opacity="0"] {{+ fill: {pin_name} !important;+ }}+ .pin-highlight path {{+ stroke: #00e6dc !important;+ }}+ .pin-highlight g.stroked-text path {{+ stroke: #00e6dc !important;+ }}+ .group-highlight g.stroked-text path {{+ stroke: #FFD180 !important;+ }}+]]></style>"#,+ body_fill = BODY_FILL,+ body_fill_grad = BODY_FILL_GRAD,+ pin_name = PIN_NAME,+ );++ let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();+ let s = svg_open+ .replace(s.as_ref(), format!("${{1}}\n{}\n{}", defs_and_style, canvas_bg_rect))+ .to_string();++ // Direct color replacements — CASE-INSENSITIVE on the hex digits+ // because kicad-cli emits both #840000 and #FFFFC2 (uppercase) and+ // the gallia source pattern uses /gi. Use Regex with `(?i)` for the+ // hex tail; literal prefix stays case-sensitive.+ let s = ci_replace_all(&s, &[+ (format!("stroke:{}", KICAD_BODY_STROKE), format!("stroke:{}", PIN_WIRE)),+ (format!("stroke=\"{}\"", KICAD_BODY_STROKE), format!("stroke=\"{}\"", PIN_WIRE)),+ (format!("fill:{}", KICAD_BODY_STROKE), format!("fill:{}", PIN_DOT)),+ (format!("fill=\"{}\"", KICAD_BODY_STROKE), format!("fill=\"{}\"", PIN_DOT)),+ (format!("fill:{}", KICAD_BODY_FILL), "fill:url(#adom-body-grad)".to_string()),+ (format!("fill=\"{}\"", KICAD_BODY_FILL), "fill=\"url(#adom-body-grad)\"".to_string()),+ (format!("stroke:{}", KICAD_TEXT_STROKE), format!("stroke:{}", PIN_NAME)),+ (format!("stroke=\"{}\"", KICAD_TEXT_STROKE), format!("stroke=\"{}\"", PIN_NAME)),+ (format!("fill:{}", KICAD_TEXT_STROKE), format!("fill:{}", PIN_NAME)),+ (format!("fill=\"{}\"", KICAD_TEXT_STROKE), format!("fill=\"{}\"", PIN_NAME)),+ (format!("stroke:{}", KICAD_HIDDEN_TEXT), format!("stroke:{}", REF_TEXT)),+ (format!("fill:{}", KICAD_HIDDEN_TEXT), format!("fill:{}", REF_TEXT)),+ ]);+ // rgb(...) form for the body fill — kicad-cli sometimes uses rgb()+ // notation for the same #FFFFC2 yellow.+ let rgb_body = Regex::new(r#"fill="rgb\(\s*255\s*,\s*255\s*,\s*194\s*\)""#).unwrap();+ let s = rgb_body.replace_all(&s, "fill=\"url(#adom-body-grad)\"").to_string();++ // Strip the root <g> default fill:#000000 so symbol body shapes don't+ // come out solid black on the dark canvas.+ let root_g_black = Regex::new(r#"(<g\s+style="fill:#000000[^"]*")"#).unwrap();+ let s = root_g_black+ .replace(&s, |c: ®ex::Captures| {+ c[1].replace("fill:#000000", "fill:none")+ })+ .to_string();++ s+}++/// Re-theme a KiCad-CLI footprint SVG to match the FpView app:+/// - dark canvas #0d1117 + 20-unit PCB-style grid behind the geometry+/// - strip invisible REF**/value text + stroked-text groups (else they+/// inflate the viewBox)+/// - keep kicad-cli's native pad/silk/courtyard colors as-is, just+/// painted on the dark grid+pub fn theme_footprint_svg(raw: &str) -> String {+ let mut s = raw.replace("\r\n", "\n");+ s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);+ s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);+ s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);+ s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);+ s = strip_all_matches(&s, r#"<text[^>]*opacity="0"[^<]*</text>"#);+ s = strip_all_dotall(&s, r#"<g class="stroked-text">.*?</g>"#);+ let s = s.trim();++ // Pull the SVG's viewBox so the bg rect covers the entire canvas.+ let vb = match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)+ .unwrap()+ .captures(s)+ {+ Some(c) => (+ c[1].parse::<f64>().unwrap_or(-50.0),+ c[2].parse::<f64>().unwrap_or(-50.0),+ c[3].parse::<f64>().unwrap_or(100.0),+ c[4].parse::<f64>().unwrap_or(100.0),+ ),+ None => (-50.0, -50.0, 100.0, 100.0),+ };++ let defs = r#"+<defs>+ <pattern id="adom-pcb-grid" x="0" y="0" width="1" height="1" patternUnits="userSpaceOnUse">+ <line x1="0" y1="0" x2="1" y2="0" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>+ <line x1="0" y1="0" x2="0" y2="1" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>+ </pattern>+</defs>"#;+ let bg = format!(+ "<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"{CANVAS_BG}\"/>\n<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"url(#adom-pcb-grid)\"/>",+ x = vb.0, y = vb.1, w = vb.2, h = vb.3,+ );++ let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();+ let s = svg_open+ .replace(s, format!("${{1}}\n{}\n{}", defs, bg))+ .to_string();++ // Strip any KiCad-CLI default white-ish page-background rect that+ // would render in front of our dark canvas.+ let white_bg_rect = Regex::new(+ r#"<rect[^>]*fill="(?i)#?(?:f{2,6}|white)"[^>]*/?>"#,+ ).unwrap();+ let s = white_bg_rect.replace_all(&s, "").to_string();++ s+}++fn strip_first_match(s: &str, pat: &str) -> String {+ Regex::new(pat).unwrap().replace(s, "").to_string()+}+fn strip_all_matches(s: &str, pat: &str) -> String {+ Regex::new(pat).unwrap().replace_all(s, "").to_string()+}+fn strip_all_dotall(s: &str, pat: &str) -> String {+ Regex::new(&format!(r"(?s){}", pat))+ .unwrap()+ .replace_all(s, "")+ .to_string()+}+fn simple_replace_all(s: &str, pairs: &[(String, String)]) -> String {+ let mut out = s.to_string();+ for (from, to) in pairs {+ out = out.replace(from, to);+ }+ out+}++/// Case-insensitive replace_all on each (from, to) pair. The literal+/// prefix (`fill:`, `stroke="`) stays case-sensitive; only the hex tail+/// after the `#` is matched insensitively. This matches the JS source+/// `/.../gi` semantics from gallia/symbol-creator/lib/svg-theme.js.+fn ci_replace_all(s: &str, pairs: &[(String, String)]) -> String {+ let mut out = s.to_string();+ for (from, to) in pairs {+ // Split prefix-up-to-and-including-# vs. hex-tail at the first '#'+ // so we can keep the prefix literal and apply (?i) to the hex.+ let pat = if let Some(hash_idx) = from.find('#') {+ let prefix = regex::escape(&from[..hash_idx + 1]);+ let hex = regex::escape(&from[hash_idx + 1..]);+ format!("{prefix}(?i){hex}")+ } else {+ regex::escape(from)+ };+ let re = match Regex::new(&pat) {+ Ok(r) => r,+ Err(_) => continue,+ };+ out = re.replace_all(&out, to.as_str()).to_string();+ }+ out+}
src/ui.rsadded+93@@ -0,0 +1,93 @@+//! ANSI-colored output helpers, mirroring the ok!/err!/hint!/note! pattern+//! used by step2glb and chip-fetcher so log scraping is consistent across+//! the Adom CLI ecosystem. is_terminal()-gated per gallia/skills/+//! adom-cli-design/SKILL.md — never leak raw ANSI to pipes / log files.++#![allow(dead_code)]++use std::io::IsTerminal;+use std::sync::OnceLock;++pub const GREEN: &str = "\x1b[32m";+pub const RED: &str = "\x1b[31m";+pub const YELLOW: &str = "\x1b[33m";+pub const CYAN: &str = "\x1b[36m";+pub const BOLD: &str = "\x1b[1m";+pub const DIM: &str = "\x1b[2m";+pub const RESET: &str = "\x1b[0m";++static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();++pub fn stdout_is_tty() -> bool {+ *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal())+}+pub fn stderr_is_tty() -> bool {+ *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal())+}++/// Strip CSI sequences like `\x1b[36m`, `\x1b[0m`, `\x1b[1;32m` etc.+/// Used when stdout/stderr isn't a TTY so log files don't get raw escape+/// codes leaked into them.+pub fn strip_ansi(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ let mut chars = s.chars().peekable();+ while let Some(c) = chars.next() {+ if c == '\x1b' && chars.peek() == Some(&'[') {+ chars.next();+ for c2 in chars.by_ref() {+ if c2.is_ascii_alphabetic() {+ break;+ }+ }+ continue;+ }+ out.push(c);+ }+ out+}++pub fn ok<S: AsRef<str>>(msg: S) {+ let s = msg.as_ref();+ if stdout_is_tty() {+ println!("{GREEN}{BOLD}OK:{RESET} {s}");+ } else {+ println!("OK: {}", strip_ansi(s));+ }+}+pub fn err<S: AsRef<str>>(msg: S) {+ let s = msg.as_ref();+ if stderr_is_tty() {+ eprintln!("{RED}{BOLD}ERROR:{RESET} {s}");+ } else {+ eprintln!("ERROR: {}", strip_ansi(s));+ }+}+pub fn warn<S: AsRef<str>>(msg: S) {+ let s = msg.as_ref();+ if stderr_is_tty() {+ eprintln!("{YELLOW}{BOLD}WARN:{RESET} {s}");+ } else {+ eprintln!("WARN: {}", strip_ansi(s));+ }+}+pub fn hint<S: AsRef<str>>(msg: S) {+ let s = msg.as_ref();+ if stderr_is_tty() {+ eprintln!("{DIM}Hint:{RESET} {s}");+ } else {+ eprintln!("Hint: {}", strip_ansi(s));+ }+}++#[macro_export]+macro_rules! note {+ ($($arg:tt)*) => {{+ let __s = format!($($arg)*);+ if $crate::ui::stdout_is_tty() {+ println!("{}", __s);+ } else {+ println!("{}", $crate::ui::strip_ansi(&__s));+ }+ }};+}