added export to pm4n
This commit is contained in:
3
scripts/.gitignore
vendored
3
scripts/.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
__pycache__/
|
||||
panel/
|
||||
gerbers/
|
||||
output/
|
||||
gcode/
|
||||
.venv/
|
||||
BIN
scripts/Dummy.pm4n
Normal file
BIN
scripts/Dummy.pm4n
Normal file
Binary file not shown.
@@ -57,7 +57,11 @@ nano millproject
|
||||
Run the export by providing the `.kicad_pcb` file as a first argument:
|
||||
|
||||
```bash
|
||||
./export.sh panel/Flow_Controller_Panel.kicad_pcb
|
||||
# Input layers/filenames and all milling/drilling parameters are taken from the config file: 'millproject'.
|
||||
docker run --rm -i -t \
|
||||
-u "$(id -u):$(id -g)" \
|
||||
-v "$(pwd):/data" \
|
||||
ptodorov/pcb2gcode
|
||||
```
|
||||
The script will first generate gerber files in the `gerbers` directory and then generate gcode files in the `gcode` directory.
|
||||
|
||||
@@ -75,5 +79,174 @@ Set up the entire back side as one big GND pour. Then, increase the thermal spok
|
||||

|
||||
|
||||
|
||||
# MSLA PCB Exposure: KiCad → Photon Mono 4
|
||||
|
||||
Convert KiCad PCB layers to `.pm4n` files for direct UV exposure on an **Anycubic Photon Mono 4** (9024×5120 px, 17 µm/px).
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `export.sh` | Main entry point — exports Gerbers from KiCad, converts to `.pm4n` |
|
||||
| `gerber_to_pm4n.py` | Python converter (Gerber → RLE → pm4n binary surgery) |
|
||||
| `Dummy.pm4n` | Template file for your specific printer. |
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create the Dummy.pm4n
|
||||
|
||||
Open **CHITUBOX_Basic** slicer, select printer **Anycubic Photon Mono 4**, slice any tiny STL (a 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.
|
||||
|
||||
### 2. 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.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
./export.sh [OPTIONS] <path/to/board.kicad_pcb>
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
62
scripts/diagnose_pm4n.py
Normal file
62
scripts/diagnose_pm4n.py
Normal file
@@ -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('<I', data, 8)[0]
|
||||
n_sections = struct.unpack_from('<I', data, 12)[0]
|
||||
header_size = struct.unpack_from('<I', data, 16)[0]
|
||||
print(f"Magic: {magic}")
|
||||
print(f"Bytes 8-11: {data[8:12].hex()} (={version})")
|
||||
print(f"Section count: {n_sections}")
|
||||
print(f"Header size: {header_size} (0x{header_size:X})")
|
||||
print()
|
||||
|
||||
# Section table: n_sections entries of (offset:u32, length:u32) starting at byte 20
|
||||
print(f"Section table ({n_sections} entries from offset 20):")
|
||||
sections = []
|
||||
for i in range(n_sections):
|
||||
base = 20 + i * 8
|
||||
if base + 8 > fsize:
|
||||
break
|
||||
off = struct.unpack_from('<I', data, base)[0]
|
||||
ln = struct.unpack_from('<I', data, base+4)[0]
|
||||
sections.append((off, ln))
|
||||
|
||||
for idx, (off, ln) in enumerate(sections):
|
||||
tag = data[off:off+4] if off + 4 <= fsize else b'????'
|
||||
tag_str = tag.decode('ascii', errors='replace')
|
||||
print(f" [{idx:2d}] offset=0x{off:06X} ({off:7d}) length={ln:7d} tag@offset={tag_str!r}")
|
||||
|
||||
print()
|
||||
# Also peek at each section start for tag-like content
|
||||
print("Content at each section offset (first 32 bytes):")
|
||||
for idx, (off, ln) in enumerate(sections):
|
||||
if off + 16 <= fsize:
|
||||
chunk = data[off:off+32]
|
||||
# Try to find sub-tags
|
||||
for sub_off in range(0, min(32, len(chunk))-3):
|
||||
sub_tag = chunk[sub_off:sub_off+4]
|
||||
if all(32 <= b < 127 for b in sub_tag):
|
||||
sub_len = struct.unpack_from('<I', chunk, sub_off+4)[0] if sub_off+8 <= len(chunk) else 0
|
||||
print(f" section[{idx}]+0x{sub_off:02X} tag={sub_tag.decode()!r} next_u32={sub_len}")
|
||||
break
|
||||
else:
|
||||
print(f" section[{idx}] hex: {chunk[:16].hex()}")
|
||||
|
||||
print()
|
||||
# Look for exposure-like floats (1.0 to 600.0) across the whole file
|
||||
print("Float values in range [1.0 .. 600.0] across whole file:")
|
||||
for off in range(0, fsize - 3, 4):
|
||||
v = struct.unpack_from('<f', data, off)[0]
|
||||
if 1.0 <= v <= 600.0 and v == round(v, 1):
|
||||
# Show context
|
||||
section_hint = next((f"sec[{i}]+{off-s:d}" for i,(s,l) in enumerate(sections) if s <= off < s+l), "outside")
|
||||
print(f" 0x{off:06X} {v:.1f} ({section_hint})")
|
||||
@@ -1,35 +1,156 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# export.sh — KiCad Gerber export + pm4n generation for Anycubic Photon Mono 4
|
||||
#
|
||||
# Usage:
|
||||
# ./export.sh [OPTIONS] <path/to/board.kicad_pcb>
|
||||
#
|
||||
# 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 → <board>-F_Cu.gbr
|
||||
# B.Cu → <board>-B_Cu.gbr
|
||||
# F.Mask → <board>-F_Mask.gbr
|
||||
# B.Mask → <board>-B_Mask.gbr
|
||||
# F.SilkS → <board>-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 <kicad_pcb_file>"
|
||||
exit 1
|
||||
sed -n '/^# Usage/,/^[^#]/{ /^#/{ s/^# \{0,1\}//; p } }' "$0"
|
||||
exit 0
|
||||
}
|
||||
|
||||
PCB_FILE="$1"
|
||||
contains() { # contains <list> <item> — 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] <board.kicad_pcb>"
|
||||
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 <path>"
|
||||
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[*]:-<none>} exposure=${EXPOSURE}s"
|
||||
|
||||
"$PYTHON" "$CONVERTER" \
|
||||
"$DUMMY" \
|
||||
"$GBR_FILE" \
|
||||
--output "$OUT_PM4N" \
|
||||
--exposure "$EXPOSURE" \
|
||||
"${FLAGS[@]}"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "=== Done ==="
|
||||
echo "pm4n files in: $PM4N_DIR"
|
||||
287
scripts/gerber_to_pm4n.py
Executable file
287
scripts/gerber_to_pm4n.py
Executable file
@@ -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 <dummy.pm4n> <board.gbr> [options]
|
||||
|
||||
Options:
|
||||
-o OUTPUT Output file path [default: <board>.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('<I', data, i+4)[0]
|
||||
sections.append((tag, i + SECTION_HDR, length))
|
||||
i += SECTION_HDR + length
|
||||
return sections
|
||||
|
||||
def find_section(data: bytes, tag: bytes) -> 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('<I', data, offset, value)
|
||||
|
||||
def patch_f32(data: bytearray, offset: int, value: float):
|
||||
struct.pack_into('<f', data, offset, value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gerber → PIL Image
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_gerber(gbr_path: Path, dpmm: float,
|
||||
invert: bool, mirror: bool,
|
||||
pos_mm: tuple | None) -> 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('<f', raw, off)[0]
|
||||
if 0.5 <= val <= 600.0:
|
||||
patch_f32(raw, off, exposure_sec)
|
||||
|
||||
# Locate layer image via LAYERDEF
|
||||
ld_off, _ = find_section(raw, b'LAYE')
|
||||
layer_count = struct.unpack_from('<I', raw, ld_off)[0]
|
||||
if layer_count != 1:
|
||||
print(f"WARNING: dummy has {layer_count} layers; only layer 0 will be replaced.")
|
||||
entry_off = ld_off + 4
|
||||
img_offset = struct.unpack_from('<I', raw, entry_off)[0]
|
||||
img_len = struct.unpack_from('<I', raw, entry_off + 4)[0]
|
||||
|
||||
print(f" Dummy RLE: offset=0x{img_offset:08X} {img_len} bytes")
|
||||
print(f" New RLE: {len(new_rle)} bytes")
|
||||
|
||||
# Splice new RLE in place
|
||||
old_end = img_offset + img_len
|
||||
raw[img_offset:old_end] = new_rle
|
||||
|
||||
# Update LAYERDEF length field
|
||||
patch_u32(raw, entry_off + 4, len(new_rle))
|
||||
|
||||
# Update any enclosing section's length field
|
||||
for tag, sec_off, sec_len in read_sections(bytes(raw)):
|
||||
if tag not in (b'HEAD', b'LAYE', b'PREV') and sec_off <= img_offset < sec_off + sec_len:
|
||||
patch_u32(raw, sec_off - 4, sec_len + len(new_rle) - img_len)
|
||||
break
|
||||
|
||||
output_path.write_bytes(raw)
|
||||
print(f" Written: {output_path} ({len(raw):,} bytes)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(
|
||||
description='Convert Gerber → Anycubic Photon Mono 4 .pm4n PCB exposure file',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
p.add_argument('dummy', help='Dummy .pm4n template (from Photon Workshop)')
|
||||
p.add_argument('gerber', help='Input Gerber file')
|
||||
p.add_argument('-o', '--output', default=None, help='Output .pm4n path')
|
||||
p.add_argument('--invert', action='store_true', help='Invert image (positive-working resist)')
|
||||
p.add_argument('--mirror', action='store_true', help='Mirror X axis (copper-side-down placement)')
|
||||
p.add_argument('--exposure', type=float, default=60.0, help='Exposure seconds (default: 60)')
|
||||
p.add_argument('--dpmm', type=float, default=NATIVE_DPMM, help=f'Dots/mm (default: {NATIVE_DPMM:.3f})')
|
||||
p.add_argument('--pos', default=None, help='Board position X,Y mm from top-left')
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
dummy = Path(args.dummy)
|
||||
gbr = Path(args.gerber)
|
||||
|
||||
for p, label in [(dummy, 'dummy'), (gbr, 'gerber')]:
|
||||
if not p.exists():
|
||||
sys.exit(f"ERROR: {label} file not found: {p}")
|
||||
|
||||
out = Path(args.output) if args.output else gbr.with_suffix('.pm4n')
|
||||
pos_mm = None
|
||||
if args.pos:
|
||||
try:
|
||||
x, y = map(float, args.pos.split(','))
|
||||
pos_mm = (x, y)
|
||||
except Exception:
|
||||
sys.exit("ERROR: --pos must be X,Y e.g. --pos 10.5,8.0")
|
||||
|
||||
print(f"Gerber: {gbr}")
|
||||
print(f"Dummy: {dummy}")
|
||||
print(f"Output: {out}")
|
||||
print(f"Invert: {args.invert} Mirror: {args.mirror} Exposure: {args.exposure}s dpmm: {args.dpmm:.3f}")
|
||||
print()
|
||||
|
||||
print("Rendering Gerber...")
|
||||
img = render_gerber(gbr, dpmm=args.dpmm, invert=args.invert,
|
||||
mirror=args.mirror, pos_mm=pos_mm)
|
||||
print(f"Canvas: {img.size[0]}×{img.size[1]} px")
|
||||
|
||||
preview = out.with_suffix('.preview.png')
|
||||
img.resize((img.size[0] // 4, img.size[1] // 4), Image.NEAREST).save(preview)
|
||||
print(f"Preview: {preview}")
|
||||
|
||||
print("Patching .pm4n...")
|
||||
patch_pm4n(dummy, img, args.exposure, out)
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,10 +1,10 @@
|
||||
front=gerbers/Flow_Controller_Panel-Front.gtl
|
||||
back=gerbers/Flow_Controller_Panel-Back.gbl
|
||||
drill=gerbers/Flow_Controller_Panel.drl
|
||||
front=output/gerbers/Flow_Controller_Panel-Front.gtl
|
||||
back=output/gerbers/Flow_Controller_Panel-Back.gbl
|
||||
drill=output/gerbers/Flow_Controller_Panel.drl
|
||||
# Use the 'User-Eco1' layer instead as it contains panelized board's slot outlines only.
|
||||
outline=gerbers/Flow_Controller_Panel-User-Eco1.gbr
|
||||
outline=output/gerbers/Flow_Controller_Panel-User-Eco1.gbr
|
||||
# Do not use the 'Edge_Cuts' layer as it contains also the panel outline.
|
||||
#outline=gerbers/Flow_Controller_Panel-Edge_Cuts.gm1
|
||||
#outline=output/gerbers/Flow_Controller_Panel-Edge_Cuts.gm1
|
||||
|
||||
# Generic
|
||||
metric=true # use metric units for parameters
|
||||
|
||||
Reference in New Issue
Block a user