Files
grabowski 8994d8e743 Overlay push: verify deletes, clear unwritten slots, one undo step
kipy's Board.remove_items discards the DeleteItemsResponse, and the
proto warns the overall status may read OK even when nothing was
deleted - a locked image comes back IDS_IMMUTABLE. remove_overlays
went through the proto layer already for create; do the same for
delete, so a locked overlay is reported instead of silently surviving
while a second image is stacked on top of it.

Slots that a narrower run does not write kept the previous solve's
heatmap and read as current; clear them. The whole push is now one
commit, so a single undo reverts it rather than one layer of it.

_pad_polygons probed F.Cu before B.Cu regardless of which side the
joint protrudes from, so a pad sized differently per copper layer had
its solder coat measured from the wrong face; probe the solder side
first. Pads on no single copper layer are now noted rather than
silently skipped.

_fail could report nothing at all: before the output directory exists
no PNG is written, and with no GUI toolkit plots only opens saved
PNGs - the broken-plugin-environment case the docstring promises to
cover. Fall back to the temp dir, and never let reporting mask the
fault. Frequency input now keeps its specific rejection reason, as
the other numeric fields do.
2026-07-22 16:21:18 +07:00

916 lines
37 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, 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, SelectionError
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
SurfaceBuildup, 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}"
)
# --- 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 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_reference_image(board: Board, ref) -> 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)
cmd.items.append(pack_any(ref.proto))
result = board._kicad.send(cmd, CreateItemsResponse).created_items[0]
if result.status.code != 1: # 1 = ISC_OK
raise RuntimeError(
f"KiCad rejected the image (status {result.status.code}) "
f"{result.status.error_message or ''} - is the layer enabled "
f"in Board Setup? (KiCad >= 10.0.1 required)")
def remove_overlays(board: Board, layer) -> int:
"""Remove every reference image on the given layer; returns count.
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 image comes
back IDS_IMMUTABLE. Unchecked, the stale image 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)
ours = [r for r in board.get_reference_images() if r.layer == layer]
if not ours:
return 0
cmd = DeleteItems()
cmd.header.document.CopyFrom(board._doc)
cmd.item_ids.extend([r.id for r in ours])
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 overlay image(s) could not be removed"
+ (f" ({locked} locked)" if locked else "")
+ " - unlock them in KiCad, or delete them by hand, then run "
"again (a new image would otherwise stack on top).")
return len(results)
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_reference_image(board, ref)
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
# --- 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) -> 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,
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.electrodes1 + problem.electrodes2
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 .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)
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))
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)
save_problem(problem, out)
print(f"wrote {out}")