bfb97d5259
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>
157 lines
6.0 KiB
Python
157 lines
6.0 KiB
Python
"""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()
|