Model slotted (oblong) THT holes as stadiums, not circles

The drill's y dimension was discarded (padstack.drill.diameter.x only),
so a milled slot became a round hole of its x size - contact rings,
drill mouths and lead cones painted circles larger than the oblong pad
itself, and the cone was skipped outright (pad_min <= drill).

Slots now keep their true stadium shape, rotated with the pad (KiCad
CCW, y down): Electrode/ViaLink carry the end-cap offset vector,
drill_nm becomes the slot WIDTH, and a shared slot_distance() reduces
to the plain radius for round holes. The barrel wall ring, mouth
coverage, cone taper and the solver's attachment search all follow the
slot; barrel_resistance uses the stadium perimeter and bore area. The
stitching-coat fallback becomes a capsule along the slot (inscribed
disc when the axis is unknown) instead of a largest-dimension disc.
Slot fields round-trip through the JSON dumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-17 20:37:01 +07:00
parent bc95b444b4
commit b070d7444e
6 changed files with 291 additions and 43 deletions
+28 -22
View File
@@ -23,7 +23,7 @@ from scipy import ndimage
from . import config
from .errors import ElectrodeError, GridSizeError
from .geometry import Electrode, Problem, Rect
from .geometry import Electrode, Problem, Rect, slot_distance
# 4-connectivity: matches the in-plane 5-point stencil of the solver
_STRUCT4 = ndimage.generate_binary_structure(2, 1)
@@ -270,29 +270,31 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
y = (e.rect.y0 + e.rect.y1) / 2.0
seen.add((int(x), int(y)))
if e.solder and e.protrusion_side:
# oblong pads: taper to the inscribed circle (conservative)
# oblong pads: taper from the (slot) wall to the inscribed
# dimension (conservative)
jobs.append((x, y, e.drill_nm, e.pad_min_nm or e.pad_nm,
e.protrusion_side))
e.protrusion_side, e.slot_dx_nm, e.slot_dy_nm))
for v in problem.vias:
if v.kind == "pad" and v.solder_filled and v.protrusion_side \
and (v.x, v.y) not in seen:
jobs.append((v.x, v.y, v.drill_nm, v.pad_min_nm or v.pad_nm,
v.protrusion_side))
v.protrusion_side, v.slot_dx_nm, v.slot_dy_nm))
for x, y, drill_nm, pad_nm, side in jobs:
for x, y, drill_nm, pad_nm, side, sdx, sdy in jobs:
li = index.get(side)
if li is None or pad_nm <= drill_nm:
continue
ra, rb = drill_nm / 2.0, pad_nm / 2.0
j0 = max(0, math.floor((x - rb - stack.x0_nm) / h))
j1 = min(nx, math.floor((x + rb - stack.x0_nm) / h) + 1)
i0 = max(0, math.floor((y - rb - stack.y0_nm) / h))
i1 = min(ny, math.floor((y + rb - stack.y0_nm) / h) + 1)
ex, ey = rb + abs(sdx), rb + abs(sdy)
j0 = max(0, math.floor((x - ex - stack.x0_nm) / h))
j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1)
i0 = max(0, math.floor((y - ey - stack.y0_nm) / h))
i1 = min(ny, math.floor((y + ey - stack.y0_nm) / h) + 1)
if i0 >= i1 or j0 >= j1:
continue
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y
r = np.sqrt(ys[:, None] ** 2 + xs[None, :] ** 2)
r = slot_distance(xs[None, :], ys[:, None], sdx, sdy)
t_sn = H * np.clip((rb - r) / (rb - ra), 0.0, 1.0)
t_eq = t_sn * (problem.rho_ohm_m / problem.solder_rho_ohm_m)
factor = 1.0 + t_eq / problem.layers[li].thickness_nm
@@ -353,18 +355,20 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
if via.kind == "pad" and via.solder_filled:
continue
r = via.drill_nm / 2.0
j0 = max(0, math.floor((via.x - r - stack.x0_nm) / h))
j1 = min(nx, math.floor((via.x + r - stack.x0_nm) / h) + 1)
i0 = max(0, math.floor((via.y - r - stack.y0_nm) / h))
i1 = min(ny, math.floor((via.y + r - stack.y0_nm) / h) + 1)
ex, ey = r + abs(via.slot_dx_nm), r + abs(via.slot_dy_nm)
j0 = max(0, math.floor((via.x - ex - stack.x0_nm) / h))
j1 = min(nx, math.floor((via.x + ex - stack.x0_nm) / h) + 1)
i0 = max(0, math.floor((via.y - ey - stack.y0_nm) / h))
i1 = min(ny, math.floor((via.y + ey - stack.y0_nm) / h) + 1)
if i0 >= i1 or j0 >= j1:
continue
xs = stack.x0_nm + (np.arange(j0, j1)[:, None] + sub[None, :]) * h \
- via.x
ys = stack.y0_nm + (np.arange(i0, i1)[:, None] + sub[None, :]) * h \
- via.y
cov = ((ys[:, None, :, None] ** 2 + xs[None, :, None, :] ** 2)
<= r * r).mean(axis=(2, 3))
cov = (slot_distance(xs[None, :, None, :], ys[:, None, :, None],
via.slot_dx_nm, via.slot_dy_nm)
<= r).mean(axis=(2, 3))
if not (cov > 0).any():
continue # mouth far smaller than h
if stack.thick_scale is None:
@@ -477,7 +481,8 @@ def _electrode_cells2d(stack: RasterStack, e: Electrode) -> np.ndarray:
def _barrel_ring2d(stack: RasterStack, e: Electrode,
mask2d: np.ndarray) -> np.ndarray:
"""Contact cells of a barrel electrode on one layer: the copper ring
at the drill wall (cell centers within one cell of radius drill/2),
at the drill wall (cell centers within one cell of radius drill/2;
slotted holes: within one cell of the stadium-shaped slot wall),
where the lead/wire soldered into the hole actually meets the layer.
If rasterization or an antipad leaves no copper there, fall back to
the nearest copper ring within the pad footprint (+1 cell of slop) -
@@ -491,16 +496,17 @@ def _barrel_ring2d(stack: RasterStack, e: Electrode,
y = (e.rect.y0 + e.rect.y1) / 2.0
r = e.drill_nm / 2.0
rw = max(e.pad_nm, e.drill_nm + 300_000) / 2.0 + h
ex, ey = rw + abs(e.slot_dx_nm), rw + abs(e.slot_dy_nm)
out = np.zeros((ny, nx), dtype=bool)
j0 = max(0, math.floor((x - rw - stack.x0_nm) / h))
j1 = min(nx, math.floor((x + rw - stack.x0_nm) / h) + 1)
i0 = max(0, math.floor((y - rw - stack.y0_nm) / h))
i1 = min(ny, math.floor((y + rw - stack.y0_nm) / h) + 1)
j0 = max(0, math.floor((x - ex - stack.x0_nm) / h))
j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1)
i0 = max(0, math.floor((y - ey - stack.y0_nm) / h))
i1 = min(ny, math.floor((y + ey - stack.y0_nm) / h) + 1)
if i0 >= i1 or j0 >= j1:
return out
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y
d = np.sqrt(ys[:, None] ** 2 + xs[None, :] ** 2)
d = slot_distance(xs[None, :], ys[:, None], e.slot_dx_nm, e.slot_dy_nm)
m = mask2d[i0:i1, j0:j1]
ring = m & (np.abs(d - r) <= h)
if not ring.any():