← Commit history

1.0.25: heatsink vias carry KiCad's pad_prop_heatsink property

John Lauer ·0944a78658 ·1mo ago ·parent 63ad013
11 files changed +38−22
CHANGELOG.md+3
@@ -1,5 +1,8 @@ # Changelog +## 1.0.25 (2026-09-20)+- Heatsink vias written by `heatsinkVias` now carry KiCad's `(property pad_prop_heatsink)` (with `(remove_unused_layers no)`), so KiCad's DRC and fab outputs identify them as thermal vias. 1.0.23 wrote correct plated, tented vias but left them unmarked; found by installing 1.0.24 from the registry and checking its output against the hand-built ToF footprints. A hand-written thermal via can set `"heatsink": true` on its pad.+ ## 1.0.23 (2026-09-19) - `heatsinkVias {pad, drill_mm, pitch_mm, ring_mm, tented}` in the create JSON expands to plated `thru_hole` via pads inside the exposed thermal pad, numbered like that pad, on a centred grid that keeps a full annular ring inside it. Only `pad` is required; the defaults are 0.3 mm drill, 0.8 mm pitch, 0.15 mm ring, tented. adom-lbr carries the vias to Altium and Fusion unchanged. - `create` prints a `HEATSINK VIAS MISSING` hint when the largest SMD pad is a big exposed pad with no plated holes under it, and a via count when they are present.
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-footprint"-version = "1.0.24"+version = "1.0.25" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-footprint"-version = "1.0.24"+version = "1.0.25" edition = "2021" description = "Create, preview, lint, and deliver KiCad footprints (.kicad_mod) with interactive FpView viewer" 
README.md+2
@@ -57,6 +57,8 @@ renders outside the container. A thermal pad without vias into the plane is a part that overheats, and vias that have to be redrawn per EDA tool do not get drawn. So they are written once, into the footprint: +From 1.0.25 each via also carries `(property pad_prop_heatsink)`, the marker KiCad uses for thermal vias. A thermal via you place by hand gets the same marker with `"heatsink": true` on the pad.+ ```json {   "footprintName": "QFN-16-EP",
SKILL.md+1−1
@@ -29,7 +29,7 @@ Node.js server (footprint-creator/)  **Heatsink vias (1.0.23).** `"heatsinkVias": {"pad": "B4", "drill_mm": 0.3, "pitch_mm": 0.8, "ring_mm": 0.15, "tented": true}` in the create JSON (only `pad` is required; those are the-defaults) writes plated `thru_hole` pads numbered like the exposed pad on a centred grid that+defaults) writes plated `thru_hole` pads (marked `pad_prop_heatsink` from 1.0.25) numbered like the exposed pad on a centred grid that keeps a full ring inside it. Same-number plated holes are how KiCad, Altium (through adom-lbr) and Fusion/EAGLE all express "same net, drilled", so the vias are drawn ONCE and reach every tool. `create` prints HEATSINK VIAS MISSING when the largest pad is a big exposed pad without
VERSION+1−1
@@ -1 +1 @@-1.0.24+1.0.25
bin/adom-footprint
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.24",+  "version": "1.0.25",   "type": "app",   "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360",   "dependencies": {
page.json+3−3
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.24",+  "version": "1.0.25",   "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+19−13
@@ -18,6 +18,9 @@ pub struct Pad {     /// Drill diameter (mm) for thru-hole pads. Emits `(drill D)` — without it a     /// thru-hole pad is meaningless and adom-lbr can only make an EAGLE `<smd>`.     pub drill: Option<f64>,+    /// A thermal via under an exposed pad (from `heatsinkVias`): KiCad marks it with+    /// `(property pad_prop_heatsink)` so DRC and fab outputs know what it is.+    pub heatsink: bool, }  pub struct Rect {@@ -358,6 +361,9 @@ pub fn generate_kicad_mod(input: &FpInput) -> String {         if shape == "roundrect" {             s += " (roundrect_rratio 0.25)";         }+        if pad.heatsink {+            s += " (remove_unused_layers no) (property pad_prop_heatsink)";+        }         s += ")\n";     } @@ -401,8 +407,8 @@ mod tests {         let input = FpInput {             name: "R_0402_1005Metric".into(),             pads: vec![-                Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },-                Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },+                Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None, heatsink: false },+                Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None, heatsink: false },             ],             body: Some(Rect { x1: -0.5, y1: -0.27, x2: 0.5, y2: 0.27 }),             courtyard: Some(Rect { x1: -0.93, y1: -0.47, x2: 0.93, y2: 0.47 }),@@ -420,8 +426,8 @@ mod tests {         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 },+                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, heatsink: false },+                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, heatsink: false },             ],             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 }),@@ -464,7 +470,7 @@ mod tests {         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,+                number: (*nm).into(), x, y, dx: 0.5, dy: 0.4, shape: None, angle: None, layers: None, mount: None, drill: None, heatsink: false             }).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,@@ -484,9 +490,9 @@ mod tests {             name: "LQFP_TEST".into(),             pads: vec![                 // bottom-row pad: outer edge at y = 5.9 + 1.5/2 = 6.65-                Pad { number: "1".into(), x: -3.5, y: 5.9, dx: 0.3, dy: 1.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },+                Pad { number: "1".into(), x: -3.5, y: 5.9, dx: 0.3, dy: 1.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None, heatsink: false },                 // top-row pad: outer edge at y = -5.9 - 1.5/2 = -6.65-                Pad { number: "2".into(), x: 3.5, y: -5.9, dx: 0.3, dy: 1.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None },+                Pad { number: "2".into(), x: 3.5, y: -5.9, dx: 0.3, dy: 1.5, shape: Some("roundrect".into()), angle: None, layers: None, mount: None, drill: None, heatsink: false },             ],             // Deliberately too-tight: courtyard/body only ±5.0 while pads reach ±6.65.             body: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),@@ -512,8 +518,8 @@ mod tests {         let big = FpInput {             name: "LQFP".into(),             pads: vec![-                Pad { number: "1".into(), x: -3.5, y: 5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None, mount: None, drill: None },-                Pad { number: "2".into(), x: 3.5, y: -5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None, mount: None, drill: None },+                Pad { number: "1".into(), x: -3.5, y: 5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None, mount: None, drill: None, heatsink: false },+                Pad { number: "2".into(), x: 3.5, y: -5.9, dx: 0.3, dy: 1.5, shape: None, angle: None, layers: None, mount: None, drill: None, heatsink: false },             ],             body: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),             courtyard: Some(Rect { x1: -5.0, y1: -5.0, x2: 5.0, y2: 5.0 }),@@ -537,8 +543,8 @@ mod tests {         let tiny = FpInput {             name: "R_0402".into(),             pads: vec![-                Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None, mount: None, drill: None },-                Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None, mount: None, drill: None },+                Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None, mount: None, drill: None, heatsink: false },+                Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: None, angle: None, layers: None, mount: None, drill: None, heatsink: false },             ],             body: Some(Rect { x1: -0.5, y1: -0.27, x2: 0.5, y2: 0.27 }),             courtyard: Some(Rect { x1: -0.93, y1: -0.47, x2: 0.93, y2: 0.47 }),@@ -561,7 +567,7 @@ mod tests {             pads: vec![Pad {                 number: "1".into(), x: 0.0, y: 0.0, dx: 1.6, dy: 1.6,                 shape: Some("circle".into()), angle: None, layers: None,-                mount: Some("thru_hole".into()), drill: Some(1.1),+                mount: Some("thru_hole".into()), drill: Some(1.1), heatsink: false             }],             body: Some(Rect { x1: -1.0, y1: -1.0, x2: 1.0, y2: 1.0 }),             courtyard: Some(Rect { x1: -1.2, y1: -1.2, x2: 1.2, y2: 1.2 }),@@ -585,7 +591,7 @@ mod tests {             pads: vec![Pad {                 number: "1".into(), x: 0.0, y: 0.0, dx: 1.6, dy: 1.6,                 shape: Some("circle".into()), angle: None, layers: None,-                mount: Some("thru_hole".into()), drill: Some(1.1),+                mount: Some("thru_hole".into()), drill: Some(1.1), heatsink: false             }],             body: Some(Rect { x1: -1.0, y1: -1.0, x2: 1.0, y2: 1.0 }),             courtyard: None,
src/main.rs+6−1
@@ -184,7 +184,7 @@ fn expand_heatsink_vias(input: &mut CreateInput) -> usize {             let x = ep.x + (i as f64 - (nx - 1) as f64 / 2.0) * hv.pitch_mm;             let y = ep.y + (j as f64 - (ny - 1) as f64 / 2.0) * hv.pitch_mm;             input.pads.push(JsonPad { number: hv.pad.clone(), x, y, dx: dia, dy: dia, shape: Some("circle".into()), angle: None,-                layers: Some(if hv.tented { "*.Cu".into() } else { "*.Cu *.Mask".into() }), mount: Some("thru_hole".into()), drill: Some(hv.drill_mm) });+                layers: Some(if hv.tented { "*.Cu".into() } else { "*.Cu *.Mask".into() }), mount: Some("thru_hole".into()), drill: Some(hv.drill_mm), heatsink: true });             n += 1;         }     }@@ -227,6 +227,9 @@ struct JsonPad {     /// Drill diameter (mm) — accept `drill`, `drillDiameter`, or `hole`.     #[serde(default, alias = "drillDiameter", alias = "hole")]     drill: Option<f64>,+    /// set by expand_heatsink_vias; a hand-written thermal via may set `heatsink: true` too+    #[serde(default)]+    heatsink: bool, }  #[derive(serde::Deserialize)]@@ -339,6 +342,7 @@ fn to_input(c: &CreateInput) -> fp_gen::FpInput {                 layers: p.layers.clone(),                 mount: p.mount.clone(),                 drill: p.drill,+                heatsink: p.heatsink,             })             .collect(),         body: c.body.as_ref().map(|r| fp_gen::Rect { x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2 }),@@ -422,6 +426,7 @@ fn fpinput_from_ds2sf(v: &serde_json::Value) -> Result<fp_gen::FpInput> {             layers: None,             mount: p.get("mount").or_else(|| p.get("type")).and_then(|x| x.as_str()).map(String::from),             drill: p.get("drill").or_else(|| p.get("drillDiameter")).or_else(|| p.get("hole")).and_then(|x| x.as_f64()),+            heatsink: p.get("heatsink").and_then(|x| x.as_bool()).unwrap_or(false),         });     }     if pads.is_empty() {