app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
//! adom-footprint — standalone Rust CLI + live app for KiCad PCB footprints.
//! No Node, no gallia. Generation is native Rust (`fp_gen`); SVG rendering goes
//! through service-kicad (`render`); `serve` runs the AI-drivable live app.
mod provenance;
mod deliver;
mod fp_gen;
mod paste;
mod fp_render;
mod render;
mod server;
mod theme;
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use regex::Regex;
use std::fs;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "adom-footprint", version, about = "Create and preview KiCad PCB footprints (standalone Rust)")]
struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Create a footprint from structured pad data (JSON on stdin or --file).
Create {
#[arg(long)]
file: Option<PathBuf>,
#[arg(long)]
out: Option<PathBuf>,
},
/// Render an existing .kicad_mod to a themed viewer HTML.
Preview {
#[arg(long)]
file: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
},
/// Render any `.kicad_mod` to a themed thumbnail SVG (for source variants).
Render {
#[arg(long)]
file: PathBuf,
#[arg(long)]
out: PathBuf,
},
/// Render a ds2sf `<mpn>-footprint.extracted.json` to a themed thumbnail SVG.
/// Used by chip-fetcher for the ds2sf footprint source variant.
RenderDs2sf {
#[arg(long)]
file: PathBuf,
#[arg(long)]
out: PathBuf,
},
/// Compute the canonical RefDes/Value `textPlacement` for a footprint and
/// (with --write) inject it into a `<mpn>-footprint.extracted.json` so every
/// exporter validates against ONE explicit placement instead of re-guessing.
/// Accepts a ds2sf extracted.json or an adom-footprint create JSON.
Placement {
#[arg(long)]
file: PathBuf,
/// Write the `textPlacement` block back into the JSON file (in place).
#[arg(long)]
write: bool,
},
/// Export a self-contained, INTERACTIVE HTML package (pan/zoom + a Layers
/// panel with the InstaPCB paste-dot overlay) for embedding in an iframe —
/// e.g. a wiki component page. No server, no external assets.
Embed {
#[arg(long)]
file: PathBuf,
/// Output .html path (default: <footprintName>-embed.html next to the input)
#[arg(long)]
out: Option<PathBuf>,
},
/// Drive the RUNNING live app's UI from the CLI (ai-driven-apps contract).
/// Examples: `ui layer silk off` · `ui layer paste-dots on` · `ui zoom fit` ·
/// `ui send kicad` · `ui toast "hi"` · `ui state` · `ui list`.
Ui {
/// Action: layer | zoom | send | toast | state | list
action: String,
/// Arguments (e.g. `layer silk off`)
args: Vec<String>,
#[arg(long, default_value_t = 8783)]
port: u16,
},
/// Run the live app server (AI-drivable: /state, /eval, /console).
Serve {
#[arg(long, default_value_t = 8782)]
port: u16,
#[arg(long)]
file: Option<PathBuf>,
},
}
/// Drive the running app's `window.ui` API over the /eval channel.
fn cmd_ui(action: String, args: Vec<String>, port: u16) -> Result<()> {
let arglist: Vec<String> = args.iter().map(|a| serde_json::to_string(a).unwrap_or_else(|_| format!("{a:?}"))).collect();
let code = format!("(window.ui && window.ui.{action}) ? window.ui.{action}({}) : 'window.ui not ready — is a browser tab open?'", arglist.join(","));
let base = format!("http://127.0.0.1:{port}");
let client = reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(10)).build()?;
let resp = client.post(format!("{base}/eval")).json(&serde_json::json!({ "code": code })).send()
.with_context(|| format!("POST {base}/eval — is the app running? (adom-footprint serve --port {port})"))?;
let id = resp.json::<serde_json::Value>()?.get("id").and_then(|v| v.as_str()).map(String::from).ok_or_else(|| anyhow!("no eval id"))?;
for _ in 0..60 {
std::thread::sleep(std::time::Duration::from_millis(120));
let v: serde_json::Value = client.get(format!("{base}/eval/{id}")).send()?.json().unwrap_or(serde_json::Value::Null);
if v.get("done").and_then(|d| d.as_bool()) == Some(true) {
let inner = v.get("result").cloned().unwrap_or(serde_json::Value::Null);
if let Some(err) = inner.get("error") { if !err.is_null() { return Err(anyhow!("UI error: {}", err.as_str().unwrap_or(""))); } }
match inner.get("result") {
Some(r) if r.is_string() => println!("{}", r.as_str().unwrap_or("")),
Some(r) if !r.is_null() => println!("{}", serde_json::to_string_pretty(r).unwrap_or_default()),
_ => println!("ok"),
}
return Ok(());
}
}
Err(anyhow!("timed out — open a browser tab on http://127.0.0.1:{port}/ so the UI can run the command"))
}
#[derive(serde::Deserialize)]
struct CreateInput {
#[serde(rename = "footprintName")]
footprint_name: String,
pads: Vec<JsonPad>,
#[serde(default)]
body: Option<JsonRect>,
#[serde(default)]
courtyard: Option<JsonRect>,
#[serde(default)]
description: String,
#[serde(default)]
tags: String,
/// Body/silk outline shape — accept `bodyShape`, `body_shape`, or `shape`
/// ("circle" for round parts → fp_circle silk/fab instead of a box).
#[serde(default, rename = "bodyShape", alias = "body_shape", alias = "shape")]
body_shape: Option<String>,
}
#[derive(serde::Deserialize)]
struct JsonPad {
number: String,
x: f64,
y: f64,
dx: f64,
dy: f64,
#[serde(default)]
shape: Option<String>,
#[serde(default)]
angle: Option<f64>,
#[serde(default)]
layers: Option<String>,
/// Mount type — accept `mount`, `type`, or `padType` (smd | thru_hole | np_thru_hole).
#[serde(default, alias = "type", alias = "padType")]
mount: Option<String>,
/// Drill diameter (mm) — accept `drill`, `drillDiameter`, or `hole`.
#[serde(default, alias = "drillDiameter", alias = "hole")]
drill: Option<f64>,
}
#[derive(serde::Deserialize)]
struct JsonRect {
x1: f64,
y1: f64,
x2: f64,
y2: f64,
}
fn main() {
if let Err(e) = run() {
eprintln!("ERROR: {e:#}");
std::process::exit(1);
}
}
/// Build the standalone interactive embed HTML for one footprint — the lean,
/// self-contained viewer driven by the SHARED viewer core (same as the live app).
/// Reused by both the `embed` CLI and the live server's `/embed` route.
pub fn build_embed_html(kicad_mod: &str, name: &str) -> Result<String> {
let svg = render::render_footprint_svg_with_paste(kicad_mod, bbox_from_mod(kicad_mod))?;
let aps = paste::paste_apertures(kicad_mod);
let dot_count = paste::paste_overlay_svg(&aps, 0.0, 0.0).2;
let meta = serde_json::json!({ "pasteApertures": aps.len(), "pasteDots": dot_count });
Ok(include_str!("embed.html")
.replace("/*__AV_CSS__*/", include_str!("viewer_core.css"))
.replace("/*__AV_JS__*/", include_str!("viewer_core.js"))
.replace("__NAME__", &esc(name))
.replace("__SVG__", &svg)
.replace("__META__", &meta.to_string()))
}
fn cmd_embed(file: PathBuf, out: Option<PathBuf>) -> Result<()> {
let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());
let html = build_embed_html(&content, &name)?;
let out = out.unwrap_or_else(|| {
let folder = file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
folder.join(format!("{name}-embed.html"))
});
fs::write(&out, &html).with_context(|| format!("writing {}", out.display()))?;
println!("OK: interactive embed ({} pads) → {}", content.matches("(pad ").count(), out.display());
Ok(())
}
fn run() -> Result<()> {
match Cli::parse().command {
Cmd::Create { file, out } => cmd_create(file, out),
Cmd::Render { file, out } => {
let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let svg = render::render_footprint_svg(&content, bbox_from_mod(&content))?;
fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;
println!("OK: rendered {} → {}", file.display(), out.display());
Ok(())
}
Cmd::RenderDs2sf { file, out } => cmd_render_ds2sf(file, out),
Cmd::Placement { file, write } => cmd_placement(file, write),
Cmd::Embed { file, out } => cmd_embed(file, out),
Cmd::Ui { action, args, port } => cmd_ui(action, args, port),
Cmd::Preview { file, out } => cmd_preview(file, out),
Cmd::Serve { port, file } => {
if let Some(f) = file {
let content = fs::read_to_string(&f).with_context(|| format!("reading {}", f.display()))?;
let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());
// Always bake the solder-paste dot group (same as /load) so the
// paste-dots layer is present and toggleable in all cases.
let svg = render::render_footprint_svg_with_paste(&content, bbox_from_mod(&content))?;
// When launched on a chip-fetcher library chip
// (<root>/<MPN>/<MPN>*.kicad_mod), wire the left library rail: mark
// this chip current and set the root so the rail lists its siblings.
let mut mpn = String::new();
if let Some(dir) = f.parent() {
if let Some(dname) = dir.file_name().and_then(|s| s.to_str()) {
mpn = dname.to_string();
}
if let Some(root) = dir.parent() {
server::set_library_root(root.to_string_lossy().to_string());
}
}
server::set_loaded_path(Some(f.to_string_lossy().to_string()));
server::set_state(server::AppState {
footprint_name: name,
svg,
pad_count: content.matches("(pad ").count(),
kicad_mod: content,
mpn,
..Default::default()
});
}
server::run(port)
}
}
}
fn to_input(c: &CreateInput) -> fp_gen::FpInput {
fp_gen::FpInput {
name: c.footprint_name.clone(),
pads: c
.pads
.iter()
.map(|p| fp_gen::Pad {
number: p.number.clone(),
x: p.x,
y: p.y,
dx: p.dx,
dy: p.dy,
shape: p.shape.clone(),
angle: p.angle,
layers: p.layers.clone(),
mount: p.mount.clone(),
drill: p.drill,
})
.collect(),
body: c.body.as_ref().map(|r| fp_gen::Rect { x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2 }),
courtyard: c.courtyard.as_ref().map(|r| fp_gen::Rect { x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2 }),
description: c.description.clone(),
tags: c.tags.clone(),
body_shape: c.body_shape.clone(),
}
}
fn cmd_create(file: Option<PathBuf>, out: Option<PathBuf>) -> Result<()> {
let body = match file {
Some(p) => fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?,
None => {
use std::io::Read;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
s
}
};
let input: CreateInput = serde_json::from_str(&body).context("parsing create JSON")?;
if input.footprint_name.is_empty() {
return Err(anyhow!("footprintName is required"));
}
if input.pads.is_empty() {
return Err(anyhow!("pads array is required"));
}
let fpinput = to_input(&input);
let kicad_mod = fp_gen::generate_kicad_mod(&fpinput);
let bbox = fp_gen::bbox(&fpinput);
let folder = out.unwrap_or_else(|| PathBuf::from(&input.footprint_name));
fs::create_dir_all(&folder)?;
let mod_path = folder.join(format!("{}.kicad_mod", input.footprint_name));
fs::write(&mod_path, &kicad_mod)?;
let svg = render::render_footprint_svg(&kicad_mod, bbox)?;
let svg_path = folder.join(format!("{}.svg", input.footprint_name));
fs::write(&svg_path, &svg)?;
let html_path = folder.join(format!("{}-viewer.html", input.footprint_name));
fs::write(&html_path, viewer_html(&input.footprint_name, &svg, input.pads.len(), &input.description, &input.tags))?;
println!("created {}", mod_path.display());
println!("svg {}", svg_path.display());
println!("viewer {}", html_path.display());
Ok(())
}
fn cmd_render_ds2sf(file: PathBuf, out: PathBuf) -> Result<()> {
let raw = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let v: serde_json::Value = serde_json::from_str(&raw).context("parsing ds2sf footprint JSON")?;
let input = fpinput_from_ds2sf(&v)?;
let kicad_mod = fp_gen::generate_kicad_mod(&input);
let svg = render::render_footprint_svg(&kicad_mod, fp_gen::bbox(&input))?;
fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;
println!("OK: rendered ds2sf footprint → {} ({} pads)", out.display(), input.pads.len());
Ok(())
}
/// Build an `FpInput` from a ds2sf `<mpn>-footprint.extracted.json` value
/// (pads[].at/.size, bodyDimensions, optional courtyard rect).
fn fpinput_from_ds2sf(v: &serde_json::Value) -> Result<fp_gen::FpInput> {
let name = v.get("footprintName").and_then(|x| x.as_str()).unwrap_or("footprint").to_string();
let arr = v.get("pads").and_then(|x| x.as_array()).ok_or_else(|| anyhow!("footprint JSON has no pads[]"))?;
let num = |a: Option<&serde_json::Value>| a.and_then(|x| x.as_f64()).unwrap_or(0.0);
let mut pads = Vec::new();
for p in arr {
let at = p.get("at").and_then(|x| x.as_array());
let size = p.get("size").and_then(|x| x.as_array());
pads.push(fp_gen::Pad {
number: p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string(),
x: num(at.and_then(|a| a.first())),
y: num(at.and_then(|a| a.get(1))),
dx: num(size.and_then(|a| a.first())),
dy: num(size.and_then(|a| a.get(1))),
shape: p.get("shape").and_then(|x| x.as_str()).map(String::from),
angle: p.get("rotation").and_then(|x| x.as_f64()).or_else(|| p.get("angle").and_then(|x| x.as_f64())),
layers: None,
mount: p.get("mount").or_else(|| p.get("type")).and_then(|x| x.as_str()).map(String::from),
drill: p.get("drill").or_else(|| p.get("drillDiameter")).or_else(|| p.get("hole")).and_then(|x| x.as_f64()),
});
}
if pads.is_empty() {
return Err(anyhow!("footprint extraction has no pads"));
}
// Body outline from bodyDimensions ([w,h] or {x,y}/{w,h}), centered.
let body = v.get("bodyDimensions").and_then(|b| {
let (w, h) = if let Some(a) = b.as_array() {
(a.first().and_then(|x| x.as_f64())?, a.get(1).and_then(|x| x.as_f64())?)
} else {
let g = |k1: &str, k2: &str| b.get(k1).or_else(|| b.get(k2)).and_then(|x| x.as_f64());
(g("x", "w")?, g("y", "h")?)
};
Some(fp_gen::Rect { x1: -w / 2.0, y1: -h / 2.0, x2: w / 2.0, y2: h / 2.0 })
});
// Optional explicit courtyard rect {x1,y1,x2,y2} if the extraction has one.
let courtyard = v.get("courtyard").and_then(|c| {
let g = |k: &str| c.get(k).and_then(|x| x.as_f64());
Some(fp_gen::Rect { x1: g("x1")?, y1: g("y1")?, x2: g("x2")?, y2: g("y2")? })
});
Ok(fp_gen::FpInput {
name,
pads,
body,
courtyard,
description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),
tags: String::new(),
body_shape: v.get("bodyShape").or_else(|| v.get("shape")).and_then(|x| x.as_str()).map(String::from),
})
}
/// Compute the canonical `textPlacement` and (optionally) write it into the JSON.
/// Accepts a ds2sf extracted.json (pads[].at/.size) or an adom-footprint create
/// JSON (pads[].x/.dx). Placement is computed ONCE here (fp_gen::text_placement) —
/// exporters consume it; they do not re-derive it.
fn cmd_placement(file: PathBuf, write: bool) -> Result<()> {
let raw = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let mut v: serde_json::Value = serde_json::from_str(&raw).context("parsing footprint JSON")?;
let is_create = v.get("pads").and_then(|p| p.as_array()).and_then(|a| a.first())
.map(|p| p.get("x").is_some() && p.get("dx").is_some())
.unwrap_or(false);
let input = if is_create {
let c: CreateInput = serde_json::from_value(v.clone()).context("parsing create JSON")?;
to_input(&c)
} else {
fpinput_from_ds2sf(&v)?
};
let tp = fp_gen::text_placement(&input);
let tp_val = serde_json::to_value(&tp)?;
println!("{}", serde_json::to_string_pretty(&tp_val)?);
if write {
let obj = v.as_object_mut().ok_or_else(|| anyhow!("JSON root is not an object; cannot add textPlacement"))?;
obj.insert("textPlacement".to_string(), tp_val);
fs::write(&file, serde_json::to_string_pretty(&v)? + "\n").with_context(|| format!("writing {}", file.display()))?;
eprintln!("wrote textPlacement → {}", file.display());
}
Ok(())
}
fn cmd_preview(file: PathBuf, out: Option<PathBuf>) -> Result<()> {
let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());
let svg = render::render_footprint_svg(&content, bbox_from_mod(&content))?;
let folder = out.unwrap_or_else(|| file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")));
let pads = content.matches("(pad ").count();
let html_path = folder.join(format!("{name}-viewer.html"));
fs::write(&html_path, viewer_html(&name, &svg, pads, "", ""))?;
println!("viewer {}", html_path.display());
Ok(())
}
pub(crate) fn first_footprint_name(content: &str) -> Option<String> {
// Modern: (footprint "NAME" …). Legacy KiCad 5/6: (footprint NAME (layer …)).
if let Some(start) = content.find("(footprint \"") {
let i = start + "(footprint \"".len();
let j = content[i..].find('"')? + i;
return Some(content[i..j].to_string());
}
let i = content.find("(footprint ")? + "(footprint ".len();
let rest = &content[i..];
let end = rest.find(|c: char| c.is_whitespace() || c == '(').unwrap_or(rest.len());
let name = rest[..end].trim();
if name.is_empty() { None } else { Some(name.to_string()) }
}
/// Current RefDes (`reference`/`>NAME`) and Value (`value`/`>VALUE`) text
/// positions + size, parsed from a `.kicad_mod` — fed to the live app so it can
/// render the draggable text labels at the right spot.
pub(crate) fn fp_text_state(km: &str) -> serde_json::Value {
let one = |kind: &str| -> serde_json::Value {
// `(at x y [rot] [unlocked|locked])` — tolerate the trailing rotation and/or
// KiCad's lock keyword (newer footprints write `(at 0 0 unlocked)`).
let at = Regex::new(&format!(
r#"\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+(-?[\d.]+)\s+(-?[\d.]+)[^)]*\)"#
)).unwrap();
match at.captures(km) {
Some(c) => {
let x: f64 = c[1].parse().unwrap_or(0.0);
let y: f64 = c[2].parse().unwrap_or(0.0);
let size = Regex::new(&format!(r#"\(fp_text {kind}\b[\s\S]*?\(size\s+(-?[\d.]+)"#))
.unwrap()
.captures(km)
.and_then(|m| m[1].parse::<f64>().ok())
.unwrap_or(1.0);
// Hidden? Look for a `hide` keyword within this fp_text's block (up
// to the next fp_text/pad) — common in vendor footprints (value hidden
// at 0,0). The editor skips hidden labels so they don't garble.
let start = c.get(0).map(|m| m.start()).unwrap_or(0);
let tail = &km[start..];
let end = tail
.get(1..)
.and_then(|t| t.find("(fp_text ").map(|i| i + 1))
.or_else(|| tail.find("\n (pad "))
.unwrap_or_else(|| tail.len().min(400));
let hidden = tail[..end.min(tail.len())].contains(" hide");
serde_json::json!({ "x": x, "y": y, "size": size, "hidden": hidden })
}
None => serde_json::Value::Null,
}
};
serde_json::json!({ "reference": one("reference"), "value": one("value") })
}
/// Rewrite the `reference`/`value` fp_text `(at x y)` positions in a `.kicad_mod`
/// (rotation dropped → horizontal, per the placement rule). Other text untouched.
pub(crate) fn rewrite_fp_text(km: &str, refx: f64, refy: f64, valx: f64, valy: f64) -> String {
let r2 = |v: f64| (v * 100.0).round() / 100.0;
let mut out = km.to_string();
for (kind, x, y) in [("reference", r2(refx), r2(refy)), ("value", r2(valx), r2(valy))] {
// Drop any trailing rotation / lock keyword → clean horizontal `(at x y)`.
let re = Regex::new(&format!(
r#"(\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+)-?[\d.]+\s+-?[\d.]+[^)]*(\))"#
)).unwrap();
let rep = format!("${{1}}{x} {y}${{2}}");
out = re.replace(&out, rep.as_str()).into_owned();
}
out
}
/// Rewrite the font `(size H W)` + `(thickness T)` of a `reference`/`value`
/// fp_text (thickness tracks size at ~15%). No-op if that text has no font block.
pub(crate) fn set_fp_text_size(km: &str, kind: &str, size: f64) -> String {
let r2 = |v: f64| (v * 100.0).round() / 100.0;
let (sz, th) = (r2(size), r2(size * 0.15));
let re = Regex::new(&format!(
r#"(\(fp_text {kind}\b[\s\S]*?\(size )[-\d.]+ [-\d.]+(\)[\s\S]*?\(thickness )[-\d.]+"#
)).unwrap();
let rep = format!("${{1}}{sz} {sz}${{2}}{th}");
re.replace(km, rep.as_str()).into_owned()
}
/// Bounding box (minx,miny,maxx,maxy) from pad + fp_rect extents in a .kicad_mod.
pub(crate) fn bbox_from_mod(content: &str) -> (f64, f64, f64, f64) {
let mut xs = Vec::new();
let mut ys = Vec::new();
let pad = Regex::new(r"\(at (-?[\d.]+) (-?[\d.]+)(?: [-\d.]+)?\) \(size (-?[\d.]+) (-?[\d.]+)\)").unwrap();
for c in pad.captures_iter(content) {
if let (Ok(x), Ok(y), Ok(dx), Ok(dy)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {
xs.push(x - dx / 2.0);
xs.push(x + dx / 2.0);
ys.push(y - dy / 2.0);
ys.push(y + dy / 2.0);
}
}
let rect = Regex::new(r"\(start (-?[\d.]+) (-?[\d.]+)\) \(end (-?[\d.]+) (-?[\d.]+)\)").unwrap();
for c in rect.captures_iter(content) {
if let (Ok(x1), Ok(y1), Ok(x2), Ok(y2)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {
xs.push(x1);
xs.push(x2);
ys.push(y1);
ys.push(y2);
}
}
if xs.is_empty() {
return (-2.0, -2.0, 2.0, 2.0);
}
(
xs.iter().cloned().fold(f64::INFINITY, f64::min),
ys.iter().cloned().fold(f64::INFINITY, f64::min),
xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
)
}
fn viewer_html(name: &str, svg: &str, pads: usize, desc: &str, tags: &str) -> String {
let meta = [format!("{pads} pads"), tags.to_string()].into_iter().filter(|s| !s.is_empty() && s != "0 pads").collect::<Vec<_>>().join(" · ");
format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{t} — adom-footprint</title>\
<style>body{{margin:0;background:#0d1117;color:#e6edf3;font-family:-apple-system,sans-serif;min-height:100vh;display:flex;flex-direction:column}}\
header{{display:flex;align-items:center;gap:10px;padding:14px 22px;border-bottom:1px solid #222b38}}.dot{{width:10px;height:10px;border-radius:50%;background:#00e6dc;box-shadow:0 0 12px #00e6dc}}\
.brand{{font-weight:700}}.brand span{{color:#8b949e;font-weight:400}}.wrap{{flex:1;display:flex;align-items:center;justify-content:center;padding:32px}}\
.card{{background:#11161f;border:1px solid #222b38;border-radius:14px;padding:22px 26px;max-width:min(92vw,720px)}}.name{{font-size:22px;font-weight:700}}.meta{{color:#8b949e;font-size:13px;margin-top:2px}}.desc{{color:#aeb6c2;font-size:13px;margin-top:6px}}\
.svgbox{{margin-top:16px;border:1px solid #222b38;border-radius:10px;background:#0d1117;overflow:hidden}}.svgbox svg{{display:block;width:100%;height:auto;max-height:64vh}}footer{{text-align:center;color:#5b6675;font-size:11px;padding:10px}}</style></head>\
<body><header><span class=\"dot\"></span><span class=\"brand\">adom-footprint <span>· PCB footprint viewer</span></span></header>\
<div class=\"wrap\"><div class=\"card\"><div class=\"name\">{t}</div>{m}{d}<div class=\"svgbox\">{svg}</div></div></div>\
<footer>rendered locally in Rust — no KiCad, no service round-trip</footer></body></html>",
t = esc(name),
m = if meta.is_empty() { String::new() } else { format!("<div class=\"meta\">{}</div>", esc(&meta)) },
d = if desc.is_empty() { String::new() } else { format!("<div class=\"desc\">{}</div>", esc(desc)) },
svg = svg,
)
}
fn esc(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}