#!/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('