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 5143794..0000000 Binary files a/diagnose/Dummy.FCStd and /dev/null differ diff --git a/diagnose/Dummy.stl b/diagnose/Dummy.stl deleted file mode 100644 index 67b40a1..0000000 Binary files a/diagnose/Dummy.stl and /dev/null differ diff --git a/diagnose/decode_prev.py b/diagnose/decode_prev.py deleted file mode 100644 index 4c08ec9..0000000 --- a/diagnose/decode_prev.py +++ /dev/null @@ -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 -""" -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('