From 9bcea25f78253a0f31295fc0da52f45bd33743ae Mon Sep 17 00:00:00 2001 From: cpu Date: Sun, 14 Jun 2026 11:38:45 +0200 Subject: [PATCH] added UVtools patch --- .gitignore | 5 +- Readme.md | 111 ++++++------ UVtools/Readme.md | 192 +++++++++++++++++++++ UVtools/pcb-expose-official.sh | 133 +++++++++++++++ UVtools/pcb-expose-patched.sh | 28 +++ UVtools/pcb-expose.patch | 173 +++++++++++++++++++ diagnose/Dummy.FCStd | Bin 14637 -> 0 bytes diagnose/Dummy.stl | Bin 684 -> 0 bytes diagnose/decode_prev.py | 71 -------- diagnose/decode_rle_blocks.py | 76 --------- diagnose/diagnose_pm4n.py | 107 ------------ diagnose/probe_laye.py | 66 -------- export.sh | 154 ----------------- gerber_to_pm4n.py | 301 --------------------------------- 14 files changed, 577 insertions(+), 840 deletions(-) create mode 100644 UVtools/Readme.md create mode 100644 UVtools/pcb-expose-official.sh create mode 100644 UVtools/pcb-expose-patched.sh create mode 100644 UVtools/pcb-expose.patch delete mode 100644 diagnose/Dummy.FCStd delete mode 100644 diagnose/Dummy.stl delete mode 100644 diagnose/decode_prev.py delete mode 100644 diagnose/decode_rle_blocks.py delete mode 100644 diagnose/diagnose_pm4n.py delete mode 100644 diagnose/probe_laye.py delete mode 100755 export.sh delete mode 100755 gerber_to_pm4n.py diff --git a/.gitignore b/.gitignore index f56ada8..bf5e92a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -__pycache__/ -output/ -.venv/ +gerbers/ +pm4n/ diff --git a/Readme.md b/Readme.md index 55e2ef3..b8894f4 100644 --- a/Readme.md +++ b/Readme.md @@ -118,94 +118,81 @@ Use the project [kicad2gcode](https://gitea.virtonline.eu/2HoursProject/kicad2gc ## 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 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 -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. - -### 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 \ +kicad-cli pcb export gerbers \ + -o gerbers \ + -l F.Cu,B.Cu,F.Mask,B.Mask,F.SilkS,B.SilkS \ ../kicad2panel/panel/Flow_Controller_Panel.kicad_pcb ``` -It should output: +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 +Plotted to 'gerbers/Flow_Controller_Panel-Front.gtl'. +Plotted to 'gerbers/Flow_Controller_Panel-Back.gbl'. +Plotted to 'gerbers/Flow_Controller_Panel-F_Silkscreen.gto'. +Plotted to 'gerbers/Flow_Controller_Panel-B_Silkscreen.gbo'. +Plotted to 'gerbers/Flow_Controller_Panel-F_Mask.gts'. +Plotted to 'gerbers/Flow_Controller_Panel-B_Mask.gbs'. ``` -#### 3.1. Check the layer preview -Check the `output/pm4n/Flow_Controller_Panel-*.preview.png` images — traces should appear black on white background. +### 4. Exposure + +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 - 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 30–120s 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 -```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'. +pcb-expose.sh Dummy.pm4n gerbers/Flow_Controller_Panel-Back.gtl \ + --invert --exposure 120 \ + --output pm4n/Flow_Controller_Panel-Back.pm4n ``` -Convert the gerber to pm4n and preview +Export the front and back **soldermask** layers for 300 seconds exposure ```bash -python3 gerber_to_pm4n.py Dummy.pm4n output/gerbers/Flow_Controller_Panel-Front.gtl \ - --invert --mirror --exposure 120 +pcb-expose.sh Dummy.pm4n gerbers/Flow_Controller_Panel-F_Mask.gtl \ + --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 -output/pm4n/Flow_Controller_Panel-Front.pm4n -output/pm4n/Flow_Controller_Panel-Front.preview.png +pcb-expose.sh Dummy.pm4n gerbers/Flow_Controller_Panel-F_Silkscreen.gtl \ + --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 -Open the preview image -```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. +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). --- diff --git a/UVtools/Readme.md b/UVtools/Readme.md new file mode 100644 index 0000000..3fe8faa --- /dev/null +++ b/UVtools/Readme.md @@ -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` | diff --git a/UVtools/pcb-expose-official.sh b/UVtools/pcb-expose-official.sh new file mode 100644 index 0000000..49066d4 --- /dev/null +++ b/UVtools/pcb-expose-official.sh @@ -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 PCBExposure \ +# -p FileArray= -p FileArray= ... \ +# -p InvertColor=true -p Mirror=true -p ExposureTime=120 ... \ +# --output +# +# Usage: +# pcb-expose-official.sh [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 Sets ExposureTime +# -i, --invert Sets InvertColor=true +# -m, --mirror Sets Mirror=true +# --merge Sets MergeFiles=true +# --aa Sets EnableAntiAliasing=true +# --offset-x Sets OffsetX +# --offset-y Sets OffsetY +# -o, --output 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 [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[@]}" diff --git a/UVtools/pcb-expose-patched.sh b/UVtools/pcb-expose-patched.sh new file mode 100644 index 0000000..bf37ecc --- /dev/null +++ b/UVtools/pcb-expose-patched.sh @@ -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 [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 "$@" diff --git a/UVtools/pcb-expose.patch b/UVtools/pcb-expose.patch new file mode 100644 index 0000000..278092a --- /dev/null +++ b/UVtools/pcb-expose.patch @@ -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 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. ++ * 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("gerber-files") ++ { ++ Description = "One or more Gerber/drill files (.gbr .gtl .gbl .gts .gbs .drl etc.)", ++ }; ++ ++ var exposureOption = new Option("-e", "--exposure") ++ { ++ Description = "Exposure time in seconds (0 = keep value from input file)", ++ DefaultValueFactory = _ => 0m, ++ }; ++ ++ var invertOption = new Option("-i", "--invert") ++ { ++ Description = "Invert image color", ++ }; ++ ++ var mirrorOption = new Option("-m", "--mirror") ++ { ++ Description = "Mirror the image horizontally", ++ }; ++ ++ var mergeOption = new Option("--merge") ++ { ++ Description = "Merge all gerber files into a single layer", ++ }; ++ ++ var invertPolarityOption = new Option("--invert-polarity") ++ { ++ Description = "Invert Gerber drawing polarity for all supplied files", ++ }; ++ ++ var antiAliasingOption = new Option("--aa") ++ { ++ Description = "Enable anti-aliasing", ++ }; ++ ++ var offsetXOption = new Option("--offset-x") ++ { ++ Description = "X offset in mm", ++ DefaultValueFactory = _ => 0m, ++ }; ++ ++ var offsetYOption = new Option("--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; ++ } ++} diff --git a/diagnose/Dummy.FCStd b/diagnose/Dummy.FCStd deleted file mode 100644 index 5143794796af017303e1e19bf6e384fbcdf3ca77..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14637 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 diff --git a/diagnose/Dummy.stl b/diagnose/Dummy.stl deleted file mode 100644 index 67b40a1a0481a9aead42cd6f6bbc2b4ce15d7f4b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 684 zcmb`EK@P$o6hvJcuhJtJRwgd&su%h<;{tjoeZLGDs0$bV1Q?ik{F1J>?Y^vko6eUL z6(yH_Lf5uO%ggiE^Kr#XB2b@lXC{gWf}QeIZ8s3e^8+zEPNWtQ>{N3~-jRrgxPx}0 zst4KL$5Z8ZqTS_0YVXne&;yrZmFAo=RDPK|q!|<|-cX^0{uBw -""" -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(' 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('> 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}") diff --git a/diagnose/decode_rle_blocks.py b/diagnose/decode_rle_blocks.py deleted file mode 100644 index 70d85e0..0000000 --- a/diagnose/decode_rle_blocks.py +++ /dev/null @@ -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('> 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(' 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(' fsize: - break - off = struct.unpack_from('= 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(' fsize: - break - exp = struct.unpack_from('= 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('= 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(' -# -# 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 ===" \ No newline at end of file diff --git a/gerber_to_pm4n.py b/gerber_to_pm4n.py deleted file mode 100755 index 67bdb49..0000000 --- a/gerber_to_pm4n.py +++ /dev/null @@ -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 [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 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(' 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('