app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
← Commit history
1.0.25: heatsink vias carry KiCad's pad_prop_heatsink property
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" } ]-}+}
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() {