added UVtools patch

This commit is contained in:
cpu
2026-06-14 11:38:45 +02:00
parent f5eca9efa4
commit 9bcea25f78
14 changed files with 577 additions and 840 deletions

5
.gitignore vendored
View File

@@ -1,3 +1,2 @@
__pycache__/ gerbers/
output/ pm4n/
.venv/

111
Readme.md
View File

@@ -118,94 +118,81 @@ Use the project [kicad2gcode](https://gitea.virtonline.eu/2HoursProject/kicad2gc
## Appendix: Automated script ## Appendix: Automated script
Automate the conversion from KiCAD to print files using a script. Automate the conversion from KiCAD to print files using a CLI.
### 1. The `Dummy.pm4n` file ### 1. The `Dummy.pm4n` file
Make sure you have the `Dummy.pm4n` file specific to your printer. See the section [5. Create the `Dummy.pm4n` file](#5-create-the-dummypm4n-file) above. Make sure you have the `Dummy.pm4n` file specific to your printer. See the section [5. Create the `Dummy.pm4n` file](#5-create-the-dummypm4n-file) above.
### 2. Install dependencies ### 2. Install UVtools wrapper
Follow the UVtools wrapper [installation guide](UVtools/Readme.md)
### 3. Export KiCAD gerbers
```bash ```bash
python3 -m venv .venv kicad-cli pcb export gerbers \
source .venv/bin/activate -o gerbers \
pip install pygerber Pillow numpy -l F.Cu,B.Cu,F.Mask,B.Mask,F.SilkS,B.SilkS \
```
Or activate the `venv` once and put `source .venv/bin/activate` in your shell profile.
### 3. Export multiple layers (e.g. copper + soldermask + silkscreen)
```bash
./export.sh \
--layers F.Cu,B.Cu,F.Mask,B.Mask,F.SilkS,B.SilkS \
--invert F.Cu,B.Cu,F.Mask,B.Mask,F.SilkS,B.SilkS \
--mirror F.Cu,F.Mask,F.SilkS \
--exposure 60 \
../kicad2panel/panel/Flow_Controller_Panel.kicad_pcb ../kicad2panel/panel/Flow_Controller_Panel.kicad_pcb
``` ```
It should output: Output:
```bash ```bash
Plotted to './output/gerbers/Flow_Controller_Panel-Front.gbr'. Plotted to 'gerbers/Flow_Controller_Panel-Front.gtl'.
Plotted to './output/gerbers/Flow_Controller_Panel-Back.gbr'. Plotted to 'gerbers/Flow_Controller_Panel-Back.gbl'.
Plotted to './output/gerbers/Flow_Controller_Panel-F_Silkscreen.gbr'. Plotted to 'gerbers/Flow_Controller_Panel-F_Silkscreen.gto'.
Plotted to './output/gerbers/Flow_Controller_Panel-B_Silkscreen.gbr'. Plotted to 'gerbers/Flow_Controller_Panel-B_Silkscreen.gbo'.
Plotted to './output/gerbers/Flow_Controller_Panel-F_Mask.gbr'. Plotted to 'gerbers/Flow_Controller_Panel-F_Mask.gts'.
Plotted to './output/gerbers/Flow_Controller_Panel-B_Mask.gbr'. Plotted to 'gerbers/Flow_Controller_Panel-B_Mask.gbs'.
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
``` ```
#### 3.1. Check the layer preview ### 4. Exposure
Check the `output/pm4n/Flow_Controller_Panel-*.preview.png` images — traces should appear black on white background.
We need to **invert all** layers: `F.Cu,B.Cu,F.Mask,B.Mask,F.SilkS,B.SilkS`.
We need to **mirror all front** layers: `F.Cu,F.Mask,F.SilkS`.
Traces should appear black on white background.
- background = UV exposed = resist removed = etched away - background = UV exposed = resist removed = etched away
- traces = dark = resist kept = copper stays - traces = dark = resist kept = copper stays
#### 3.2. Check the printer exposure
Open the `.pm4n` in `Chitubox Basic` slicer to visually verify before printing.
#### 3.3. Adjust exposure
Start with `--exposure 60` and bracket from there — Bungard presensitized at 405nm typically lands between 30120s depending on board vintage and storage. Start with `--exposure 60` and bracket from there — Bungard presensitized at 405nm typically lands between 30120s depending on board vintage and storage.
### 4. Export single layer (e.g. copper) Export the front and back **copper** layers for 120 seconds exposure
```bash
pcb-expose.sh Dummy.pm4n gerbers/Flow_Controller_Panel-Front.gtl \
--invert --mirror --exposure 120 \
--output pm4n/Flow_Controller_Panel-Front.pm4n
Export the front layer as gerber pcb-expose.sh Dummy.pm4n gerbers/Flow_Controller_Panel-Back.gtl \
```bash --invert --exposure 120 \
kicad-cli pcb export gerbers -o output/gerbers -l F.Cu ../kicad2panel/panel/Flow_Controller_Panel.kicad_pcb --output pm4n/Flow_Controller_Panel-Back.pm4n
```
It should output
```bash
Plotted to 'output/gerbers/Flow_Controller_Panel-Front.gtl'.
``` ```
Convert the gerber to pm4n and preview Export the front and back **soldermask** layers for 300 seconds exposure
```bash ```bash
python3 gerber_to_pm4n.py Dummy.pm4n output/gerbers/Flow_Controller_Panel-Front.gtl \ pcb-expose.sh Dummy.pm4n gerbers/Flow_Controller_Panel-F_Mask.gtl \
--invert --mirror --exposure 120 --invert --mirror --exposure 300 \
--output pm4n/Flow_Controller_Panel-F_Mask.pm4n
pcb-expose.sh Dummy.pm4n gerbers/Flow_Controller_Panel-B_Mask.gtl \
--invert --exposure 300 \
--output pm4n/Flow_Controller_Panel-B_Mask.pm4n
``` ```
It should output
Export the front and back **silkscreen** layers for 300 seconds exposure
```bash ```bash
output/pm4n/Flow_Controller_Panel-Front.pm4n pcb-expose.sh Dummy.pm4n gerbers/Flow_Controller_Panel-F_Silkscreen.gtl \
output/pm4n/Flow_Controller_Panel-Front.preview.png --invert --mirror --exposure 300 \
--output pm4n/Flow_Controller_Panel-F_Silkscreen.pm4n
pcb-expose.sh Dummy.pm4n gerbers/Flow_Controller_Panel-B_Silkscreen.gtl \
--invert --exposure 300 \
--output pm4n/Flow_Controller_Panel-B_Silkscreen.pm4n
``` ```
### 5. Verify ### 5. Verify
Open the preview image Open the `.pm4n` files in **Chitubox Basic** slicer to visually verify before printing. See the section [4. Check the printer exposure](#4-check-the-printer-exposure).
```bash
xdg-open output/pm4n/Flow_Controller_Panel-Front.preview.png
```
Continue at the [4. Check the printer exposure](#4-check-the-printer-exposure) section.
--- ---

192
UVtools/Readme.md Normal file
View File

@@ -0,0 +1,192 @@
# UVtools PCB Exposure CLI — Build & Usage Guide
This guide covers building a wrapper for the`UVtoolsCmd`, for headless Gerber → `.pm4n` (or any slicer format) conversion on Ubuntu.
> **Note:** The UVtools maintainer is implementing an official equivalent via
> the generic `run` command and a new `FileArray` reflection property
> (`-p FileArray=file.gbr`). Once that ships in a release
> ([issue 1127](https://github.com/sn4k3/UVtools/issues/1127)), you can switch to
> `pcb-expose-official.sh` and skip the patch/compile steps entirely — see
> [Option B](#option-b-official-release-no-compiling-required) below.
---
## Option A: Patched local build (`pcb-expose` command)
### 1. Install .NET SDK
```bash
sudo snap install dotnet
export DOTNET_ROOT=/var/snap/dotnet/common/dotnet
dotnet --version
```
Add the `export` line to your `~/.bashrc` so it persists across shells:
```bash
echo 'export DOTNET_ROOT=/var/snap/dotnet/common/dotnet' >> ~/.bashrc
```
### 2. Clone the repo
```bash
cd ~/proj
git clone --depth 1 https://github.com/sn4k3/UVtools.git
cd UVtools
```
### 3. Apply the patch
Copy `pcb-expose.patch` into the repo root, then:
```bash
git apply ../kicad2msla/UVtools/pcb-expose.patch
```
This does two things:
- Adds `UVtools.Cmd/Symbols/PcbExposeCommand.cs` (new `pcb-expose` subcommand)
- Registers it in `UVtools.Cmd/Program.cs`
Verify it applied cleanly:
```bash
git add .
git status
# modified: UVtools.Cmd/Program.cs
# new file: UVtools.Cmd/Symbols/PcbExposeCommand.cs
```
### 4. Restore & build
```bash
dotnet restore
dotnet publish UVtools.Cmd/UVtools.Cmd.csproj \
-c Release \
-r linux-x64 \
--self-contained false \
-o ./publish/
```
### 5. Copy the OpenCV native library
The published output doesn't include `libcvextern.so` — copy it from the repo:
```bash
cp build/platforms/linux-x64/libcvextern.so ./publish/
```
### 6. Test it
```bash
./publish/UVtoolsCmd pcb-expose \
~/proj/kicad2msla/Dummy.pm4n \
~/proj/kicad2msla/gerbers/Flow_Controller_Panel-Front.gtl \
--invert \
--mirror \
--exposure 120 \
--output ~/proj/kicad2msla/Flow_Controller_Panel-Front.pm4n
```
### 7. Install the wrapper
Copy `pcb-expose-patched.sh` somewhere on your `$PATH`, e.g.:
```bash
# Copy the script
cp ~/proj/kicad2msla/UVtools/pcb-expose-patched.sh ~/.local/bin
# Make it executable
chmod +x ~/.local/bin/pcb-expose-patched.sh
# Create a symlink
ln -s ~/.local/bin/pcb-expose-patched.sh ~/.local/bin/pcb-expose.sh
```
By default the wrapper looks for the build at `~/proj/UVtools/publish/UVtoolsCmd`.
If yours is elsewhere, either edit the `UVTOOLSCMD` variable at the top of the
script, or set it as an environment variable each time:
```bash
export UVTOOLSCMD=~/proj/UVtools/publish/UVtoolsCmd
```
### 8. Usage
```bash
pcb-expose.sh ~/proj/kicad2msla/Dummy.pm4n \
~/proj/kicad2msla/gerbers/Flow_Controller_Panel-Front.gtl \
--invert --mirror --exposure 120 \
--output ~/proj/kicad2msla/Flow_Controller_Panel-Front.pm4n
```
Multiple files / per-file polarity inversion / merging into one layer:
```bash
pcb-expose.sh Dummy.pm4n \
Board-F.Cu.gtl Board-B.Cu.gtl \
--merge --invert-polarity --mirror --exposure 120 \
--output Board-combined.pm4n
```
---
## Option B: Official release (no compiling required)
Once the maintainer's `FileArray` property ships in an official UVtools
release:
### 1. Download & install the release
Grab the Linux release from the
[UVtools releases page](https://github.com/sn4k3/UVtools/releases) and
extract it, e.g. to `~/UVtools/`. `libcvextern.so` is bundled in official
releases, so no extra steps are needed there.
### 2. Install the wrapper
```bash
# Copy the script
cp ~/proj/kicad2msla/UVtools/pcb-expose-official.sh ~/.local/bin
# Make it executable
chmod +x ~/.local/bin/pcb-expose-official.sh
# Create a symlink
ln -s ~/.local/bin/pcb-expose-official.sh ~/.local/bin/pcb-expose.sh
```
By default it looks for `~/UVtools/UVtoolsCmd`. Override with:
```bash
export UVTOOLSCMD=~/UVtools/UVtoolsCmd
```
### 3. Usage
Same interface as Option A (minus `--invert-polarity`, which the official
`FileArray` property doesn't support per-file):
```bash
pcb-expose.sh ~/proj/kicad2msla/Dummy.pm4n \
~/proj/kicad2msla/gerbers/Flow_Controller_Panel-Front.gtl \
--invert --mirror --exposure 120 \
--output ~/proj/kicad2msla/Flow_Controller_Panel-Front.pm4n
```
Internally this runs:
```bash
UVtoolsCmd run Dummy.pm4n PCBExposure \
-p FileArray=Board-F.Cu.gtl \
-p InvertColor=true \
-p Mirror=true \
-p ExposureTime=120 \
--output Board-F.Cu.pm4n
```
---
## Files in this bundle
| File | Purpose |
|---|---|
| `pcb-expose.patch` | `git apply`-able patch adding the `pcb-expose` command (Option A) |
| `pcb-expose-patched.sh` | Wrapper for the patched local build |
| `pcb-expose-official.sh` | Wrapper for the official release using `run` + `FileArray` |

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
#
# Wrapper for the OFFICIAL UVtoolsCmd release, using the generic `run` command
# with the new FileArray-capable PCBExposure operation.
#
# Mirrors the interface of pcb-expose-patched.sh, but maps everything onto:
#
# UVtoolsCmd run <dummy-file> PCBExposure \
# -p FileArray=<gerber1> -p FileArray=<gerber2> ... \
# -p InvertColor=true -p Mirror=true -p ExposureTime=120 ... \
# --output <output-file>
#
# Usage:
# pcb-expose-official.sh <dummy-file> <gerber-files...> [options]
#
# Example:
# pcb-expose-official.sh Dummy.pm4n \
# gerbers/Flow_Controller_Panel-Front.gtl \
# --invert --mirror --exposure 120 \
# --output Flow_Controller_Panel-Front.pm4n
#
# Options:
# -e, --exposure <seconds> Sets ExposureTime
# -i, --invert Sets InvertColor=true
# -m, --mirror Sets Mirror=true
# --merge Sets MergeFiles=true
# --aa Sets EnableAntiAliasing=true
# --offset-x <mm> Sets OffsetX
# --offset-y <mm> Sets OffsetY
# -o, --output <file> Output file (default: overwrite dummy file)
#
# NOTE: --invert-polarity (per-file InvertPolarity) is NOT supported by this
# wrapper, because the official FileArray property only accepts plain
# file paths, not the InvertPolarity flag per file. If you need that,
# use pcb-expose-patched.sh instead.
#
# Configure the path to your official build below, or override via:
# UVTOOLSCMD=/path/to/UVtoolsCmd pcb-expose-official.sh ...
set -euo pipefail
UVTOOLSCMD="${UVTOOLSCMD:-$HOME/UVtools/UVtoolsCmd}"
if [ ! -x "$UVTOOLSCMD" ]; then
echo "Error: UVtoolsCmd not found or not executable at: $UVTOOLSCMD" >&2
echo "Set UVTOOLSCMD=/path/to/UVtoolsCmd or edit this script." >&2
exit 1
fi
if [ $# -lt 2 ]; then
echo "Usage: $0 <dummy-file> <gerber-files...> [options]" >&2
exit 1
fi
DUMMY_FILE="$1"
shift
GERBERS=()
PROPS=() # FileArray entries + scalar properties, built below
OUTPUT_FILE=""
EXPOSURE=""
INVERT="false"
MIRROR="false"
MERGE="false"
AA="false"
OFFSET_X=""
OFFSET_Y=""
INVERT_POLARITY="false"
while [ $# -gt 0 ]; do
case "$1" in
-e|--exposure)
EXPOSURE="$2"; shift 2 ;;
-i|--invert)
INVERT="true"; shift ;;
-m|--mirror)
MIRROR="true"; shift ;;
--merge)
MERGE="true"; shift ;;
--aa)
AA="true"; shift ;;
--offset-x)
OFFSET_X="$2"; shift 2 ;;
--offset-y)
OFFSET_Y="$2"; shift 2 ;;
--invert-polarity)
INVERT_POLARITY="true"; shift ;;
-o|--output)
OUTPUT_FILE="$2"; shift 2 ;;
-*)
echo "Unknown option: $1" >&2
exit 1 ;;
*)
GERBERS+=("$1"); shift ;;
esac
done
if [ "$INVERT_POLARITY" = "true" ]; then
echo "Warning: --invert-polarity is not supported via the official FileArray" >&2
echo " property and will be ignored. Use pcb-expose-patched.sh instead." >&2
fi
if [ ${#GERBERS[@]} -eq 0 ]; then
echo "Error: specify at least one gerber file." >&2
exit 1
fi
for g in "${GERBERS[@]}"; do
if [ ! -f "$g" ]; then
echo "Error: gerber file not found: $g" >&2
exit 1
fi
done
# Build -p FileArray=... entries
for g in "${GERBERS[@]}"; do
PROPS+=(-p "FileArray=$g")
done
[ -n "$EXPOSURE" ] && PROPS+=(-p "ExposureTime=$EXPOSURE")
[ "$INVERT" = "true" ] && PROPS+=(-p "InvertColor=true")
[ "$MIRROR" = "true" ] && PROPS+=(-p "Mirror=true")
[ "$MERGE" = "true" ] && PROPS+=(-p "MergeFiles=true")
[ "$AA" = "true" ] && PROPS+=(-p "EnableAntiAliasing=true")
[ -n "$OFFSET_X" ] && PROPS+=(-p "OffsetX=$OFFSET_X")
[ -n "$OFFSET_Y" ] && PROPS+=(-p "OffsetY=$OFFSET_Y")
CMD=("$UVTOOLSCMD" run "$DUMMY_FILE" PCBExposure "${PROPS[@]}")
[ -n "$OUTPUT_FILE" ] && CMD+=(--output "$OUTPUT_FILE")
echo "+ ${CMD[*]}" >&2
exec "${CMD[@]}"

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# Wrapper for a locally patched UVtoolsCmd build that provides the
# native `pcb-expose` command (see pcb-expose.patch).
#
# Usage:
# pcb-expose-patched.sh <dummy-file> <gerber-files...> [options]
#
# Example:
# pcb-expose-patched.sh Dummy.pm4n \
# Board-F.Cu.gtl \
# --invert --mirror --exposure 120 \
# --output Board-F.Cu.pm4n
#
# Configure the path to your patched build below, or override via:
# UVTOOLSCMD=/path/to/UVtoolsCmd pcb-expose-patched.sh ...
set -euo pipefail
UVTOOLSCMD="${UVTOOLSCMD:-$HOME/proj/UVtools/publish/UVtoolsCmd}"
if [ ! -x "$UVTOOLSCMD" ]; then
echo "Error: UVtoolsCmd not found or not executable at: $UVTOOLSCMD" >&2
echo "Set UVTOOLSCMD=/path/to/UVtoolsCmd or edit this script." >&2
exit 1
fi
exec "$UVTOOLSCMD" pcb-expose "$@"

173
UVtools/pcb-expose.patch Normal file
View File

@@ -0,0 +1,173 @@
diff --git a/UVtools.Cmd/Program.cs b/UVtools.Cmd/Program.cs
index 6d2d1ff..38da048 100644
--- a/UVtools.Cmd/Program.cs
+++ b/UVtools.Cmd/Program.cs
@@ -45,6 +45,7 @@ public static async Task<int> Main(params string[] args)
ExtractCommand.CreateCommand(),
CopyParametersCommand.CreateCommand(),
SetThumbnailCommand.CreateCommand(),
+ PcbExposeCommand.CreateCommand(),
CompareCommand.CreateCommand(),
diff --git a/UVtools.Cmd/Symbols/PcbExposeCommand.cs b/UVtools.Cmd/Symbols/PcbExposeCommand.cs
new file mode 100644
index 0000000..e67e876
--- /dev/null
+++ b/UVtools.Cmd/Symbols/PcbExposeCommand.cs
@@ -0,0 +1,155 @@
+/*
+ * GNU AFFERO GENERAL PUBLIC LICENSE
+ * Version 3, 19 November 2007
+ * Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ * Everyone is permitted to copy and distribute verbatim copies
+ * of this license document, but changing it is not allowed.
+ */
+
+using System;
+using System.CommandLine;
+using System.IO;
+using UVtools.Core.FileFormats;
+using UVtools.Core.Operations;
+
+namespace UVtools.Cmd.Symbols;
+
+internal static class PcbExposeCommand
+{
+ internal static Command CreateCommand()
+ {
+ var gerberArgument = new Argument<FileInfo[]>("gerber-files")
+ {
+ Description = "One or more Gerber/drill files (.gbr .gtl .gbl .gts .gbs .drl etc.)",
+ };
+
+ var exposureOption = new Option<decimal>("-e", "--exposure")
+ {
+ Description = "Exposure time in seconds (0 = keep value from input file)",
+ DefaultValueFactory = _ => 0m,
+ };
+
+ var invertOption = new Option<bool>("-i", "--invert")
+ {
+ Description = "Invert image color",
+ };
+
+ var mirrorOption = new Option<bool>("-m", "--mirror")
+ {
+ Description = "Mirror the image horizontally",
+ };
+
+ var mergeOption = new Option<bool>("--merge")
+ {
+ Description = "Merge all gerber files into a single layer",
+ };
+
+ var invertPolarityOption = new Option<bool>("--invert-polarity")
+ {
+ Description = "Invert Gerber drawing polarity for all supplied files",
+ };
+
+ var antiAliasingOption = new Option<bool>("--aa")
+ {
+ Description = "Enable anti-aliasing",
+ };
+
+ var offsetXOption = new Option<decimal>("--offset-x")
+ {
+ Description = "X offset in mm",
+ DefaultValueFactory = _ => 0m,
+ };
+
+ var offsetYOption = new Option<decimal>("--offset-y")
+ {
+ Description = "Y offset in mm",
+ DefaultValueFactory = _ => 0m,
+ };
+
+ var command = new Command("pcb-expose", "Inject a Gerber file into a slicer file for PCB UV exposure")
+ {
+ GlobalArguments.InputFileArgument,
+ gerberArgument,
+
+ exposureOption,
+ invertOption,
+ mirrorOption,
+ mergeOption,
+ invertPolarityOption,
+ antiAliasingOption,
+ offsetXOption,
+ offsetYOption,
+ GlobalOptions.OutputFile,
+ };
+
+ command.SetAction(result =>
+ {
+ var inputFile = result.GetRequiredValue(GlobalArguments.InputFileArgument);
+ var gerbers = result.GetRequiredValue(gerberArgument);
+ var exposure = result.GetValue(exposureOption);
+ var invert = result.GetValue(invertOption);
+ var mirror = result.GetValue(mirrorOption);
+ var merge = result.GetValue(mergeOption);
+ var invertPolarity = result.GetValue(invertPolarityOption);
+ var aa = result.GetValue(antiAliasingOption);
+ var offsetX = result.GetValue(offsetXOption);
+ var offsetY = result.GetValue(offsetYOption);
+ var outputFile = result.GetValue(GlobalOptions.OutputFile);
+
+ if (gerbers.Length == 0)
+ {
+ Program.WriteLineError("Specify at least one gerber file.");
+ return;
+ }
+
+ foreach (var g in gerbers)
+ {
+ if (!g.Exists)
+ {
+ Program.WriteLineError($"Gerber file not found: {g.FullName}");
+ return;
+ }
+ }
+
+ var slicerFile = Program.OpenInputFile(inputFile);
+
+ var op = new OperationPCBExposure(slicerFile)
+ {
+ MergeFiles = merge,
+ Mirror = mirror,
+ InvertColor = invert,
+ EnableAntiAliasing = aa,
+ OffsetX = offsetX,
+ OffsetY = offsetY,
+ };
+
+ if (exposure > 0)
+ op.ExposureTime = exposure;
+
+ foreach (var g in gerbers)
+ op.Files.Add(new OperationPCBExposure.PCBExposureFile(g.FullName, invertPolarity));
+
+ var spawnError = op.ValidateSpawn();
+ if (!string.IsNullOrWhiteSpace(spawnError))
+ {
+ Program.WriteLineError(spawnError);
+ return;
+ }
+
+ var validationError = op.ValidateInternally();
+ if (!string.IsNullOrWhiteSpace(validationError))
+ {
+ Program.WriteLineError(validationError.TrimEnd());
+ return;
+ }
+
+ Program.ProgressBarWork(
+ $"PCB exposure: {string.Join(", ", Array.ConvertAll(gerbers, g => g.Name))}",
+ () => op.Execute(Program.Progress));
+
+ Program.SaveFile(slicerFile, outputFile);
+ });
+
+ return command;
+ }
+}

Binary file not shown.

Binary file not shown.

View File

@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""
Decode the PREV (preview thumbnail) section from a pm4n file and save as PNG.
Run: python3 decode_prev.py <file.pm4n>
"""
import struct, sys
from pathlib import Path
path = sys.argv[1]
data = open(path, 'rb').read()
prev_off = data.find(b'PREV')
print(f"PREV at 0x{prev_off:06X}")
# PREV section layout (from ANYCUBIC format docs and common reverse-engineering):
# +0x00 "PREV"
# +0x04 u32 section_length
# +0x08 u32 image_width
# +0x0C u32 image_height
# +0x10 u32 image_data_length
# +0x14 image data (RGB565 or RLE)
for hdr_size in [0x14, 0x18, 0x10, 0x0C]:
w = struct.unpack_from('<I', data, prev_off + 0x08)[0]
h = struct.unpack_from('<I', data, prev_off + 0x0C)[0]
dlen = struct.unpack_from('<I', data, prev_off + 0x10)[0]
print(f" PREV+0x08: w={w}, h={h}, dlen={dlen}")
if 100 < w < 2000 and 100 < h < 2000:
print(f" -> plausible dimensions {w}x{h}, data at +0x14, {dlen} bytes")
break
# Show first 64 bytes of PREV section
print(f"\nPREV raw (first 64 bytes):")
chunk = data[prev_off:prev_off+64]
for i in range(0, 64, 16):
row = chunk[i:i+16]
hex_p = ' '.join(f'{b:02X}' for b in row)
u_vals = [struct.unpack_from('<I', row, j)[0] for j in range(0, min(16,len(row))-3, 4)]
print(f" +0x{i:02X} {hex_p:<48} {u_vals}")
# Try to decode as RGB565 (common for ANYCUBIC previews)
# Each pixel = 2 bytes, little-endian RGB565
# R = bits[15:11], G = bits[10:5], B = bits[4:0]
img_data_off = prev_off + 0x14
img_data_len = struct.unpack_from('<I', data, prev_off + 0x10)[0]
w = struct.unpack_from('<I', data, prev_off + 0x08)[0]
h = struct.unpack_from('<I', data, prev_off + 0x0C)[0]
print(f"\nAttempting RGB565 decode: {w}x{h}, {img_data_len} bytes at 0x{img_data_off:06X}")
if 0 < w < 2000 and 0 < h < 2000 and img_data_len == w * h * 2:
print(f" Size matches RGB565 ({w}*{h}*2={w*h*2}) ✓")
try:
from PIL import Image
pixels = []
raw = data[img_data_off:img_data_off + img_data_len]
for i in range(0, len(raw)-1, 2):
px = raw[i] | (raw[i+1] << 8)
r = ((px >> 11) & 0x1F) << 3
g = ((px >> 5) & 0x3F) << 2
b = (px & 0x1F) << 3
pixels.append((r, g, b))
img = Image.new('RGB', (w, h))
img.putdata(pixels)
out = Path(path).with_suffix('.prev_thumb.png')
img.save(out)
print(f" Saved thumbnail to {out}")
except Exception as e:
print(f" Error: {e}")
else:
print(f" Size mismatch or bad dims, skipping decode")
print(f" Expected {w*h*2} bytes for RGB565, got {img_data_len}")

View File

@@ -1,76 +0,0 @@
#!/usr/bin/env python3
"""
Decode the composite block and first layer block from a pm4n file,
report pixel counts and first/last pixel values.
Run: python3 decode_rle_blocks.py output/pm4n/Flow_Controller_Panel-Front.pm4n
"""
import struct, sys
path = sys.argv[1]
data = open(path, 'rb').read()
fsize = len(data)
print(f"File: {path} ({fsize} bytes)\n")
LAYE_HDR = 0x20
STRIDE = 0x20
laye = data.find(b'LAYE')
n_entries = struct.unpack_from('<I', data, laye + 0x10)[0]
composite_off = struct.unpack_from('<I', data, laye + 0x14)[0]
block_size = struct.unpack_from('<I', data, laye + 0x18)[0]
print(f"LAYE n_entries={n_entries} composite_off=0x{composite_off:06X} block_size={block_size}")
def decode_rle(data, offset, max_bytes):
"""Decode RLE starting at offset, stop after max_bytes read. Returns pixel list."""
pixels = []
i = offset
end = min(offset + max_bytes, len(data))
while i + 1 < end:
b0 = data[i]; b1 = data[i+1]
nibble = (b0 >> 4) & 0xF
colour = 255 if nibble == 0xF else 0
run = ((b0 & 0x0F) << 8) | b1
run += 1
pixels.extend([colour] * run)
i += 2
return pixels, i - offset
def summarize_block(name, offset, size):
print(f"\n{name} at 0x{offset:06X}, {size} bytes:")
if offset + size > fsize:
print(f" *** PAST EOF (file ends at 0x{fsize:06X}) ***")
size = max(0, fsize - offset)
# Show first 16 bytes raw
raw = data[offset:offset+16]
print(f" First 16 bytes: {raw.hex()}")
# Decode RLE
pixels, bytes_consumed = decode_rle(data, offset, size)
print(f" Decoded: {len(pixels)} pixels from {bytes_consumed} bytes")
print(f" Expected: {9024*5120} pixels ({9024}×{5120})")
if pixels:
whites = sum(1 for p in pixels if p >= 128)
blacks = len(pixels) - whites
print(f" White px: {whites} ({100*whites/len(pixels):.1f}%)")
print(f" Black px: {blacks} ({100*blacks/len(pixels):.1f}%)")
# Check if it decodes to correct pixel count
if len(pixels) == 9024 * 5120:
print(f" ✓ Correct pixel count")
else:
print(f" *** WRONG pixel count (off by {len(pixels) - 9024*5120})")
# Show first few runs
print(f" First pixels: {pixels[:20]}")
# Composite block
summarize_block("Composite block (block 0)", composite_off, block_size)
# First entry block
e0_base = laye + LAYE_HDR
e0_data_off = struct.unpack_from('<I', data, e0_base + 0x14)[0]
e0_data_sz = struct.unpack_from('<I', data, e0_base + 0x18)[0]
summarize_block("Entry[0] block", e0_data_off, e0_data_sz)

View File

@@ -1,107 +0,0 @@
#!/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})")
# ---------------------------------------------------------------------------
# Extra: dump LAYE entries with CORRECT field layout
# (verified from binary probe: HDR=0x20, exposure@+0x00, data_off@+0x14, data_size@+0x18)
# ---------------------------------------------------------------------------
def find_tag2(d, tag):
i = d.find(tag)
return i if i >= 0 else None
LAYE_HDR_SIZE2 = 0x20
ENTRY_STRIDE2 = 0x20
laye2 = find_tag2(data, b'LAYE')
if laye2 is not None:
n_entries2 = struct.unpack_from('<I', data, laye2 + 0x10)[0]
composite_off2 = struct.unpack_from('<I', data, laye2 + 0x14)[0]
block_size2 = struct.unpack_from('<I', data, laye2 + 0x18)[0]
print(f"\nLAYE (correct layout) at 0x{laye2:06X}:")
print(f" n_entries = {n_entries2}")
print(f" composite_off= 0x{composite_off2:06X}")
print(f" block_size = {block_size2}")
expected_off = composite_off2
ok = True
for i in range(n_entries2):
base = laye2 + LAYE_HDR_SIZE2 + i * ENTRY_STRIDE2
if base + ENTRY_STRIDE2 > fsize:
break
exp = struct.unpack_from('<f', data, base + 0x00)[0]
z_pos = struct.unpack_from('<f', data, base + 0x04)[0]
d_off = struct.unpack_from('<I', data, base + 0x14)[0]
d_sz = struct.unpack_from('<I', data, base + 0x18)[0]
expected_off += block_size2
match = "OK" if d_off == expected_off and d_sz == block_size2 else "*** MISMATCH ***"
if match != "OK":
ok = False
print(f" entry[{i:2d}]: exp={exp:.1f}s z={z_pos:.2f}mm "
f"data_off=0x{d_off:06X} (exp 0x{expected_off:06X}) "
f"size={d_sz} {match}")
if ok:
print(" All entries consistent ✓")
# Also confirm global header px dimensions
gw = struct.unpack_from('<I', data, 0x7C)[0]
gh = struct.unpack_from('<I', data, 0x80)[0]
print(f"\nGlobal header px dimensions: {gw}×{gh} "
f"({'✓ correct for Mono 4' if gw==9024 and gh==5120 else '*** WRONG ***'})")

View File

@@ -1,66 +0,0 @@
#!/usr/bin/env python3
"""
probe_laye.py — find where 9024 and 5120 are actually stored in a pm4n file,
and dump LAYE + Mode sections in detail.
Run: python3 probe_laye.py Dummy.pm4n
"""
import struct, sys
path = sys.argv[1]
data = open(path, 'rb').read()
fsize = len(data)
print(f"File: {path} ({fsize} bytes)\n")
# Search for 9024 and 5120 as u32 LE, u16 LE, and as floats
targets = {
'u32 9024': struct.pack('<I', 9024),
'u32 5120': struct.pack('<I', 5120),
'u16 9024': struct.pack('<H', 9024),
'u16 5120': struct.pack('<H', 5120),
'f32 9024': struct.pack('<f', 9024.0),
'f32 5120': struct.pack('<f', 5120.0),
'f32 153.408': struct.pack('<f', 153.408),
'f32 87.040': struct.pack('<f', 87.040),
}
for label, needle in targets.items():
pos = 0
while True:
idx = data.find(needle, pos)
if idx < 0:
break
print(f" {label} found at 0x{idx:06X} ({idx})")
pos = idx + 1
print()
# Dump LAYE section
laye_off = data.find(b'LAYE')
if laye_off >= 0:
print(f"LAYE raw (first 160 bytes from 0x{laye_off:06X}):")
for i in range(0, 160, 16):
row = data[laye_off+i : laye_off+i+16]
if not row: break
hex_p = ' '.join(f'{b:02X}' for b in row)
interp = []
for j in range(0, len(row)-3, 4):
u = struct.unpack_from('<I', row, j)[0]
f = struct.unpack_from('<f', row, j)[0]
interp.append(f"0x{u:08X}/f={f:.5g}")
print(f" +0x{i:02X} {hex_p:<48} {' | '.join(interp)}")
print()
# Dump Mode section
mode_off = data.find(b'Mode')
if mode_off >= 0:
print(f"Mode raw (first 96 bytes from 0x{mode_off:06X}):")
for i in range(0, 96, 16):
row = data[mode_off+i : mode_off+i+16]
if not row: break
hex_p = ' '.join(f'{b:02X}' for b in row)
interp = []
for j in range(0, len(row)-3, 4):
u = struct.unpack_from('<I', row, j)[0]
f = struct.unpack_from('<f', row, j)[0]
interp.append(f"0x{u:08X}/f={f:.5g}")
print(f" +0x{i:02X} {hex_p:<48} {' | '.join(interp)}")
print()

154
export.sh
View File

@@ -1,154 +0,0 @@
#!/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)
# --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.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 ==="

View File

@@ -1,301 +0,0 @@
#!/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 = 0x20 # bytes before first entry within LAYE section
#
# LAYE header layout (verified from binary probe):
# +0x00 tag "LAYE"
# +0x04 tag "RDEF"
# +0x08 u32 0
# +0x0C u32 0xC4
# +0x10 u32 n_entries
# +0x14 u32 composite_image_offset
# +0x18 u32 block_size (RLE bytes per block)
# +0x1C f32 lift_height_mm
#
# LAYE entry layout (0x20 bytes each, immediately after header):
# +0x00 f32 exposure_sec <-- was wrongly assumed at +0x04
# +0x04 f32 z_position_mm
# +0x08 f32 layer_thickness_mm
# +0x0C u32 unknown
# +0x10 u32 unknown
# +0x14 u32 image_data_offset <-- was wrongly assumed at +0x18
# +0x18 u32 image_data_size <-- was wrongly assumed at +0x1C
# +0x1C f32 lift_speed
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:
"""Read n_entries directly from LAYE header at +0x10."""
return unpack_u32(data, laye_off + 0x10)
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 (entry+0x00 = exposure_sec float)
for i in range(n_entries):
base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
struct.pack_into('<f', raw, base + 0x00, 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.
# Layout: [composite block][layer-0 block][layer-1 block]...
# entry+0x14 = data offset, entry+0x18 = data size
for i in range(n_entries):
base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
struct.pack_into('<I', raw, base + 0x14, composite_off + (i + 1) * new_rle_size)
struct.pack_into('<I', raw, base + 0x18, new_rle_size)
# Splice new image data (composite block + one block per layer, all identical)
# Layout: composite block at composite_off, then one block per entry.
# The file contains exactly n_entries blocks total (composite counts as block 0;
# the last entry's data_off is therefore one block past the last stored block,
# which Chitubox tolerates). We mirror the same layout.
n_blocks = n_entries # composite + (n_entries-1) layer blocks = n_entries total
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}")
if args.output:
out = Path(args.output)
else:
# If in 'output/gerbers/', default to 'output/pm4n/'
parent = gbr.parent
if parent.name == 'gerbers' and parent.parent.name == 'output':
out = parent.parent / 'pm4n' / gbr.with_suffix('.pm4n').name
else:
out = 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()