Files
kicad-zone-resistance/fill_resistance/board_io.py
T
janikandClaude Fable 5 26b1cfaa45
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
Multiple Thevenin supplies and prescribed-current loads on one net,
solved in absolute volts with the Tellegen power balance verified per
run; a source-sink pair table (effective copper resistance per
supply x load pair plus an exactly-summing proportional-sharing loss
attribution), in summary.txt and as its own figure. Bonded terminals
short a package's contacts into one lug so the per-pin split becomes
a solve outcome. Geometry dumps carry the terminal set (schema v8).

The dialog gained a Classic/PDN mode selector and a full PDN editor:
per-role supply/load tables built from the marker rectangles (or a
config's terminal set, which never pins mode or net), with Component
hints, per-terminal Layer scopes, Active checkboxes, comments, a
per-net row filter, resizable tables and a scrolling, screen-sized
dialog. Numbers accept SI suffixes (50m, 4.7k) everywhere.

fill_res_config.json fully specifies a run (classic or PDN) with
validation, comments, named side-by-side configs (the one called
default auto-loads), Load/Save buttons with an editable file name,
and saves that never drop anything drawn on the board.

347 tests, green on Python 3.13 and on the 3.9 macOS wheel stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:01:24 +07:00

1528 lines
63 KiB
Python

"""All KiCad IPC access. This is the ONLY module that imports kipy;
everything downstream works on plain geometry dataclasses.
Run `python -m fill_resistance.board_io dump.json [net]` against a live
KiCad to extract without the dialog (all layers of the net, defaults).
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from pathlib import Path
from kipy import KiCad
from kipy.board import Board
from kipy.board_types import ArcTrack, BoardRectangle, BoardText, Pad, Via
from kipy.proto.board.board_pb2 import BoardStackupLayerType
from kipy.proto.board.board_types_pb2 import ZoneType
from kipy.util.board_layer import (canonical_name, is_copper_layer,
layer_from_canonical_name)
import numpy as np
from . import config
from .errors import (ApiVersionError, CandidateError, ConfigError,
SelectionError)
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
SurfaceBuildup, Terminal, TrackSeg, ViaLink,
contact_solder_buildups, linearize_ring,
tht_joint_buildups)
MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"}
# zone fills are polygonal in practice; tolerance only guards arc nodes
ARC_TOL_NM = 10_000
def connect() -> tuple[KiCad, Board]:
try:
kicad = KiCad()
kicad.ping()
except Exception as e:
raise ApiVersionError(
f"Could not connect to KiCad's IPC API: {e}\n"
f"Is KiCad running with the API server enabled "
f"(Preferences > Plugins > Enable KiCad API)?"
)
try:
print(f"connected to KiCad {kicad.get_version()}")
except Exception:
pass
try:
board = kicad.get_board()
except Exception as e:
raise SelectionError(
f"Could not get the open board from KiCad: {e}\n"
f"Open the PCB in the board editor and run again."
)
return kicad, board
def board_dir(board: Board) -> Path:
# document.board_filename is a bare file name (no directory) in
# KiCad 10.0.1; the project path is the reliable location
try:
path = board.get_project().path
if path and Path(path).is_dir():
return Path(path)
except Exception:
pass
try:
filename = getattr(board.document, "board_filename", "") or ""
if Path(filename).is_absolute():
return Path(filename).parent
except Exception:
pass
return Path.cwd()
# --- stackup geometry --------------------------------------------------------
@dataclass
class StackupInfo:
names: list[str] # copper layers, top to bottom
thickness_nm: dict[str, int]
z_nm: dict[str, int] # copper center depth
z_bot_nm: int # total stack thickness
def get_stackup_info(board: Board) -> StackupInfo:
names: list[str] = []
thickness: dict[str, int] = {}
z_center: dict[str, int] = {}
z = 0
for sl in board.get_stackup().layers:
t = int(sl.thickness or 0)
if sl.type == BoardStackupLayerType.BSLT_COPPER:
name = canonical_name(sl.layer)
if t <= 0:
t = int(config.FALLBACK_THICKNESS_UM * 1000)
print(f"warning: stackup gives no thickness for {name}; "
f"assuming {config.FALLBACK_THICKNESS_UM} um")
names.append(name)
thickness[name] = t
z_center[name] = z + t // 2
z += t
if not names:
raise CandidateError(
"Could not read any copper layer from the board stackup."
)
return StackupInfo(names=names, thickness_nm=thickness, z_nm=z_center,
z_bot_nm=z)
# --- electrodes from selection ----------------------------------------------
def _box2_to_rect(box, layer_name: str) -> Rect:
try:
pos, size = box.pos, box.size
return Rect.normalized(pos.x, pos.y, pos.x + size.x, pos.y + size.y,
layer_name)
except AttributeError:
c, s = box.center, box.size
return Rect.normalized(c.x - s.x // 2, c.y - s.y // 2,
c.x + s.x // 2, c.y + s.y // 2, layer_name)
def _convert_poly(poly_with_holes) -> Polygon:
def ring(polyline):
nodes = []
for node in polyline.nodes:
if node.has_point:
nodes.append(("pt", (node.point.x, node.point.y)))
elif node.has_arc:
arc = node.arc
nodes.append(("arc", ((arc.start.x, arc.start.y),
(arc.mid.x, arc.mid.y),
(arc.end.x, arc.end.y))))
return linearize_ring(nodes, ARC_TOL_NM)
return Polygon(outline=ring(poly_with_holes.outline),
holes=[ring(h) for h in poly_with_holes.holes])
def _drill_info(pad_or_via) -> tuple[int, int, int]:
"""(width_nm, slot_dx_nm, slot_dy_nm) of a padstack drill. Round
holes: (diameter, 0, 0). Slotted (oblong) holes: width is the
NARROW dimension, (slot_dx, slot_dy) the board-frame offset from
the drill center to each end-cap center of the slot. The slot
follows the pad rotation (KiCad rotates CCW with y down:
x' = x cos + y sin, y' = y cos - x sin)."""
try:
d = pad_or_via.padstack.drill.diameter
dx, dy = int(d.x), int(d.y)
except Exception:
return 0, 0, 0
if dx <= 0 or dy <= 0 or dx == dy:
return max(dx, 0), 0, 0
half = (max(dx, dy) - min(dx, dy)) / 2.0
try:
th = math.radians(pad_or_via.padstack.angle.degrees)
except Exception:
th = 0.0
ux, uy = (1.0, 0.0) if dx > dy else (0.0, 1.0)
return (min(dx, dy),
int(round(half * (ux * math.cos(th) + uy * math.sin(th)))),
int(round(half * (uy * math.cos(th) - ux * math.sin(th)))))
def _pad_drill_nm(pad_or_via) -> int:
return _drill_info(pad_or_via)[0]
def _pad_default_contact(pad: Pad) -> str:
if _pad_drill_nm(pad) > 0:
return "all" # through-hole: contacts the stack
try:
copper = [canonical_name(l) for l in pad.padstack.layers
if is_copper_layer(l)]
if len(copper) == 1:
return copper[0] # SMD: its own layer
except Exception:
pass
return "all"
def _pad_polygons(board: Board, pad: Pad, contact: str,
prefer: str | None = None) -> list[Polygon] | None:
"""Exact pad copper. The first probed layer that has a shape wins, so
`prefer` (the solder side of a THT joint) must be tried before the
F.Cu/B.Cu fallback: KiCad allows a different pad size per copper
layer, and the solder coat is sized from this shape."""
layer_ids = []
for name in (contact if contact != "all" else None, prefer):
if not name:
continue
try:
layer_ids.append(layer_from_canonical_name(name))
except Exception:
pass
for name in ("F.Cu", "B.Cu"):
try:
layer_ids.append(layer_from_canonical_name(name))
except Exception:
pass
for lid in layer_ids:
try:
shape = board.get_pad_shapes_as_polygons(pad, layer=lid)
if shape is not None:
return [_convert_poly(shape)]
except Exception:
continue
return None
def _footprint_pad_map(footprints) -> dict:
"""(x, y, number) -> owning FootprintInstance. Footprint pads are
stored with absolute positions, so the lookup is exact."""
out = {}
for fp in footprints or []:
try:
for fpad in fp.definition.pads:
out[(fpad.position.x, fpad.position.y, fpad.number)] = fp
except Exception:
continue
return out
def _pad_owner(pad: Pad, pad_map: dict):
return pad_map.get((pad.position.x, pad.position.y, pad.number))
def _tht_protrusion_side(pad: Pad, pad_map: dict, quiet: bool = False) -> str:
"""Outer layer where the clipped THT lead protrudes (tent + solder
cone): the side OPPOSITE the component. Unknown owner -> assume the
component sits on F.Cu (lead tents on B.Cu)."""
fp = _pad_owner(pad, pad_map)
if fp is not None:
try:
side = canonical_name(fp.layer)
return "F.Cu" if side == "B.Cu" else "B.Cu"
except Exception:
pass
if not quiet:
print(f"note: no footprint found for pad {pad.number} - assuming "
f"its lead protrudes on B.Cu")
return "B.Cu"
def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
pad_map: dict | None = None) -> Electrode:
if isinstance(item, BoardRectangle):
tl, br = item.top_left, item.bottom_right
rect = Rect.normalized(tl.x, tl.y, br.x, br.y,
canonical_name(item.layer))
cx = (rect.x0 + rect.x1) / 2e6
cy = (rect.y0 + rect.y1) / 2e6
return Electrode(rect=rect, contact="all",
label=f"rect({cx:.1f},{cy:.1f})")
if isinstance(item, Via):
via: Via = item
x, y = via.position.x, via.position.y
drill = int(via.drill_diameter or 0) or _pad_drill_nm(via)
if drill <= 0:
raise SelectionError(
f"Selected via at ({x / 1e6:.2f}, {y / 1e6:.2f}) mm has no "
f"drill diameter - cannot use it as a contact.")
pad_nm = _padstack_pad_nm(via)
r = max(pad_nm, drill) // 2
rect = Rect.normalized(x - r, y - r, x + r, y + r, "via")
return Electrode(
rect=rect, contact="all", label=f"via({x / 1e6:.1f},{y / 1e6:.1f})",
drill_nm=drill, pad_nm=pad_nm, center=(x, y),
barrel_z=(_padstack_span(via.padstack, stackup)
if stackup is not None else None))
# Pad
pad: Pad = item
contact = _pad_default_contact(pad)
net = pad.net.name if pad.net is not None else "?"
label = f"pad {pad.number}@{net}"
box = board.get_item_bounding_box(pad)
if box is None:
raise SelectionError(f"Could not get the bounding box of {label}.")
rect = _box2_to_rect(box, "pad")
drill, slot_dx, slot_dy = _drill_info(pad)
prot = _tht_protrusion_side(pad, pad_map or {}) if drill > 0 else None
return Electrode(rect=rect, contact=contact,
polygons=_pad_polygons(board, pad, contact, prefer=prot),
label=label,
# through-hole pad: current enters at the soldered
# barrel; the joint is solder-filled + pad-coated,
# with a solder cone around the protruding lead
drill_nm=drill, pad_nm=_padstack_pad_nm(pad),
pad_min_nm=_padstack_pad_min_nm(pad),
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
center=(pad.position.x, pad.position.y),
solder=drill > 0, protrusion_side=prot)
def _net_hint_of(items: list) -> str | None:
for item in items:
if item.net is not None:
return item.net.name
return None
def get_electrodes(board: Board, stackup: StackupInfo | None = None
) -> tuple[list[Electrode], list[Electrode], str | None]:
"""Terminals from the selection. Each terminal may have MULTIPLE parts
(all merged into one externally-bonded contact):
- rectangles on ELECTRODE_POS_LAYER -> V+ parts, on ELECTRODE_NEG_LAYER
-> V- parts; selected pads/vias fill a side that has no rectangles;
- no marker rectangles selected: legacy mode, exactly 2 items
(rects/pads/vias, any layer) -> one part each;
- empty selection: board-wide scan of both marker layers.
Selected vias and through-hole pads become BARREL contacts: current
enters at the drill-wall ring (the soldered lead/wire), not the pad
face. Draw a marker rectangle over the pad instead to model a probe
pressed onto the pad face.
"""
pos_l = config.ELECTRODE_POS_LAYER
neg_l = config.ELECTRODE_NEG_LAYER
scheme = (f"Draw V+ rectangle(s) on {pos_l} and V- rectangle(s) on "
f"{neg_l} (axis-aligned), and/or select pads/vias for a side "
f"without rectangles.")
selection = list(board.get_selection())
rects = [s for s in selection if isinstance(s, BoardRectangle)]
pads = [s for s in selection if isinstance(s, (Pad, Via))]
# protrusion-side lookup needs the owning footprints (THT pads only)
pad_map = (_footprint_pad_map(board.get_footprints())
if any(isinstance(s, Pad) and _pad_drill_nm(s) > 0
for s in pads) else {})
if not selection:
allr = [s for s in board.get_shapes() if isinstance(s, BoardRectangle)]
pos = [r for r in allr if canonical_name(r.layer) == pos_l]
neg = [r for r in allr if canonical_name(r.layer) == neg_l]
if pos and neg:
print(f"selection empty - using {len(pos)} rectangle(s) on "
f"{pos_l} as V+ and {len(neg)} on {neg_l} as V-")
return ([_to_electrode(board, r) for r in pos],
[_to_electrode(board, r) for r in neg], None)
raise SelectionError(
f"Nothing selected, and the board-wide scan found "
f"{len(pos)} rectangle(s) on {pos_l} / {len(neg)} on {neg_l} "
f"(need at least one on each).\n{scheme}"
)
pos = [r for r in rects if canonical_name(r.layer) == pos_l]
neg = [r for r in rects if canonical_name(r.layer) == neg_l]
other = [r for r in rects if canonical_name(r.layer) not in (pos_l, neg_l)]
if pos or neg:
if other:
raise SelectionError(
f"{len(other)} selected rectangle(s) are on neither marker "
f"layer ({pos_l} = V+, {neg_l} = V-). {scheme}"
)
es1 = [_to_electrode(board, r) for r in pos]
es2 = [_to_electrode(board, r) for r in neg]
if pads and es1 and es2:
raise SelectionError(
f"Cannot assign the {len(pads)} selected pad(s)/via(s): both "
f"marker layers already provide rectangles. Use pads/vias "
f"only for a side that has none."
)
if pads:
pad_parts = [_to_electrode(board, p, stackup, pad_map)
for p in pads]
if not es1:
es1 = pad_parts
else:
es2 = pad_parts
if es1 and es2:
return es1, es2, _net_hint_of(pads)
raise SelectionError(
f"Only one terminal defined: V+ has {len(es1)} and V- has "
f"{len(es2)} contact(s). {scheme}"
)
items = rects + pads
if len(items) == 2:
return ([_to_electrode(board, items[0], stackup, pad_map)],
[_to_electrode(board, items[1], stackup, pad_map)],
_net_hint_of(pads))
raise SelectionError(
f"The selection has {len(rects)} rectangle(s) (none on the marker "
f"layers) and {len(pads)} pad(s)/via(s); without marker layers "
f"exactly 2 contacts are needed.\n{scheme}"
)
# --- config-file terminal resolution -----------------------------------------
def _pair_rect_labels(board: Board, layer: str) -> list:
"""[(BoardRectangle, name_or_None), ...] for one marker layer. A
rectangle is named by a text item on the same layer whose anchor
lies inside it (BoardRectangle itself has no name in the IPC API);
a rectangle containing several text items is ambiguous and errors.
Duplicate-name policy is the CALLER's (config lookup warns/skips
unnamed rects; the PDN editor scan needs them too)."""
rects = [s for s in board.get_shapes()
if isinstance(s, BoardRectangle)
and canonical_name(s.layer) == layer]
texts = [t for t in board.get_text()
if isinstance(t, BoardText)
and canonical_name(t.layer) == layer]
out = []
for r in rects:
tl, br = r.top_left, r.bottom_right
x0, x1 = min(tl.x, br.x), max(tl.x, br.x)
y0, y1 = min(tl.y, br.y), max(tl.y, br.y)
inside = [t for t in texts
if x0 <= t.position.x <= x1
and y0 <= t.position.y <= y1]
if len(inside) > 1:
names = ", ".join(repr(t.value) for t in inside[:4])
raise ConfigError(
f"the rectangle on {layer} at "
f"({x0 / 1e6:.1f}, {y0 / 1e6:.1f}) mm contains "
f"{len(inside)} text items ({names}) - keep "
f"exactly one so its name is unambiguous."
)
out.append((r, inside[0].value.strip() if inside else None))
return out
@dataclass
class MarkerTerminal:
"""One PDN-editor terminal candidate: one or several marker
rectangles with the role taken from the layer they sit on
(ELECTRODE_POS_LAYER = supply, ELECTRODE_NEG_LAYER = load). In PDN
mode every rectangle is its own terminal - unlike classic mode,
which merges each layer into one V+/V- contact - EXCEPT that
rectangles sharing one text-item name group into a single BONDED
terminal (a multi-pin package: total current known, per-contact
split solved through the internal bond)."""
name: str
role: str # "supply" | "load"
labeled: bool # named by a text item: saves as a
# live rect:NAME ref; unnamed rects
# save as frozen rect_mm coordinates
electrodes: list # [Electrode]; > 1 only when labeled
bonded: bool = False # grouped rects are bonded into one lug
def scan_marker_terminals(board: Board,
require_both: bool = True
) -> list[MarkerTerminal]:
"""Board-wide marker-rectangle scan for the PDN dialog editor (the
selection is deliberately ignored: PDN terminals are the drawn
rectangles, nothing else). Order is stable reading order - supplies
first, each group by the (y, x) of its first rectangle - because
the dialog's row identity is POSITIONAL; auto names S1../L1.. skip
names already taken by a label. Raises SelectionError when either
layer has no rectangle (require_both False skips that check: the
merge with a config's terminal set treats empty layers as simply
'nothing new') and ConfigError on naming problems (both just
disable the editor upstream; classic mode still runs)."""
pos_l = config.ELECTRODE_POS_LAYER
neg_l = config.ELECTRODE_NEG_LAYER
pairs = {l: _pair_rect_labels(board, l) for l in (pos_l, neg_l)}
if require_both and (not pairs[pos_l] or not pairs[neg_l]):
raise SelectionError(
f"PDN mode needs marker rectangles on both layers; found "
f"{len(pairs[pos_l])} on {pos_l} (supplies) and "
f"{len(pairs[neg_l])} on {neg_l} (loads). Draw supply "
f"rectangle(s) on {pos_l} and load rectangle(s) on {neg_l} "
f"(axis-aligned); a text item inside a rectangle names it."
)
# name uniqueness ACROSS marker layers: a name is a terminal name
# here and becomes a rect:NAME ref on save - both need exactly one
# owning layer (User.3 labels count: rect:NAME searches there too).
# WITHIN a layer a repeated name is the grouping mechanism, not an
# error: those rectangles form one bonded terminal
check_layers = []
for l in (pos_l, neg_l, config.ELECTRODE_PDN_LAYER):
if l not in check_layers:
check_layers.append(l)
seen: dict = {}
for l in check_layers:
prs = pairs[l] if l in pairs else _pair_rect_labels(board, l)
for _r, n in prs:
if n is None:
continue
if n in seen and seen[n] != l:
raise ConfigError(
f"rectangle name '{n}' exists on {seen[n]} and {l} "
f"- marker rectangle names must be unique across "
f"the marker layers."
)
seen[n] = l
def reading_order(pair):
tl, br = pair[0].top_left, pair[0].bottom_right
return (min(tl.y, br.y), min(tl.x, br.x))
taken = set(seen)
out: list = []
counter = {"supply": 0, "load": 0}
prefix = {"supply": "S", "load": "L"}
for layer, role in ((pos_l, "supply"), (neg_l, "load")):
groups: dict = {} # name -> MarkerTerminal, in reading
for r, name in sorted(pairs[layer], key=reading_order):
labeled = name is not None
if not labeled:
while True:
counter[role] += 1
name = f"{prefix[role]}{counter[role]}"
if name not in taken:
break
taken.add(name)
e = _to_electrode(board, r)
e.label = name
if labeled and name in groups:
mt = groups[name]
mt.electrodes.append(e)
mt.bonded = True # grouped = one externally bonded lug
continue
mt = MarkerTerminal(name=name, role=role, labeled=labeled,
electrodes=[e])
groups[name] = mt
out.append(mt)
return out
RECT_MATCH_TOL_MM = 1e-3 # frozen rect_mm coords are written with
# 1e-6 rounding; 1 um absorbs both that
# and the nm->mm float trip
def new_marker_terminals(specs: list, marker_terms: list
) -> list[MarkerTerminal]:
"""The scanned marker terminals NOT already referenced by the
config's TerminalSpec list: drawing a new rectangle on a marker
layer creates a new terminal even while a config provides the set.
A scanned rectangle is covered when its label appears as a
rect:NAME part (drawing MORE rects with that name extends that
very terminal at resolve time, so the scan group is not new
either) or when its geometry matches a frozen rect_mm part. A
label colliding with an unrelated config terminal name is skipped
with a printed note (rename one of the two); colliding auto names
are simply renumbered."""
covered_labels = set()
covered_rects = []
names = set()
for spec in specs:
names.add(spec.name)
for part in spec.parts:
if part.kind == "rect_label":
covered_labels.add(part.label)
elif part.kind == "rect_mm":
x0, y0, x1, y1 = part.rect_mm
covered_rects.append((min(x0, x1), min(y0, y1),
max(x0, x1), max(y0, y1)))
def frozen(e) -> bool:
r = e.rect
mm = (r.x0 / 1e6, r.y0 / 1e6, r.x1 / 1e6, r.y1 / 1e6)
return any(all(abs(a - b) <= RECT_MATCH_TOL_MM
for a, b in zip(mm, c)) for c in covered_rects)
taken = names | {mt.name for mt in marker_terms}
out = []
for mt in marker_terms:
if mt.labeled and mt.name in covered_labels:
continue
if all(frozen(e) for e in mt.electrodes):
continue
if mt.name in names:
if mt.labeled:
print(f"note: rectangle '{mt.name}' collides with the "
f"config terminal '{mt.name}' (which does not "
f"reference it) - rename one of the two to add "
f"the rectangle as a terminal")
continue
prefix = "S" if mt.role == "supply" else "L"
i = 1
while f"{prefix}{i}" in taken:
i += 1
mt.name = f"{prefix}{i}"
taken.add(mt.name)
for e in mt.electrodes:
e.label = mt.name
out.append(mt)
return out
def component_hints(board: Board, electrode_groups: list) -> list:
"""One row-identification string per electrode group for the PDN
dialog's Component column: the reference designators of footprints
with a pad intersecting any of the group's contact rectangles,
else "near <ref>" for the footprint whose pad center is closest.
Purely spatial - no net or layer filter: this identifies WHERE a
terminal sits, it plays no electrical role. Pads are approximated
by squares of their largest copper diameter (exact enough for
naming the owner). Empty string for a group when the board has no
usable footprints."""
fps = []
for fp in board.get_footprints():
try:
ref = fp.reference_field.text.value
pads = [(int(p.position.x), int(p.position.y),
_padstack_pad_nm(p) // 2)
for p in fp.definition.pads]
except Exception:
continue # identification only: skip odd
if ref and pads: # footprints, never fail the run
fps.append((ref, pads))
out = []
for electrodes in electrode_groups:
hits = []
near = None # (distance_nm, ref)
for ref, pads in fps:
best = None
for x, y, r in pads:
for e in electrodes:
rc = e.rect
# center-to-rectangle axis distances; both within
# the pad half-size = the square pad overlaps
dx = max(rc.x0 - x, x - rc.x1, 0)
dy = max(rc.y0 - y, y - rc.y1, 0)
d = 0.0 if (dx <= r and dy <= r) \
else float(dx * dx + dy * dy) ** 0.5
if best is None or d < best:
best = d
if best == 0.0:
hits.append(ref)
elif best is not None and (near is None or best < near[0]):
near = (best, ref)
if hits:
out.append(", ".join(hits[:3])
+ (f" +{len(hits) - 3}" if len(hits) > 3 else ""))
elif near is not None:
out.append(f"near {near[1]}")
else:
out.append("")
return out
class _RefContext:
"""Resolves configfile.PartRef entries against a live board. Board
queries (footprints, pads, shapes, texts, vias) are fetched once,
lazily - every map is built from the SAME get_footprints() call so
ownership comparisons stay identity-safe."""
def __init__(self, board: Board, stackup: StackupInfo | None, net: str):
self.board = board
self.stackup = stackup
self.net = net
self._by_ref: dict | None = None
self._pad_map: dict | None = None
self._pads: list | None = None
self._rects: dict = {} # layer -> {name: BoardRectangle}
self._vias: list | None = None
def _footprints(self) -> dict:
if self._by_ref is None:
fps = list(self.board.get_footprints())
self._by_ref = {}
for fp in fps:
try:
ref = fp.reference_field.text.value
except Exception:
continue
if ref:
self._by_ref.setdefault(ref, []).append(fp)
self._pad_map = _footprint_pad_map(fps)
return self._by_ref
def _board_pads(self) -> list:
if self._pads is None:
self._pads = list(self.board.get_pads())
return self._pads
def _fp_of(self, ref: str, where: str):
by_ref = self._footprints()
fps = by_ref.get(ref)
if not fps:
raise ConfigError(
f"{where}: footprint '{ref}' not found on the board."
)
if len(fps) > 1:
raise ConfigError(
f"{where}: reference '{ref}' is ambiguous - "
f"{len(fps)} footprints share it."
)
return fps[0]
def _pads_of_fp(self, fp) -> list:
self._footprints()
return [p for p in self._board_pads()
if _pad_owner(p, self._pad_map) is fp]
def _labeled_rects(self, layer: str) -> dict:
"""name -> [BoardRectangle, ...] on one marker layer (see
_pair_rect_labels). Cached per layer; unnamed rectangles are
skipped with a warning. Several rectangles sharing one name are
ONE multi-part reference (the grouping mechanism for bonded
multi-contact terminals), not an error."""
if layer not in self._rects:
named: dict = {}
for r, name in _pair_rect_labels(self.board, layer):
if name is None:
tl, br = r.top_left, r.bottom_right
print(f"config warning: unnamed rectangle on {layer} "
f"at ({min(tl.x, br.x) / 1e6:.1f}, "
f"{min(tl.y, br.y) / 1e6:.1f}) mm - place a "
f"text item inside it to use it as rect:NAME")
continue
named.setdefault(name, []).append(r)
self._rects[layer] = named
return self._rects[layer]
def _net_vias(self) -> list:
if self._vias is None:
self._vias = [v for v in self.board.get_vias()
if v.net is not None and v.net.name == self.net]
return self._vias
def resolve(self, part, where: str) -> list[Electrode]:
"""PartRef -> Electrode list (footprints can span several pads).
All errors are ConfigError with the terminal context in
`where`."""
if part.kind == "footprint":
fp = self._fp_of(part.ref, where)
pads = self._pads_of_fp(fp)
on_net = [p for p in pads
if p.net is not None and p.net.name == self.net]
if not on_net:
nets = sorted({p.net.name for p in pads
if p.net is not None})
raise ConfigError(
f"{where}: footprint '{part.ref}' has no pads on net "
f"'{self.net}'"
+ (f" (its nets: {', '.join(nets)})." if nets
else " (it has no connected pads).")
)
return [_to_electrode(self.board, p, self.stackup,
self._pad_map) for p in on_net]
if part.kind == "pad":
fp = self._fp_of(part.ref, where)
pads = self._pads_of_fp(fp)
matches = [p for p in pads if p.number == part.pad]
if not matches:
nums = ", ".join(sorted({p.number for p in pads})[:16])
raise ConfigError(
f"{where}: '{part.ref}' has no pad '{part.pad}'"
+ (f" (its pads: {nums})." if nums else ".")
)
for p in matches:
pnet = p.net.name if p.net is not None else "no net"
if pnet != self.net:
raise ConfigError(
f"{where}: pad '{part.ref}.{part.pad}' is on "
f"'{pnet}', not '{self.net}'."
)
return [_to_electrode(self.board, p, self.stackup,
self._pad_map) for p in matches]
if part.kind == "rect_label":
# rect:NAME searches every marker layer, so labeled
# PDN-editor rectangles (User.1/User.2) resolve too; a name
# existing on several layers is ambiguous and errors
layers = []
for l in (config.ELECTRODE_PDN_LAYER,
config.ELECTRODE_POS_LAYER,
config.ELECTRODE_NEG_LAYER):
if l not in layers:
layers.append(l)
hits = [(l, self._labeled_rects(l)[part.label])
for l in layers
if part.label in self._labeled_rects(l)]
if not hits:
names = ", ".join(sorted(
{n for l in layers for n in self._labeled_rects(l)}
)[:16])
raise ConfigError(
f"{where}: no rectangle named '{part.label}' on "
f"{', '.join(layers)}"
+ (f" (found: {names})." if names else
" (no named rectangles found there).")
)
if len(hits) > 1:
raise ConfigError(
f"{where}: rectangle name '{part.label}' exists on "
f"{' and '.join(l for l, _ in hits)} - marker "
f"rectangle names must be unique across layers."
)
# every same-named rectangle on the owning layer is one
# part of the reference (multi-contact terminals)
return [_to_electrode(self.board, r) for r in hits[0][1]]
if part.kind == "rect_mm":
x0, y0, x1, y1 = part.rect_mm
rect = Rect.normalized(int(x0 * 1e6), int(y0 * 1e6),
int(x1 * 1e6), int(y1 * 1e6),
"config")
return [Electrode(rect=rect,
contact=part.contact or "all",
label=f"rect({x0:g},{y0:g})")]
# via_mm
x = int(part.via_mm[0] * 1e6)
y = int(part.via_mm[1] * 1e6)
best, bd = None, 0.0
for v in self._net_vias():
d = math.hypot(v.position.x - x, v.position.y - y)
if best is None or d < bd:
best, bd = v, d
if best is None:
raise ConfigError(
f"{where}: net '{self.net}' has no vias "
f"({part.describe()})."
)
if bd > 1e6:
raise ConfigError(
f"{where}: {part.describe()} - the nearest via of "
f"'{self.net}' is {bd / 1e6:.2f} mm away (limit 1 mm)."
)
return [_to_electrode(self.board, best, self.stackup)]
def _resolve_parts(ctx: _RefContext, spec_contact: str, parts: list,
where: str) -> list[Electrode]:
"""Resolve a part list and apply the contact-scope precedence: an
explicit part-level contact wins, else the terminal-level scope
(unless 'auto' = keep what resolution decided)."""
out = []
for part in parts:
els = ctx.resolve(part, where)
for e in els:
if part.contact:
e.contact = part.contact
elif spec_contact and spec_contact != "auto":
e.contact = spec_contact
out.extend(els)
return out
def resolve_terminal_specs(board: Board, stackup: StackupInfo | None,
specs: list, net: str) -> list[Terminal]:
"""configfile.TerminalSpec list -> geometry.Terminal list, resolved
against the live board. Raises ConfigError naming the terminal and
the offending reference."""
ctx = _RefContext(board, stackup, net)
terminals = []
for spec in specs:
where = f"{spec.role} '{spec.name}'"
electrodes = _resolve_parts(ctx, spec.contact, spec.parts, where)
terminals.append(Terminal(
role=spec.role, electrodes=electrodes, label=spec.name,
i_draw_a=spec.i_draw_a, r_out_ohm=spec.r_out_ohm,
v_oc=spec.v_oc, bonded=spec.bonded,
comment=getattr(spec, "comment", "")))
return terminals
def resolve_classic_parts(board: Board, stackup: StackupInfo | None,
pos: list, neg: list, net: str
) -> tuple[list[Electrode], list[Electrode]]:
"""classic.pos / classic.neg part references -> V+/V- electrode
lists (the config file then fully replaces the board selection)."""
ctx = _RefContext(board, stackup, net)
return (_resolve_parts(ctx, "", pos, "classic.pos"),
_resolve_parts(ctx, "", neg, "classic.neg"))
# --- fills -------------------------------------------------------------------
def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]:
"""net -> layer_name -> merged fill polygons (non-empty only)."""
fills: dict[str, dict[str, list[Polygon]]] = {}
for zone in board.get_zones():
# teardrop fills are conducting copper too, but KiCad types them
# ZT_TEARDROP instead of ZT_COPPER
if zone.type not in (ZoneType.ZT_COPPER, ZoneType.ZT_TEARDROP):
continue
net = zone.net.name if zone.net is not None else "<no net>"
for layer, polys in zone.filled_polygons.items():
if not is_copper_layer(layer) or not polys:
continue
fills.setdefault(net, {}).setdefault(
canonical_name(layer), []).extend(
_convert_poly(p) for p in polys)
return fills
def gather_net_tracks(board: Board) -> dict[str, dict[str, list[TrackSeg]]]:
"""net -> layer -> TrackSeg (centerline + width). Traces conduct
together with the zone fills; the raster decides per run whether a
trace is rasterized from its outline or becomes a 1D chain."""
out: dict[str, dict[str, list[TrackSeg]]] = {}
for t in board.get_tracks():
if not is_copper_layer(t.layer):
continue
width = int(t.width or 0)
if width <= 0:
continue
if isinstance(t, ArcTrack):
pts = np.array([[t.start.x, t.start.y], [t.mid.x, t.mid.y],
[t.end.x, t.end.y]], dtype=np.int64)
else:
pts = np.array([[t.start.x, t.start.y], [t.end.x, t.end.y]],
dtype=np.int64)
net = t.net.name if t.net is not None else "<no net>"
layer = canonical_name(t.layer)
out.setdefault(net, {}).setdefault(layer, []).append(
TrackSeg(layer_name=layer, points=pts, width_nm=width))
return out
def tracks_as_polygons(tracks: dict) -> dict:
"""net -> layer -> outline polygons of the tracks (for the bbox-based
candidate detection; the Problem keeps the TrackSegs themselves)."""
return {
net: {layer: [Polygon(outline=seg.outline(ARC_TOL_NM))
for seg in segs]
for layer, segs in per_layer.items()}
for net, per_layer in tracks.items()
}
def merge_copper(fills: dict, tracks: dict) -> dict:
"""net -> layer -> fill + track polygons, for candidate detection
and the dialog's layer lists (build_problem merges the same way)."""
out: dict[str, dict[str, list[Polygon]]] = {}
for src in (fills, tracks):
for net, per_layer in src.items():
for layer, polys in per_layer.items():
out.setdefault(net, {}).setdefault(layer, []).extend(polys)
return out
def _rect_overlaps(rect: Rect, polygons: list[Polygon]) -> bool:
for p in polygons:
px0, py0 = p.outline.min(axis=0)
px1, py1 = p.outline.max(axis=0)
if rect.x0 <= px1 and rect.x1 >= px0 and rect.y0 <= py1 and rect.y1 >= py0:
return True
return False
def nets_overlapping(fills: dict, es1: list[Electrode],
es2: list[Electrode]) -> list[str]:
"""Nets whose fills overlap both terminals (any part, any layer each -
the connection may go through vias). Permissive bbox prefilter."""
out = []
for net, per_layer in fills.items():
hit1 = any(_rect_overlaps(e.rect, polys) for e in es1
for polys in per_layer.values())
hit2 = any(_rect_overlaps(e.rect, polys) for e in es2
for polys in per_layer.values())
if hit1 and hit2:
out.append(net)
return sorted(out)
def group_nets(copper: dict, electrode_groups: list) -> list:
"""Per electrode group: the frozenset of nets whose copper overlaps
any of the group's contact rectangles (any layer - the connection
may go through vias; same permissive bbox prefilter as
nets_overlapping). The PDN editor uses this to show only the
rectangles that actually sit on the selected net."""
out = []
for electrodes in electrode_groups:
nets = set()
for net, per_layer in copper.items():
if any(_rect_overlaps(e.rect, polys) for e in electrodes
for polys in per_layer.values()):
nets.add(net)
out.append(frozenset(nets))
return out
def gather_mask_buildups(board: Board) -> dict[str, list[Polygon]]:
"""Zones on F.Mask/B.Mask (mask openings) -> fill polygons keyed by
the outer copper layer they expose."""
out: dict[str, list[Polygon]] = {}
for zone in board.get_zones():
try:
filled = zone.filled_polygons
except Exception:
continue
for layer, polys in filled.items():
copper = MASK_TO_COPPER.get(canonical_name(layer))
if copper and polys:
out.setdefault(copper, []).extend(
_convert_poly(p) for p in polys)
return out
def any_zone_unfilled(board: Board) -> bool:
return any(z.type in (ZoneType.ZT_COPPER, ZoneType.ZT_TEARDROP)
and not z.filled for z in board.get_zones())
def refill(board: Board) -> None:
print("refilling zones - this modifies the open document ...")
board.refill_zones(block=True)
# --- barrels -----------------------------------------------------------------
def _padstack_pad_nm(item) -> int:
"""Largest copper pad diameter of a via/pad padstack; 0 if unknown.
Used to bound the barrel-to-fill connection search in the solver."""
try:
sizes = [max(int(l.size.x), int(l.size.y))
for l in item.padstack.copper_layers]
return max(sizes) if sizes else 0
except Exception:
return 0
def _padstack_pad_min_nm(item) -> int:
"""Smallest dimension of the (largest) copper pad of a padstack; 0
if unknown. Bounds the lead-cone taper on oblong pads: the cone
stays within the inscribed circle."""
try:
sizes = [min(int(l.size.x), int(l.size.y))
for l in item.padstack.copper_layers]
return max(sizes) if sizes else 0
except Exception:
return 0
def _padstack_span(padstack, stackup: StackupInfo) -> tuple[int, int]:
"""(z_top, z_bot) of the barrel; falls back to the full stack."""
try:
copper = [canonical_name(l) for l in padstack.layers
if is_copper_layer(l)]
zs = [stackup.z_nm[c] for c in copper if c in stackup.z_nm]
if len(zs) >= 2:
return min(zs) - 1, max(zs) + 1
except Exception:
pass
return -1, stackup.z_bot_nm + 1
def gather_barrels(board: Board, net_name: str,
stackup: StackupInfo) -> list[ViaLink]:
barrels = []
for via in board.get_vias():
if via.net is None or via.net.name != net_name:
continue
drill = int(via.drill_diameter or 0) or _pad_drill_nm(via)
if drill <= 0:
continue
z_top, z_bot = _padstack_span(via.padstack, stackup)
barrels.append(ViaLink(x=via.position.x, y=via.position.y,
drill_nm=drill, z_top_nm=z_top,
z_bot_nm=z_bot, kind="via",
pad_nm=_padstack_pad_nm(via)))
if config.INCLUDE_TH_PADS:
net_pads = [pad for pad in board.get_pads()
if pad.net is not None and pad.net.name == net_name
and _pad_drill_nm(pad) > 0]
# populated (non-DNP) THT pads carry a soldered joint: filled
# hole + coat + lead cone on the side opposite the component
pad_map = (_footprint_pad_map(board.get_footprints())
if net_pads else {})
unknown = 0
for pad in net_pads:
fp = _pad_owner(pad, pad_map)
unknown += fp is None
populated = True
if fp is not None:
try:
populated = not fp.attributes.do_not_populate
except Exception:
pass
drill, slot_dx, slot_dy = _drill_info(pad)
barrels.append(ViaLink(
x=pad.position.x, y=pad.position.y,
drill_nm=drill, z_top_nm=-1,
z_bot_nm=stackup.z_bot_nm + 1, kind="pad",
pad_nm=_padstack_pad_nm(pad),
pad_min_nm=_padstack_pad_min_nm(pad),
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
solder_filled=populated,
protrusion_side=(_tht_protrusion_side(pad, pad_map,
quiet=True)
if populated else None)))
if unknown:
print(f"note: {unknown} THT pad(s) without an identifiable "
f"footprint - assumed populated, leads on B.Cu")
return barrels
def gather_smd_pad_copper(board: Board, net_name: str
) -> dict[str, list[Polygon]]:
"""layer name -> exact copper shape(s) of every SMD (undrilled) pad
on the net. Pads are junctions: traces and thermal-relief spokes
meet ON the pad copper, and without it the junction necks down to
the accidental overlap of the track ends - or is severed outright.
Dead-end pads (component terminals) become floating islands that
the solver's connectivity restriction drops. One API call per pad;
pads whose copper layer cannot be determined are skipped."""
shapes: dict[str, list[Polygon]] = {}
for pad in board.get_pads():
if pad.net is None or pad.net.name != net_name \
or _pad_drill_nm(pad) > 0:
continue
layer = _pad_default_contact(pad) # SMD: its own copper layer
if layer == "all":
# zero or >1 copper layers (custom padstack): no single layer
# to stamp it on. Say so - a silent skip loses a real junction
print(f"note: pad {pad.number}@{net_name} sits on no single "
f"copper layer - its pad copper is not modelled")
continue
polys = _pad_polygons(board, pad, layer)
if polys:
shapes.setdefault(layer, []).extend(polys)
return shapes
def gather_tht_pad_copper(board: Board, net_name: str
) -> dict[tuple[int, int], list[Polygon]]:
"""(x, y) -> exact copper shape(s) of every drilled (THT) pad on the
net. The annular-ring copper conducts on every layer the barrel
spans, so build_problem stamps these onto each included layer. One
API call per pad; the outer-layer shape stands in for the inner
rings (approximation - inner rings are usually the same or
smaller)."""
shapes: dict[tuple[int, int], list[Polygon]] = {}
for pad in board.get_pads():
if pad.net is None or pad.net.name != net_name \
or _pad_drill_nm(pad) <= 0:
continue
polys = _pad_polygons(board, pad, "all")
if polys:
shapes[(pad.position.x, pad.position.y)] = polys
return shapes
# --- in-KiCad result overlays (EXPERIMENTAL) ---------------------------------
# KiCad sizes reference images as pixels * (1 inch / PPI) * image_scale
# and assumes 300 PPI for PNGs without a density chunk (BITMAP_BASE)
OVERLAY_PIX_NM = 25.4e6 / 300
def _create_items_checked(board: Board, items, what: str,
hint: str = "") -> None:
"""create_items with the per-item status surfaced (kipy <= 0.7.1
swallows it and returns an empty wrapper on failure)."""
from kipy.proto.common.commands.editor_commands_pb2 import (
CreateItems, CreateItemsResponse)
from kipy.util import pack_any
cmd = CreateItems()
cmd.header.document.CopyFrom(board._doc)
for item in items:
cmd.items.append(pack_any(item.proto))
results = board._kicad.send(cmd, CreateItemsResponse).created_items
bad = [r for r in results if r.status.code != 1] # 1 = ISC_OK
if bad or len(results) != len(items):
detail = (f"status {bad[0].status.code} "
f"{bad[0].status.error_message or ''}" if bad
else f"{len(items) - len(results)} item(s) not created")
raise RuntimeError(
f"KiCad rejected the {what} ({detail}) - is the layer "
f"enabled in Board Setup?{hint}")
def _remove_items_checked(board: Board, items, what: str) -> int:
"""remove_items with the per-item status surfaced: kipy discards the
DeleteItemsResponse, and its own proto warns the overall status "may
return IRS_OK even if no items were deleted" - a locked item comes
back IDS_IMMUTABLE. Unchecked, the stale item survives and the new
one is stacked on top of it instead of replacing it."""
from kipy.proto.common.commands.editor_commands_pb2 import (
DeleteItems, DeleteItemsResponse, ItemDeletionStatus)
if not items:
return 0
cmd = DeleteItems()
cmd.header.document.CopyFrom(board._doc)
cmd.item_ids.extend([it.id for it in items])
results = board._kicad.send(cmd, DeleteItemsResponse).deleted_items
stuck = [r for r in results
if r.status not in (ItemDeletionStatus.IDS_OK,
ItemDeletionStatus.IDS_NONEXISTENT)]
if stuck:
locked = sum(1 for r in stuck
if r.status == ItemDeletionStatus.IDS_IMMUTABLE)
raise RuntimeError(
f"{len(stuck)} existing {what}(s) could not be removed"
+ (f" ({locked} locked)" if locked else "")
+ " - unlock them in KiCad, or delete them by hand, then run "
"again (the replacement would otherwise stack on top).")
return len(results)
def remove_overlays(board: Board, layer) -> int:
"""Remove every reference image on the given layer; returns count."""
return _remove_items_checked(
board, [r for r in board.get_reference_images() if r.layer == layer],
"overlay image")
def push_result_overlays(board: Board, stack, result,
lock: bool = False) -> None:
"""EXPERIMENTAL: the solved |J| of every included copper layer as an
unlocked reference image on config.OVERLAY_LAYERS (stackup order,
top first; existing images there are replaced, and slots this run
does not write are cleared so no stale heatmap is left behind).
The whole push is one commit, so a single undo reverts it. Editor-
only - reference images never plot. Per-layer failures are reported
and skipped, never fatal to the run."""
from kipy.board_types import ReferenceImage
from kipy.geometry import Vector2
from .overlay import heatmap_png
names = stack.layer_names
pairs = list(zip(names, config.OVERLAY_LAYERS))
if len(names) > len(config.OVERLAY_LAYERS):
print(f"overlays: more copper layers than slots - "
f"{', '.join(names[len(config.OVERLAY_LAYERS):])} skipped")
ny, nx = stack.shape2d
w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm
commit = board.begin_commit() if hasattr(board, "begin_commit") else None
done = False
try:
# a narrower run than last time writes fewer slots; whatever the
# zip above left out still holds the previous solve's heatmap and
# would read as current, so clear it
for dest_name in config.OVERLAY_LAYERS[len(pairs):]:
try:
if remove_overlays(board, layer_from_canonical_name(dest_name)):
print(f"overlay: cleared stale {dest_name}")
except Exception as e:
print(f"overlay: clearing stale {dest_name} failed: {e}")
for src, dest_name in pairs:
try:
dest = layer_from_canonical_name(dest_name)
png = heatmap_png(result.Jmag * 1e-6, names.index(src))
remove_overlays(board, dest)
ref = ReferenceImage()
ref.layer = dest
ref.position = Vector2.from_xy(round(stack.x0_nm + w_nm / 2),
round(stack.y0_nm + h_nm / 2))
ref.image_scale = w_nm / (nx * OVERLAY_PIX_NM)
ref.image_data = png
ref.locked = lock
_create_items_checked(board, [ref], "image",
" (KiCad >= 10.0.1 required)")
print(f"overlay: |J| of {src} -> {dest_name} "
f"({len(png) / 1024:.0f} kB)")
except Exception as e:
print(f"overlay: {src} -> {dest_name} failed: {e}")
if commit is not None:
board.push_commit(commit, "Fill Resistance |J| overlays")
done = True
finally:
if commit is not None and not done:
try:
board.drop_commit(commit)
except Exception:
pass
# --- low-current copper polygons (EXPERIMENTAL) ------------------------------
def remove_trim_polygons(board: Board, layer) -> int:
"""Remove every graphic polygon on the given layer; returns count."""
from kipy.board_types import BoardPolygon
return _remove_items_checked(
board, [s for s in board.get_shapes()
if isinstance(s, BoardPolygon) and s.layer == layer],
"trim polygon")
def _trim_shape(tp, layer, lock: bool):
"""One filled BoardPolygon (outline + holes) on the given layer -
individually selectable, so Edit > Convert can turn it into a rule
area or a zone cutout by hand."""
from kipy.board_types import BoardPolygon
from kipy.geometry import PolygonWithHoles, PolyLine, PolyLineNode
def poly_line(ring) -> PolyLine:
line = PolyLine()
for x, y in ring.tolist():
line.append(PolyLineNode.from_xy(int(x), int(y)))
line.closed = True
return line
pwh = PolygonWithHoles()
pwh.outline = poly_line(tp.outline)
for hole in tp.holes:
pwh.add_hole(poly_line(hole))
shape = BoardPolygon()
shape.layer = layer
shape.locked = lock
shape.attributes.fill.filled = True
shape.polygons.append(pwh)
return shape
def push_trim_polygons(board: Board, trim, lock: bool = False) -> None:
"""EXPERIMENTAL: the below-threshold copper of every included layer
as filled graphic polygons on config.TRIM_LAYERS (stackup order, top
first; existing polygons on those layers are REPLACED, and slots
this run does not write are cleared so no stale suggestion is left
behind). The whole push is one commit, so a single undo reverts it.
Per-layer failures are reported and skipped, never fatal to the
run."""
pairs = list(zip(trim.layers, config.TRIM_LAYERS))
if len(trim.layers) > len(config.TRIM_LAYERS):
skipped = [lt.layer for lt in trim.layers[len(config.TRIM_LAYERS):]]
print(f"trim: more copper layers than slots - "
f"{', '.join(skipped)} skipped")
commit = board.begin_commit() if hasattr(board, "begin_commit") else None
done = False
try:
for dest_name in config.TRIM_LAYERS[len(pairs):]:
try:
if remove_trim_polygons(board,
layer_from_canonical_name(dest_name)):
print(f"trim: cleared stale {dest_name}")
except Exception as e:
print(f"trim: clearing stale {dest_name} failed: {e}")
for lt, dest_name in pairs:
try:
dest = layer_from_canonical_name(dest_name)
remove_trim_polygons(board, dest)
if lt.polygons:
_create_items_checked(
board,
[_trim_shape(tp, dest, lock) for tp in lt.polygons],
"trim polygon")
print(f"trim: {lt.layer} -> {dest_name} "
f"({len(lt.polygons)} polygon(s), "
f"{lt.marked_mm2:.1f} mm2)")
except Exception as e:
print(f"trim: {lt.layer} -> {dest_name} failed: {e}")
if commit is not None:
board.push_commit(commit, "Fill Resistance low-current copper")
done = True
finally:
if commit is not None and not done:
try:
board.drop_commit(commit)
except Exception:
pass
# --- top level ----------------------------------------------------------------
def build_problem(board: Board, net: str, layer_names: list[str],
es1: list[Electrode], es2: list[Electrode],
stackup: StackupInfo, fills: dict,
buildups: dict[str, list[Polygon]] | None = None,
extra_cu_um: float | None = None,
tracks: dict | None = None,
vias_capped: bool | None = None,
cap_max_drill_mm: float | None = None,
terminals: list[Terminal] | None = None) -> Problem:
per_layer = fills.get(net, {})
per_layer_tracks = (tracks or {}).get(net, {})
layers = []
segs: list[TrackSeg] = []
for name in stackup.names: # keep stackup order
if name not in layer_names:
continue
polys = list(per_layer.get(name, []))
layer_segs = per_layer_tracks.get(name, [])
if not polys and not layer_segs:
print(f"note: net {net} has no copper on {name} - layer skipped")
continue
if config.COPPER_THICKNESS_UM is not None:
t = int(config.COPPER_THICKNESS_UM * 1000)
else:
t = stackup.thickness_nm[name]
layers.append(LayerFill(layer_name=name, thickness_nm=t,
z_nm=stackup.z_nm[name], polygons=polys))
segs.extend(layer_segs)
if not layers:
raise CandidateError(
f"Net {net} has no fill on any of the selected layers "
f"({', '.join(layer_names)})."
)
# barrels matter on a single layer too: via rings + drill mouths
# perforate the plane, THT joints locally stiffen it
vias = gather_barrels(board, net, stackup)
# THT pad copper is part of the conductor: stamp the exact pad
# shapes onto every included layer (the barrel spans the stack)
pad_shapes = (gather_tht_pad_copper(board, net)
if any(v.kind == "pad" for v in vias) else {})
if pad_shapes:
extra = [poly for polys in pad_shapes.values() for poly in polys]
for layer in layers:
layer.polygons = list(layer.polygons) + extra
print(f"{len(pad_shapes)} THT pad shape(s) stamped on every "
f"included layer")
# SMD pad copper too: pads are the junctions where traces/spokes
# meet (also gives selected SMD-pad contacts their real copper)
smd_shapes = (gather_smd_pad_copper(board, net)
if config.INCLUDE_SMD_PADS else {})
if smd_shapes:
n = 0
for layer in layers:
polys = smd_shapes.get(layer.layer_name, [])
if polys:
layer.polygons = list(layer.polygons) + polys
n += len(polys)
if n:
print(f"{n} SMD pad shape(s) stamped on their layers")
included = {l.layer_name for l in layers}
buildup_list = [
SurfaceBuildup(layer_name=name, polygons=polys)
for name, polys in (buildups or {}).items() if name in included
]
print(f"net {net}: {len(layers)} layer(s) "
f"({', '.join(l.layer_name for l in layers)}), "
f"{len(segs)} track(s), {len(vias)} via/pad barrel(s)"
+ (f", solder buildup on "
f"{', '.join(b.layer_name for b in buildup_list)}"
if buildup_list else ""))
problem = Problem(
board_path=board.name or "",
net_name=net,
rho_ohm_m=config.RHO_CU_OHM_M,
plating_nm=int(config.VIA_PLATING_UM * 1000),
layers=layers,
vias=vias,
electrodes1=es1,
electrodes2=es2,
terminals=terminals or [],
thickness_source=("override" if config.COPPER_THICKNESS_UM is not None
else "stackup"),
buildups=buildup_list,
solder_thickness_nm=int(config.SOLDER_THICKNESS_UM * 1000),
solder_rho_ohm_m=config.SOLDER_RHO_OHM_M,
extra_cu_nm=int((extra_cu_um if extra_cu_um is not None
else config.BUILDUP_EXTRA_CU_UM) * 1000),
tracks=segs,
vias_capped=(vias_capped if vias_capped is not None
else config.VIAS_CAPPED),
cap_plating_nm=int(config.CAP_PLATING_UM * 1000),
cap_max_drill_nm=int((cap_max_drill_mm if cap_max_drill_mm is not None
else config.CAP_MAX_DRILL_MM) * 1e6),
tht_protrusion_nm=int(config.THT_LEAD_PROTRUSION_MM * 1e6),
tht_lead_clearance_nm=int(config.THT_LEAD_CLEARANCE_MM * 1e6),
tht_lead_rho_ohm_m=config.THT_LEAD_RHO_OHM_M,
)
solder_layers = contact_solder_buildups(problem)
if solder_layers:
sides = sorted({e.protrusion_side
for e in problem.contact_electrodes()
if e.solder and e.protrusion_side})
cone = (f", {config.THT_LEAD_PROTRUSION_MM:g} mm lead + solder cone "
f"on {', '.join(sides)}"
if sides and problem.tht_protrusion_nm > 0 else "")
print(f"THT contact(s): solder-filled hole + "
f"{config.SOLDER_THICKNESS_UM:g} um average solder coat on the "
f"pad face ({', '.join(solder_layers)}){cone}")
tht_joint_buildups(problem, pad_shapes)
n_joint = sum(1 for v in problem.vias
if v.kind == "pad" and v.solder_filled)
n_dnp = sum(1 for v in problem.vias
if v.kind == "pad" and not v.solder_filled)
if n_joint or n_dnp:
print(f"{n_joint} populated THT pad joint(s): lead + solder in the "
f"hole, coat + cone on the solder side"
+ (f"; {n_dnp} DNP pad(s): open hole, plating-only"
if n_dnp else ""))
return problem
if __name__ == "__main__":
import sys
from . import configfile
from .geometry import save_problem
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("geometry_dump.json")
_, board = connect()
stackup = get_stackup_info(board)
cfg_path = configfile.find_config(board_dir(board),
getattr(board, "name", "") or "")
cfg = configfile.load_config(cfg_path) if cfg_path else None
pdn = cfg is not None and cfg.mode == "pdn"
terminals = None
if cfg is not None:
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
configfile.apply_physics(cfg)
if pdn:
es1, es2, net_hint = [], [], cfg.net
elif cfg is not None and cfg.pos_parts is not None:
es1, es2 = resolve_classic_parts(board, stackup, cfg.pos_parts,
cfg.neg_parts, cfg.net)
net_hint = cfg.net
else:
es1, es2, net_hint = get_electrodes(board, stackup)
if any_zone_unfilled(board):
refill(board)
fills = gather_net_fills(board)
tracks = gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
copper = merge_copper(fills, tracks_as_polygons(tracks))
if pdn:
net = cfg.net
terminals = resolve_terminal_specs(board, stackup, cfg.terminals,
net)
else:
nets = nets_overlapping(copper, es1, es2)
if len(sys.argv) > 2:
net = sys.argv[2]
elif net_hint in nets:
net = net_hint
elif len(nets) == 1:
net = nets[0]
else:
print(f"candidate nets: {nets}; pass one as second argument")
sys.exit(1)
problem = build_problem(board, net, list(copper.get(net, {})), es1, es2,
stackup, fills, tracks=tracks,
terminals=terminals)
save_problem(problem, out)
print(f"wrote {out}")