app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
← Commit history
Publish 1.0.18
7 files changed
+128−9
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-footprint"-version = "1.0.17"+version = "1.0.18" dependencies = [ "anyhow", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-footprint"-version = "1.0.17"+version = "1.0.18" edition = "2021" description = "Create, preview, lint, and deliver KiCad footprints (.kicad_mod) with interactive FpView viewer"
bin/adom-footprint⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-footprint",- "version": "1.0.17",+ "version": "1.0.18", "type": "app", "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360", "dependencies": {
page.json+2−2@@ -1,6 +1,6 @@ { "slug": "adom-footprint",- "version": "1.0.17",+ "version": "1.0.18", "type": "app", "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360", "dependencies": {@@ -36,4 +36,4 @@ "org": "adom", "updated_at": "2026-06-25T01:59:28.504Z", "source_visibility": "public"-}+}
src/app.html+18−2@@ -418,7 +418,10 @@ {key:'paste-ap', name:'Paste apertures', kind:'group', sel:'.adom-layer-paste-apertures', sw:'#8aa0b8', ring:true}, {key:'paste-dots',name:'Paste dots', kind:'group', sel:'.adom-layer-paste-dots', sw:'#37c8a6', special:true}, ];- const LPREF='adomFpLayers';+ // v2: reset once — earlier prefs were saved while vendor silk/courtyard/fab+ // were (buggily) empty, leaving critical layers stuck off. Start fresh so the+ // correct defaults (copper/silk/fab/courtyard ON, paste-dots OFF) apply.+ const LPREF='adomFpLayers_v2'; function loadLayerState(){ try{ return JSON.parse(localStorage.getItem(LPREF))||{}; }catch{ return {}; } } function saveLayerState(st){ try{ localStorage.setItem(LPREF, JSON.stringify(st)); }catch{} } function svgEl(){ return document.querySelector('#svgbox svg'); }@@ -440,9 +443,17 @@ let layerState=loadLayerState(); // A layer is "present" only if the render actually contains its geometry — // so the HUD lists exactly the layers this footprint has, no empty rows.+ // True only if the group actually contains drawn geometry (not just an empty+ // <g> placeholder) — so layers with nothing in THIS footprint aren't listed.+ function groupHasGeo(def){+ const sv=svgEl(); if(!sv) return false;+ let n=0;+ sv.querySelectorAll(def.sel).forEach(g=>{ n+=g.querySelectorAll('path,rect,circle,line,polygon,polyline,text').length; });+ return n>0;+ } function layerPresent(def){ const sv=svgEl(); if(!sv) return false;- if(def.kind==='group') return sv.querySelectorAll(def.sel).length>0;+ if(def.kind==='group') return groupHasGeo(def); let found=false; sv.querySelectorAll('path,rect,circle,polygon,line').forEach(el=>{ if(colorMatch(el,def.color)) found=true; }); return found;@@ -453,6 +464,11 @@ btn.style.display='inline-flex'; const body=document.getElementById('layersBody'); body.innerHTML=''; LAYER_DEFS.forEach(def=>{+ // Skip layers with no geometry in THIS footprint (e.g. drill on an SMD+ // part) — a permanently-dim, empty row just looks broken and is the source+ // of "why is this dimmed and unclickable?". Computed overlays (paste dots)+ // are special-cased and always kept so the user can enable them.+ if(def.kind==='group' && !def.special && !groupHasGeo(def)) return; // Standard layers default visible (unless turned off); the paste-dots // overlay is opt-in → default OFF so it never obscures the copper (the // dots are always computed regardless — this only toggles their display).
src/fp_render.rs+105−2@@ -25,16 +25,54 @@ pub struct Pad { 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.@@ -69,16 +107,27 @@ pub fn parse(content: &str) -> Footprint { 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();- let layer_re = Regex::new(r##"\(layer\s+"([^"]+)"\)"##).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 ──@@ -131,6 +180,39 @@ pub fn parse(content: &str) -> Footprint { _ => {} } }+ // ── 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 } @@ -145,7 +227,13 @@ pub fn render(fp: &Footprint) -> (String, f64, f64) { 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()) { for &pt in &poly.pts { pts.push(pt); } }+ 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;@@ -161,6 +249,8 @@ pub fn render(fp: &Footprint) -> (String, f64, f64) { 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) ──@@ -168,6 +258,8 @@ pub fn render(fp: &Footprint) -> (String, f64, f64) { 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) ──@@ -202,6 +294,8 @@ pub fn render(fp: &Footprint) -> (String, f64, f64) { 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) ──@@ -227,3 +321,12 @@ fn poly_d(pts: &[(f64, f64)]) -> String { 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+}