← Commit history

1.0.22: silkscreen never lands on copper; pin-1 mark only on polarized parts (new polarized input). The body-outline rectangle ran through the pads of every chip passive and every part got a pin-1 bar; found on all 130 generated Adom Basic Parts.

Colby Knox ·090ab00a33 ·1mo ago ·parent f95dffe
7 files changed +192−21
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-footprint"-version = "1.0.20"+version = "1.0.22" edition = "2021" description = "Create, preview, lint, and deliver KiCad footprints (.kicad_mod) with interactive FpView viewer" 
VERSION+1−1
@@ -1 +1 @@-1.0.17\ No newline at end of file+1.0.22
package.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.21",+  "version": "1.0.22",   "type": "app",   "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360",   "dependencies": {@@ -59,4 +59,4 @@       "prompt": "Auto-place the RefDes and Value clear of the pads and save it"     }   ]-}\ No newline at end of file+}
page.json+3−3
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.21",+  "version": "1.0.22",   "type": "app",   "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360",   "dependencies": {@@ -48,7 +48,7 @@     "send footprint to fusion",     "kicad footprint for this chip"   ],-  "discovery_pitch": "Create, view and edit KiCad PCB footprints in a live layer viewer — auto-placed RefDes/Value, auto-generated heatsink vias, heatsink-aware solder paste, and one-click delivery to KiCad/Fusion/Altium/OrCAD.",+  "discovery_pitch": "Create, view and edit KiCad PCB footprints in a live layer viewer \u2014 auto-placed RefDes/Value, auto-generated heatsink vias, heatsink-aware solder paste, and one-click delivery to KiCad/Fusion/Altium/OrCAD.",   "sample_prompts": [     {       "label": "Make a footprint",@@ -67,4 +67,4 @@       "prompt": "Auto-place the RefDes and Value clear of the pads and save it"     }   ]-}\ No newline at end of file+}
src/fp_gen.rs+176−11
@@ -38,6 +38,111 @@ pub struct FpInput {     /// `circle` emits `fp_circle` on F.Fab + F.SilkS (real round silk) instead of     /// a bounding-box rectangle — adom-lbr transcribes it to an EAGLE `<circle>`.     pub body_shape: Option<String>,+    /// Whether the part has a polarity (LED, diode, tantalum; pad 1 = cathode or+    /// pin 1). Drives the pin-1 mark. Default: two-pad parts are not polarized,+    /// parts with more pads are.+    pub polarized: Option<bool>,+}++/// Silkscreen stroke, clearance from copper, and the shortest clipped piece kept.+pub const SILK_WIDTH: f64 = 0.12;+pub const SILK_CLEARANCE: f64 = 0.15;+const SILK_MIN_PIECE: f64 = 0.2;++fn r4(x: f64) -> f64 {+    let v = (x * 10000.0).round() / 10000.0;+    if v == 0.0 { 0.0 } else { v }+}++fn pad_box(p: &Pad) -> (f64, f64, f64, f64) {+    let (mut w, mut h) = (p.dx, p.dy);+    if let Some(a) = p.angle {+        if (a.round() as i64).rem_euclid(180) == 90 { std::mem::swap(&mut w, &mut h); }+    }+    (p.x - w / 2.0, p.y - h / 2.0, p.x + w / 2.0, p.y + h / 2.0)+}++/// The parts of a segment that stay outside every box.+fn clip_segment(ax: f64, ay: f64, bx: f64, by: f64, boxes: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64, f64, f64)> {+    let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt();+    if len == 0.0 { return vec![]; }+    let n = ((len / 0.01) as usize).max(40);+    let at = |t: f64| (ax + (bx - ax) * t, ay + (by - ay) * t);+    let inside = |t: f64| { let (x, y) = at(t); boxes.iter().any(|b| b.0 < x && x < b.2 && b.1 < y && y < b.3) };+    let mut keep = vec![];+    let mut start: Option<f64> = None;+    for k in 0..=n {+        let t = k as f64 / n as f64;+        let ins = inside(t);+        if !ins && start.is_none() { start = Some(t); }+        if (ins || k == n) && start.is_some() {+            let s0 = start.take().unwrap();+            let end = if ins { (k as f64 - 1.0) / n as f64 } else { t };+            if (end - s0) * len >= SILK_MIN_PIECE {+                let (x0, y0) = at(s0);+                let (x1, y1) = at(end);+                keep.push((x0, y0, x1, y1));+            }+        }+    }+    keep+}++/// Silkscreen lines for a rectangular part, never on copper.+///+/// Two-pad parts get two lines parallel to the pads, CLEARANCE outside the+/// copper band, spanning the body; a polarized two-pad part adds a bar just+/// outside pad 1 and extends both lines to it. Other parts keep the body+/// outline (and a pin-1 bar when polarized) with every piece within CLEARANCE+/// of a pad cut away. (A body-outline rectangle used to run straight through+/// the pads of every chip passive, and every part carried a pin-1 bar.)+pub fn silk_segments(input: &FpInput) -> Vec<(f64, f64, f64, f64)> {+    let grow = SILK_CLEARANCE + SILK_WIDTH / 2.0;+    let mut names: Vec<&str> = input.pads.iter().map(|p| p.number.as_str()).collect();+    names.sort();+    names.dedup();+    let polarized = input.polarized.unwrap_or(names.len() > 2);+    let body = input.body.as_ref().map(|b| (b.x1.min(b.x2), b.y1.min(b.y2), b.x1.max(b.x2), b.y1.max(b.y2)));+    if input.pads.len() == 2 && names.len() == 2 {+        let i1 = input.pads.iter().position(|p| p.number == "1").unwrap_or(0);+        let (p1, p2) = (&input.pads[i1], &input.pads[1 - i1]);+        let along_x = (p2.x - p1.x).abs() >= (p2.y - p1.y).abs();+        let u = |x: f64, y: f64| if along_x { x } else { y };+        let v = |x: f64, y: f64| if along_x { y } else { x };+        let pad_v = [p1, p2].iter().map(|p| { let b = pad_box(p); v(b.0, b.1).abs().max(v(b.2, b.3).abs()) }).fold(0.0, f64::max);+        let body_v = body.map(|b| v(b.0, b.1).abs().max(v(b.2, b.3).abs())).unwrap_or(0.0);+        let voff = pad_v.max(body_v) + grow;+        let (mut u_lo, mut u_hi) = match body {+            Some(b) => { let (a, c) = (u(b.0, b.1), u(b.2, b.3)); (a.min(c), a.max(c)) }+            None => (-0.5, 0.5),+        };+        let mut bar = None;+        if polarized {+            let side = if u(p1.x, p1.y) > 0.0 { 1.0 } else { -1.0 };+            let b = pad_box(p1);+            let outer = u(b.0, b.1).abs().max(u(b.2, b.3).abs());+            let u_bar = side * (outer + grow);+            if side < 0.0 { u_lo = u_lo.min(u_bar) } else { u_hi = u_hi.max(u_bar) }+            bar = Some((u_bar, -voff, u_bar, voff));+        }+        let mut lines = vec![(u_lo, -voff, u_hi, -voff), (u_lo, voff, u_hi, voff)];+        if let Some(b) = bar { lines.push(b); }+        return lines.into_iter().map(|(ua, va, ub, vb)| {+            let (ax, ay) = if along_x { (ua, va) } else { (va, ua) };+            let (bx, by) = if along_x { (ub, vb) } else { (vb, ub) };+            (r4(ax), r4(ay), r4(bx), r4(by))+        }).collect();+    }+    let Some(b) = body else { return vec![] };+    let m = 0.12;+    let (x1, y1, x2, y2) = (b.0 - m, b.1 - m, b.2 + m, b.3 + m);+    let mut segs = vec![(x1, y1, x2, y1), (x2, y1, x2, y2), (x2, y2, x1, y2), (x1, y2, x1, y1)];+    if polarized { segs.push((b.0 - 0.3, b.1 - 0.3, b.0 + 0.3, b.1 - 0.3)); }+    let boxes: Vec<_> = input.pads.iter().map(|p| { let bb = pad_box(p); (bb.0 - grow, bb.1 - grow, bb.2 + grow, bb.3 + grow) }).collect();+    segs.into_iter()+        .flat_map(|(ax, ay, bx, by)| clip_segment(ax, ay, bx, by, &boxes))+        .map(|(a, b, c, d)| (r4(a), r4(b), r4(c), r4(d)))+        .collect() }  /// Print an f64 the way JS `${x}` / Number.toString does (shortest round-trip,@@ -188,23 +293,19 @@ pub fn generate_kicad_mod(input: &FpInput) -> String {         }     } -    // Silkscreen outline (slightly outside fab)+    // Silkscreen outline for round parts (slightly outside fab)     if let Some(b) = &input.body {         if round {             let (cx, cy, r) = circle_of(b, 0.12);             s += &format!("  (fp_circle (center {} {}) (end {} {})\n", n(cx), n(cy), n(cx + r), n(cy));             s += "    (stroke (width 0.12) (type default)) (fill none) (layer \"F.SilkS\"))\n";-        } else {-            let m = 0.12;-            s += &format!("  (fp_rect (start {} {}) (end {} {})\n", n(b.x1 - m), n(b.y1 - m), n(b.x2 + m), n(b.y2 + m));-            s += "    (stroke (width 0.12) (type default)) (fill none) (layer \"F.SilkS\"))\n";         }     }--    // Pin 1 marker — a corner chevron only makes sense for a rectangular body.-    if !input.pads.is_empty() && !round {-        if let Some(b) = &input.body {-            s += &format!("  (fp_line (start {} {}) (end {} {})\n", n(b.x1 - 0.3), n(b.y1 - 0.3), n(b.x1 + 0.3), n(b.y1 - 0.3));+    // Rectangular parts: silkscreen that never lands on copper, and a pin-1+    // mark only on polarized parts (see silk_segments).+    if !round && input.body.is_some() {+        for (ax, ay, bx, by) in silk_segments(input) {+            s += &format!("  (fp_line (start {} {}) (end {} {})\n", n(ax), n(ay), n(bx), n(by));             s += "    (stroke (width 0.12) (type default)) (layer \"F.SilkS\"))\n";         }     }@@ -308,10 +409,69 @@ mod tests {             description: "0402 (1005 metric) resistor".into(),             tags: "resistor 0402".into(),             body_shape: None,+            polarized: None,         };         let got = generate_kicad_mod(&input);         let golden = include_str!("../tests/golden/R_0402.kicad_mod");-        assert_eq!(got, golden, "must be byte-identical to the Node generator");+        assert_eq!(got, golden, "must match the golden 0402 (silkscreen clear of both pads, no pin-1 bar)");+    }++    fn two_pad(polarized: Option<bool>) -> FpInput {+        FpInput {+            name: "CHIP_0402".into(),+            pads: vec![+                Pad { number: "1".into(), x: -0.5, y: 0.0, dx: 0.6, dy: 0.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },+                Pad { number: "2".into(), x: 0.5, y: 0.0, dx: 0.6, dy: 0.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },+            ],+            body: Some(Rect { x1: -0.5, y1: -0.25, x2: 0.5, y2: 0.25 }),+            courtyard: Some(Rect { x1: -0.95, y1: -0.55, x2: 0.95, y2: 0.55 }),+            description: String::new(), tags: String::new(), body_shape: None, polarized,+        }+    }++    fn clear_of_pads(input: &FpInput, segs: &[(f64, f64, f64, f64)]) -> bool {+        let g = SILK_CLEARANCE + SILK_WIDTH / 2.0 - 1e-6;+        let boxes: Vec<_> = input.pads.iter().map(|p| { let b = pad_box(p); (b.0 - g, b.1 - g, b.2 + g, b.3 + g) }).collect();+        segs.iter().all(|&(ax, ay, bx, by)| (0..=100).all(|k| {+            let t = k as f64 / 100.0; let (x, y) = (ax + (bx - ax) * t, ay + (by - ay) * t);+            !boxes.iter().any(|b| b.0 < x && x < b.2 && b.1 < y && y < b.3)+        }))+    }++    #[test]+    fn chip_passive_silk_never_touches_copper_and_has_no_polarity_mark() {+        let input = two_pad(None);+        let segs = silk_segments(&input);+        assert_eq!(segs.len(), 2, "two lines, no pin-1 bar on a non-polar part: {segs:?}");+        assert!(segs.iter().all(|s| s.1 == s.3), "both lines run along the pads: {segs:?}");+        assert!(clear_of_pads(&input, &segs), "silk clears copper by the clearance: {segs:?}");+        let km = generate_kicad_mod(&input);+        assert!(!km.contains("fp_rect (start -0.62"), "no body-outline rectangle through the pads");+    }++    #[test]+    fn polarized_two_pad_part_gets_a_bracket_at_pad_1() {+        let input = two_pad(Some(true));+        let segs = silk_segments(&input);+        assert_eq!(segs.len(), 3, "two lines and a bar: {segs:?}");+        let bar = segs.iter().find(|s| s.0 == s.2).expect("a bar across the pads");+        assert!(bar.0 < -0.8, "the bar sits outside pad 1 (pad 1 is on the left): {bar:?}");+        assert!(clear_of_pads(&input, &segs), "still clear of copper: {segs:?}");+    }++    #[test]+    fn multi_pad_outline_is_cut_away_around_pads() {+        let input = FpInput {+            name: "QUAD".into(),+            pads: ["1", "2", "3", "4"].iter().zip([(-0.6, -0.4), (0.6, -0.4), (0.6, 0.4), (-0.6, 0.4)]).map(|(nm, (x, y))| Pad {+                number: (*nm).into(), x, y, dx: 0.5, dy: 0.4, shape: None, angle: None, layers: None, mount: None, drill: None,+            }).collect(),+            body: Some(Rect { x1: -1.0, y1: -1.0, x2: 1.0, y2: 1.0 }),+            courtyard: None, description: String::new(), tags: String::new(), body_shape: None, polarized: None,+        };+        let segs = silk_segments(&input);+        assert!(!segs.is_empty(), "some outline survives");+        assert!(clear_of_pads(&input, &segs), "every surviving piece clears copper: {segs:?}");     }      #[test]@@ -334,6 +494,7 @@ mod tests {             description: String::new(),             tags: String::new(),             body_shape: None,+            polarized: None,         };         let got = generate_kicad_mod(&input);         // pad outer extent ±6.65, +1 mm margin -> ±7.65@@ -359,6 +520,7 @@ mod tests {             description: String::new(),             tags: String::new(),             body_shape: None,+            polarized: None,         };         let tp = text_placement(&big);         assert_eq!((tp.reference.x, tp.value.x), (0.0, 0.0), "centered on X");@@ -383,6 +545,7 @@ mod tests {             description: String::new(),             tags: String::new(),             body_shape: None,+            polarized: None,         };         let tt = text_placement(&tiny);         assert!(tt.reference.size < 1.0, "tiny part scales text below 1 mm, got {}", tt.reference.size);@@ -404,6 +567,7 @@ mod tests {             courtyard: Some(Rect { x1: -1.2, y1: -1.2, x2: 1.2, y2: 1.2 }),             description: String::new(), tags: String::new(),             body_shape: Some("circle".into()),+            polarized: None,         };         let km = generate_kicad_mod(&input);         assert!(km.contains("(pad \"1\" thru_hole circle (at 0 0) (size 1.6 1.6) (drill 1.1)"),@@ -427,6 +591,7 @@ mod tests {             courtyard: None,             description: String::new(), tags: String::new(),             body_shape: Some("circle".into()),+            polarized: None,         };         let km = generate_kicad_mod(&input);         assert!(km.contains("(fp_circle (center 0 0)") && km.contains("(layer \"F.SilkS\"))"),
src/main.rs+6
@@ -139,6 +139,10 @@ struct CreateInput {     /// ("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>,+    /// LED, diode, tantalum: true draws the pin-1 mark. Default: false for+    /// two-pad parts, true otherwise.+    #[serde(default)]+    polarized: Option<bool>, }  #[derive(serde::Deserialize)]@@ -279,6 +283,7 @@ fn to_input(c: &CreateInput) -> fp_gen::FpInput {         description: c.description.clone(),         tags: c.tags.clone(),         body_shape: c.body_shape.clone(),+        polarized: c.polarized,     } } @@ -380,6 +385,7 @@ fn fpinput_from_ds2sf(v: &serde_json::Value) -> Result<fp_gen::FpInput> {         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),+        polarized: v.get("polarized").and_then(|x| x.as_bool()),     }) } 
tests/golden/R_0402.kicad_mod+3−3
@@ -9,9 +9,9 @@     (stroke (width 0.05) (type default)) (fill none) (layer "F.CrtYd"))   (fp_rect (start -0.5 -0.27) (end 0.5 0.27)     (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab"))-  (fp_rect (start -0.62 -0.39) (end 0.62 0.39)-    (stroke (width 0.12) (type default)) (fill none) (layer "F.SilkS"))-  (fp_line (start -0.8 -0.5700000000000001) (end -0.2 -0.5700000000000001)+  (fp_line (start -0.5 -0.56) (end 0.5 -0.56)+    (stroke (width 0.12) (type default)) (layer "F.SilkS"))+  (fp_line (start -0.5 0.56) (end 0.5 0.56)     (stroke (width 0.12) (type default)) (layer "F.SilkS"))   (fp_text reference "REF**" (at 0 -1.47)     (layer "F.SilkS") (effects (font (size 0.7 0.7) (thickness 0.11))))