app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
//! Render a `.kicad_mod` to a themed, cropped footprint SVG via **service-kicad**
//! (no local KiCad, no gallia). Wraps the footprint in a minimal one-footprint
//! board, POSTs to `/kicad/pcb/export/svg`, crops the page to the footprint
//! bounding box, and applies the Adom footprint theme.
use anyhow::{anyhow, Context, Result};
use regex::Regex;
const KICAD_SERVICE_DEFAULT: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";
fn service_url() -> String {
std::env::var("KICAD_SERVICE_API")
.unwrap_or_else(|_| KICAD_SERVICE_DEFAULT.to_string())
.trim_end_matches('/')
.to_string()
}
// Where the footprint is placed on the wrapper board (page center, fully on-page).
const OX: f64 = 148.5;
const OY: f64 = 105.0;
const MARGIN: f64 = 1.0;
/// Render the footprint to a themed SVG, cropped to `bbox` (footprint-local mm).
/// CLEAN by default — NO InstaPCB paste-dot overlay. John's standing rule: the
/// default footprint render (cards, thumbnails, preview, the live app's initial
/// view) must show the bare footprint; the InstaPCB paste layer is opt-in. Use
/// `render_footprint_svg_with_paste` only where InstaPCB is the point (`embed`).
pub fn render_footprint_svg(kicad_mod: &str, _bbox: (f64, f64, f64, f64)) -> Result<String> {
let normalized = normalize_legacy(kicad_mod);
let fp = crate::fp_render::parse(&normalized);
let (svg, _ox, _oy) = crate::fp_render::render(&fp);
Ok(svg)
}
/// Same render, PLUS the InstaPCB paste apertures + 300 µm jetted-dot overlay.
/// Only the `embed` flow (the interactive InstaPCB layer panel) uses this.
pub fn render_footprint_svg_with_paste(kicad_mod: &str, _bbox: (f64, f64, f64, f64)) -> Result<String> {
let normalized = normalize_legacy(kicad_mod);
let fp = crate::fp_render::parse(&normalized);
let (svg, ox, oy) = crate::fp_render::render(&fp);
let aps = crate::paste::paste_apertures(&normalized);
let (apert_g, dots_g, _n) = crate::paste::paste_overlay_svg(&aps, ox, oy);
let out = if let Some(idx) = svg.rfind("</svg>") {
let mut s = String::with_capacity(svg.len() + apert_g.len() + dots_g.len());
s.push_str(&svg[..idx]);
s.push_str(&apert_g);
s.push_str(&dots_g);
s.push_str(&svg[idx..]);
s
} else {
svg
};
Ok(out)
}
/// Bounding box of the real drawn geometry in a service-kicad PCB SVG, in the
/// SVG's own user units (mm). Scans `<circle>`, `<rect>`, and `<path d=…>`
/// coordinate pairs, then keeps only the points within `CLUSTER_R` of the
/// **known placement center** (`cx_seed`, `cy_seed`) — where we placed the
/// footprint on the page. This rejects the A4 page frame AND the worksheet
/// title block (a dense ~1500-point text cluster in the bottom-right corner
/// that would otherwise capture the median and blow up the crop).
fn svg_geometry_bbox(svg: &str, cx_seed: f64, cy_seed: f64) -> Option<(f64, f64, f64, f64)> {
const CLUSTER_R: f64 = 30.0; // half-extent ceiling: parts up to ~60 mm across
let mut xs: Vec<f64> = Vec::new();
let mut ys: Vec<f64> = Vec::new();
let circle = Regex::new(r#"<circle\b[^>]*?cx="(-?[\d.]+)"[^>]*?cy="(-?[\d.]+)"(?:[^>]*?r="(-?[\d.]+)")?"#).unwrap();
for c in circle.captures_iter(svg) {
if let (Ok(cx), Ok(cy)) = (c[1].parse::<f64>(), c[2].parse::<f64>()) {
let r = c.get(3).and_then(|m| m.as_str().parse::<f64>().ok()).unwrap_or(0.0);
xs.push(cx - r); xs.push(cx + r);
ys.push(cy - r); ys.push(cy + r);
}
}
let rect = Regex::new(r#"<rect\b[^>]*?x="(-?[\d.]+)"[^>]*?y="(-?[\d.]+)"[^>]*?width="(-?[\d.]+)"[^>]*?height="(-?[\d.]+)""#).unwrap();
for c in rect.captures_iter(svg) {
if let (Ok(x), Ok(y), Ok(w), Ok(h)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {
xs.push(x); xs.push(x + w);
ys.push(y); ys.push(y + h);
}
}
let dattr = Regex::new(r#"\bd="([^"]*)""#).unwrap();
let pair = Regex::new(r"(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)").unwrap();
for d in dattr.captures_iter(svg) {
for p in pair.captures_iter(&d[1]) {
if let (Ok(x), Ok(y)) = (p[1].parse::<f64>(), p[2].parse::<f64>()) {
xs.push(x);
ys.push(y);
}
}
}
if xs.is_empty() {
return None;
}
let (mut minx, mut miny, mut maxx, mut maxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for (&x, &y) in xs.iter().zip(ys.iter()) {
if (x - cx_seed).abs() > CLUSTER_R || (y - cy_seed).abs() > CLUSTER_R {
continue; // page frame / worksheet title block — not the footprint
}
minx = minx.min(x); maxx = maxx.max(x);
miny = miny.min(y); maxy = maxy.max(y);
}
if minx.is_finite() && maxx > minx {
Some((minx, miny, maxx, maxy))
} else {
None
}
}
/// Wrap a `.kicad_mod` footprint into a minimal one-footprint board, placed at
/// the page center.
/// Upgrade a legacy KiCad-5 `(module …)` footprint to the modern `(footprint …)`
/// s-expression that KiCad-10's board loader accepts. Real vendor mods (Ultra
/// Librarian, older SnapEDA) still ship the old format, which the strict board
/// parser rejects token-by-token. Handles: the top keyword, the 3D model block
/// (`(at (xyz` → `(offset (xyz`, quote path), unquoted `fp_text` values, legacy
/// graphic widths (`… (width W))` → `(stroke (width W) (type solid)))`), and
/// legacy arcs (`(start=center)(end=point)(angle)` → modern `(start)(mid)(end)`).
fn normalize_legacy(m: &str) -> String {
let mut s = m.replacen("(module ", "(footprint ", 1);
// 3D model: quote the path, and (at (xyz → (offset (xyz.
let model_re = Regex::new(r"(?m)\(model\s+([^\s()\n]+)").unwrap();
s = model_re.replace_all(&s, |c: ®ex::Captures| {
let p = &c[1];
let pq = if p.starts_with('"') { p.to_string() } else { format!("\"{p}\"") };
format!("(model {pq}")
}).into_owned();
s = s.replace("(at (xyz", "(offset (xyz");
// fp_text reference/value/user: quote an unquoted value token.
let txt_re = Regex::new(r#"\(fp_text (reference|value|user) ([^"\s()][^\s()]*) "#).unwrap();
s = txt_re.replace_all(&s, |c: ®ex::Captures| format!("(fp_text {} \"{}\" ", &c[1], &c[2])).into_owned();
// legacy fp_arc: start=center, end=arc start point, angle=sweep° → 3-point.
let arc_re = Regex::new(r"\(fp_arc \(start (-?[\d.]+) (-?[\d.]+)\) \(end (-?[\d.]+) (-?[\d.]+)\) \(angle (-?[\d.]+)\) \(layer ([^)]+)\) \(width ([\d.]+)\)\)").unwrap();
s = arc_re.replace_all(&s, |c: ®ex::Captures| {
let (cx, cy): (f64, f64) = (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0));
let (px, py): (f64, f64) = (c[3].parse().unwrap_or(0.0), c[4].parse().unwrap_or(0.0));
let sweep = c[5].parse::<f64>().unwrap_or(0.0).to_radians();
let (layer, w) = (&c[6], &c[7]);
let r = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt();
let t0 = (py - cy).atan2(px - cx);
let (ex, ey) = (cx + r * (t0 + sweep).cos(), cy + r * (t0 + sweep).sin());
let (mx, my) = (cx + r * (t0 + sweep / 2.0).cos(), cy + r * (t0 + sweep / 2.0).sin());
format!("(fp_arc (start {px:.4} {py:.4}) (mid {mx:.4} {my:.4}) (end {ex:.4} {ey:.4}) (stroke (width {w}) (type solid)) (layer {layer}))")
}).into_owned();
// remaining legacy graphic widths (fp_line / fp_circle / fp_poly).
let w_re = Regex::new(r"\(width ([\d.]+)\)\)").unwrap();
s = w_re.replace_all(&s, "(stroke (width $1) (type solid)))").into_owned();
s
}
fn build_pcb_wrap(kicad_mod: &str) -> String {
// Upgrade legacy KiCad-5 `(module …)` footprints so the strict board loader
// accepts them (real vendor mods still ship the old format).
let normalized = normalize_legacy(kicad_mod);
let kicad_mod = normalized.as_str();
// Insert an (at OX OY) right after the footprint's layer line so the part
// is placed at a known page location. Handle BOTH the modern quoted
// `(layer "F.Cu")` and the legacy unquoted `(layer F.Cu)` (KiCad 5/6 mods
// still carry the latter) — matching only the quoted form silently left old
// footprints at their native position and off the computed viewBox.
let layer_re = Regex::new(r#"\(layer "?F\.Cu"?\)"#).unwrap();
let placed = match layer_re.find(kicad_mod) {
Some(m) => {
let mut s = kicad_mod.trim_end().to_string();
s.insert_str(m.end(), &format!("\n (at {OX} {OY})"));
s
}
None => kicad_mod.trim_end().to_string(),
};
let indented = placed.replace('\n', "\n ");
format!(
"(kicad_pcb (version 20240108) (generator \"adom\")\n \
(general (thickness 1.6))\n (paper \"A4\")\n \
(layers\n \
(0 \"F.Cu\" signal)\n (31 \"B.Cu\" signal)\n \
(36 \"B.SilkS\" user) (37 \"F.SilkS\" user)\n \
(38 \"B.Mask\" user) (39 \"F.Mask\" user)\n \
(44 \"Edge.Cuts\" user)\n \
(49 \"F.Fab\" user) (51 \"B.Fab\" user)\n \
(46 \"B.CrtYd\" user) (47 \"F.CrtYd\" user)\n )\n \
{indented}\n)\n"
)
}