commit 9af1c69b745399f6225c8a55219371c7dca1855b Author: cpu Date: Thu Jun 11 18:30:03 2026 +0200 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f56ada8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +output/ +.venv/ diff --git a/Dummy.pm4n b/Dummy.pm4n new file mode 100644 index 0000000..e54b8b4 Binary files /dev/null and b/Dummy.pm4n differ diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..06cb271 --- /dev/null +++ b/Readme.md @@ -0,0 +1,126 @@ +TODO: Add table of content +# MSLA PCB Exposure: KiCad → Photon Mono 4 +Using an MSLA (Masked Stereolithography Apparatus) resin printer for creating PCBs is a technique known as "Direct UV Exposure." Instead of printing a plastic part, you use the printer's LCD screen as a dynamic digital mask to cure photosensitive materials (etch resist, solder resist and silkscreen) on a copper board. + +KiCad PCB layers need to be converted to `.pm4n` files for direct UV exposure on an **Anycubic Photon Mono 4** (9024×5120 px, 17 µm/px, 1494.12 DPI) on the screen size of 153.408 x 87.040 mm. + +--- + +## Setup + +### 1. KiCad: Set your design grid +In KiCad's PCB editor, go to `Preferences → Preferences → PCB Editor → Grids` and add a custom grid of 0.017 mm. This ensures trace edges land on pixel boundaries and avoids the sub-pixel rounding that causes the ±1px size error people see with arbitrary grids. + +### 2. Create the `Dummy.pm4n` file + + +Open **CHITUBOX Basic** slicer (or any other slicer that works for your resin printer), select printer **Anycubic Photon Mono 4**, slice any tiny STL (e.g.: 1×1×0.05 mm box), and save as `Dummy.pm4n` in the same directory as `export.sh`. + +This file is reused for every job — it carries the correct LCD resolution metadata. + +### 3. Install dependencies + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install pygerber Pillow numpy +``` + +Or activate the `venv` once and put `source .venv/bin/activate` in your shell profile. + +### 4. Export multiple layers (e.g. copper + soldermask + silkscreen) + +```bash +./export.sh \ + --layers Front,Back,F.Mask,B.Mask,F.SilkS,B.SilkS \ + --invert Front,Back,F.Mask,B.Mask,F.SilkS,B.SilkS \ + --mirror Front,F.Mask,F.SilkS \ + --exposure 60 \ + ../kicad2panel/panel/Flow_Controller_Panel.kicad_pcb +``` +It should output: +```bash +Plotted to './output/gerbers/Flow_Controller_Panel-Front.gbr'. +Plotted to './output/gerbers/Flow_Controller_Panel-Back.gbr'. +Plotted to './output/gerbers/Flow_Controller_Panel-F_Silkscreen.gbr'. +Plotted to './output/gerbers/Flow_Controller_Panel-B_Silkscreen.gbr'. +Plotted to './output/gerbers/Flow_Controller_Panel-F_Mask.gbr'. +Plotted to './output/gerbers/Flow_Controller_Panel-B_Mask.gbr'. +output/pm4n/Flow_Controller_Panel-Front.pm4n +output/pm4n/Flow_Controller_Panel-Front.preview.png +output/pm4n/Flow_Controller_Panel-Back.pm4n +output/pm4n/Flow_Controller_Panel-Back.preview.png +output/pm4n/Flow_Controller_Panel-F_Mask.pm4n +output/pm4n/Flow_Controller_Panel-F_Mask.preview.png +output/pm4n/Flow_Controller_Panel-B_Mask.pm4n +output/pm4n/Flow_Controller_Panel-B_Mask.preview.png +output/pm4n/Flow_Controller_Panel-F_Silkscreen.pm4n +output/pm4n/Flow_Controller_Panel-F_Silkscreen.preview.png +output/pm4n/Flow_Controller_Panel-B_Silkscreen.pm4n +output/pm4n/Flow_Controller_Panel-B_Silkscreen.preview.png +``` +#### 4.1. Check the layer preview +Check the `output/pm4n/Flow_Controller_Panel-*.preview.png` — traces should appear black on white background. +- background = UV exposed = resist removed = etched away +- traces = dark = resist kept = copper stays + +#### 4.2. Check the printer exposure +Open the `.pm4n` in `Chitubox Basic` slicer to visually verify before printing. + +#### 4.3. Adjust exposure +Start with `--exposure 60` and bracket from there — Bungard presensitized at 405nm typically lands between 30–120s depending on board vintage and storage. + + +#### Export single layer (e.g. copper) + +Export the front layer as gerber +```bash +kicad-cli pcb export gerbers -o output/gerbers -l F.Cu ../kicad2panel/panel/Flow_Controller_Panel.kicad_pcb +``` +It should output +```bash +Plotted to 'output/gerbers/Flow_Controller_Panel-Front.gtl'. +``` + +Convert the gerber to pm4n and preview +```bash +python3 gerber_to_pm4n.py Dummy.pm4n output/gerbers/Flow_Controller_Panel-Front.gtl \ + --invert --mirror --exposure 120 +It should output +```bash +output/gerbers/Flow_Controller_Panel-Front.pm4n +output/gerbers/Flow_Controller_Panel-Front.preview.png +``` + +Open the preview image +```bash +xdg-open output/gerbers/Flow_Controller_Panel-Front.preview.png +``` + +Open the `Flow_Controller_Panel-Front.pm4n` in `Chitubox Basic` slicer to visually verify before printing. + +--- + +## Troubleshooting + +**`kicad-cli: command not found`** — add KiCad to PATH: +```bash +export PATH="/usr/lib/kicad/bin:$PATH" +``` +Or on Flatpak: +```bash +alias kicad-cli='flatpak run --command=kicad-cli org.kicad.KiCad' +``` + +**Expected Gerber not found** — KiCad's layer→filename mapping: + +| Layer | Filename stem | +|---|---| +| `F.Cu` | `Front` | +| `B.Cu` | `Back` | +| `F.Mask` | `F_Mask` | +| `B.Mask` | `B_Mask` | +| `F.SilkS` | `F_Silkscreen` | + +--- + diff --git a/diagnose_pm4n.py b/diagnose_pm4n.py new file mode 100644 index 0000000..71ee2ec --- /dev/null +++ b/diagnose_pm4n.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Run this on your machine: python3 diagnose_pm4n.py Dummy.pm4n""" +import struct, sys + +path = sys.argv[1] if len(sys.argv) > 1 else 'Dummy.pm4n' +data = open(path, 'rb').read() +fsize = len(data) +print(f"File: {path} ({fsize} bytes)") +print() + +# Parse ANYCUBIC header +magic = data[0:8] +version = struct.unpack_from(' fsize: + break + off = struct.unpack_from(' +# +# Options: +# --layers LAYER,LAYER,... KiCad layer names to export (default: F.Cu) +# --invert LAYER,LAYER,... Layers to invert (comma-separated) +# --mirror LAYER,LAYER,... Layers to mirror (comma-separated) +# --exposure SECONDS Exposure time in seconds (default: 60) +# --dummy FILE Dummy .pm4n template (default: Dummy.pm4n beside this script) +# --out DIR Output directory (default: ./output) +# --dpmm N Render resolution in dots/mm (default: native 58.824) +# --pos X,Y Board position on LCD in mm (default: centred) +# --preview-scale N Downsample preview PNG by N (default: 1 = full resolution) +# --verbose Print detailed progress +# -h, --help Show this help +# +# Normal output: one output filepath per layer. +# Verbose output: full progress from KiCad and the converter. +# +# Example: +# ./export_for_msla.sh --invert F.Cu,B.Mask --mirror F.Cu,F.Mask panel/board.kicad_pcb +# +# KiCad layer name → Gerber filename stem: +# F.Cu → F_Cu B.Cu → B_Cu +# F.Mask → F_Mask B.Mask → B_Mask +# F.SilkS → F_Silkscreen B.SilkS → B_Silkscreen +# F.Paste → F_Paste B.Paste → B_Paste +# Edge.Cuts → Edge_Cuts +# Front → Front Back → Back + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON="${PYTHON:-python3}" +CONVERTER="$SCRIPT_DIR/gerber_to_pm4n.py" + +# ---- defaults ---- +LAYERS="F.Cu" +INVERT_LAYERS="" +MIRROR_LAYERS="" +EXPOSURE="60" +DUMMY="$SCRIPT_DIR/Dummy.pm4n" +OUT_DIR="./output" +DPMM="" +POS="" +VERBOSE=0 +PREVIEW_SCALE="" + +# ---- helpers ---- +usage() { + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +log() { [[ $VERBOSE -eq 1 ]] && echo "$@" || true; } + +contains() { + local list="$1" item="$2" + echo "$list" | tr ',' '\n' | grep -qx "$item" +} + +layer_to_filename() { + case "$1" in + F.Cu) echo "Front" ;; + B.Cu) echo "Back" ;; + F.SilkS) echo "F_Silkscreen" ;; + B.SilkS) echo "B_Silkscreen" ;; + Edge.Cuts) echo "Edge_Cuts" ;; + *) echo "${1//./_}" ;; + esac +} + +# ---- parse arguments ---- +PCB_FILE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --layers) LAYERS="$2"; shift 2 ;; + --invert) INVERT_LAYERS="$2"; shift 2 ;; + --mirror) MIRROR_LAYERS="$2"; shift 2 ;; + --exposure) EXPOSURE="$2"; shift 2 ;; + --dummy) DUMMY="$2"; shift 2 ;; + --out) OUT_DIR="$2"; shift 2 ;; + --dpmm) DPMM="$2"; shift 2 ;; + --pos) POS="$2"; shift 2 ;; + --preview-scale) PREVIEW_SCALE="$2"; shift 2 ;; + --verbose) VERBOSE=1; shift ;; + -h|--help) usage ;; + -*) echo "ERROR: unknown option: $1" >&2; exit 1 ;; + *) PCB_FILE="$1"; shift ;; + esac +done + +[[ -z "$PCB_FILE" ]] && { echo "ERROR: no .kicad_pcb file specified" >&2; exit 1; } +[[ ! -f "$PCB_FILE" ]] && { echo "ERROR: file not found: $PCB_FILE" >&2; exit 1; } +[[ ! -f "$DUMMY" ]] && { echo "ERROR: Dummy.pm4n not found: $DUMMY" >&2; exit 1; } + +BOARD_NAME="$(basename "$PCB_FILE" .kicad_pcb)" +GERBERS_DIR="$OUT_DIR/gerbers" +PM4N_DIR="$OUT_DIR/pm4n" +mkdir -p "$GERBERS_DIR" "$PM4N_DIR" + +# ---- Step 1: export Gerbers ---- +log "=== Exporting Gerbers ===" +log " Board: $PCB_FILE" +log " Layers: $LAYERS" + +kicad-cli pcb export gerbers \ + --output "$GERBERS_DIR" \ + --layers "$LAYERS" \ + --no-protel-ext \ + --subtract-soldermask \ + --no-netlist \ + "$PCB_FILE" \ + 2>/dev/null + +log "" + +# ---- Step 2: convert to pm4n ---- +log "=== Converting to .pm4n ===" + +IFS=',' read -ra LAYER_LIST <<< "$LAYERS" +for LAYER in "${LAYER_LIST[@]}"; do + LAYER_STEM="$(layer_to_filename "$LAYER")" + GBR_FILE="$GERBERS_DIR/${BOARD_NAME}-${LAYER_STEM}.gbr" + + if [[ ! -f "$GBR_FILE" ]]; then + echo "WARNING: Gerber not found for layer $LAYER (expected: $GBR_FILE)" >&2 + continue + fi + + OUT_PM4N="$PM4N_DIR/${BOARD_NAME}-${LAYER_STEM}.pm4n" + + FLAGS=() + contains "$INVERT_LAYERS" "$LAYER" && FLAGS+=(--invert) + contains "$MIRROR_LAYERS" "$LAYER" && FLAGS+=(--mirror) + [[ -n "$DPMM" ]] && FLAGS+=(--dpmm "$DPMM") + [[ -n "$POS" ]] && FLAGS+=(--pos "$POS") + [[ -n "$PREVIEW_SCALE" ]] && FLAGS+=(--preview-scale "$PREVIEW_SCALE") + [[ $VERBOSE -eq 1 ]] && FLAGS+=(--verbose) + + log " $LAYER → $OUT_PM4N [${FLAGS[*]:-} exposure=${EXPOSURE}s]" + + "$PYTHON" "$CONVERTER" \ + "$DUMMY" \ + "$GBR_FILE" \ + --output "$OUT_PM4N" \ + --exposure "$EXPOSURE" \ + "${FLAGS[@]}" +done + +log "=== Done ===" \ No newline at end of file diff --git a/gerber_to_pm4n.py b/gerber_to_pm4n.py new file mode 100755 index 0000000..b1f947d --- /dev/null +++ b/gerber_to_pm4n.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +""" +gerber_to_pm4n.py – Anycubic Photon Mono 4 PCB exposure file generator + +Usage: + python3 gerber_to_pm4n.py [options] + +Options: + -o OUTPUT Output file path [default: .pm4n] + --invert Invert the image (for positive-working resist like Bungard standard) + --mirror Mirror X axis (for copper-side-down placement on FEP) + --exposure SEC Exposure time in seconds [default: 60] + --dpmm N Render resolution in dots/mm [default: 58.824, native 17µm/px] + --pos X,Y Board position mm from top-left (default: centred) + --verbose Print detailed progress + +Photon Mono 4 specs: 9024 × 5120 px | 153.408 × 87.040 mm | 17.001 µm/px +""" + +import argparse +import struct +import sys +import io +from pathlib import Path +from PIL import Image, ImageOps + +# --------------------------------------------------------------------------- +# Printer constants +# --------------------------------------------------------------------------- +LCD_W_PX = 9024 +LCD_H_PX = 5120 +LCD_W_MM = 153.408 +LCD_H_MM = 87.040 +NATIVE_DPMM = LCD_W_PX / LCD_W_MM # 58.824 dpmm (1 px ≈ 17.001 µm) + +# --------------------------------------------------------------------------- +# pm4n format constants (reverse-engineered from Dummy.pm4n) +# --------------------------------------------------------------------------- +LAYE_TAG = b'LAYE' +MODE_TAG = b'Mode' +ENTRY_STRIDE = 0x20 # 32 bytes per layer entry +LAYE_HDR_SIZE = 0x1C # bytes before first entry within LAYE section + + +def find_tag(data: bytes, tag: bytes, start: int = 0) -> int: + """Return file offset of first occurrence of tag aligned to 4 bytes.""" + i = (start + 3) & ~3 + while i + 4 <= len(data): + if data[i:i+4] == tag: + return i + i += 4 + pos = data.find(tag, start) + if pos < 0: + raise ValueError(f"Tag {tag!r} not found in file") + return pos + + +def count_laye_entries(data: bytes, laye_off: int) -> int: + """Count layer entries by scanning until EXTR tag or implausible float.""" + entry_start = laye_off + LAYE_HDR_SIZE + n = 0 + while True: + pos = entry_start + n * ENTRY_STRIDE + if pos + 4 > len(data): + break + word = data[pos:pos+4] + if word in (b'EXTR', b'MACH', b'Mode', b'HEAD', b'PREV', b'LAYE'): + break + v = struct.unpack_from(' int: + return struct.unpack_from(' bytes: + out = bytearray() + i, n = 0, len(pixels) + while i < n: + colour = pixels[i] + nibble = 0xF if colour >= 0x80 else 0x0 + j = i + 1 + while j < n and pixels[j] == colour and (j - i) < MAX_RUN: + j += 1 + run = j - i + enc = run - 1 + out.append((nibble << 4) | ((enc >> 8) & 0x0F)) + out.append(enc & 0xFF) + i = j + return bytes(out) + + +# --------------------------------------------------------------------------- +# Gerber → PIL Image at LCD resolution +# --------------------------------------------------------------------------- + +def render_gerber(gbr_path: Path, dpmm: float, + invert: bool, mirror: bool, + pos_mm: tuple | None, + verbose: bool = False) -> Image.Image: + try: + from pygerber.gerberx3.api.v2 import ( + GerberFile, ColorScheme, PixelFormatEnum, ImageFormatEnum + ) + except ImportError: + sys.exit( + "ERROR: pygerber not found.\n" + "Activate the venv: source .venv/bin/activate\n" + "Or install: pip install pygerber Pillow" + ) + + buf = io.BytesIO() + GerberFile.from_file(str(gbr_path)).parse().render_raster( + buf, + dpmm=int(round(dpmm)), + color_scheme=ColorScheme.DEFAULT_GRAYSCALE, + pixel_format=PixelFormatEnum.RGB, + image_format=ImageFormatEnum.PNG, + ) + buf.seek(0) + layer_img = Image.open(buf).convert('L') + + cw, ch = layer_img.size + canvas = Image.new('L', (LCD_W_PX, LCD_H_PX), 0) + + if pos_mm is not None: + px = max(0, int(round(pos_mm[0] * dpmm))) + py = max(0, int(round(pos_mm[1] * dpmm))) + else: + px = (LCD_W_PX - cw) // 2 + py = (LCD_H_PX - ch) // 2 + + canvas.paste(layer_img, (px, py)) + + if mirror: + canvas = ImageOps.mirror(canvas) + if invert: + canvas = ImageOps.invert(canvas) + + canvas = canvas.point(lambda v: 255 if v >= 128 else 0) + + if verbose: + print(f" Gerber rendered: {layer_img.size[0]}×{layer_img.size[1]} px" + f" placed at ({px},{py}) invert={invert} mirror={mirror}") + + return canvas + + +# --------------------------------------------------------------------------- +# pm4n surgery +# --------------------------------------------------------------------------- + +def patch_pm4n(dummy_path: Path, image: Image.Image, + exposure_sec: float, output_path: Path, + verbose: bool = False): + + def log(*a): + if verbose: + print(*a) + + raw = bytearray(dummy_path.read_bytes()) + + new_rle = encode_rle(image.convert('L').tobytes()) + new_rle_size = len(new_rle) + + laye_off = find_tag(raw, LAYE_TAG) + n_entries = count_laye_entries(raw, laye_off) + log(f" LAYE at 0x{laye_off:06X}, {n_entries} layer entries") + + composite_off = unpack_u32(raw, laye_off + 0x14) + old_block_size = unpack_u32(raw, laye_off + 0x18) + log(f" Image blocks: first=0x{composite_off:06X}, " + f"old_size={old_block_size}, new_size={new_rle_size}") + + # Patch exposure in all entries + for i in range(n_entries): + base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE + struct.pack_into('