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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
//! 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))
}