Files
janik bfb97d5259
Build PCM package / build (push) Successful in 6s
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 <noreply@anthropic.com>
2026-07-17 20:39:13 +07:00

65 lines
2.4 KiB
Python

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