Experimental: push |J| heatmap overlays into KiCad (dialog opt-in)
Build PCM package / build (push) Successful in 6s
Build PCM package / build (push) Successful in 6s
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>
This commit is contained in:
@@ -75,6 +75,17 @@ SWIG API. Requires KiCad **10.0.1+**.
|
|||||||
per-via current and dissipation, and the **current through each
|
per-via current and dissipation, and the **current through each
|
||||||
injection area** — computed flux with the equipotential model,
|
injection area** — computed flux with the equipotential model,
|
||||||
prescribed area share with the uniform model), `geometry_dump.json`.
|
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
|
## Model & limits
|
||||||
|
|
||||||
|
|||||||
@@ -631,6 +631,77 @@ def gather_tht_pad_copper(board: Board, net_name: str
|
|||||||
return shapes
|
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 ----------------------------------------------------------------
|
# --- top level ----------------------------------------------------------------
|
||||||
|
|
||||||
def build_problem(board: Board, net: str, layer_names: list[str],
|
def build_problem(board: Board, net: str, layer_names: list[str],
|
||||||
|
|||||||
@@ -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
|
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
|
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 grid ---
|
||||||
ADAPTIVE_CELLS = True # solve on a 2:1-balanced quadtree: fine at
|
ADAPTIVE_CELLS = True # solve on a 2:1-balanced quadtree: fine at
|
||||||
# copper boundaries/electrodes/features,
|
# copper boundaries/electrodes/features,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class Selection:
|
|||||||
vias_capped: bool = True
|
vias_capped: bool = True
|
||||||
cap_max_drill_mm: float = 0.5
|
cap_max_drill_mm: float = 0.5
|
||||||
adaptive: bool = True
|
adaptive: bool = True
|
||||||
|
push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays
|
||||||
|
|
||||||
|
|
||||||
class _Dialog(QDialog):
|
class _Dialog(QDialog):
|
||||||
@@ -121,6 +122,14 @@ class _Dialog(QDialog):
|
|||||||
self.extracu_edit.setEnabled(bool(buildup_layers))
|
self.extracu_edit.setEnabled(bool(buildup_layers))
|
||||||
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
|
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 = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
buttons.accepted.connect(self._try_accept)
|
buttons.accepted.connect(self._try_accept)
|
||||||
buttons.rejected.connect(self.reject)
|
buttons.rejected.connect(self.reject)
|
||||||
@@ -237,7 +246,8 @@ class _Dialog(QDialog):
|
|||||||
include_tracks=self.tracks_check.isChecked(),
|
include_tracks=self.tracks_check.isChecked(),
|
||||||
vias_capped=self.capped_check.isChecked(),
|
vias_capped=self.capped_check.isChecked(),
|
||||||
cap_max_drill_mm=cap_max_drill,
|
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:
|
def _try_accept(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -102,9 +102,14 @@ def main() -> None:
|
|||||||
raise UserFacingError(f"KiCad API error: {e}")
|
raise UserFacingError(f"KiCad API error: {e}")
|
||||||
|
|
||||||
report.write_geometry_dump(outdir, problem)
|
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,
|
pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
|
||||||
freq_hz=selection.freq_hz,
|
freq_hz=selection.freq_hz,
|
||||||
contact_model=selection.contact_model)
|
contact_model=selection.contact_model,
|
||||||
|
overlay=overlay_cb)
|
||||||
except UserFacingError as e:
|
except UserFacingError as e:
|
||||||
_fail(str(e), outdir)
|
_fail(str(e), outdir)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -12,7 +12,9 @@ from .solver import Result
|
|||||||
|
|
||||||
def run(problem: Problem, outdir: Path | None, show: bool = True,
|
def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||||
i_test: float | None = None, freq_hz: float = 0.0,
|
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:
|
if i_test is None:
|
||||||
i_test = config.TEST_CURRENT_A
|
i_test = config.TEST_CURRENT_A
|
||||||
if i_test <= 0:
|
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)
|
report.write_summary(outdir, problem, stack, result)
|
||||||
print(report.result_line(result, problem, stack))
|
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 = [
|
figs = [
|
||||||
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
|
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
|
||||||
(plots.fig_potential(result, stack, e1, e2, problem), "2_potential"),
|
(plots.fig_potential(result, stack, e1, e2, problem), "2_potential"),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 <dest>.
|
||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user