From 8fddcbe292b9edbfa59bd29987f51066f804de8e Mon Sep 17 00:00:00 2001 From: cpu Date: Thu, 11 Jun 2026 18:31:24 +0200 Subject: [PATCH] initial --- .gitignore | 2 + Readme.md | 47 ++++++++++ __pycache__/cleanup.cpython-312.pyc | Bin 0 -> 5428 bytes cleanup.py | 96 ++++++++++++++++++++ export_panel_outlines_gerber.py | 44 ++++++++++ myPreset.json | 106 ++++++++++++++++++++++ tooling_plugin.py | 131 ++++++++++++++++++++++++++++ 7 files changed, 426 insertions(+) create mode 100644 .gitignore create mode 100644 Readme.md create mode 100644 __pycache__/cleanup.cpython-312.pyc create mode 100644 cleanup.py create mode 100755 export_panel_outlines_gerber.py create mode 100644 myPreset.json create mode 100644 tooling_plugin.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1605a40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +panel/ + diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..992490e --- /dev/null +++ b/Readme.md @@ -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/). + diff --git a/__pycache__/cleanup.cpython-312.pyc b/__pycache__/cleanup.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1eed6533524c6354dbed5a7d5095485b581f7974 GIT binary patch literal 5428 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 literal 0 HcmV?d00001 diff --git a/cleanup.py b/cleanup.py new file mode 100644 index 0000000..b492d8f --- /dev/null +++ b/cleanup.py @@ -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) \ No newline at end of file diff --git a/export_panel_outlines_gerber.py b/export_panel_outlines_gerber.py new file mode 100755 index 0000000..9c3f38e --- /dev/null +++ b/export_panel_outlines_gerber.py @@ -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() \ No newline at end of file diff --git a/myPreset.json b/myPreset.json new file mode 100644 index 0000000..79fe701 --- /dev/null +++ b/myPreset.json @@ -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 + } +} diff --git a/tooling_plugin.py b/tooling_plugin.py new file mode 100644 index 0000000..b9cd6eb --- /dev/null +++ b/tooling_plugin.py @@ -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) +