Files
kicad-zone-resistance/tests/test_trim.py
T
janikandClaude Fable 5 6d8634802d
Build PCM package / build (push) Successful in 7s
tests / ubuntu-latest · py3.11 (push) Successful in 53s
tests / ubuntu-latest · py3.13 (push) Successful in 1m43s
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 49s
tests / fedora:latest (push) Successful in 1m1s
tests / ubuntu:24.04 (push) Successful in 1m26s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
Mark low-current copper as polygons on user layers (dialog opt-in)
New EXPERIMENTAL dialog option (default off): after the solve, copper
whose |J| is below a threshold (default 10% of the mean |J| over all
solved copper cells - mean, not max, since contact-corner spikes would
dwarf a max-relative threshold) is vectorized into filled graphic
polygons on TRIM_LAYERS (User.5..User.8, configurable), one polygon
per region so Edit > Convert can turn one into a rule area by hand.
Areas are printed and the polygons saved to low_current_copper.json.

The mask -> polygon step is the 0.5 contour of the binary field via
contourpy (already in every venv as matplotlib dependency), padded so
regions touching the raster edge close, simplified with
Douglas-Peucker at 0.4 cells: staircase bevels collapse, one-cell-wide
strips survive. Specks under TRIM_MIN_AREA_MM2 are dropped.

Explicitly a suggestion, not a safe cut list (docstring, dialog and
README all say so): copper carries little current BECAUSE the rest
carries it, so removal redistributes |J| - the constant-density
optimizer that iterates this to convergence is future work.

board_io: the create/delete-with-status-surfaced helpers are now
generic (_create_items_checked / _remove_items_checked) and shared
between reference-image overlays and trim polygons.

Tested on the dev stack (3.13) and the Python 3.9 mac-stack venv, 148
passed each; contourpy 1.3.x has identical API on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:54:25 +07:00

119 lines
4.4 KiB
Python

"""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)