app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
//! Solder-paste **dispensing positions** — an Adom-computed overlay.
//!
//! Standard PCB CAD shows a solid F.Paste aperture (the stencil opening). Adom's
//! paste-jet process doesn't use a stencil — it *jets* solder paste dot-by-dot. So
//! a footprint in the Adom ecosystem also carries the actual dispensing positions:
//! a grid of ~300 µm paste dots tiled across each aperture — and kept CLEAR of any
//! thermal via, so jetted paste never sits over a via barrel (it would wick down
//! the hole during reflow, starving the joint and causing voids). This module
//! parses the pads from a `.kicad_mod`, computes those dots, and emits two SVG
//! layer groups (aperture outlines + dispensing dots) in the same page coordinates
//! the footprint is rendered at, so they overlay exactly.
use regex::Regex;
/// One paste aperture: center, size (pre-rotation), rotation in degrees.
pub struct Aperture {
pub cx: f64,
pub cy: f64,
pub w: f64,
pub h: f64,
pub rot: f64,
}
const DOT_DIA: f64 = 0.30; // 300 µm jetted paste dot
const DOT_PITCH: f64 = 0.25; // center-to-center spacing — slight intentional overlap of 0.30mm dots (john/adom-solder-ball-layer-standard)
const MARGIN: f64 = 0.06; // keep dots just inside the aperture edge
const MAX_DOTS: usize = 600; // safety cap so a huge thermal pad can't explode the DOM
/// Keep-out radius (mm) from a thermal-via centre — paste dots inside this are
/// dropped so jetted paste never lands over a via barrel. ≈ via-ring radius (0.30)
/// + dot radius (0.15) + a little clearance.
const VIA_KEEPOUT: f64 = 0.55;
/// Detect the exposed thermal pad among the paste apertures and return its
/// auto-generated thermal-via centres (footprint-local mm). MUST mirror
/// `fp_render::heatsink_vias` so the paste keep-out lines up with the rendered vias.
fn heatsink_vias(aps: &[Aperture]) -> Vec<(f64, f64)> {
let ep = aps.iter()
.filter(|a| a.w.min(a.h) >= 1.8 && { let r = a.w / a.h.max(0.001); (0.6..=1.67).contains(&r) })
.max_by(|a, b| (a.w * a.h).partial_cmp(&(b.w * b.h)).unwrap_or(std::cmp::Ordering::Equal));
let ep = match ep { Some(p) => p, None => return Vec::new() };
let pitch = 1.2_f64;
let margin = 0.6 / 2.0 + 0.15;
let span = |dim: f64| (dim - 2.0 * margin).max(0.0);
let nx = ((span(ep.w) / pitch).floor() as i32 + 1).max(1);
let ny = ((span(ep.h) / pitch).floor() as i32 + 1).max(1);
let x0 = ep.cx - (nx as f64 - 1.0) * pitch / 2.0;
let y0 = ep.cy - (ny as f64 - 1.0) * pitch / 2.0;
let mut out = Vec::new();
for j in 0..ny { for i in 0..nx { out.push((x0 + i as f64 * pitch, y0 + j as f64 * pitch)); } }
out
}
/// Parse SMD pad apertures (pads that get solder paste) from a `.kicad_mod`.
pub fn paste_apertures(kicad_mod: &str) -> Vec<Aperture> {
let mut out = Vec::new();
let bytes = kicad_mod.as_bytes();
let at_re = Regex::new(r"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\)").unwrap();
let size_re = Regex::new(r"\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();
// Walk each "(pad " occurrence and take a balanced-paren slice as the block.
let mut search = 0;
while let Some(rel) = kicad_mod[search..].find("(pad ") {
let start = search + rel;
let mut depth = 0i32;
let mut end = start;
for i in start..kicad_mod.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
}
let block = &kicad_mod[start..=end.min(kicad_mod.len() - 1)];
search = end + 1;
// Only SMD pads get jetted paste. Skip NPTH/edge pads.
let smd = block.contains(" smd ");
if !smd {
continue;
}
if let (Some(a), Some(s)) = (at_re.captures(block), size_re.captures(block)) {
let cx = a[1].parse().unwrap_or(0.0);
let cy = a[2].parse().unwrap_or(0.0);
let rot = a.get(3).and_then(|m| m.as_str().parse().ok()).unwrap_or(0.0);
let w: f64 = s[1].parse().unwrap_or(0.0);
let h: f64 = s[2].parse().unwrap_or(0.0);
if w <= 0.0 || h <= 0.0 {
continue;
}
let has_paste = block.contains("F.Paste") || !block.contains("(layers"); // default smd → paste
// The exposed thermal pad still gets jetted paste (in the via gaps) even
// when the source omits F.Paste — the paddle needs solder to bond, and
// Adom jets paste directly (no stencil), so F.Paste in the file isn't a
// prerequisite. Matches the exposed-pad test in heatsink_vias().
let square = { let r = w / h.max(0.001); (0.6..=1.67).contains(&r) };
let is_exposed_pad = w.min(h) >= 1.8 && square;
if has_paste || is_exposed_pad {
out.push(Aperture { cx, cy, w, h, rot });
}
}
}
out
}
/// Emit two SVG `<g>` layer groups (apertures + 300 µm dispensing dots) in page
/// coordinates: footprint-local (ax,ay) maps to (ox+ax, oy+ay). Returns
/// `(apertures_group, dots_group, dot_count)`.
pub fn paste_overlay_svg(aps: &[Aperture], ox: f64, oy: f64) -> (String, String, usize) {
let mut apert = String::from(r#"<g class="adom-layer-paste-apertures">"#);
let mut dots = String::from(r#"<g class="adom-layer-paste-dots">"#);
let mut n = 0usize;
// Thermal-via centres on the exposed pad — paste dots that land on one are
// dropped so jetted paste doesn't wick down the barrel during reflow.
let vias = heatsink_vias(aps);
for ap in aps {
let (px, py) = (ox + ap.cx, oy + ap.cy);
let (rad, cos, sin) = {
let r = ap.rot.to_radians();
(r, r.cos(), r.sin())
};
let _ = rad;
// Aperture outline (rotated rect via transform).
apert.push_str(&format!(
r##"<rect x="{x:.3}" y="{y:.3}" width="{w:.3}" height="{h:.3}" rx="0.05" transform="rotate({rot:.2} {px:.3} {py:.3})" fill="none" stroke="#8aa0b8" stroke-width="0.03" stroke-dasharray="0.12 0.08" opacity="0.7"/>"##,
x = px - ap.w / 2.0, y = py - ap.h / 2.0, w = ap.w, h = ap.h, rot = ap.rot, px = px, py = py,
));
// Dispensing dot grid, centered in the aperture, inset by MARGIN.
let usable_w = (ap.w - 2.0 * MARGIN - DOT_DIA).max(0.0);
let usable_h = (ap.h - 2.0 * MARGIN - DOT_DIA).max(0.0);
let nx = (usable_w / DOT_PITCH).floor() as i32 + 1;
let ny = (usable_h / DOT_PITCH).floor() as i32 + 1;
let span_x = (nx - 1) as f64 * DOT_PITCH;
let span_y = (ny - 1) as f64 * DOT_PITCH;
for ix in 0..nx {
for iy in 0..ny {
if n >= MAX_DOTS {
break;
}
// local offset within aperture (centered), then rotate.
let lx = -span_x / 2.0 + ix as f64 * DOT_PITCH;
let ly = -span_y / 2.0 + iy as f64 * DOT_PITCH;
// Footprint-local dot centre (before the page offset) — used to test
// against the thermal vias, which are in footprint-local coords.
let local_dx = ap.cx + lx * cos - ly * sin;
let local_dy = ap.cy + lx * sin + ly * cos;
if vias.iter().any(|&(vx, vy)| ((local_dx - vx).powi(2) + (local_dy - vy).powi(2)).sqrt() < VIA_KEEPOUT) {
continue; // paste dot would sit over a via — skip it
}
let dx = px + lx * cos - ly * sin;
let dy = py + lx * sin + ly * cos;
dots.push_str(&format!(
r##"<circle cx="{dx:.3}" cy="{dy:.3}" r="{r:.3}" fill="#37c8a6" opacity="0.92"/>"##,
dx = dx, dy = dy, r = DOT_DIA / 2.0,
));
n += 1;
}
}
}
apert.push_str("</g>");
dots.push_str("</g>");
(apert, dots, n)
}