app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
//! Self-render a `.kicad_mod` to a themed footprint SVG — every layer in its own
//! toggleable `<g>`, in the ORIGINAL adom-footprint palette (ported faithfully
//! from gallia `kicad-footprint-viewer.js`). No service-kicad dependency, so
//! silkscreen, drill holes and pad numbers come back and the colours are exact:
//! Courtyard #FF26E2 (magenta, dashed) · Fab #6e7681 (grey) · Silk #00cccc
//! (cyan) · Copper pads #C83434/#ef5350 (red) · Drill dark w/ #666 ring · white
//! pad numbers. Paste apertures + the InstaPCB dots layer on top of all of it.
use regex::Regex;
use std::fmt::Write as _;
#[derive(Clone)]
pub struct Pad {
pub number: String,
pub mount: String, // smd | thru_hole | np_thru_hole
pub shape: String, // rect | circle | oval | roundrect | custom | trapezoid
pub x: f64,
pub y: f64,
pub rot: f64,
pub w: f64,
pub h: f64,
pub rratio: f64,
pub drill: f64,
}
struct Seg { x1: f64, y1: f64, x2: f64, y2: f64 }
struct Rect { x1: f64, y1: f64, x2: f64, y2: f64 }
struct Poly { pts: Vec<(f64, f64)> }
struct Circle { cx: f64, cy: f64, r: f64, filled: bool }
pub struct Footprint {
pub pads: Vec<Pad>,
courtyard_lines: Vec<Seg>,
courtyard_rects: Vec<Rect>,
courtyard_arcs: Vec<Poly>,
courtyard_circles: Vec<Circle>,
fab_lines: Vec<Seg>,
fab_rects: Vec<Rect>,
fab_polys: Vec<Poly>,
fab_arcs: Vec<Poly>,
fab_circles: Vec<Circle>,
silk_lines: Vec<Seg>,
silk_polys: Vec<Poly>,
silk_arcs: Vec<Poly>,
silk_circles: Vec<Circle>,
}
/// Sample a KiCad *legacy* arc — `(start=CENTER) (end=ARC-START-POINT) (angle …)` —
/// into a polyline. Robust regardless of stroke flags; 1 segment per ~10°.
fn arc_from_legacy(cx: f64, cy: f64, sx: f64, sy: f64, angle_deg: f64) -> Vec<(f64, f64)> {
let r = ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt();
let a0 = (sy - cy).atan2(sx - cx);
let sweep = angle_deg.to_radians();
let n = ((angle_deg.abs() / 10.0).ceil() as usize).clamp(2, 64);
(0..=n).map(|i| { let a = a0 + sweep * (i as f64 / n as f64); (cx + r * a.cos(), cy + r * a.sin()) }).collect()
}
/// Sample a KiCad *modern* three-point arc — `(start) (mid) (end)` — into a
/// polyline. Finds the circumcircle, then sweeps start→end through mid.
fn arc_from_3pt(sx: f64, sy: f64, mx: f64, my: f64, ex: f64, ey: f64) -> Vec<(f64, f64)> {
use std::f64::consts::PI;
let d = 2.0 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my));
if d.abs() < 1e-9 { return vec![(sx, sy), (ex, ey)]; } // collinear → straight
let (s2, m2, e2) = (sx * sx + sy * sy, mx * mx + my * my, ex * ex + ey * ey);
let cx = (s2 * (my - ey) + m2 * (ey - sy) + e2 * (sy - my)) / d;
let cy = (s2 * (ex - mx) + m2 * (sx - ex) + e2 * (mx - sx)) / d;
let r = ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt();
let a0 = (sy - cy).atan2(sx - cx);
let am = (my - cy).atan2(mx - cx);
let a1 = (ey - cy).atan2(ex - cx);
let n2pi = |a: f64| { let mut a = a % (2.0 * PI); if a < 0.0 { a += 2.0 * PI; } a };
let sweep_ccw = n2pi(a1 - a0);
let mid_ccw = n2pi(am - a0);
let sweep = if mid_ccw <= sweep_ccw { sweep_ccw } else { sweep_ccw - 2.0 * PI };
let n = ((sweep.abs().to_degrees() / 10.0).ceil() as usize).clamp(2, 64);
(0..=n).map(|i| { let a = a0 + sweep * (i as f64 / n as f64); (cx + r * a.cos(), cy + r * a.sin()) }).collect()
}
/// Balanced-paren slice of every `(<tag> …)` block.
fn blocks<'a>(s: &'a str, tag: &str) -> Vec<&'a str> {
let bytes = s.as_bytes();
let pat = format!("({} ", tag);
let mut out = Vec::new();
let mut search = 0;
while let Some(rel) = s[search..].find(&pat) {
let start = search + rel;
let mut depth = 0i32;
let mut end = start;
for i in start..s.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => { depth -= 1; if depth == 0 { end = i; break; } }
_ => {}
}
}
out.push(&s[start..=end.min(s.len() - 1)]);
search = end + 1;
}
out
}
fn layer_of(block: &str, re: &Regex) -> String {
re.captures(block).map(|c| c[1].to_string()).unwrap_or_default()
}
pub fn parse(content: &str) -> Footprint {
let at_re = Regex::new(r"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\)").unwrap();
let size_re = Regex::new(r"\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
let drill_re = Regex::new(r"\(drill\s+(-?[\d.]+)").unwrap();
let rr_re = Regex::new(r"\(roundrect_rratio\s+([\d.]+)\)").unwrap();
// Accept BOTH quoted `(layer "F.SilkS")` and bare `(layer F.SilkS)` — vendor /
// manufacturer footprints (and older KiCad) write the layer name unquoted, and
// requiring quotes silently dropped all their silk/courtyard/fab geometry.
let layer_re = Regex::new(r##"\(layer\s+"?([^"()\s]+)"?\s*\)"##).unwrap();
let se_re = Regex::new(r"\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)\s*\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
// Pad number may be quoted ("13") or bare (13, older KiCad) — accept both.
let pad_head = Regex::new(r##"\(pad\s+"?([^"\s]+)"?\s+(\w+)\s+(\w+)"##).unwrap();
let xy_re = Regex::new(r"\(xy\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
let start_re = Regex::new(r"\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
let end_re = Regex::new(r"\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
let mid_re = Regex::new(r"\(mid\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
let angle_re = Regex::new(r"\(angle\s+(-?[\d.]+)\)").unwrap();
let center_re = Regex::new(r"\(center\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
let mut fp = Footprint {
pads: vec![], courtyard_lines: vec![], courtyard_rects: vec![],
courtyard_arcs: vec![], courtyard_circles: vec![],
fab_lines: vec![], fab_rects: vec![], fab_polys: vec![],
fab_arcs: vec![], fab_circles: vec![],
silk_lines: vec![], silk_polys: vec![],
silk_arcs: vec![], silk_circles: vec![],
};
// ── Pads ──
for b in blocks(content, "pad") {
let head = match pad_head.captures(b) { Some(h) => h, None => continue };
let number = head[1].to_string();
if number.is_empty() { continue; }
let (at, size) = match (at_re.captures(b), size_re.captures(b)) { (Some(a), Some(s)) => (a, s), _ => continue };
fp.pads.push(Pad {
number,
mount: head[2].to_string(),
shape: head[3].to_string(),
x: at[1].parse().unwrap_or(0.0),
y: at[2].parse().unwrap_or(0.0),
rot: at.get(3).and_then(|m| m.as_str().parse().ok()).unwrap_or(0.0),
w: size[1].parse().unwrap_or(0.0),
h: size[2].parse().unwrap_or(0.0),
rratio: rr_re.captures(b).and_then(|c| c[1].parse().ok()).unwrap_or(0.25),
drill: drill_re.captures(b).and_then(|c| c[1].parse().ok()).unwrap_or(0.0),
});
}
// ── fp_line by layer ──
for b in blocks(content, "fp_line") {
let cap = match se_re.captures(b) { Some(c) => c, None => continue };
let seg = Seg { x1: cap[1].parse().unwrap_or(0.0), y1: cap[2].parse().unwrap_or(0.0), x2: cap[3].parse().unwrap_or(0.0), y2: cap[4].parse().unwrap_or(0.0) };
match layer_of(b, &layer_re).as_str() {
"F.CrtYd" => fp.courtyard_lines.push(seg),
"F.Fab" => fp.fab_lines.push(seg),
"F.SilkS" => fp.silk_lines.push(seg),
_ => {}
}
}
// ── fp_rect by layer ──
for b in blocks(content, "fp_rect") {
let cap = match se_re.captures(b) { Some(c) => c, None => continue };
let r = Rect { x1: cap[1].parse().unwrap_or(0.0), y1: cap[2].parse().unwrap_or(0.0), x2: cap[3].parse().unwrap_or(0.0), y2: cap[4].parse().unwrap_or(0.0) };
match layer_of(b, &layer_re).as_str() {
"F.CrtYd" => fp.courtyard_rects.push(r),
"F.Fab" => fp.fab_rects.push(r),
_ => {}
}
}
// ── fp_poly by layer ──
for b in blocks(content, "fp_poly") {
let pts: Vec<(f64, f64)> = xy_re.captures_iter(b).map(|c| (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0))).collect();
if pts.is_empty() { continue; }
match layer_of(b, &layer_re).as_str() {
"F.Fab" => fp.fab_polys.push(Poly { pts }),
"F.SilkS" => fp.silk_polys.push(Poly { pts }),
_ => {}
}
}
// ── fp_arc by layer (sampled to a polyline) ──
for b in blocks(content, "fp_arc") {
let (s, e) = match (start_re.captures(b), end_re.captures(b)) { (Some(s), Some(e)) => (s, e), _ => continue };
let (sx, sy) = (s[1].parse().unwrap_or(0.0), s[2].parse().unwrap_or(0.0));
let (ex, ey) = (e[1].parse().unwrap_or(0.0), e[2].parse().unwrap_or(0.0));
let pts = if let Some(m) = mid_re.captures(b) {
arc_from_3pt(sx, sy, m[1].parse().unwrap_or(0.0), m[2].parse().unwrap_or(0.0), ex, ey)
} else if let Some(a) = angle_re.captures(b) {
// legacy: start = centre, end = a point on the arc
arc_from_legacy(sx, sy, ex, ey, a[1].parse().unwrap_or(0.0))
} else { vec![(sx, sy), (ex, ey)] };
match layer_of(b, &layer_re).as_str() {
"F.CrtYd" => fp.courtyard_arcs.push(Poly { pts }),
"F.Fab" => fp.fab_arcs.push(Poly { pts }),
"F.SilkS" => fp.silk_arcs.push(Poly { pts }),
_ => {}
}
}
// ── fp_circle by layer (center + a point on the circle) ──
for b in blocks(content, "fp_circle") {
let (c, e) = match (center_re.captures(b), end_re.captures(b)) { (Some(c), Some(e)) => (c, e), _ => continue };
let (cx, cy): (f64, f64) = (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0));
let (ex, ey): (f64, f64) = (e[1].parse().unwrap_or(0.0), e[2].parse().unwrap_or(0.0));
let r = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt();
let filled = b.contains("(fill solid)") || b.contains("(fill yes)");
let circ = Circle { cx, cy, r, filled };
match layer_of(b, &layer_re).as_str() {
"F.CrtYd" => fp.courtyard_circles.push(circ),
"F.Fab" => fp.fab_circles.push(circ),
"F.SilkS" => fp.silk_circles.push(circ),
_ => {}
}
}
fp
}
fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) }
/// Render the footprint to a themed SVG with per-layer `<g class="adom-layer-…">`
/// groups. `ox,oy` is unused here (geometry is in footprint-local mm, the viewBox
/// is computed from it); kept for signature parity with the paste overlay.
pub fn render(fp: &Footprint) -> (String, f64, f64) {
// bounds over everything
let mut pts: Vec<(f64, f64)> = vec![];
for p in &fp.pads { pts.push((p.x - p.w / 2.0, p.y - p.h / 2.0)); pts.push((p.x + p.w / 2.0, p.y + p.h / 2.0)); }
for s in fp.courtyard_lines.iter().chain(fp.fab_lines.iter()).chain(fp.silk_lines.iter()) { pts.push((s.x1, s.y1)); pts.push((s.x2, s.y2)); }
for r in fp.courtyard_rects.iter().chain(fp.fab_rects.iter()) { pts.push((r.x1, r.y1)); pts.push((r.x2, r.y2)); }
for poly in fp.fab_polys.iter().chain(fp.silk_polys.iter())
.chain(fp.courtyard_arcs.iter()).chain(fp.fab_arcs.iter()).chain(fp.silk_arcs.iter()) {
for &pt in &poly.pts { pts.push(pt); }
}
for c in fp.courtyard_circles.iter().chain(fp.fab_circles.iter()).chain(fp.silk_circles.iter()) {
pts.push((c.cx - c.r, c.cy - c.r)); pts.push((c.cx + c.r, c.cy + c.r));
}
if pts.is_empty() { pts.push((-5.0, -5.0)); pts.push((5.0, 5.0)); }
let margin = 1.0;
let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min) - margin;
let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min) - margin;
let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max) + margin;
let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max) + margin;
let (w, h) = (max_x - min_x, max_y - min_y);
let mut s = String::new();
let _ = write!(s, r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.3} {:.3} {:.3} {:.3}" preserveAspectRatio="xMidYMid meet">"##, min_x, min_y, w, h);
// ── Courtyard (magenta, dashed) ──
s.push_str(r##"<g class="adom-layer-courtyard">"##);
for l in &fp.courtyard_lines { let _ = write!(s, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/>"##, l.x1, l.y1, l.x2, l.y2); }
for r in &fp.courtyard_rects { let _ = write!(s, r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="none" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/>"##, r.x1.min(r.x2), r.y1.min(r.y2), (r.x2 - r.x1).abs(), (r.y2 - r.y1).abs()); }
for a in &fp.courtyard_arcs { let _ = write!(s, r##"<path d="{}" fill="none" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/>"##, polyline_d(&a.pts)); }
for c in &fp.courtyard_circles { let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="none" stroke="#FF26E2" stroke-width="0.05" stroke-dasharray="0.2,0.1"/>"##, c.cx, c.cy, c.r); }
s.push_str("</g>");
// ── Fab outline (grey) ──
s.push_str(r##"<g class="adom-layer-fab">"##);
for l in &fp.fab_lines { let _ = write!(s, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="#6e7681" stroke-width="0.1"/>"##, l.x1, l.y1, l.x2, l.y2); }
for r in &fp.fab_rects { let _ = write!(s, r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="none" stroke="#6e7681" stroke-width="0.1"/>"##, r.x1.min(r.x2), r.y1.min(r.y2), (r.x2 - r.x1).abs(), (r.y2 - r.y1).abs()); }
for poly in &fp.fab_polys { let d = poly_d(&poly.pts); let _ = write!(s, r##"<path d="{}" fill="none" stroke="#6e7681" stroke-width="0.1"/>"##, d); }
for a in &fp.fab_arcs { let _ = write!(s, r##"<path d="{}" fill="none" stroke="#6e7681" stroke-width="0.1"/>"##, polyline_d(&a.pts)); }
for c in &fp.fab_circles { let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="{}" stroke="#6e7681" stroke-width="0.1"/>"##, c.cx, c.cy, c.r, if c.filled { "#6e7681" } else { "none" }); }
s.push_str("</g>");
// ── Copper pads (red) ──
s.push_str(r##"<g class="adom-layer-copper">"##);
for p in &fp.pads {
let (hw, hh) = (p.w / 2.0, p.h / 2.0);
let body = match p.shape.as_str() {
"circle" => format!(r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>"##, p.x, p.y, hw.min(hh)),
"oval" => format!(r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" rx="{:.3}" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>"##, p.x - hw, p.y - hh, p.w, p.h, hw.min(hh)),
"roundrect" => format!(r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" rx="{:.3}" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>"##, p.x - hw, p.y - hh, p.w, p.h, p.rratio * hw.min(hh) * 2.0),
_ => format!(r##"<rect x="{:.3}" y="{:.3}" width="{:.3}" height="{:.3}" fill="#C83434" stroke="#ef5350" stroke-width="0.04"/>"##, p.x - hw, p.y - hh, p.w, p.h),
};
if p.rot.abs() > 0.01 {
let _ = write!(s, r##"<g transform="rotate({:.3} {:.3} {:.3})" data-pad="{}" data-shape="{}" data-size="{:.3}x{:.3}" data-mount="{}">{}</g>"##, -p.rot, p.x, p.y, esc(&p.number), esc(&p.shape), p.w, p.h, esc(&p.mount), body);
} else {
let _ = write!(s, r##"<g data-pad="{}" data-shape="{}" data-size="{:.3}x{:.3}" data-mount="{}">{}</g>"##, esc(&p.number), esc(&p.shape), p.w, p.h, esc(&p.mount), body);
}
}
s.push_str("</g>");
// ── Heatsink / thermal vias (auto-generated under an exposed thermal pad) ──
// The array an EE would otherwise hand-draw: a copper ring + drilled hole per
// via, at IPC-typical pitch, tiled across the exposed pad. Rendered on top of
// the pad copper so it reads as real thermal relief.
s.push_str(r##"<g class="adom-layer-heatsink">"##);
for (vx, vy) in heatsink_vias(&fp.pads) {
let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="0.30" fill="#8a5a2b" stroke="#e8a13a" stroke-width="0.05"/><circle cx="{:.3}" cy="{:.3}" r="0.15" fill="#0d1117"/>"##, vx, vy, vx, vy);
}
s.push_str("</g>");
// ── Drill holes (dark with grey ring) ──
s.push_str(r##"<g class="adom-layer-drill">"##);
for p in &fp.pads {
if p.mount == "thru_hole" || p.mount == "np_thru_hole" {
let r = if p.drill > 0.0 { p.drill / 2.0 } else { (p.w / 2.0).min(p.h / 2.0) * 0.5 };
let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="#0d1117" stroke="#666" stroke-width="0.05"/>"##, p.x, p.y, r);
}
}
s.push_str("</g>");
// ── Silkscreen (cyan) ──
s.push_str(r##"<g class="adom-layer-silk">"##);
for l in &fp.silk_lines { let _ = write!(s, r##"<line x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" stroke="#00cccc" stroke-width="0.12" stroke-linecap="round"/>"##, l.x1, l.y1, l.x2, l.y2); }
for poly in &fp.silk_polys { let d = poly_d(&poly.pts); let _ = write!(s, r##"<path d="{}" fill="#00cccc" stroke="#00cccc" stroke-width="0.1"/>"##, d); }
for a in &fp.silk_arcs { let _ = write!(s, r##"<path d="{}" fill="none" stroke="#00cccc" stroke-width="0.12" stroke-linecap="round"/>"##, polyline_d(&a.pts)); }
for c in &fp.silk_circles { let _ = write!(s, r##"<circle cx="{:.3}" cy="{:.3}" r="{:.3}" fill="{}" stroke="#00cccc" stroke-width="0.12"/>"##, c.cx, c.cy, c.r, if c.filled { "#00cccc" } else { "none" }); }
s.push_str("</g>");
// ── Pad numbers (white) ──
s.push_str(r##"<g class="adom-layer-padnum">"##);
for p in &fp.pads {
let fs = p.w.min(p.h) * 0.42;
if fs > 0.12 {
let _ = write!(s, r##"<text x="{:.3}" y="{:.3}" text-anchor="middle" dominant-baseline="central" font-size="{:.3}" fill="#fff" font-family="sans-serif" style="pointer-events:none">{}</text>"##, p.x, p.y, fs, esc(&p.number));
}
}
s.push_str("</g>");
s.push_str("</svg>");
// origin for the paste overlay: footprint-local (ax,ay) maps to (ax,ay) here
(s, 0.0, 0.0)
}
/// Auto-generate a thermal-via grid under the exposed pad — the "heatsink" an EE
/// would otherwise hand-draw. Finds the exposed pad (the largest ~square SMD pad
/// ≥1.8 mm on a side) and tiles it with vias at IPC-typical 1.2 mm pitch, inset so
/// each 0.6 mm via ring stays inside the pad. Returns via centres in footprint mm;
/// empty when there is no exposed pad (so most parts show no heatsink layer).
fn heatsink_vias(pads: &[Pad]) -> Vec<(f64, f64)> {
let ep = pads.iter()
.filter(|p| p.mount == "smd" && p.w.min(p.h) >= 1.8 && {
let a = p.w / p.h.max(0.001);
(0.6..=1.67).contains(&a)
})
.max_by(|a, b| (a.w * a.h).partial_cmp(&(b.w * b.h)).unwrap_or(std::cmp::Ordering::Equal));
let ep = match ep { Some(p) => p, None => return Vec::new() };
let pitch = 1.2_f64;
let margin = 0.6 / 2.0 + 0.15; // via-ring radius + clearance from the pad edge
let span = |dim: f64| (dim - 2.0 * margin).max(0.0);
let nx = ((span(ep.w) / pitch).floor() as i32 + 1).max(1);
let ny = ((span(ep.h) / pitch).floor() as i32 + 1).max(1);
let x0 = ep.x - (nx as f64 - 1.0) * pitch / 2.0;
let y0 = ep.y - (ny as f64 - 1.0) * pitch / 2.0;
let mut out = Vec::with_capacity((nx * ny) as usize);
for j in 0..ny {
for i in 0..nx {
out.push((x0 + i as f64 * pitch, y0 + j as f64 * pitch));
}
}
out
}
fn poly_d(pts: &[(f64, f64)]) -> String {
let mut d = String::new();
for (i, &(x, y)) in pts.iter().enumerate() {
let _ = write!(d, "{}{:.3},{:.3} ", if i == 0 { "M" } else { "L" }, x, y);
}
d.push('Z');
d
}
/// Like `poly_d` but leaves the path OPEN (no `Z`) — for arcs/polylines.
fn polyline_d(pts: &[(f64, f64)]) -> String {
let mut d = String::new();
for (i, &(x, y)) in pts.iter().enumerate() {
let _ = write!(d, "{}{:.3},{:.3} ", if i == 0 { "M" } else { "L" }, x, y);
}
d
}