This commit is contained in:
cpu
2026-06-11 18:31:24 +02:00
commit 8fddcbe292
7 changed files with 426 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
panel/

47
Readme.md Normal file
View File

@@ -0,0 +1,47 @@
TODO: Add table of content
# KiKit Processor
Processing script for KiKit panelizes PCBs and draws a fixture sketch and positions the whole panel for easy CNC and MSLA processing.
The script:
- Panelizes the board
- Moves the finished panel to the origin `(0, 0)`
- Adds alignment holes
- Adds silskcreen text
- Extends the copper layers
The resulting output is intended for repeatable CNC/MSLA processing.
![Panel](../images/Flow_Controller_Panel.png)
## Install kikit
Install `kikit`:
```bash
pipx install --system-site-packages kikit
```
## Usage
```bash
# Panelize the PCB using the preset defined in `myPreset.json`.
kikit panelize \
-p myPreset.json \
../Flow_Controller/Flow_Controller.kicad_pcb \
panel/Flow_Controller_Panel.kicad_pcb
```
The new `panel/Flow_Controller_Panel.kicad_pcb` file will contain the panelized PCB with the following feature specified in `myPreset.json`. E.g.: Grid of 1 x 2 with space 2.1 mm and fiducials.
```json
"layout": {
"type": "grid",
"rows": 1,
"cols": 2,
"hspace": "2.1mm",
"vspace": "2.1mm"
}
```
See all values in [default.json](https://raw.githubusercontent.com/yaqwsx/KiKit/refs/heads/master/kikit/resources/panelizePresets/default.json)
Check the kikit panelization [examples](https://yaqwsx.github.io/KiKit/latest/panelization/examples/).

Binary file not shown.

96
cleanup.py Normal file
View File

@@ -0,0 +1,96 @@
import pcbnew
from shapely.geometry import MultiPolygon, Polygon
# ---------------------------------------------------------------------------
# KiKit postprocess hook cleanup.py
#
# 1. Copy all Edge.Cuts content (substrate rings) to Eco1_User as segments,
# EXCEPT the outermost panel rectangle.
# ---------------------------------------------------------------------------
TOLERANCE_NM = 1_000 # 1 µm
def _mm(nm_val):
return f"{pcbnew.ToMM(int(nm_val)):.4f} mm"
def _collect_rings(geom):
"""Return all rings as list of (shapely_ring, coord_list)."""
rings = []
if isinstance(geom, Polygon):
rings.append((geom.exterior, list(geom.exterior.coords)))
for interior in geom.interiors:
rings.append((interior, list(interior.coords)))
elif isinstance(geom, MultiPolygon):
for poly in geom.geoms:
rings.extend(_collect_rings(poly))
return rings
def _is_outer_frame(ring_geom, x0, y0, x1, y1, tol=TOLERANCE_NM):
"""True if the ring's bounding box equals the panel bounding box."""
b = ring_geom.bounds
return (abs(b[0] - x0) <= tol and abs(b[1] - y0) <= tol and
abs(b[2] - x1) <= tol and abs(b[3] - y1) <= tol)
def _shapely_to_segments(board, coords, layer, width_mm=0.05):
pts = list(coords)
if not pts:
return 0
if pts[0] != pts[-1]:
pts.append(pts[0])
count = 0
for i in range(len(pts) - 1):
seg = pcbnew.PCB_SHAPE(board)
seg.SetShape(pcbnew.SHAPE_T_SEGMENT)
seg.SetLayer(layer)
seg.SetWidth(pcbnew.FromMM(width_mm))
seg.SetStart(pcbnew.VECTOR2I(int(pts[i][0]), int(pts[i][1])))
seg.SetEnd (pcbnew.VECTOR2I(int(pts[i+1][0]), int(pts[i+1][1])))
board.Add(seg)
count += 1
return count
def kikitPostprocess(panel, arg):
print("=" * 60)
print("[cleanup] START")
board = panel.board
substrate = panel.boardSubstrate
# outer panel bbox
b = substrate.bounds()
x0, y0, x1, y1 = int(b[0]), int(b[1]), int(b[2]), int(b[3])
print(f"[cleanup] Panel bbox: ({_mm(x0)},{_mm(y0)})({_mm(x1)},{_mm(y1)})")
# get substrate geometry
geom = None
for attr in ("substrates", "substrate", "_substrate", "geometry"):
if hasattr(substrate, attr):
geom = getattr(substrate, attr)
break
if geom is None:
print("[cleanup] ERROR: cannot access substrate geometry")
return
rings = _collect_rings(geom)
print(f"[cleanup] Total rings in substrate: {len(rings)}")
total = 0
for i, (ring_geom, coords) in enumerate(rings):
rb = ring_geom.bounds
if _is_outer_frame(ring_geom, x0, y0, x1, y1):
print(f"[cleanup] ring[{i}] pts={len(coords)-1} → OUTER FRAME, skipped")
continue
n = _shapely_to_segments(board, coords, pcbnew.Eco1_User)
total += n
print(f"[cleanup] ring[{i}] pts={len(coords)-1}"
f" bbox=({_mm(rb[0])},{_mm(rb[1])})({_mm(rb[2])},{_mm(rb[3])})"
f"{n} segments on Eco1_User")
print(f"[cleanup] Total Eco1_User segments added: {total}")
print("[cleanup] END")
print("=" * 60)

44
export_panel_outlines_gerber.py Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
import argparse
import os
import pcbnew
def main():
parser = argparse.ArgumentParser(
description="Export a single PCB layer to a Gerber file using pcbnew."
)
parser.add_argument("input", help="Input .kicad_pcb file")
parser.add_argument("--output", "-o", required=True, help="Output directory")
parser.add_argument("--layers", "-l", required=True,
help="Layer name to export (e.g. User.Eco1)")
args = parser.parse_args()
board = pcbnew.LoadBoard(args.input)
# Resolve layer name to ID
layer_id = board.GetLayerID(args.layers)
if layer_id == -1:
parser.error(f"Unknown layer: {args.layers!r}. "
f"Run with a known layer name (e.g. User.Eco1, Edge.Cuts).")
# Resolve output directory to absolute path so pcbnew doesn't make it
# relative to the board file location
output_dir = os.path.abspath(args.output)
os.makedirs(output_dir, exist_ok=True)
prefix = args.layers.replace(".", "-")
pc = pcbnew.PLOT_CONTROLLER(board)
po = pc.GetPlotOptions()
po.SetOutputDirectory(output_dir)
po.SetPlotFrameRef(False)
pc.SetLayer(layer_id)
pc.OpenPlotfile(prefix, pcbnew.PLOT_FORMAT_GERBER, args.layers)
print(f"Plotting layer {args.layers!r} (id={layer_id}) to {pc.GetPlotFileName()}")
pc.PlotLayer()
pc.ClosePlot()
print("Done.")
if __name__ == "__main__":
main()

106
myPreset.json Normal file
View File

@@ -0,0 +1,106 @@
{
"layout": {
"type": "grid",
"rows": 1,
"cols": 2,
"hspace": "2.1mm",
"vspace": "2.1mm"
},
"tabs": {
"type": "fixed",
"hcount": 1,
"vcount": 1,
"hwidth": "3mm",
"vwidth": "3mm"
},
"cuts": {
"type": "mousebites",
"offset": "0.2mm",
"prolong": "0.7mm",
"drill": "0.5mm",
"spacing": "0.8mm"
},
"framing": {
"type": "tightframe",
"copperFill": true,
"slotwidth": "2.1mm",
"mintotalheight": "87.040mm",
"mintotalwidth": "153.408mm",
"maxtotalheight": "87.040mm",
"maxtotalwidth": "153.408mm"
},
"tooling": {
"type": "3hole",
"layout": "3hole",
"hoffset": "6mm",
"voffset": "6mm",
"size": "2mm",
"paste": true,
"soldermaskmargin": "0mm"
},
"text": {
"type": "simple",
"text": "Front",
"anchor": "mt",
"hoffset": "0mm",
"voffset": "10mm",
"orientation": "0deg",
"width": "3.5mm",
"height": "3.5mm",
"hjustify": "center",
"vjustify": "center",
"thickness": "0.3mm",
"layer": "F.SilkS"
},
"text2": {
"type": "simple",
"text": "Back",
"anchor": "mt",
"hoffset": "0mm",
"voffset": "10mm",
"orientation": "0deg",
"width": "3.5mm",
"height": "3.5mm",
"hjustify": "center",
"vjustify": "center",
"thickness": "0.3mm",
"layer": "B.SilkS"
},
"copperfill": {
"type": "solid",
"clearance": "0.5mm",
"edgeclearance": "0.5mm",
"layers": "F.Cu,B.Cu"
},
"post": {
"type": "auto",
"copperfill": false,
"reconstructarcs": false,
"millradius": "1mm",
"millradiusouter": "0mm",
"script": "cleanup.py",
"scriptarg": "",
"origin": "tl",
"refillzones": false,
"dimensions": true,
"edgewidth": "0.1mm"
},
"page": {
"type": "inherit",
"anchor": "tl",
"posx": "0mm",
"posy": "0mm",
"width": "1000mm",
"height": "1000mm"
},
"debug": {
"type": "none",
"drawPartitionLines": false,
"drawBackboneLines": false,
"drawboxes": false,
"trace": false,
"deterministic": false,
"drawtabfail": false,
"drawTabFillet": false
}
}

131
tooling_plugin.py Normal file
View File

@@ -0,0 +1,131 @@
from kikit.plugin import ToolingPlugin
import pcbnew
class CustomTooling(ToolingPlugin):
def buildTooling(self, panel):
board = panel.board
# panelBBox() -> (xmin, ymin, xmax, ymax)
xmin, ymin, xmax, ymax = panel.panelBBox()
min_x = pcbnew.ToMM(xmin)
min_y = pcbnew.ToMM(ymin)
max_x = pcbnew.ToMM(xmax)
max_y = pcbnew.ToMM(ymax)
margin_x_mm = 10
margin_y_mm = 2.5
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
holes = [
(min_x + margin_x_mm, min_y + margin_y_mm), # top left
(center_x, min_y + margin_y_mm), # top center
(max_x - margin_x_mm, min_y + margin_y_mm), # top right
(min_x + margin_x_mm, max_y - margin_y_mm), # bottom left
(center_x, max_y - margin_y_mm), # bottom center
(max_x - margin_x_mm, max_y - margin_y_mm), # bottom right
]
hole_d = pcbnew.FromMM(3.172)
for x_mm, y_mm in holes:
fp = pcbnew.FOOTPRINT(board)
fp.SetReference("")
pos = pcbnew.VECTOR2I(
pcbnew.FromMM(x_mm),
pcbnew.FromMM(y_mm)
)
fp.SetPosition(pos)
pad = pcbnew.PAD(fp)
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
pad.SetAttribute(pcbnew.PAD_ATTRIB_NPTH)
pad.SetSize(pcbnew.VECTOR2I(hole_d, hole_d))
pad.SetDrillSize(pcbnew.VECTOR2I(hole_d, hole_d))
pad.SetPosition(pos)
fp.Add(pad)
board.Add(fp)
# =========================
# SCREEN RECTANGLE
# =========================
screen_w = 153.4
screen_h = 87.0
# Panel center
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
# Screen rectangle corners
screen_x0 = center_x - (screen_w / 2)
screen_y0 = center_y - (screen_h / 2)
screen_x1 = center_x + (screen_w / 2)
screen_y1 = center_y + (screen_h / 2)
screen = pcbnew.PCB_SHAPE(board)
screen.SetShape(pcbnew.SHAPE_T_RECT)
screen.SetLayer(pcbnew.Dwgs_User)
screen.SetStart(
pcbnew.VECTOR2I(
pcbnew.FromMM(screen_x0),
pcbnew.FromMM(screen_y0)
)
)
screen.SetEnd(
pcbnew.VECTOR2I(
pcbnew.FromMM(screen_x1),
pcbnew.FromMM(screen_y1)
)
)
screen.SetWidth(pcbnew.FromMM(0.2))
board.Add(screen)
# =========================
# FIXTURE RECTANGLE
# =========================
fixture_w = 200.0
fixture_h = 130.0
# Fixture rectangle corners
fixture_x0 = center_x - (fixture_w / 2)
fixture_y0 = center_y - (fixture_h / 2)
fixture_x1 = center_x + (fixture_w / 2)
fixture_y1 = center_y + (fixture_h / 2)
fixture = pcbnew.PCB_SHAPE(board)
fixture.SetShape(pcbnew.SHAPE_T_RECT)
fixture.SetLayer(pcbnew.Dwgs_User)
fixture.SetStart(
pcbnew.VECTOR2I(
pcbnew.FromMM(fixture_x0),
pcbnew.FromMM(fixture_y0)
)
)
fixture.SetEnd(
pcbnew.VECTOR2I(
pcbnew.FromMM(fixture_x1),
pcbnew.FromMM(fixture_y1)
)
)
fixture.SetWidth(pcbnew.FromMM(0.2))
board.Add(fixture)