← Commit history
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"-}+}\ No newline at end of file
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+}