initial
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
output/
|
||||
.venv/
|
||||
BIN
Dummy.pm4n
Normal file
BIN
Dummy.pm4n
Normal file
Binary file not shown.
126
Readme.md
Normal file
126
Readme.md
Normal file
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
62
diagnose_pm4n.py
Normal file
62
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})")
|
||||
154
export.sh
Executable file
154
export.sh
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# export_for_msla.sh — KiCad Gerber export + pm4n generation for Anycubic Photon Mono 4
|
||||
#
|
||||
# Usage:
|
||||
# ./export_for_msla.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)
|
||||
# --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 ==="
|
||||
279
gerber_to_pm4n.py
Executable file
279
gerber_to_pm4n.py
Executable file
@@ -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 <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 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('<f', data, pos)[0]
|
||||
if not (0.0 < v < 1000.0):
|
||||
break
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def unpack_u32(data: bytes, off: int) -> int:
|
||||
return struct.unpack_from('<I', data, off)[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Photon Workshop RLE (BW — 2 bytes per run)
|
||||
# Byte0 [7:4] colour nibble: 0x0=black, 0xF=white
|
||||
# Byte0 [3:0] + Byte1: run length − 1 (12-bit, max run=4096)
|
||||
# ---------------------------------------------------------------------------
|
||||
MAX_RUN = 4096
|
||||
|
||||
def encode_rle(pixels: bytes) -> 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('<f', raw, base + 0x04, exposure_sec)
|
||||
log(f" Exposure patched to {exposure_sec}s in {n_entries} entries")
|
||||
|
||||
# Update block size in LAYE header and Mode header
|
||||
struct.pack_into('<I', raw, laye_off + 0x18, new_rle_size)
|
||||
mode_off = find_tag(raw, MODE_TAG)
|
||||
struct.pack_into('<I', raw, mode_off + 0x48, new_rle_size)
|
||||
log(f" Mode at 0x{mode_off:06X}")
|
||||
|
||||
# Update image offsets and sizes in all entries
|
||||
for i in range(n_entries):
|
||||
base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
|
||||
struct.pack_into('<I', raw, base + 0x18, composite_off + (i + 1) * new_rle_size)
|
||||
struct.pack_into('<I', raw, base + 0x1C, new_rle_size)
|
||||
|
||||
# Splice new image data (composite block + one block per layer, all identical)
|
||||
n_blocks = n_entries + 1
|
||||
old_end = composite_off + n_blocks * old_block_size
|
||||
raw[composite_off:old_end] = new_rle * n_blocks
|
||||
|
||||
output_path.write_bytes(raw)
|
||||
log(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',
|
||||
)
|
||||
p.add_argument('dummy', help='Dummy .pm4n template')
|
||||
p.add_argument('gerber', help='Input Gerber file')
|
||||
p.add_argument('-o', '--output', default=None)
|
||||
p.add_argument('--invert', action='store_true')
|
||||
p.add_argument('--mirror', action='store_true')
|
||||
p.add_argument('--exposure', type=float, default=60.0)
|
||||
p.add_argument('--dpmm', type=float, default=NATIVE_DPMM)
|
||||
p.add_argument('--pos', default=None)
|
||||
p.add_argument('--verbose', action='store_true')
|
||||
p.add_argument('--preview-scale', type=int, default=1, metavar='N',
|
||||
help='Downsample preview PNG by factor N (default: 1 = full resolution)')
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
dummy = Path(args.dummy)
|
||||
gbr = Path(args.gerber)
|
||||
v = args.verbose
|
||||
|
||||
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")
|
||||
|
||||
if v:
|
||||
print(f"Gerber: {gbr}")
|
||||
print(f"Dummy: {dummy}")
|
||||
print(f"Output: {out}")
|
||||
print(f"Invert: {args.invert} Mirror: {args.mirror}"
|
||||
f" Exposure: {args.exposure}s dpmm: {args.dpmm:.3f}")
|
||||
|
||||
img = render_gerber(gbr, dpmm=args.dpmm, invert=args.invert,
|
||||
mirror=args.mirror, pos_mm=pos_mm, verbose=v)
|
||||
|
||||
preview = out.with_suffix('.preview.png')
|
||||
scale = args.preview_scale
|
||||
if scale <= 1:
|
||||
img.save(preview)
|
||||
else:
|
||||
img.resize((img.size[0] // scale, img.size[1] // scale), Image.NEAREST).save(preview)
|
||||
|
||||
patch_pm4n(dummy, img, args.exposure, out, verbose=v)
|
||||
|
||||
# Always print the output path (quiet mode only output)
|
||||
print(out)
|
||||
print(preview)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user