92bb29637f
Tracks are now first-class Problem objects (TrackSeg: centerline + width, dump schema v5), so the wide/narrow decision replays at raster time: traces at least TRACK_1D_FACTOR (3) cells wide rasterize from their outline as before; narrower ones mark the cells their centerline crosses as copper and connect them with explicit conductance links carrying the trace's TRUE arc length per link - no staircase inflation for diagonals or arcs, and no discretization error in the trace R, at any grid size. Links across cells already joined by pour faces are skipped (union, not sum); chain-only cells get no sheet faces (their copper is narrower than a cell). Electrodes, via barrels, connectivity restriction and the skin-effect scaling all work on chain cells unchanged. This removes the need to shrink the cell size for thin traces: a 0.2 mm bridge at 500 um cells now matches its finely-rasterized ground truth within a few percent (tested), including diagonal and arc traces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
550 lines
20 KiB
Python
550 lines
20 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
|
|
|
|
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
|
|
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, linearize_ring)
|
|
|
|
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 _pad_drill_nm(pad_or_via) -> int:
|
|
try:
|
|
return int(pad_or_via.padstack.drill.diameter.x)
|
|
except Exception:
|
|
return 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) -> list[Polygon] | None:
|
|
layer_ids = []
|
|
if contact != "all":
|
|
try:
|
|
layer_ids.append(layer_from_canonical_name(contact))
|
|
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 _to_electrode(board: Board, item) -> 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})")
|
|
# 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")
|
|
return Electrode(rect=rect, contact=contact,
|
|
polygons=_pad_polygons(board, pad, contact), label=label)
|
|
|
|
|
|
def _net_hint_of(pads: list[Pad]) -> str | None:
|
|
for pad in pads:
|
|
if pad.net is not None:
|
|
return pad.net.name
|
|
return None
|
|
|
|
|
|
def get_electrodes(board: Board
|
|
) -> 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 fill a side that has no rectangles;
|
|
- no marker rectangles selected: legacy mode, exactly 2 items
|
|
(rects/pads, any layer) -> one part each;
|
|
- empty selection: board-wide scan of both marker layers.
|
|
"""
|
|
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 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)]
|
|
|
|
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): both marker "
|
|
f"layers already provide rectangles. Use pads only for a "
|
|
f"side that has none."
|
|
)
|
|
if pads:
|
|
pad_parts = [_to_electrode(board, p) 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])],
|
|
[_to_electrode(board, items[1])], _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); without marker layers exactly 2 "
|
|
f"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():
|
|
if zone.type != ZoneType.ZT_COPPER:
|
|
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 == ZoneType.ZT_COPPER 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_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:
|
|
for pad in board.get_pads():
|
|
if pad.net is None or pad.net.name != net_name:
|
|
continue
|
|
drill = _pad_drill_nm(pad)
|
|
if drill <= 0:
|
|
continue
|
|
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)))
|
|
return barrels
|
|
|
|
|
|
# --- 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) -> 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)})."
|
|
)
|
|
vias = gather_barrels(board, net, stackup) if len(layers) > 1 else []
|
|
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 ""))
|
|
return 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,
|
|
)
|
|
|
|
|
|
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)
|
|
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}")
|