diff --git a/README.md b/README.md index 2257946..fa9e618 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,24 @@ spelled out per step and in *Platform notes* below. reference images on those layers**, so don't store unrelated images there. Also available headless: `python tools/kicad_heatmap_overlay.py --net X --amps 10`. +6. **Experimental — low-current copper marking** (dialog checkbox, + default off): after the solve, the copper whose |J| is **below a + threshold** (dialog-settable, default 10 % of the mean |J| over all + solved copper) is outlined as **filled graphic polygons** on + `User.5`…`User.8` (`TRIM_LAYERS` in `fill_resistance/config.py`; + enable them in Board Setup), copper layers mapped in stackup order, + top first. Marked specks under `TRIM_MIN_AREA_MM2` (0.5 mm²) are + dropped. Each region is one selectable polygon — use KiCad's + **Edit → Convert** to turn one into a rule area or zone cutout by + hand. Per-layer areas are printed to the Messages panel and the + polygons also land in `low_current_copper.json` next to the PNGs. + Every push **replaces all graphic polygons on those layers** (one + undo step). **This is a suggestion, not a safe cut list**: copper + carries little current *because* the rest carries it — removing + copper redistributes the current and raises |J| everywhere else, so + re-run after any change. The pour may also serve thermal spreading, + EMI return paths, or plane capacitance, which this DC analysis does + not see. ## Model & limits diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index b86ba4b..6674ea0 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -649,7 +649,8 @@ def gather_tht_pad_copper(board: Board, net_name: str OVERLAY_PIX_NM = 25.4e6 / 300 -def _create_reference_image(board: Board, ref) -> None: +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 ( @@ -658,33 +659,33 @@ def _create_reference_image(board: Board, ref) -> None: 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 + 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 image (status {result.status.code}) " - f"{result.status.error_message or ''} - is the layer enabled " - f"in Board Setup? (KiCad >= 10.0.1 required)") + f"KiCad rejected the {what} ({detail}) - is the layer " + f"enabled in Board Setup?{hint}") -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 +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 image comes - back IDS_IMMUTABLE. Unchecked, the stale image survives and the new + 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) - ours = [r for r in board.get_reference_images() if r.layer == layer] - if not ours: + if not items: return 0 - cmd = DeleteItems() cmd.header.document.CopyFrom(board._doc) - cmd.item_ids.extend([r.id for r in ours]) + cmd.item_ids.extend([it.id for it in items]) results = board._kicad.send(cmd, DeleteItemsResponse).deleted_items stuck = [r for r in results @@ -694,13 +695,20 @@ def remove_overlays(board: Board, layer) -> int: 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"{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 (a new image would otherwise stack on top).") + "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 @@ -748,7 +756,8 @@ def push_result_overlays(board: Board, stack, result, ref.image_scale = w_nm / (nx * OVERLAY_PIX_NM) ref.image_data = png ref.locked = lock - _create_reference_image(board, ref) + _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: @@ -764,6 +773,94 @@ def push_result_overlays(board: Board, stack, result, 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], diff --git a/fill_resistance/config.py b/fill_resistance/config.py index 8976f1b..c5e5b1f 100644 --- a/fill_resistance/config.py +++ b/fill_resistance/config.py @@ -95,6 +95,25 @@ OVERLAY_ALPHA = 255 # overlay opacity over copper (0-255); # translucency washes out over bright # copper - toggle the User layer instead +# --- Low-current copper marking (EXPERIMENTAL) --- +TRIM_ENABLED = False # dialog default: mark the copper below + # TRIM_THRESHOLD_PCT as polygons on + # TRIM_LAYERS. A suggestion, not a safe + # cut list: copper carries little current + # BECAUSE the rest carries it - removal + # redistributes |J|, re-run after changes +TRIM_THRESHOLD_PCT = 10.0 # threshold as % of the mean |J| over all + # solved copper cells (mean, not max: + # contact-corner spikes would dwarf a + # max-relative threshold); dialog-settable +TRIM_LAYERS = ("User.5", "User.6", "User.7", "User.8") + # copper layers map here in stackup order + # (top first); existing polygons on these + # layers are REPLACED on every push; each + # must be enabled in Board Setup +TRIM_MIN_AREA_MM2 = 0.5 # marked specks smaller than this are + # dropped (nothing useful to reclaim) + # --- Adaptive grid --- ADAPTIVE_CELLS = True # solve on a 2:1-balanced quadtree: fine at # copper boundaries/electrodes/features, diff --git a/fill_resistance/dialog.py b/fill_resistance/dialog.py index c2331b7..e732310 100644 --- a/fill_resistance/dialog.py +++ b/fill_resistance/dialog.py @@ -40,6 +40,8 @@ class Selection: cap_max_drill_mm: float = 0.5 adaptive: bool = True push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays + trim_enabled: bool = False # EXPERIMENTAL low-current copper marking + trim_pct: float = 10.0 # threshold as % of the mean |J| class _Dialog(QDialog): @@ -130,6 +132,19 @@ class _Dialog(QDialog): self.overlay_check.setChecked(config.PUSH_OVERLAYS) form.addRow("Overlays:", self.overlay_check) + tfirst, tlast = config.TRIM_LAYERS[0], config.TRIM_LAYERS[-1] + self.trim_check = QCheckBox( + f"experimental: mark copper below the threshold as polygons " + f"on {tfirst}..{tlast} (replaces polygons there; a suggestion " + f"only - removing copper shifts current elsewhere)") + self.trim_check.setChecked(config.TRIM_ENABLED) + form.addRow("Low-current copper:", self.trim_check) + + self.trim_edit = QLineEdit(f"{config.TRIM_THRESHOLD_PCT:g}") + self.trim_edit.setEnabled(config.TRIM_ENABLED) + self.trim_check.toggled.connect(self.trim_edit.setEnabled) + form.addRow("Threshold [% of mean |J|]:", self.trim_edit) + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self._try_accept) buttons.rejected.connect(self.reject) @@ -227,6 +242,12 @@ class _Dialog(QDialog): extra_cu = number(self.extracu_edit, "Extra Cu") if extra_cu < 0: raise ValueError("Extra Cu must be ≥ 0 µm.") + trim_pct = config.TRIM_THRESHOLD_PCT + if self.trim_check.isChecked(): + trim_pct = number(self.trim_edit, "Trim threshold") + if not 0 < trim_pct < 100: + raise ValueError("Trim threshold must be between 0 and " + "100 (% of the mean |J|).") cap_max_drill = config.CAP_MAX_DRILL_MM if self.capped_check.isChecked(): cap_max_drill = number(self.cap_drill_edit, "Capped up to drill") @@ -251,7 +272,9 @@ class _Dialog(QDialog): vias_capped=self.capped_check.isChecked(), cap_max_drill_mm=cap_max_drill, adaptive=self.adaptive_check.isChecked(), - push_overlays=self.overlay_check.isChecked()) + push_overlays=self.overlay_check.isChecked(), + trim_enabled=self.trim_check.isChecked(), + trim_pct=trim_pct) def _try_accept(self) -> None: try: diff --git a/fill_resistance/main.py b/fill_resistance/main.py index 2a631af..af9d02a 100644 --- a/fill_resistance/main.py +++ b/fill_resistance/main.py @@ -136,10 +136,17 @@ def main() -> None: if selection.push_overlays: def overlay_cb(stack, result): board_io.push_result_overlays(board, stack, result) + trim_cb = None + if selection.trim_enabled: + def trim_cb(tr): + board_io.push_trim_polygons(board, tr) pipeline.run(problem, outdir, show=True, i_test=selection.current_a, freq_hz=selection.freq_hz, contact_model=selection.contact_model, - overlay=overlay_cb) + overlay=overlay_cb, + trim_pct=(selection.trim_pct if selection.trim_enabled + else None), + trim_push=trim_cb) except progress.Cancelled: print("cancelled") # user's own doing: no error figure except UserFacingError as e: diff --git a/fill_resistance/pipeline.py b/fill_resistance/pipeline.py index 45d07ad..a6228e9 100644 --- a/fill_resistance/pipeline.py +++ b/fill_resistance/pipeline.py @@ -4,7 +4,7 @@ from __future__ import annotations from pathlib import Path -from . import config, plots, progress, raster, report, solver +from . import config, plots, progress, raster, report, solver, trim from .errors import UserFacingError from .geometry import Problem from .solver import Result @@ -12,9 +12,15 @@ from .solver import Result def run(problem: Problem, outdir: Path | None, show: bool = True, i_test: float | None = None, freq_hz: float = 0.0, - contact_model: str | None = None, overlay=None) -> Result: + contact_model: str | None = None, overlay=None, + trim_pct: float | None = None, trim_push=None) -> Result: """overlay: optional callback(stack, result) run after the solve - (EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.""" + (EXPERIMENTAL in-KiCad overlays); its failures are non-fatal. + trim_pct: mark copper below this % of the mean |J| (None = off): + per-layer areas are printed, polygons saved to + /low_current_copper.json and handed to trim_push, an + optional callback(trim_result) that pushes them into the board + (failures non-fatal).""" if i_test is None: i_test = config.TEST_CURRENT_A if i_test <= 0: @@ -51,6 +57,17 @@ def run(problem: Problem, outdir: Path | None, show: bool = True, except Exception as e: print(f"overlay push failed: {e}") + if trim_pct is not None: + tr = trim.compute(result, stack, trim_pct) + print(trim.summary_line(tr)) + if outdir is not None: + trim.write_json(outdir, tr) + if trim_push is not None: + try: + trim_push(tr) + except Exception as e: + print(f"trim push failed: {e}") + progress.stage("rendering figures ...") figs = [ (plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"), diff --git a/fill_resistance/trim.py b/fill_resistance/trim.py new file mode 100644 index 0000000..5c3816e --- /dev/null +++ b/fill_resistance/trim.py @@ -0,0 +1,183 @@ +"""Low-current copper marking (EXPERIMENTAL): polygons around the copper +that carries almost no current at the solved operating point. + +The mask is |J| < threshold, the threshold given as a percentage of the +MEAN |J| over the copper cells of every solved layer (mean, not max: +|J| spikes at contact corners would dwarf a max-relative threshold). +Cell mask -> polygons via the 0.5 contour of the binary field +(contourpy, matplotlib's own contour engine - already installed in +every plugin venv), simplified with Douglas-Peucker so the staircase +bevels collapse but one-cell-wide strips survive. + +The marked copper is a SUGGESTION, not a safe cut list: it carries +little current BECAUSE the rest carries it - removing copper +redistributes the current and raises |J| everywhere else. Re-run after +any change. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +from . import config + +JSON_NAME = "low_current_copper.json" + + +@dataclass +class TrimPolygon: + outline: np.ndarray # (N, 2) int64 board nm, unclosed ring + holes: list[np.ndarray] # same format + + +@dataclass +class LayerTrim: + layer: str # copper layer name + polygons: list[TrimPolygon] + marked_mm2: float # below-threshold copper area + copper_mm2: float # total copper area of the layer + + +@dataclass +class TrimResult: + threshold_pct: float + threshold_a_mm2: float # the absolute threshold this run used + layers: list[LayerTrim] # stackup order, top first + + +def low_current_mask(Jmag: np.ndarray, + threshold_pct: float) -> tuple[np.ndarray, float]: + """(L, ny, nx) |J| in A/m2 with NaN outside copper -> boolean mask of + the copper cells below threshold_pct % of the mean |J|, plus the + absolute threshold (A/m2). The mean is global over all layers: a + layer that carries little current overall is exactly the copper the + mask should show, not a reason to lower its own threshold.""" + copper = np.isfinite(Jmag) + if not copper.any(): + raise ValueError("no copper cells in the solved field") + thr = float(np.nanmean(Jmag)) * threshold_pct / 100.0 + below = np.zeros(Jmag.shape, dtype=bool) + below[copper] = Jmag[copper] < thr + return below, thr + + +def _rdp(pts: np.ndarray, tol: float) -> np.ndarray: + """Iterative Douglas-Peucker; the first and last point always stay.""" + n = len(pts) + if n < 3: + return pts + keep = np.zeros(n, dtype=bool) + keep[0] = keep[-1] = True + stack = [(0, n - 1)] + while stack: + i0, i1 = stack.pop() + if i1 <= i0 + 1: + continue + seg = pts[i1] - pts[i0] + rel = pts[i0 + 1:i1] - pts[i0] + length = float(np.hypot(seg[0], seg[1])) + if length == 0.0: + d = np.hypot(rel[:, 0], rel[:, 1]) + else: + d = np.abs(rel[:, 0] * seg[1] - rel[:, 1] * seg[0]) / length + k = int(np.argmax(d)) + if d[k] > tol: + j = i0 + 1 + k + keep[j] = True + stack.append((i0, j)) + stack.append((j, i1)) + return pts[keep] + + +def _ring_area_nm2(ring: np.ndarray) -> float: + x = ring[:, 0].astype(np.float64) + y = ring[:, 1].astype(np.float64) + return abs(float(np.dot(x, np.roll(y, -1)) + - np.dot(y, np.roll(x, -1)))) / 2.0 + + +def mask_to_polygons(mask2: np.ndarray, x0_nm: float, y0_nm: float, + h_nm: float, min_area_mm2: float) -> list[TrimPolygon]: + """Boolean cell mask -> TrimPolygons in board nm. The boundary runs + along cell edges, corners cut at 45 degrees by the marching-squares + interpolation - half a cell, below the model's own resolution.""" + if not mask2.any(): + return [] + import contourpy + + # a ring of 0-cells so regions touching the grid edge close exactly + # on the raster boundary + z = np.pad(mask2.astype(np.float32), 1) + xs = x0_nm + (np.arange(z.shape[1], dtype=np.float64) - 0.5) * h_nm + ys = y0_nm + (np.arange(z.shape[0], dtype=np.float64) - 0.5) * h_nm + gen = contourpy.contour_generator( + x=xs, y=ys, z=z, fill_type=contourpy.FillType.OuterOffset) + points_list, offsets_list = gen.filled(0.5, 1.5) + + tol = 0.4 * h_nm # > 0.354h kills the staircase bevels, < 0.5h + # keeps the half-width of a one-cell-wide strip + out: list[TrimPolygon] = [] + for pts, offs in zip(points_list, offsets_list): + rings = [] + for i in range(len(offs) - 1): + ring = pts[offs[i]:offs[i + 1] - 1] # drop closing duplicate + rings.append(np.rint(_rdp(ring, tol)).astype(np.int64)) + if _ring_area_nm2(rings[0]) < min_area_mm2 * 1e12: + continue # speck: nothing to reclaim + out.append(TrimPolygon(outline=rings[0], holes=rings[1:])) + return out + + +def compute(result, stack, threshold_pct: float) -> TrimResult: + """Threshold the solved |J| and vectorize the below-threshold copper + of every layer; areas are cell counts (exact for the model).""" + below, thr = low_current_mask(result.Jmag, threshold_pct) + cell_mm2 = (stack.h_nm * 1e-6) ** 2 + layers = [] + for li, name in enumerate(stack.layer_names): + polys = mask_to_polygons(below[li], stack.x0_nm, stack.y0_nm, + stack.h_nm, config.TRIM_MIN_AREA_MM2) + layers.append(LayerTrim( + layer=name, polygons=polys, + marked_mm2=float(below[li].sum()) * cell_mm2, + copper_mm2=float(np.isfinite(result.Jmag[li]).sum()) * cell_mm2)) + return TrimResult(threshold_pct=threshold_pct, + threshold_a_mm2=thr * 1e-6, layers=layers) + + +def summary_line(trim: TrimResult) -> str: + parts = [] + for lt in trim.layers: + pct = (f" ({100.0 * lt.marked_mm2 / lt.copper_mm2:.0f}%)" + if lt.copper_mm2 else "") + parts.append(f"{lt.layer} {lt.marked_mm2:.1f} mm2{pct}") + return (f"low-current copper (|J| < {trim.threshold_pct:g}% of mean " + f"= {trim.threshold_a_mm2:.3g} A/mm2): " + "; ".join(parts)) + + +def write_json(outdir: Path, trim: TrimResult) -> Path: + def ring_mm(ring: np.ndarray) -> list: + return [[round(x * 1e-6, 4), round(y * 1e-6, 4)] + for x, y in ring.tolist()] + + p = Path(outdir) / JSON_NAME + doc = { + "threshold_pct_of_mean_J": trim.threshold_pct, + "threshold_a_per_mm2": trim.threshold_a_mm2, + "note": ("marked = copper below the threshold at the solved " + "operating point; removing copper redistributes the " + "current and raises |J| elsewhere - re-run after changes"), + "layers": [{ + "layer": lt.layer, + "marked_mm2": round(lt.marked_mm2, 3), + "copper_mm2": round(lt.copper_mm2, 3), + "polygons": [{"outline_mm": ring_mm(tp.outline), + "holes_mm": [ring_mm(h) for h in tp.holes]} + for tp in lt.polygons], + } for lt in trim.layers], + } + p.write_text(json.dumps(doc, indent=1), encoding="utf-8") + return p diff --git a/tests/test_trim.py b/tests/test_trim.py new file mode 100644 index 0000000..c182cd5 --- /dev/null +++ b/tests/test_trim.py @@ -0,0 +1,118 @@ +"""Low-current copper marking: threshold mask -> polygons in board nm. + +The kipy pushing side is exercised only against a live KiCad (as for +the overlays); the proto assembly of a single polygon is testable +offline and covered here. +""" +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from fill_resistance import trim + + +def _stack(names=("F.Cu",), h_nm=100_000, x0=0, y0=0): + return SimpleNamespace(layer_names=list(names), h_nm=h_nm, + x0_nm=x0, y0_nm=y0) + + +def test_low_current_mask_threshold(): + J = np.full((1, 4, 4), np.nan) + J[0, :2, :] = 1.0 # 8 cells carrying little + J[0, 2, :2] = 100.0 # 2 hot cells; mean = 20.8 + mask, thr = trim.low_current_mask(J, 10.0) + assert thr == pytest.approx(2.08) + assert mask[0, :2, :].all() + assert not mask[0, 2, :2].any() + assert not mask[0, 3, :].any() # NaN = no copper, never marked + + +def test_mask_rectangle_polygon(): + m = np.zeros((20, 30), dtype=bool) + m[5:15, 4:9] = True + polys = trim.mask_to_polygons(m, x0_nm=0, y0_nm=0, h_nm=1000, + min_area_mm2=0.0) + assert len(polys) == 1 + p = polys[0] + assert p.holes == [] + xs, ys = p.outline[:, 0], p.outline[:, 1] + # the boundary runs on the cell edges of the marked block + assert xs.min() == 4000 and xs.max() == 9000 + assert ys.min() == 5000 and ys.max() == 15000 + # RDP collapsed the straight runs: 2 bevel points per corner plus at + # most one leftover at the ring seam (first/last are fixed anchors) + assert len(p.outline) <= 9 + + +def test_mask_with_hole(): + m = np.zeros((20, 20), dtype=bool) + m[2:18, 2:18] = True + m[8:12, 8:12] = False + polys = trim.mask_to_polygons(m, 0, 0, 1000, min_area_mm2=0.0) + assert len(polys) == 1 + assert len(polys[0].holes) == 1 + + +def test_mask_touching_grid_edge_closes(): + # the padding ring must close regions that touch the raster edge + # exactly on the raster boundary + m = np.ones((5, 8), dtype=bool) + polys = trim.mask_to_polygons(m, 0, 0, 1000, min_area_mm2=0.0) + assert len(polys) == 1 + xs, ys = polys[0].outline[:, 0], polys[0].outline[:, 1] + assert xs.min() == 0 and xs.max() == 8000 + assert ys.min() == 0 and ys.max() == 5000 + + +def test_min_area_drops_specks(): + m = np.zeros((10, 10), dtype=bool) + m[5, 5] = True # one 100 um cell = 0.01 mm2 + assert trim.mask_to_polygons(m, 0, 0, 100_000, min_area_mm2=0.5) == [] + assert len(trim.mask_to_polygons(m, 0, 0, 100_000, + min_area_mm2=0.0)) == 1 + + +def test_compute_and_json(tmp_path): + J = np.full((2, 10, 10), np.nan) + J[0, :, :] = 10.0 + J[0, :, :5] = 0.01 # half of the top layer nearly dead + J[1, :, :] = 10.0 + stack = _stack(names=["F.Cu", "B.Cu"], h_nm=1_000_000) + tr = trim.compute(SimpleNamespace(Jmag=J), stack, 10.0) + assert [lt.layer for lt in tr.layers] == ["F.Cu", "B.Cu"] + assert tr.layers[0].polygons and not tr.layers[1].polygons + assert tr.layers[0].marked_mm2 == pytest.approx(50.0) + assert tr.layers[0].copper_mm2 == pytest.approx(100.0) + # mean = (50*0.01 + 150*10) / 200 = 7.5025 A/m2, threshold 10% of it + assert tr.threshold_a_mm2 == pytest.approx(0.75025e-6) + + p = trim.write_json(tmp_path, tr) + doc = json.loads(p.read_text(encoding="utf-8")) + assert doc["layers"][0]["marked_mm2"] == pytest.approx(50.0) + ring = doc["layers"][0]["polygons"][0]["outline_mm"] + assert all(0 <= x <= 5.5 and 0 <= y <= 10.0 for x, y in ring) + assert "F.Cu" in trim.summary_line(tr) + + +def test_trim_shape_proto(): + from kipy.util.board_layer import layer_from_canonical_name + + from fill_resistance import board_io + + tp = trim.TrimPolygon( + outline=np.array([[0, 0], [10000, 0], [10000, 5000], [0, 5000]], + dtype=np.int64), + holes=[np.array([[2000, 1000], [3000, 1000], [3000, 2000]], + dtype=np.int64)]) + layer = layer_from_canonical_name("User.5") + proto = board_io._trim_shape(tp, layer, lock=False).proto + poly = proto.shape.polygon.polygons[0] + assert len(poly.outline.nodes) == 4 and poly.outline.closed + assert len(poly.holes) == 1 and len(poly.holes[0].nodes) == 3 + assert poly.holes[0].closed + assert proto.layer == layer + from kipy.proto.common.types.base_types_pb2 import GraphicFillType + assert (proto.shape.attributes.fill.fill_type + == GraphicFillType.GFT_FILLED)