fixed export script

This commit is contained in:
cpu
2026-06-08 12:49:42 +02:00
parent 3b423af5b5
commit eada18d3f0
4 changed files with 513 additions and 552 deletions

View File

@@ -1,5 +1,5 @@
TODO: Add table of content
# KiKit Fixture Processor
# KiKit Processor
Processing script for KiKit panelizes PCBs and draws a fixture sketch and positions the whole panel for easy CNC and MSLA processing.
The script:

View File

@@ -6,25 +6,29 @@
#
# 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)
# --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)
# --verbose Print detailed progress
# -h, --help Show this help
#
# Example:
# ./export_for_msla.sh --invert F.Cu,B.Mask --mirror F.Cu,F.Mask panel/Flow_Controller_Panel.kicad_pcb
# Normal output: one output filepath per layer.
# Verbose output: full progress from KiCad and the converter.
#
# 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.)
# 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
@@ -41,21 +45,28 @@ DUMMY="$SCRIPT_DIR/Dummy.pm4n"
OUT_DIR="./output"
DPMM=""
POS=""
VERBOSE=0
# ---- helpers ----
usage() {
sed -n '/^# Usage/,/^[^#]/{ /^#/{ s/^# \{0,1\}//; p } }' "$0"
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
contains() { # contains <list> <item> — comma-separated list membership
log() { [[ $VERBOSE -eq 1 ]] && echo "$@" || true; }
contains() {
local list="$1" item="$2"
echo "$list" | tr ',' '\n' | grep -qx "$item"
}
layer_to_filename() { # KiCad layer name → Gerber filename stem
local layer="$1"
echo "$layer" | sed 's/\./_/g'
layer_to_filename() {
case "$1" in
F.SilkS) echo "F_Silkscreen" ;;
B.SilkS) echo "B_Silkscreen" ;;
Edge.Cuts) echo "Edge_Cuts" ;;
*) echo "${1//./_}" ;;
esac
}
# ---- parse arguments ----
@@ -70,54 +81,40 @@ while [[ $# -gt 0 ]]; do
--out) OUT_DIR="$2"; shift 2 ;;
--dpmm) DPMM="$2"; shift 2 ;;
--pos) POS="$2"; shift 2 ;;
--verbose) VERBOSE=1; shift ;;
-h|--help) usage ;;
-*) echo "Unknown option: $1"; exit 1 ;;
-*) echo "ERROR: unknown option: $1" >&2; exit 1 ;;
*) PCB_FILE="$1"; shift ;;
esac
done
if [[ -z "$PCB_FILE" ]]; then
echo "ERROR: no .kicad_pcb file specified"
echo "Usage: $0 [OPTIONS] <board.kicad_pcb>"
exit 1
fi
[[ -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; }
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 ""
# ---- 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 \
"$PCB_FILE"
--no-netlist \
"$PCB_FILE" \
2>/dev/null
echo ""
log ""
# ---- Step 2: convert each layer to .pm4n ----
echo "=== Converting Gerbers to .pm4n ==="
# ---- Step 2: convert to pm4n ----
log "=== Converting to .pm4n ==="
IFS=',' read -ra LAYER_LIST <<< "$LAYERS"
for LAYER in "${LAYER_LIST[@]}"; do
@@ -125,23 +122,20 @@ for LAYER in "${LAYER_LIST[@]}"; do
GBR_FILE="$GERBERS_DIR/${BOARD_NAME}-${LAYER_STEM}.gbr"
if [[ ! -f "$GBR_FILE" ]]; then
echo " WARNING: expected Gerber not found: $GBR_FILE — skipping"
echo "WARNING: Gerber not found for layer $LAYER (expected: $GBR_FILE)" >&2
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
contains "$INVERT_LAYERS" "$LAYER" && FLAGS+=(--invert)
contains "$MIRROR_LAYERS" "$LAYER" && FLAGS+=(--mirror)
[[ -n "$DPMM" ]] && FLAGS+=(--dpmm "$DPMM")
[[ -n "$POS" ]] && FLAGS+=(--pos "$POS")
[[ $VERBOSE -eq 1 ]] && FLAGS+=(--verbose)
echo " Layer: $LAYER"
echo " Gerber: $GBR_FILE"
echo " pm4n: $OUT_PM4N"
echo " Flags: ${FLAGS[*]:-<none>} exposure=${EXPOSURE}s"
log " $LAYER$OUT_PM4N [${FLAGS[*]:-} exposure=${EXPOSURE}s]"
"$PYTHON" "$CONVERTER" \
"$DUMMY" \
@@ -149,8 +143,6 @@ for LAYER in "${LAYER_LIST[@]}"; do
--output "$OUT_PM4N" \
--exposure "$EXPOSURE" \
"${FLAGS[@]}"
echo ""
done
echo "=== Done ==="
echo "pm4n files in: $PM4N_DIR"
log "=== Done ==="

View File

@@ -9,9 +9,10 @@ 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)
--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
"""
@@ -83,21 +84,19 @@ NATIVE_DPMM = LCD_W_PX / LCD_W_MM # 58.824 dpmm (1 px ≈ 17.001 µm)
# +0x4C u32 unknown
# then N RLE image blocks follow (at offsets stored in LAYE entries)
# ---------------------------------------------------------------------------
LAYE_TAG = b'LAYE'
MODE_TAG = b'Mode'
ENTRY_STRIDE = 0x20 # 32 bytes per layer entry in LAYE
LAYE_HDR_SIZE = 0x1C # bytes before first entry
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 (exact 4-byte match at 4-byte boundary)."""
i = start
"""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
# Fall back to unaligned search
pos = data.find(tag, start)
if pos < 0:
raise ValueError(f"Tag {tag!r} not found in file")
@@ -105,7 +104,7 @@ def find_tag(data: bytes, tag: bytes, start: int = 0) -> int:
def count_laye_entries(data: bytes, laye_off: int) -> int:
"""Count layer entries by scanning until we hit the 'EXTR' sub-section or end of section."""
"""Count layer entries by scanning until EXTR tag or implausible float."""
entry_start = laye_off + LAYE_HDR_SIZE
n = 0
while True:
@@ -113,10 +112,8 @@ def count_laye_entries(data: bytes, laye_off: int) -> int:
if pos + 4 > len(data):
break
word = data[pos:pos+4]
# Stop if we hit a known sub-tag marker
if word in (b'EXTR', b'MACH', b'Mode', b'HEAD', b'PREV'):
if word in (b'EXTR', b'MACH', b'Mode', b'HEAD', b'PREV', b'LAYE'):
break
# Stop if the f32 at this position is not a plausible exposure time
v = struct.unpack_from('<f', data, pos)[0]
if not (0.0 < v < 1000.0):
break
@@ -124,14 +121,6 @@ def count_laye_entries(data: bytes, laye_off: int) -> int:
return n
def pack_f32(v: float) -> bytes:
return struct.pack('<f', v)
def pack_u32(v: int) -> bytes:
return struct.pack('<I', v)
def unpack_u32(data: bytes, off: int) -> int:
return struct.unpack_from('<I', data, off)[0]
@@ -139,7 +128,7 @@ def unpack_u32(data: bytes, off: int) -> int:
# ---------------------------------------------------------------------------
# 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)
# Byte0 [3:0] + Byte1: run length 1 (12-bit, max run=4096)
# ---------------------------------------------------------------------------
MAX_RUN = 4096
@@ -166,7 +155,8 @@ def encode_rle(pixels: bytes) -> bytes:
def render_gerber(gbr_path: Path, dpmm: float,
invert: bool, mirror: bool,
pos_mm: tuple | None) -> Image.Image:
pos_mm: tuple | None,
verbose: bool = False) -> Image.Image:
try:
from pygerber.gerberx3.api.v2 import (
GerberFile, ColorScheme, PixelFormatEnum, ImageFormatEnum
@@ -206,88 +196,66 @@ def render_gerber(gbr_path: Path, dpmm: float,
if invert:
canvas = ImageOps.invert(canvas)
# Hard-binarise to strict 0/255
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 — rewrite with exact format knowledge
# pm4n surgery
# ---------------------------------------------------------------------------
def patch_pm4n(dummy_path: Path, image: Image.Image,
exposure_sec: float, output_path: Path):
exposure_sec: float, output_path: Path,
verbose: bool = False):
def log(*a):
if verbose:
print(*a)
raw = bytearray(dummy_path.read_bytes())
# --- encode new RLE ---
new_rle = encode_rle(image.convert('L').tobytes())
new_rle = encode_rle(image.convert('L').tobytes())
new_rle_size = len(new_rle)
# --- locate LAYE section ---
laye_off = find_tag(raw, LAYE_TAG)
n_entries = count_laye_entries(raw, laye_off)
print(f" LAYE at 0x{laye_off:06X}, {n_entries} layer entries")
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")
# Read composite image offset and original block size
composite_off = unpack_u32(raw, laye_off + 0x14)
composite_off = unpack_u32(raw, laye_off + 0x14)
old_block_size = unpack_u32(raw, laye_off + 0x18)
print(f" Image blocks: first=0x{composite_off:06X}, old_size={old_block_size}, new_size={new_rle_size}")
log(f" Image blocks: first=0x{composite_off:06X}, "
f"old_size={old_block_size}, new_size={new_rle_size}")
# --- Read all existing image offsets from LAYE entries ---
# Block 0 is the composite (at composite_off), blocks 1..N from entries
old_offsets = [composite_off]
# Patch exposure in all entries
for i in range(n_entries):
entry_base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
old_offsets.append(unpack_u32(raw, entry_base + 0x18))
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")
# All blocks should be contiguous and equal-sized; verify
expected = composite_off
for i, off in enumerate(old_offsets):
if off != expected:
print(f" WARNING: block {i} offset 0x{off:06X} != expected 0x{expected:06X}")
expected = off + old_block_size
# --- Patch exposure time in all LAYE entries ---
for i in range(n_entries):
entry_base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
struct.pack_into('<f', raw, entry_base + 0x04, exposure_sec)
print(f" Patched exposure to {exposure_sec}s in {n_entries} entries")
# --- Build new image data section ---
# All N+1 blocks (composite + N layers) get the SAME new RLE
# (single-layer exposure: every layer shows the same image)
n_blocks = n_entries + 1
new_image_section = new_rle * n_blocks
# --- Reconstruct file ---
# Everything before the first image block stays unchanged
prefix = bytes(raw[:composite_off])
# --- Update LAYE: block size field ---
# Update block size in LAYE header and Mode header
struct.pack_into('<I', raw, laye_off + 0x18, new_rle_size)
# --- Update Mode section: first_image_size field ---
# Mode sub-header: tag 'Mode' + u32(108) + ... + 'SUBIMGS' + ...
# first_image_size is at Mode_off + 0x48
mode_off = find_tag(raw, MODE_TAG)
struct.pack_into('<I', raw, mode_off + 0x48, new_rle_size)
print(f" Mode at 0x{mode_off:06X}, patched first_image_size")
log(f" Mode at 0x{mode_off:06X}")
# --- Update LAYE entries: image offsets and sizes ---
new_composite_off = composite_off # composite block stays at same position
# Update image offsets and sizes in all entries
for i in range(n_entries):
entry_base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
new_layer_off = composite_off + (i + 1) * new_rle_size
struct.pack_into('<I', raw, entry_base + 0x18, new_layer_off)
struct.pack_into('<I', raw, entry_base + 0x1C, new_rle_size)
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 ---
old_images_end = composite_off + n_blocks * old_block_size
raw[composite_off:old_images_end] = new_image_section
# 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)
print(f" Written: {output_path} ({len(raw):,} bytes)")
log(f" Written: {output_path} ({len(raw):,} bytes)")
# ---------------------------------------------------------------------------
@@ -298,14 +266,15 @@ 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 (from Photon Workshop)')
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', help='Invert image (positive-working resist)')
p.add_argument('--mirror', action='store_true', help='Mirror X (copper-side-down placement)')
p.add_argument('--exposure', type=float, default=60.0, help='Exposure seconds (default: 60)')
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, help='Board X,Y mm from top-left')
p.add_argument('--pos', default=None)
p.add_argument('--verbose', action='store_true')
return p.parse_args()
@@ -313,6 +282,7 @@ 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():
@@ -327,25 +297,23 @@ def main():
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()
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}")
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")
mirror=args.mirror, pos_mm=pos_mm, verbose=v)
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()
print("Patching .pm4n...")
patch_pm4n(dummy, img, args.exposure, out)
print("Done.")
patch_pm4n(dummy, img, args.exposure, out, verbose=v)
# Always print the output path (quiet mode only output)
print(out)
if __name__ == '__main__':