From ed1c4c4fd684c90580480f056435a805c97bce40 Mon Sep 17 00:00:00 2001 From: cpu Date: Mon, 8 Jun 2026 00:45:38 +0200 Subject: [PATCH] added export to pm4n --- scripts/.gitignore | 5 +- scripts/Dummy.pm4n | Bin 0 -> 300616 bytes scripts/Readme.md | 175 ++++++++++++++++++++++- scripts/diagnose_pm4n.py | 62 ++++++++ scripts/export.sh | 169 ++++++++++++++++++---- scripts/gerber_to_pm4n.py | 287 ++++++++++++++++++++++++++++++++++++++ scripts/millproject | 10 +- 7 files changed, 676 insertions(+), 32 deletions(-) create mode 100644 scripts/Dummy.pm4n create mode 100644 scripts/diagnose_pm4n.py create mode 100755 scripts/gerber_to_pm4n.py diff --git a/scripts/.gitignore b/scripts/.gitignore index 46aba20..2dd4566 100644 --- a/scripts/.gitignore +++ b/scripts/.gitignore @@ -1,4 +1,5 @@ __pycache__/ panel/ -gerbers/ -gcode/ \ No newline at end of file +output/ +gcode/ +.venv/ \ No newline at end of file diff --git a/scripts/Dummy.pm4n b/scripts/Dummy.pm4n new file mode 100644 index 0000000000000000000000000000000000000000..e54b8b4737fb7face5ccb22610f2ff11d8a2aff3 GIT binary patch literal 300616 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 +``` + +### Options + +| Option | Default | Description | +|---|---|---| +| `--layers L,L,...` | `Front,F.Mask` | KiCad layer names to process | +| `--invert L,L,...` | *(none)* | Layers to invert the image for | +| `--mirror L,L,...` | *(none)* | Layers to mirror X for | +| `--exposure N` | `60` | Exposure time in seconds | +| `--dummy FILE` | `./Dummy.pm4n` | Path to dummy template | +| `--out DIR` | `./output` | Output directory | +| `--dpmm N` | `58.824` | Render resolution (native = 17 µm/px) | +| `--pos X,Y` | centred | Board position in mm from top-left | + +--- + +## Examples + +### Typical: top copper, positive-working resist (Bungard standard) + +```bash +./export.sh \ + --layers Front \ + --invert Front \ + --mirror Front \ + --exposure 60 \ + panel/Flow_Controller_Panel.kicad_pcb +``` + +`--invert`: Bungard presensitized is positive-working — UV removes resist, so the background must be exposed (white) and traces must block UV (dark). The Gerber is positive (copper=white), so inversion is needed. + +`--mirror`: the board sits copper-side-down on the FEP, so the image must be flipped so the pattern reads correctly through the board. + +### Multiple layers (e.g. copper + soldermask) + +```bash +./export.sh \ + --layers Front,F.Mask \ + --invert Front,F.Mask \ + --mirror Front,F.Mask \ + --exposure 60 \ + panel/Flow_Controller_Panel.kicad_pcb +``` + +### Quick test at lower resolution (faster render) + +```bash +./export.sh --dpmm 30 --layers Front --invert Front --mirror Front panel/Flow_Controller_Panel.kicad_pcb +``` + +### Using gerber_to_pm4n.py directly + +```bash +python3 gerber_to_pm4n.py Dummy.pm4n output/gerbers/Flow_Controller_Panel-Front.gbr \ + --invert --mirror --exposure 60 +``` + +--- + +## Output structure + +``` +output/ +├── gerbers/ +│ ├── Flow_Controller_Panel-Front.gbr +│ └── Flow_Controller_Panel-F_Mask.gbr +└── pm4n/ + ├── Flow_Controller_Panel-Front.pm4n ← copy to USB, print on Mono 4 + ├── Flow_Controller_Panel-Front.preview.png ← visual check before printing + ├── Flow_Controller_Panel-F_Mask.pm4n + └── Flow_Controller_Panel-F_Mask.preview.png +``` + +--- + +## Invert and mirror logic + +| Setting | When to use | +|---|---| +| `--invert` | Positive-working resist (standard Bungard): UV removes resist → background must be white (exposed), traces black (masked) | +| no `--invert` | Negative-working resist: UV hardens resist → traces must be white | +| `--mirror` | Board placed **copper-side down** on FEP (normal for this workflow) | +| no `--mirror` | Board placed copper-side up | + +When in doubt: check the `.preview.png` before printing. Traces should appear **dark** on a white background for standard Bungard positive-working boards. + +--- + +## Exposure calibration + +Start at **60 s** and bracket in ±15 s steps. Typical range for Bungard presensitized at 405 nm is 30–120 s depending on board age and storage conditions. + +A correctly exposed board after development will show: +- Clear copper traces (resist intact, blue/green tint) +- Bare copper in etched areas (resist removed, shiny copper) + +--- + +## 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` | + +**Image looks wrong in preview** — check invert/mirror flags. Open `.preview.png`: for positive-working resist, traces = dark, background = white. + +**UVtools PCB Exposure freezes on per-item invert checkbox** — known v6 bug at 46 MP. Use the global invert checkbox at the bottom of the dialog instead, or use this script pipeline entirely. + +--- +First print checklist + +Open the `.pm4n` in Chitubox to visually verify before printing. +Check the `.preview.png` — traces should appear black on white background (background = UV exposed = resist removed = etched away; traces = dark = resist kept = copper stays) +Start with `--exposure 60` and bracket from there — Bungard presensitized at 405nm typically lands between 30–120s depending on board vintage and storage. diff --git a/scripts/diagnose_pm4n.py b/scripts/diagnose_pm4n.py new file mode 100644 index 0000000..71ee2ec --- /dev/null +++ b/scripts/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, e.g. F.Cu,B.Mask) +# --mirror LAYER,LAYER,... Layers to mirror (comma-separated, e.g. F.Cu,F.Mask) +# --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) +# -h, --help Show this help +# +# Example: +# ./export.sh --invert F.Cu,B.Mask --mirror F.Cu,F.Mask panel/Flow_Controller_Panel.kicad_pcb +# +# Layer name → Gerber filename mapping (KiCad default): +# F.Cu → -F_Cu.gbr +# B.Cu → -B_Cu.gbr +# F.Mask → -F_Mask.gbr +# B.Mask → -B_Mask.gbr +# F.SilkS → -F_Silkscreen.gbr +# (etc.) set -euo pipefail -GERBERS_DIR="gerbers" +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="" + +# ---- helpers ---- usage() { - echo "Usage: $0 " - exit 1 + sed -n '/^# Usage/,/^[^#]/{ /^#/{ s/^# \{0,1\}//; p } }' "$0" + exit 0 } -PCB_FILE="$1" +contains() { # contains — comma-separated list membership + local list="$1" item="$2" + echo "$list" | tr ',' '\n' | grep -qx "$item" +} -mkdir -p "$GERBERS_DIR" +layer_to_filename() { # KiCad layer name → Gerber filename stem + local layer="$1" + echo "$layer" | sed 's/\./_/g' +} -# Export drill, front and back layers as gerber files. -echo "Exporting gerbers..." -kicad-cli pcb export drill -o "$GERBERS_DIR" "$PCB_FILE" -kicad-cli pcb export gerbers -o "$GERBERS_DIR" -l Front "$PCB_FILE" -kicad-cli pcb export gerbers -o "$GERBERS_DIR" -l Back "$PCB_FILE" -kicad-cli pcb export gerbers -o "$GERBERS_DIR" -l Edge.Cuts "$PCB_FILE" +# ---- 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 ;; + -h|--help) usage ;; + -*) echo "Unknown option: $1"; exit 1 ;; + *) PCB_FILE="$1"; shift ;; + esac +done -# Export outlines of the penelized board i.e. use the layer 'User.Eco1'. -# echo "Exporting panelized outlines from layer 'User.Eco1'..." -# python3 export_panel_outlines_gerber.py \ -# --layers User.Eco1 \ -# --output "$GERBERS_DIR" \ -# "$PCB_FILE" +if [[ -z "$PCB_FILE" ]]; then + echo "ERROR: no .kicad_pcb file specified" + echo "Usage: $0 [OPTIONS] " + exit 1 +fi -# Input layers/filenames and all milling/drilling parameters are taken from the config file: 'millproject'. -echo "Exporting Gcode..." -docker run --rm -i -t \ - -u "$(id -u):$(id -g)" \ - -v "$(pwd):/data" \ - ptodorov/pcb2gcode +if [[ ! -f "$PCB_FILE" ]]; then + echo "ERROR: file not found: $PCB_FILE" + exit 1 +fi + +if [[ ! -f "$DUMMY" ]]; then + echo "ERROR: dummy .pm4n not found: $DUMMY" + echo "Place Dummy.pm4n next to export.sh, or pass --dummy " + exit 1 +fi + +# ---- derive names ---- +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 via kicad-cli ---- +echo "=== Exporting Gerbers from KiCad ===" +echo " Board: $PCB_FILE" +echo " Layers: $LAYERS" +echo " Output: $GERBERS_DIR" +echo "" + +kicad-cli pcb export gerbers \ + --output "$GERBERS_DIR" \ + --layers "$LAYERS" \ + --no-protel-ext \ + --subtract-soldermask \ + "$PCB_FILE" + +echo "" + +# ---- Step 2: convert each layer to .pm4n ---- +echo "=== Converting Gerbers 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: expected Gerber not found: $GBR_FILE — skipping" + continue + fi + + OUT_PM4N="$PM4N_DIR/${BOARD_NAME}-${LAYER_STEM}.pm4n" + + # Build flags + FLAGS=() + if contains "$INVERT_LAYERS" "$LAYER"; then FLAGS+=(--invert); fi + if contains "$MIRROR_LAYERS" "$LAYER"; then FLAGS+=(--mirror); fi + [[ -n "$DPMM" ]] && FLAGS+=(--dpmm "$DPMM") + [[ -n "$POS" ]] && FLAGS+=(--pos "$POS") + + echo " Layer: $LAYER" + echo " Gerber: $GBR_FILE" + echo " pm4n: $OUT_PM4N" + echo " Flags: ${FLAGS[*]:-} exposure=${EXPOSURE}s" + + "$PYTHON" "$CONVERTER" \ + "$DUMMY" \ + "$GBR_FILE" \ + --output "$OUT_PM4N" \ + --exposure "$EXPOSURE" \ + "${FLAGS[@]}" + echo "" +done + +echo "=== Done ===" +echo "pm4n files in: $PM4N_DIR" \ No newline at end of file diff --git a/scripts/gerber_to_pm4n.py b/scripts/gerber_to_pm4n.py new file mode 100755 index 0000000..de536c0 --- /dev/null +++ b/scripts/gerber_to_pm4n.py @@ -0,0 +1,287 @@ +#!/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 Layer exposure time in seconds [default: 60] + --dpmm N Render resolution in dots/mm [default: 58.824, native 17µm/px] + --pos X,Y Place board at X,Y mm from top-left (default: centred on LCD) + --help Show this message + +Photon Mono 4 specs: 9024 x 5120 px | 153.408 x 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) + +# --------------------------------------------------------------------------- +# Photon Workshop RLE (BW — 2 bytes per run) +# +# Byte0 [7:4] = colour nibble (0x0 = black, 0xF = white) +# Byte0 [3:0] = high 4 bits of run length (bits 11:8) +# Byte1 = low 8 bits of run length (bits 7:0) +# Run length encodes (n-1): 0x000 = 1 pixel, 0xFFF = 4096 pixels +# --------------------------------------------------------------------------- +MAX_RUN = 4096 + +def encode_rle(pixels: bytes) -> bytes: + """Encode flat 0x00/0xFF bytes → Photon Workshop BW RLE.""" + out = bytearray() + i = 0 + n = 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 + encoded = run - 1 + out.append((nibble << 4) | ((encoded >> 8) & 0x0F)) + out.append(encoded & 0xFF) + i = j + return bytes(out) + + +def decode_rle(data: bytes, expected_pixels: int) -> bytes: + """Decode PW RLE → raw pixel bytes (used for verification).""" + out = bytearray() + i = 0 + while i + 1 < len(data): + b0, b1 = data[i], data[i + 1] + nibble = (b0 >> 4) & 0x0F + colour = 0xFF if nibble == 0xF else 0x00 + run = (((b0 & 0x0F) << 8) | b1) + 1 + out.extend([colour] * run) + i += 2 + return bytes(out[:expected_pixels]) + + +# --------------------------------------------------------------------------- +# pm4n binary surgery +# +# Photon Workshop file = sequence of tagged sections: +# tag:4 length:4 payload:length +# +# Sections we care about: +# HEAD – contains exposure time as a float somewhere in a packed struct +# LAYE – layer definition table: count:u32 then N × entry(28 bytes) +# entry[0:4] = absolute file offset of RLE blob +# entry[4:8] = RLE blob length in bytes +# After the sections: raw RLE layer image blobs (referenced by LAYE offsets) +# --------------------------------------------------------------------------- +SECTION_HDR = 8 # 4-byte tag + 4-byte length + +def read_sections(data: bytes) -> list: + sections = [] + i = 0 + while i + SECTION_HDR <= len(data): + tag = data[i:i+4] + length = struct.unpack_from(' tuple: + for t, off, ln in read_sections(data): + if t == tag: + return off, ln + raise ValueError(f"Section {tag!r} not found in file") + +def patch_u32(data: bytearray, offset: int, value: int): + struct.pack_into(' Image.Image: + """ + Render a Gerber file to a binary PIL image sized to the Photon Mono 4 LCD. + copper = white on black background before any transforms. + """ + try: + from pygerber.gerberx3.api.v2 import ( + GerberFile, ColorScheme, PixelFormatEnum, ImageFormatEnum + ) + except ImportError: + sys.exit( + "ERROR: pygerber not found.\n" + "Activate the venv first: source .venv/bin/activate\n" + "Or install: pip install pygerber Pillow numpy" + ) + + 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') + + # Place onto full LCD canvas + cw, ch = layer_img.size + canvas = Image.new('L', (LCD_W_PX, LCD_H_PX), 0) + + if pos_mm is not None: + px = int(round(pos_mm[0] * dpmm)) + py = int(round(pos_mm[1] * dpmm)) + else: + px = (LCD_W_PX - cw) // 2 + py = (LCD_H_PX - ch) // 2 + + canvas.paste(layer_img, (max(0, px), max(0, py))) + + if mirror: + canvas = ImageOps.mirror(canvas) + if invert: + canvas = ImageOps.invert(canvas) + + # Hard-binarise: no antialiasing artefacts in the RLE stream + canvas = canvas.point(lambda v: 255 if v >= 128 else 0) + + return canvas + + +# --------------------------------------------------------------------------- +# pm4n surgery +# --------------------------------------------------------------------------- + +def patch_pm4n(dummy_path: Path, image: Image.Image, + exposure_sec: float, output_path: Path): + """Replace layer RLE + exposure time in a dummy .pm4n, write output.""" + raw = bytearray(dummy_path.read_bytes()) + + # Encode new layer image + pixels = image.convert('L').tobytes() + new_rle = encode_rle(pixels) + + # Patch exposure time: scan HEAD for any float in 0.5–600 s range + hdr_off, hdr_len = find_section(raw, b'HEAD') + for off in range(hdr_off, hdr_off + hdr_len - 3): + val = struct.unpack_from('