From bfb97d5259ec14ff8c749fd8cfae23d422664e38 Mon Sep 17 00:00:00 2001 From: janik Date: Fri, 17 Jul 2026 20:39:13 +0700 Subject: [PATCH] Experimental: push |J| heatmap overlays into KiCad (dialog opt-in) After a solve, the per-layer current-density maps can be pushed into the open board as reference images on User.9..User.12 (stackup order, top first) - visible right in the editor, toggled like any layer, never plotted to gerbers. Dialog checkbox, default OFF; every push replaces all reference images on those layers. Rendering (fill_resistance/overlay.py, KiCad-free and tested headless): one pixel per grid cell, opaque over copper with the log scale lifted off the colormap's near-black bottom (dark canvas), transparent elsewhere, one pixel of half-alpha edge bleed so the overlay reaches the drawn outline instead of stopping half a cell short. Pushing lives in board_io (ReferenceImage via the IPC API, KiCad >= 10.0.1; scale = width / (pixels * 1 inch / 300 PPI), position = image center); kipy 0.7.1 swallows creation errors, so the per-item status is read from the raw CreateItemsResponse. pipeline.run takes an optional overlay callback; failures are reported, never fatal. tools/kicad_overlay_test.py pushes a fiducial alignment pattern (KiCad bbox readback verified placement to half a pixel); tools/ kicad_heatmap_overlay.py runs the whole thing headless against the open board, filtering marker rectangles of other nets' analyses via the exact copper test. Co-Authored-By: Claude Fable 5 --- README.md | 11 +++ fill_resistance/board_io.py | 71 +++++++++++++++ fill_resistance/config.py | 14 +++ fill_resistance/dialog.py | 12 ++- fill_resistance/main.py | 7 +- fill_resistance/overlay.py | 64 ++++++++++++++ fill_resistance/pipeline.py | 10 ++- tests/test_overlay.py | 60 +++++++++++++ tools/kicad_heatmap_overlay.py | 141 +++++++++++++++++++++++++++++ tools/kicad_overlay_test.py | 156 +++++++++++++++++++++++++++++++++ 10 files changed, 543 insertions(+), 3 deletions(-) create mode 100644 fill_resistance/overlay.py create mode 100644 tests/test_overlay.py create mode 100644 tools/kicad_heatmap_overlay.py create mode 100644 tools/kicad_overlay_test.py diff --git a/README.md b/README.md index 3ac100d..6270085 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,17 @@ SWIG API. Requires KiCad **10.0.1+**. per-via current and dissipation, and the **current through each injection area** — computed flux with the equipotential model, prescribed area share with the uniform model), `geometry_dump.json`. +5. **Experimental — overlays inside KiCad** (dialog checkbox, default + off; KiCad ≥ 10.0.1): after the solve, the per-layer **|J| heatmaps + are pushed into the open board** as unlocked reference images on + `User.9`…`User.12` (`OVERLAY_LAYERS`; enable them in Board Setup), + copper layers mapped in stackup order, top first. Toggle them in the + Appearance panel like any layer; opaque over copper, transparent + elsewhere, cold end lifted so it stays visible on the dark canvas. + Reference images never plot to gerbers. Every push **replaces all + 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`. ## Model & limits diff --git a/fill_resistance/board_io.py b/fill_resistance/board_io.py index 4957363..a530d8c 100644 --- a/fill_resistance/board_io.py +++ b/fill_resistance/board_io.py @@ -631,6 +631,77 @@ def gather_tht_pad_copper(board: Board, net_name: str 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.""" + ours = [r for r in board.get_reference_images() if r.layer == layer] + if ours: + board.remove_items(ours) + return len(ours) + + +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). 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 + 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}") + + # --- 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 d05f8a0..43c8b07 100644 --- a/fill_resistance/config.py +++ b/fill_resistance/config.py @@ -78,6 +78,20 @@ ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts ALWAYS_REFILL = False # refill zones even if KiCad says they are filled +# --- In-KiCad result overlays (EXPERIMENTAL) --- +PUSH_OVERLAYS = False # after solving, push the per-layer |J| + # heatmaps into the open board as unlocked + # reference images (editor-only, never + # plotted); dialog-toggleable +OVERLAY_LAYERS = ("User.9", "User.10", "User.11", "User.12") + # copper layers map here in stackup order + # (top first); existing reference images on + # these layers are REPLACED on every push; + # each must be enabled in Board Setup +OVERLAY_ALPHA = 255 # overlay opacity over copper (0-255); + # translucency washes out over bright + # copper - toggle the User layer instead + # --- 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 bb23a27..8d2beba 100644 --- a/fill_resistance/dialog.py +++ b/fill_resistance/dialog.py @@ -39,6 +39,7 @@ class Selection: vias_capped: bool = True cap_max_drill_mm: float = 0.5 adaptive: bool = True + push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays class _Dialog(QDialog): @@ -121,6 +122,14 @@ class _Dialog(QDialog): self.extracu_edit.setEnabled(bool(buildup_layers)) form.addRow("Extra Cu in openings [µm]:", self.extracu_edit) + first, last = config.OVERLAY_LAYERS[0], config.OVERLAY_LAYERS[-1] + self.overlay_check = QCheckBox( + f"experimental: push per-layer |J| heatmaps into the board as " + f"reference images on {first}..{last} (replaces images there; " + f"layers must be enabled in Board Setup)") + self.overlay_check.setChecked(config.PUSH_OVERLAYS) + form.addRow("Overlays:", self.overlay_check) + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self._try_accept) buttons.rejected.connect(self.reject) @@ -237,7 +246,8 @@ class _Dialog(QDialog): include_tracks=self.tracks_check.isChecked(), vias_capped=self.capped_check.isChecked(), cap_max_drill_mm=cap_max_drill, - adaptive=self.adaptive_check.isChecked()) + adaptive=self.adaptive_check.isChecked(), + push_overlays=self.overlay_check.isChecked()) def _try_accept(self) -> None: try: diff --git a/fill_resistance/main.py b/fill_resistance/main.py index 26e03c8..8403086 100644 --- a/fill_resistance/main.py +++ b/fill_resistance/main.py @@ -102,9 +102,14 @@ def main() -> None: raise UserFacingError(f"KiCad API error: {e}") report.write_geometry_dump(outdir, problem) + overlay_cb = None + if selection.push_overlays: + def overlay_cb(stack, result): + board_io.push_result_overlays(board, stack, result) pipeline.run(problem, outdir, show=True, i_test=selection.current_a, freq_hz=selection.freq_hz, - contact_model=selection.contact_model) + contact_model=selection.contact_model, + overlay=overlay_cb) except UserFacingError as e: _fail(str(e), outdir) except Exception: diff --git a/fill_resistance/overlay.py b/fill_resistance/overlay.py new file mode 100644 index 0000000..900b1e8 --- /dev/null +++ b/fill_resistance/overlay.py @@ -0,0 +1,64 @@ +"""Rendering for the experimental in-KiCad result overlays: a solved +field (|J|) as an RGBA PNG, one pixel per grid cell, transparent where +there is no copper. The pushing side (ReferenceImages via the IPC API) +lives in board_io; this module stays KiCad-free so it is testable +headless. +""" +from __future__ import annotations + +import io + +import numpy as np + +from . import config + +# the colormap's near-black bottom must stay distinguishable from +# KiCad's dark canvas (matplotlib figures sit on a light background +# instead), so the log scale starts this far up the colormap +FLOOR = 0.18 + + +def heatmap_png(data3: np.ndarray, li: int, alpha: int | None = None, + bleed: bool = True) -> bytes: + """One layer of a field (e.g. |J|, NaN = no copper) as opaque-over- + copper RGBA PNG bytes. Color scale matches the plugin's log figure + (global vmax across layers). `bleed` extends the edge color one + pixel outward at half opacity: the raster mask covers cells whose + CENTER is inside the copper, so without it the overlay stops half a + cell short of the outline KiCad draws.""" + import matplotlib + from PIL import Image + from scipy import ndimage + + if alpha is None: + alpha = config.OVERLAY_ALPHA + if not np.isfinite(data3).any(): + raise ValueError("field is empty - nothing to overlay") + vmax = float(np.nanmax(data3)) + if vmax <= 0: + raise ValueError("field is empty - nothing to overlay") + vmin = vmax / config.CURRENT_DYNAMIC_RANGE + d = np.clip(data3[li], vmin, vmax) + if config.LOG_CURRENT_SCALE: + u = (np.log(d) - np.log(vmin)) / (np.log(vmax) - np.log(vmin)) + else: + u = d / vmax + u = FLOOR + (1.0 - FLOOR) * u + cmap = matplotlib.colormaps[config.CMAP_CURRENT] + rgba = (cmap(np.nan_to_num(u)) * 255).astype(np.uint8) + copper = ~np.isnan(data3[li]) + rgba[..., 3] = np.where(copper, alpha, 0) + + if bleed and copper.any() and not copper.all(): + ring = ndimage.binary_dilation( + copper, structure=np.ones((3, 3), dtype=bool)) & ~copper + iy, ix = ndimage.distance_transform_edt( + ~copper, return_distances=False, return_indices=True) + rgba[ring, :3] = rgba[iy[ring], ix[ring], :3] + rgba[ring, 3] = alpha // 2 + + buf = io.BytesIO() + # no dpi metadata: KiCad assumes its 300 PPI default, which the + # pusher's scale computation relies on + Image.fromarray(rgba, "RGBA").save(buf, format="PNG") + return buf.getvalue() diff --git a/fill_resistance/pipeline.py b/fill_resistance/pipeline.py index fee5b3c..fc0392d 100644 --- a/fill_resistance/pipeline.py +++ b/fill_resistance/pipeline.py @@ -12,7 +12,9 @@ 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) -> Result: + contact_model: str | None = None, overlay=None) -> Result: + """overlay: optional callback(stack, result) run after the solve + (EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.""" if i_test is None: i_test = config.TEST_CURRENT_A if i_test <= 0: @@ -43,6 +45,12 @@ def run(problem: Problem, outdir: Path | None, show: bool = True, report.write_summary(outdir, problem, stack, result) print(report.result_line(result, problem, stack)) + if overlay is not None: + try: + overlay(stack, result) + except Exception as e: + print(f"overlay push failed: {e}") + figs = [ (plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"), (plots.fig_potential(result, stack, e1, e2, problem), "2_potential"), diff --git a/tests/test_overlay.py b/tests/test_overlay.py new file mode 100644 index 0000000..e7c4727 --- /dev/null +++ b/tests/test_overlay.py @@ -0,0 +1,60 @@ +"""In-KiCad overlay rendering (fill_resistance.overlay): copper-shaped +RGBA heatmaps with a visibility floor and a soft edge bleed. The kipy +pushing side is exercised only against a live KiCad (tools/).""" +import io + +import numpy as np +import pytest +from PIL import Image + +from fill_resistance import config, overlay + + +def _field(ny=20, nx=30): + """Two-layer |J| field: copper disc on layer 0, NaN elsewhere.""" + data = np.full((2, ny, nx), np.nan) + yy, xx = np.mgrid[:ny, :nx] + disc = (yy - ny / 2) ** 2 + (xx - nx / 2) ** 2 <= 8 ** 2 + data[0][disc] = 1.0 + xx[disc] # spans the log range + data[1][disc] = 1e-12 # below the global log floor + return data, disc + + +def test_heatmap_png_shape_and_alpha(): + data, disc = _field() + img = Image.open(io.BytesIO(overlay.heatmap_png(data, 0, bleed=False))) + assert img.size == (30, 20) + rgba = np.asarray(img) + assert (rgba[..., 3][disc] == config.OVERLAY_ALPHA).all() + assert (rgba[..., 3][~disc] == 0).all() + + +def test_heatmap_floor_not_black(): + """The coldest copper must stay distinguishable from a dark canvas: + the colormap starts FLOOR up, never at its near-black bottom.""" + data, disc = _field() + rgba = np.asarray(Image.open(io.BytesIO( + overlay.heatmap_png(data, 1, bleed=False)))) # layer 1: all-cold + floor = np.array(__import__("matplotlib").colormaps[ + config.CMAP_CURRENT](overlay.FLOOR)[:3]) * 255 + assert np.abs(rgba[..., :3][disc] - floor).max() <= 1 + assert rgba[..., :3][disc].sum(axis=-1).min() > 30 # not near-black + + +def test_heatmap_bleed_ring(): + """bleed=True: one pixel of half-alpha edge color outside the copper + (the mask stops half a cell short of the drawn outline).""" + from scipy import ndimage + data, disc = _field() + rgba = np.asarray(Image.open(io.BytesIO(overlay.heatmap_png(data, 0)))) + ring = ndimage.binary_dilation( + disc, structure=np.ones((3, 3), dtype=bool)) & ~disc + assert (rgba[..., 3][ring] == config.OVERLAY_ALPHA // 2).all() + outside = ~disc & ~ring + assert (rgba[..., 3][outside] == 0).all() + assert (rgba[..., 3][disc] == config.OVERLAY_ALPHA).all() + + +def test_heatmap_empty_field(): + with pytest.raises(ValueError): + overlay.heatmap_png(np.full((1, 4, 4), np.nan), 0) diff --git a/tools/kicad_heatmap_overlay.py b/tools/kicad_heatmap_overlay.py new file mode 100644 index 0000000..390472d --- /dev/null +++ b/tools/kicad_heatmap_overlay.py @@ -0,0 +1,141 @@ +"""Standalone runner for the EXPERIMENTAL in-KiCad result overlays +(also available as a dialog checkbox in the plugin): solve the open +board headlessly and push per-layer |J| heatmaps as unlocked +ReferenceImages, transparent outside copper. + + python tools/kicad_heatmap_overlay.py --net VOUT+ --amps 45 + -> all included copper layers onto config.OVERLAY_LAYERS + (User.9..User.12, stackup order, top first) + python tools/kicad_heatmap_overlay.py --net X --source B.Cu --dest Eco1.User + -> a single layer wherever you want + +Needs KiCad >= 10.0.1 with the board open, electrode markers or a +selection as in a normal plugin run, and the destination layers enabled +in Board Setup. Re-running replaces the previous overlays. Remove with +tools/kicad_overlay_test.py --remove --layer . +""" +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from kipy.board_types import ReferenceImage +from kipy.geometry import Vector2 +from kipy.util.board_layer import layer_from_canonical_name + +from fill_resistance import config, raster, solver +from fill_resistance import board_io as bio +from fill_resistance.overlay import heatmap_png + + +def extract_problem(board, net_arg=None): + """Same flow as `python -m fill_resistance.board_io` (dump path).""" + # a clicked overlay must not switch the electrode scan into + # selection mode - reference images can never be contacts + sel = list(board.get_selection()) + if sel and all(isinstance(s, ReferenceImage) for s in sel): + board.clear_selection() + stackup = bio.get_stackup_info(board) + es1, es2, net_hint = bio.get_electrodes(board, stackup) + if bio.any_zone_unfilled(board): + bio.refill(board) + fills = bio.gather_net_fills(board) + tracks = bio.gather_net_tracks(board) if config.INCLUDE_TRACKS else {} + copper = bio.merge_copper(fills, bio.tracks_as_polygons(tracks)) + nets = bio.nets_overlapping(copper, es1, es2) + if net_arg: + net = net_arg + elif net_hint in nets: + net = net_hint + elif len(nets) == 1: + net = nets[0] + else: + raise SystemExit(f"candidate nets: {nets}; pass one with --net") + # marker rectangles may exist for SEVERAL nets (board-wide scan): + # keep only the parts overlapping the chosen net's copper + per_layer = copper.get(net, {}) + def on_net(e): + return any(bio._rect_overlaps(e.rect, polys) + for polys in per_layer.values()) + es1, es2 = [e for e in es1 if on_net(e)], [e for e in es2 if on_net(e)] + if not es1 or not es2: + raise SystemExit(f"no V+/V- marker overlaps {net} copper") + print(f"{len(es1)} V+ / {len(es2)} V- marker(s) on {net}") + return bio.build_problem(board, net, list(per_layer), es1, es2, + stackup, fills, tracks=tracks) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--source", default=None, + help="single copper layer to overlay (default: ALL " + "included layers onto config.OVERLAY_LAYERS)") + ap.add_argument("--dest", default=None, + help="destination layer for --source (default User.9; " + "must be enabled in Board Setup)") + ap.add_argument("--net", default=None, help="net name (default: auto)") + ap.add_argument("--amps", type=float, default=None, + help="test current [A] (default: config)") + ap.add_argument("--lock", action="store_true", + help="lock the overlays (default unlocked: easier to " + "delete; reruns replace them either way)") + ap.add_argument("--alpha", type=int, default=None, + help="overlay opacity over copper, 0-255 (default " + "config.OVERLAY_ALPHA)") + args = ap.parse_args() + if args.alpha is not None: + config.OVERLAY_ALPHA = args.alpha + + _, board = bio.connect() + problem = extract_problem(board, args.net) + + h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers)) + print(f"rasterizing at {h / 1000:.1f} um ...") + stack = raster.rasterize_stack(problem, h) + # board-wide marker scan: drop parts that land on no copper of THIS + # net (markers belonging to other nets' analyses) + for name in ("electrodes1", "electrodes2"): + parts = getattr(problem, name) + keep = [e for e in parts + if raster._part_mask3d(stack, problem, e).any()] + if len(keep) != len(parts): + print(f"ignoring {len(parts) - len(keep)} marker(s) off-net " + f"({name[-1] == '1' and 'V+' or 'V-'})") + if not keep: + raise SystemExit(f"no {name} marker lands on this net's copper") + setattr(problem, name, keep) + e1, e2 = raster.electrode_masks(stack, problem) + i_test = args.amps if args.amps is not None else config.TEST_CURRENT_A + print(f"solving @ {i_test:g} A DC ...") + result = solver.run_solve(problem, stack, e1, e2, i_test) + print(f"R = {result.R_ohm * 1e3:.4f} mOhm, P = {result.P_total:.3f} W " + f"@ {i_test:g} A") + + if args.source is None: + bio.push_result_overlays(board, stack, result, lock=args.lock) + return + + names = stack.layer_names + if args.source not in names: + raise SystemExit(f"layer {args.source} not in solve ({names})") + png = heatmap_png(result.Jmag * 1e-6, names.index(args.source)) + ny, nx = stack.shape2d + w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm + dest_name = args.dest or "User.9" + dest = layer_from_canonical_name(dest_name) + n = bio.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 * bio.OVERLAY_PIX_NM) + ref.image_data = png + ref.locked = args.lock + bio._create_reference_image(board, ref) + print(f"{args.source} -> {dest_name} ({nx}x{ny} px, " + f"{len(png) / 1024:.0f} kB" + (f", replaced {n}" if n else "") + ")") + + +if __name__ == "__main__": + main() diff --git a/tools/kicad_overlay_test.py b/tools/kicad_overlay_test.py new file mode 100644 index 0000000..3b7c2e0 --- /dev/null +++ b/tools/kicad_overlay_test.py @@ -0,0 +1,156 @@ +"""Route-A experiment: push a bitmap overlay into the open KiCad board as +a locked ReferenceImage on a User layer via the IPC API. + +Pushes a fiducial test pattern (corner + center crosshairs, 10 mm grid, +translucent gradient) sized to the board outline so alignment and scale +can be verified by eye in the editor. Re-running replaces the previous +overlay. Requires KiCad >= 10.0.1 (ReferenceImage over the API). + + python tools/kicad_overlay_test.py [--layer Cmts.User] [--remove] + python tools/kicad_overlay_test.py --image heat.png --bbox x0,y0,x1,y1 + (mm; push an arbitrary PNG instead) + +The overlay is editor-only: reference images never plot to gerbers. +Delete it any time by selecting it in KiCad (it sits on the chosen +layer) or with --remove. The layer must be enabled in Board Setup: +User.1..User.45 usually are NOT (KiCad refuses the item with 'no +overlapping layers with the board'); Cmts.User/Eco1.User always exist. +""" +import argparse +import io +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from kipy.board_types import ReferenceImage +from kipy.geometry import Vector2 +from kipy.util.board_layer import canonical_name, layer_from_canonical_name + +from fill_resistance.board_io import (OVERLAY_PIX_NM as PIX_NM, + _create_reference_image, connect, + remove_overlays) + +NM = 1_000_000 + + +def board_bbox_nm(board): + """Union bbox of the Edge.Cuts shapes (fallback: all pads).""" + items = [s for s in board.get_shapes() + if canonical_name(s.layer) == "Edge.Cuts"] + if not items: + items = list(board.get_pads()) + if not items: + raise SystemExit("board has no Edge.Cuts shapes and no pads") + x0 = y0 = None + x1 = y1 = None + for it in items: + box = board.get_item_bounding_box(it) + if box is None: + continue + lo_x, lo_y = box.pos.x, box.pos.y + hi_x, hi_y = lo_x + box.size.x, lo_y + box.size.y + x0 = lo_x if x0 is None else min(x0, lo_x) + y0 = lo_y if y0 is None else min(y0, lo_y) + x1 = hi_x if x1 is None else max(x1, hi_x) + y1 = hi_y if y1 is None else max(y1, hi_y) + return x0, y0, x1, y1 + + +def fiducial_png(w_nm: float, h_nm: float, px_per_mm: float = 16.0): + """RGBA test pattern: translucent gradient, 10 mm grid, opaque + crosshairs at the four corners and the center.""" + import numpy as np + from PIL import Image + + w_px = max(2, round(w_nm / NM * px_per_mm)) + h_px = max(2, round(h_nm / NM * px_per_mm)) + xx = np.linspace(0.0, 1.0, w_px)[None, :] + yy = np.linspace(0.0, 1.0, h_px)[:, None] + rgba = np.zeros((h_px, w_px, 4), dtype=np.uint8) + rgba[..., 0] = (255 * xx).astype(np.uint8) # red ramp -> + rgba[..., 2] = (255 * yy).astype(np.uint8) # blue ramp v + rgba[..., 1] = 60 + rgba[..., 3] = 70 # mostly see-through + + step = round(10.0 * px_per_mm) # 10 mm grid + for x in range(0, w_px, step): + rgba[:, x:x + 2, :3] = 255 + rgba[:, x:x + 2, 3] = 150 + for y in range(0, h_px, step): + rgba[y:y + 2, :, :3] = 255 + rgba[y:y + 2, :, 3] = 150 + + def cross(cx, cy, arm=round(3 * px_per_mm)): + x_lo, x_hi = max(0, cx - arm), min(w_px, cx + arm + 1) + y_lo, y_hi = max(0, cy - arm), min(h_px, cy + arm + 1) + cy2 = np.clip(cy, 0, h_px - 2) + cx2 = np.clip(cx, 0, w_px - 2) + rgba[cy2:cy2 + 2, x_lo:x_hi] = (255, 0, 0, 255) + rgba[y_lo:y_hi, cx2:cx2 + 2] = (255, 0, 0, 255) + + for cx in (0, w_px - 1): + for cy in (0, h_px - 1): + cross(cx, cy) + cross(w_px // 2, h_px // 2) + + buf = io.BytesIO() + # no dpi= : without a density chunk KiCad assumes the 300 PPI default + Image.fromarray(rgba, "RGBA").save(buf, format="PNG") + return buf.getvalue(), w_px, h_px + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--layer", default="Cmts.User", + help="destination layer (default Cmts.User; must be " + "enabled in Board Setup)") + ap.add_argument("--remove", action="store_true", + help="only remove existing overlays on the layer") + ap.add_argument("--image", help="push this PNG instead of the pattern") + ap.add_argument("--bbox", help="x0,y0,x1,y1 [mm] for --image") + args = ap.parse_args() + + _, board = connect() + layer = layer_from_canonical_name(args.layer) + + n = remove_overlays(board, layer) + if n: + print(f"removed {n} previous overlay(s) on {args.layer}") + if args.remove: + return + + if args.image: + if not args.bbox: + raise SystemExit("--image needs --bbox x0,y0,x1,y1 [mm]") + x0, y0, x1, y1 = (float(v) * NM for v in args.bbox.split(",")) + png = Path(args.image).read_bytes() + from PIL import Image + w_px, h_px = Image.open(io.BytesIO(png)).size + else: + x0, y0, x1, y1 = board_bbox_nm(board) + png, w_px, h_px = fiducial_png(x1 - x0, y1 - y0) + + scale = (x1 - x0) / (w_px * PIX_NM) + + ref = ReferenceImage() + ref.layer = layer + ref.position = Vector2.from_xy(round((x0 + x1) / 2), round((y0 + y1) / 2)) + ref.image_scale = scale + ref.image_data = png + ref.locked = False # unlocked: easy to delete; reruns replace + _create_reference_image(board, ref) + + got = [r for r in board.get_reference_images() if r.layer == layer] + print(f"pushed {len(png) / 1024:.0f} kB PNG ({w_px}x{h_px} px) onto " + f"{args.layer}: {(x1 - x0) / NM:.2f} x {(y1 - y0) / NM:.2f} mm at " + f"({x0 / NM:.2f}, {y0 / NM:.2f}) mm, scale {scale:.4f}") + for r in got: + print(f"readback: {r!r}") + print(f"-> enable layer '{args.layer}' in the Appearance panel; the " + f"red crosshairs must sit on the board bbox corners/center and " + f"the white grid must be 10 mm. Remove with --remove.") + + +if __name__ == "__main__": + main()