clean up
This commit is contained in:
@@ -1,30 +1,26 @@
|
|||||||
|
TODO: Add table of content
|
||||||
|
# KiKit Fixture Processor
|
||||||
|
Processing script for KiKit panelizes PCBs and draws a fixture sketch and positions the whole panel for easy CNC and MSLA processing.
|
||||||
|
|
||||||
|
The script:
|
||||||
|
|
||||||
|
- Panelizes the board
|
||||||
|
- Moves the finished panel to the origin `(0, 0)`
|
||||||
|
- Adds alignment holes
|
||||||
|
- Adds silskcreen text
|
||||||
|
- Extends the copper layers
|
||||||
|
|
||||||
|
The resulting output is intended for repeatable CNC/MSLA processing.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# Install kikit
|
## Install kikit
|
||||||
Install `kikit`:
|
Install `kikit`:
|
||||||
```bash
|
```bash
|
||||||
pipx install --system-site-packages kikit
|
pipx install --system-site-packages kikit
|
||||||
```
|
```
|
||||||
|
|
||||||
# KiKit Fixture Processor
|
## Usage
|
||||||
Processing script for KiKit panelizes PCBs and draws a fixture sketch and positions the whole panel for easy CNC processing.
|
|
||||||
|
|
||||||
The script:
|
|
||||||
|
|
||||||
- Draws fixture reference geometry
|
|
||||||
- Centres the finished panel inside a predefined fixture frame
|
|
||||||
- Adds mechanical alignment pin holes
|
|
||||||
- Moves the finished panel to the origin `(0, 0)`
|
|
||||||
|
|
||||||
The resulting output is intended for repeatable CNC manufacturing workflows
|
|
||||||
where drilling, routing, and UV exposure all share the same physical fixture
|
|
||||||
and alignment holes.
|
|
||||||
|
|
||||||
Check the kikit panelization [examples](https://yaqwsx.github.io/KiKit/latest/panelization/examples/).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Usage
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Panelize the PCB using the preset defined in `myPreset.json`.
|
# Panelize the PCB using the preset defined in `myPreset.json`.
|
||||||
@@ -46,7 +42,13 @@ The new `panel/Flow_Controller_Panel.kicad_pcb` file will contain the panelized
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
See all values in [default.json](https://raw.githubusercontent.com/yaqwsx/KiKit/refs/heads/master/kikit/resources/panelizePresets/default.json)
|
See all values in [default.json](https://raw.githubusercontent.com/yaqwsx/KiKit/refs/heads/master/kikit/resources/panelizePresets/default.json)
|
||||||
## Exporting gcode files from KiCad
|
|
||||||
|
Check the kikit panelization [examples](https://yaqwsx.github.io/KiKit/latest/panelization/examples/).
|
||||||
|
|
||||||
|
# Exporting gcode for CNC
|
||||||
|
|
||||||
|
## pcb2gcode
|
||||||
|
Use CNC for drilling holes and milling board outlines. You can also use CNC for isolation traces milling. However, the best result will give you MSLA PCB exposure. TODO: Add link to the section
|
||||||
|
|
||||||
Adapt milling and drilling parameters in `millproject`. Look up [pcb2gcode/wiki](https://github.com/pcb2gcode/pcb2gcode/wiki) for help.
|
Adapt milling and drilling parameters in `millproject`. Look up [pcb2gcode/wiki](https://github.com/pcb2gcode/pcb2gcode/wiki) for help.
|
||||||
```bash
|
```bash
|
||||||
@@ -54,22 +56,15 @@ nano millproject
|
|||||||
```
|
```
|
||||||
*Make sure to set `mirror-axis` in the `millproject` to half of your board width!!!*
|
*Make sure to set `mirror-axis` in the `millproject` to half of your board width!!!*
|
||||||
|
|
||||||
Run the export by providing the `.kicad_pcb` file as a first argument:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Input layers/filenames and all milling/drilling parameters are taken from the config file: 'millproject'.
|
./export_for_cnc.sh panel/Flow_Controller_Panel.kicad_pcb
|
||||||
docker run --rm -i -t \
|
|
||||||
-u "$(id -u):$(id -g)" \
|
|
||||||
-v "$(pwd):/data" \
|
|
||||||
ptodorov/pcb2gcode
|
|
||||||
```
|
```
|
||||||
The script will first generate gerber files in the `gerbers` directory and then generate gcode files in the `gcode` directory.
|
|
||||||
|
|
||||||
Launch the `gSender` program.
|
Launch the `gSender` program.
|
||||||
* Load the `gcode/drill.ngc` file for drilling holes.
|
* Load the `output/gcode/drill.ngc` file for drilling holes.
|
||||||
* Load the `gcode/outline.ngc` file for milling the board outlines.
|
* Load the `output/gcode/outline.ngc` file for milling the board outlines.
|
||||||
* Load the `gcode/back.ngc` file if you want to mill the isolation traces.
|
* Load the `output/gcode/back.ngc` file if you want to mill the isolation traces.
|
||||||
* Load the `gcode/front.ngc` file if you want to mill the isolation traces.
|
* Load the `output/gcode/front.ngc` file if you want to mill the isolation traces.
|
||||||
|
|
||||||
## Milling tip: Increase the thermal spoke and trace width
|
## Milling tip: Increase the thermal spoke and trace width
|
||||||
When routing for milling, use the widest traces possible. 1mm, 2mm and wider, the machine doesn't care, but later you won't be soldering leads to small fragile strips of copper. You can use copper pours for routing too.
|
When routing for milling, use the widest traces possible. 1mm, 2mm and wider, the machine doesn't care, but later you won't be soldering leads to small fragile strips of copper. You can use copper pours for routing too.
|
||||||
@@ -85,21 +80,11 @@ Convert KiCad PCB layers to `.pm4n` files for direct UV exposure on an **Anycubi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `export.sh` | Main entry point — exports Gerbers from KiCad, converts to `.pm4n` |
|
|
||||||
| `gerber_to_pm4n.py` | Python converter (Gerber → RLE → pm4n binary surgery) |
|
|
||||||
| `Dummy.pm4n` | Template file for your specific printer. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### 1. Create the Dummy.pm4n
|
### 1. Create the Dummy.pm4n
|
||||||
|
|
||||||
Open **CHITUBOX_Basic** slicer, select printer **Anycubic Photon Mono 4**, slice any tiny STL (a 1×1×0.05 mm box), and save as `Dummy.pm4n` in the same directory as `export.sh`.
|
Open **CHITUBOX Basic** slicer, select printer **Anycubic Photon Mono 4**, slice any tiny STL (a 1×1×0.05 mm box), and save as `Dummy.pm4n` in the same directory as `export_for_msla.sh`.
|
||||||
|
|
||||||
This file is reused for every job — it carries the correct LCD resolution metadata.
|
This file is reused for every job — it carries the correct LCD resolution metadata.
|
||||||
|
|
||||||
@@ -113,64 +98,18 @@ pip install pygerber Pillow numpy
|
|||||||
|
|
||||||
Or activate the venv once and put `source .venv/bin/activate` in your shell profile.
|
Or activate the venv once and put `source .venv/bin/activate` in your shell profile.
|
||||||
|
|
||||||
---
|
### 3. Export multiple layers (e.g. copper + soldermask)
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
./export.sh [OPTIONS] <path/to/board.kicad_pcb>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Options
|
|
||||||
|
|
||||||
| Option | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `--layers L,L,...` | `Front,F.Mask` | KiCad layer names to process |
|
|
||||||
| `--invert L,L,...` | *(none)* | Layers to invert the image for |
|
|
||||||
| `--mirror L,L,...` | *(none)* | Layers to mirror X for |
|
|
||||||
| `--exposure N` | `60` | Exposure time in seconds |
|
|
||||||
| `--dummy FILE` | `./Dummy.pm4n` | Path to dummy template |
|
|
||||||
| `--out DIR` | `./output` | Output directory |
|
|
||||||
| `--dpmm N` | `58.824` | Render resolution (native = 17 µm/px) |
|
|
||||||
| `--pos X,Y` | centred | Board position in mm from top-left |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Typical: top copper, positive-working resist (Bungard standard)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./export.sh \
|
./export_for_msla.sh \
|
||||||
--layers Front \
|
--layers Front,Back,F.Mask,B.Mask,F.SilkS,B.SilkS \
|
||||||
--invert Front \
|
--invert Front,Back,F.Mask,B.Mask,F.SilkS,B.SilkS \
|
||||||
--mirror Front \
|
--mirror Front,F.Mask,F.SilkS \
|
||||||
--exposure 60 \
|
--exposure 60 \
|
||||||
panel/Flow_Controller_Panel.kicad_pcb
|
panel/Flow_Controller_Panel.kicad_pcb
|
||||||
```
|
```
|
||||||
|
|
||||||
`--invert`: Bungard presensitized is positive-working — UV removes resist, so the background must be exposed (white) and traces must block UV (dark). The Gerber is positive (copper=white), so inversion is needed.
|
#### Using gerber_to_pm4n.py directly
|
||||||
|
|
||||||
`--mirror`: the board sits copper-side-down on the FEP, so the image must be flipped so the pattern reads correctly through the board.
|
|
||||||
|
|
||||||
### Multiple layers (e.g. copper + soldermask)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./export.sh \
|
|
||||||
--layers Front,F.Mask \
|
|
||||||
--invert Front,F.Mask \
|
|
||||||
--mirror Front,F.Mask \
|
|
||||||
--exposure 60 \
|
|
||||||
panel/Flow_Controller_Panel.kicad_pcb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quick test at lower resolution (faster render)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./export.sh --dpmm 30 --layers Front --invert Front --mirror Front panel/Flow_Controller_Panel.kicad_pcb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using gerber_to_pm4n.py directly
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 gerber_to_pm4n.py Dummy.pm4n output/gerbers/Flow_Controller_Panel-Front.gbr \
|
python3 gerber_to_pm4n.py Dummy.pm4n output/gerbers/Flow_Controller_Panel-Front.gbr \
|
||||||
@@ -195,29 +134,6 @@ output/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Invert and mirror logic
|
|
||||||
|
|
||||||
| Setting | When to use |
|
|
||||||
|---|---|
|
|
||||||
| `--invert` | Positive-working resist (standard Bungard): UV removes resist → background must be white (exposed), traces black (masked) |
|
|
||||||
| no `--invert` | Negative-working resist: UV hardens resist → traces must be white |
|
|
||||||
| `--mirror` | Board placed **copper-side down** on FEP (normal for this workflow) |
|
|
||||||
| no `--mirror` | Board placed copper-side up |
|
|
||||||
|
|
||||||
When in doubt: check the `.preview.png` before printing. Traces should appear **dark** on a white background for standard Bungard positive-working boards.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Exposure calibration
|
|
||||||
|
|
||||||
Start at **60 s** and bracket in ±15 s steps. Typical range for Bungard presensitized at 405 nm is 30–120 s depending on board age and storage conditions.
|
|
||||||
|
|
||||||
A correctly exposed board after development will show:
|
|
||||||
- Clear copper traces (resist intact, blue/green tint)
|
|
||||||
- Bare copper in etched areas (resist removed, shiny copper)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**`kicad-cli: command not found`** — add KiCad to PATH:
|
**`kicad-cli: command not found`** — add KiCad to PATH:
|
||||||
@@ -239,14 +155,10 @@ alias kicad-cli='flatpak run --command=kicad-cli org.kicad.KiCad'
|
|||||||
| `B.Mask` | `B_Mask` |
|
| `B.Mask` | `B_Mask` |
|
||||||
| `F.SilkS` | `F_Silkscreen` |
|
| `F.SilkS` | `F_Silkscreen` |
|
||||||
|
|
||||||
**Image looks wrong in preview** — check invert/mirror flags. Open `.preview.png`: for positive-working resist, traces = dark, background = white.
|
|
||||||
|
|
||||||
**UVtools PCB Exposure freezes on per-item invert checkbox** — known v6 bug at 46 MP. Use the global invert checkbox at the bottom of the dialog instead, or use this script pipeline entirely.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
First print checklist
|
First print checklist
|
||||||
|
|
||||||
Open the `.pm4n` in Chitubox to visually verify before printing.
|
Open the `.pm4n` in `Chitubox Basic` slicer to visually verify before printing.
|
||||||
Check the `.preview.png` — traces should appear black on white background (background = UV exposed = resist removed = etched away; traces = dark = resist kept = copper stays)
|
Check the `.preview.png` — traces should appear black on white background (background = UV exposed = resist removed = etched away; traces = dark = resist kept = copper stays)
|
||||||
Start with `--exposure 60` and bracket from there — Bungard presensitized at 405nm typically lands between 30–120s depending on board vintage and storage.
|
Start with `--exposure 60` and bracket from there — Bungard presensitized at 405nm typically lands between 30–120s depending on board vintage and storage.
|
||||||
|
|
||||||
|
|||||||
37
scripts/export_for_cnc.sh
Executable file
37
scripts/export_for_cnc.sh
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GERBERS_DIR="output/gerbers"
|
||||||
|
GCODE_DIR="output/gcode"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 <kicad_pcb_file>"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
PCB_FILE="$1"
|
||||||
|
|
||||||
|
mkdir -p "$GERBERS_DIR"
|
||||||
|
mkdir -p "$GCODE_DIR"
|
||||||
|
|
||||||
|
# Export drill, front and back layers as gerber files.
|
||||||
|
echo "Exporting gerbers..."
|
||||||
|
kicad-cli pcb export drill -o "$GERBERS_DIR" "$PCB_FILE"
|
||||||
|
kicad-cli pcb export gerbers -o "$GERBERS_DIR" -l Front "$PCB_FILE"
|
||||||
|
kicad-cli pcb export gerbers -o "$GERBERS_DIR" -l Back "$PCB_FILE"
|
||||||
|
kicad-cli pcb export gerbers -o "$GERBERS_DIR" -l Edge.Cuts "$PCB_FILE"
|
||||||
|
|
||||||
|
# Export outlines of the penelized board i.e. use the layer 'User.Eco1'.
|
||||||
|
# echo "Exporting panelized outlines from layer 'User.Eco1'..."
|
||||||
|
# python3 export_panel_outlines_gerber.py \
|
||||||
|
# --layers User.Eco1 \
|
||||||
|
# --output "$GERBERS_DIR" \
|
||||||
|
# "$PCB_FILE"
|
||||||
|
|
||||||
|
# Input layers/filenames and all milling/drilling parameters are taken from the config file: 'millproject'.
|
||||||
|
echo "Exporting Gcode..."
|
||||||
|
docker run --rm -i -t \
|
||||||
|
-u "$(id -u):$(id -g)" \
|
||||||
|
-v "$(pwd):/data" \
|
||||||
|
ptodorov/pcb2gcode
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# export.sh — KiCad Gerber export + pm4n generation for Anycubic Photon Mono 4
|
# export_for_msla.sh — KiCad Gerber export + pm4n generation for Anycubic Photon Mono 4
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./export.sh [OPTIONS] <path/to/board.kicad_pcb>
|
# ./export_for_msla.sh [OPTIONS] <path/to/board.kicad_pcb>
|
||||||
#
|
#
|
||||||
# Options:
|
# Options:
|
||||||
# --layers LAYER,LAYER,... KiCad layer names to export (default: F.Cu)
|
# --layers LAYER,LAYER,... KiCad layer names to export (default: F.Cu)
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
# -h, --help Show this help
|
# -h, --help Show this help
|
||||||
#
|
#
|
||||||
# Example:
|
# Example:
|
||||||
# ./export.sh --invert F.Cu,B.Mask --mirror F.Cu,F.Mask panel/Flow_Controller_Panel.kicad_pcb
|
# ./export_for_msla.sh --invert F.Cu,B.Mask --mirror F.Cu,F.Mask panel/Flow_Controller_Panel.kicad_pcb
|
||||||
#
|
#
|
||||||
# Layer name → Gerber filename mapping (KiCad default):
|
# Layer name → Gerber filename mapping (KiCad default):
|
||||||
# F.Cu → <board>-F_Cu.gbr
|
# F.Cu → <board>-F_Cu.gbr
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
gerber_to_pm4n.py - Anycubic Photon Mono 4 PCB exposure file generator
|
gerber_to_pm4n.py – Anycubic Photon Mono 4 PCB exposure file generator
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 gerber_to_pm4n.py <dummy.pm4n> <board.gbr> [options]
|
python3 gerber_to_pm4n.py <dummy.pm4n> <board.gbr> [options]
|
||||||
@@ -12,9 +12,8 @@ Options:
|
|||||||
--exposure SEC Layer exposure time in seconds [default: 60]
|
--exposure SEC Layer exposure time in seconds [default: 60]
|
||||||
--dpmm N Render resolution in dots/mm [default: 58.824, native 17µm/px]
|
--dpmm N Render resolution in dots/mm [default: 58.824, native 17µm/px]
|
||||||
--pos X,Y Place board at X,Y mm from top-left (default: centred on LCD)
|
--pos X,Y Place board at X,Y mm from top-left (default: centred on LCD)
|
||||||
--help Show this message
|
|
||||||
|
|
||||||
Photon Mono 4 specs: 9024 x 5120 px | 153.408 x 87.040 mm | 17.001 µm/px
|
Photon Mono 4 specs: 9024 × 5120 px | 153.408 × 87.040 mm | 17.001 µm/px
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -34,20 +33,119 @@ LCD_H_MM = 87.040
|
|||||||
NATIVE_DPMM = LCD_W_PX / LCD_W_MM # 58.824 dpmm (1 px ≈ 17.001 µm)
|
NATIVE_DPMM = LCD_W_PX / LCD_W_MM # 58.824 dpmm (1 px ≈ 17.001 µm)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Photon Workshop RLE (BW — 2 bytes per run)
|
# pm4n / ANYCUBIC file format constants (reverse-engineered from Dummy.pm4n)
|
||||||
#
|
#
|
||||||
# Byte0 [7:4] = colour nibble (0x0 = black, 0xF = white)
|
# File layout:
|
||||||
# Byte0 [3:0] = high 4 bits of run length (bits 11:8)
|
# 0x00 ANYCUBIC magic (8 bytes)
|
||||||
# Byte1 = low 8 bits of run length (bits 7:0)
|
# 0x08 unknown u32 (0)
|
||||||
# Run length encodes (n-1): 0x000 = 1 pixel, 0xFFF = 4096 pixels
|
# 0x0C version u32 (0x00000206)
|
||||||
|
# 0x10 section_count u32 (5)
|
||||||
|
# 0x14 header_size u32 (0x40 = 64)
|
||||||
|
# 0x18 section table: 5 × (length:u32, offset:u32)
|
||||||
|
# entry 0: PREV preview images
|
||||||
|
# entry 1: LAYE layer definitions
|
||||||
|
# entry 2: MACH machine params
|
||||||
|
# entry 3: Mode layer image data (header + RLE blocks)
|
||||||
|
# entry 4: HEAD printer config
|
||||||
|
#
|
||||||
|
# Sections located by scanning for 4-byte tags from known offsets.
|
||||||
|
# Tags: HEAD(0x40), PREV(0xB0), LAYE(0x0126D4), MACH(0x0127F0), Mode(0x012974)
|
||||||
|
# Note: actual offsets vary per dummy file — we scan for tags.
|
||||||
|
#
|
||||||
|
# LAYE section layout (at its offset):
|
||||||
|
# +0x00 'LAYE' tag
|
||||||
|
# +0x04 'REDF' sub-tag
|
||||||
|
# +0x08 u32=0
|
||||||
|
# +0x0C u32 payload_size
|
||||||
|
# +0x10 u32 (AA level or mode)
|
||||||
|
# +0x14 u32 composite_image_offset (absolute file offset of block 0)
|
||||||
|
# +0x18 u32 image_block_size (size of EACH block, all identical)
|
||||||
|
# +0x1C layer entries begin (N entries × 32 bytes):
|
||||||
|
# +0x00 f32 bottom_exposure
|
||||||
|
# +0x04 f32 normal_exposure ← patched with --exposure value
|
||||||
|
# +0x08 f32 lift_mm
|
||||||
|
# +0x0C f32 layer_height
|
||||||
|
# +0x10 u32 unknown
|
||||||
|
# +0x14 u32 unknown
|
||||||
|
# +0x18 u32 image_data_offset (absolute file offset for this layer)
|
||||||
|
# +0x1C u32 image_data_size (patched when RLE size changes)
|
||||||
|
#
|
||||||
|
# Mode section layout:
|
||||||
|
# +0x00 'Model\0\0\0' tag (8 bytes)
|
||||||
|
# +0x04 u32 sub-header size (108)
|
||||||
|
# +0x08..+0x2B bounding box / Z params as floats
|
||||||
|
# +0x2C 'SUBIMGS\0\0\0\0\0' sub-tag (12 bytes)
|
||||||
|
# +0x38 u32 SUBIMGS table size
|
||||||
|
# +0x3C u32 layer_count (N)
|
||||||
|
# +0x40 u32 bytes_per_pixel (1)
|
||||||
|
# +0x44 u32 first_image_offset (= composite_image_offset, same as LAYE+0x14)
|
||||||
|
# +0x48 u32 first_image_size (= image_block_size, same as LAYE+0x18)
|
||||||
|
# +0x4C u32 unknown
|
||||||
|
# then N RLE image blocks follow (at offsets stored in LAYE entries)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LAYE_TAG = b'LAYE'
|
||||||
|
MODE_TAG = b'Mode'
|
||||||
|
ENTRY_STRIDE = 0x20 # 32 bytes per layer entry in LAYE
|
||||||
|
LAYE_HDR_SIZE = 0x1C # bytes before first entry
|
||||||
|
|
||||||
|
|
||||||
|
def find_tag(data: bytes, tag: bytes, start: int = 0) -> int:
|
||||||
|
"""Return file offset of first occurrence of tag (exact 4-byte match at 4-byte boundary)."""
|
||||||
|
i = start
|
||||||
|
while i + 4 <= len(data):
|
||||||
|
if data[i:i+4] == tag:
|
||||||
|
return i
|
||||||
|
i += 4
|
||||||
|
# Fall back to unaligned search
|
||||||
|
pos = data.find(tag, start)
|
||||||
|
if pos < 0:
|
||||||
|
raise ValueError(f"Tag {tag!r} not found in file")
|
||||||
|
return pos
|
||||||
|
|
||||||
|
|
||||||
|
def count_laye_entries(data: bytes, laye_off: int) -> int:
|
||||||
|
"""Count layer entries by scanning until we hit the 'EXTR' sub-section or end of section."""
|
||||||
|
entry_start = laye_off + LAYE_HDR_SIZE
|
||||||
|
n = 0
|
||||||
|
while True:
|
||||||
|
pos = entry_start + n * ENTRY_STRIDE
|
||||||
|
if pos + 4 > len(data):
|
||||||
|
break
|
||||||
|
word = data[pos:pos+4]
|
||||||
|
# Stop if we hit a known sub-tag marker
|
||||||
|
if word in (b'EXTR', b'MACH', b'Mode', b'HEAD', b'PREV'):
|
||||||
|
break
|
||||||
|
# Stop if the f32 at this position is not a plausible exposure time
|
||||||
|
v = struct.unpack_from('<f', data, pos)[0]
|
||||||
|
if not (0.0 < v < 1000.0):
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def pack_f32(v: float) -> bytes:
|
||||||
|
return struct.pack('<f', v)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_u32(v: int) -> bytes:
|
||||||
|
return struct.pack('<I', v)
|
||||||
|
|
||||||
|
|
||||||
|
def unpack_u32(data: bytes, off: int) -> int:
|
||||||
|
return struct.unpack_from('<I', data, off)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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
|
MAX_RUN = 4096
|
||||||
|
|
||||||
def encode_rle(pixels: bytes) -> bytes:
|
def encode_rle(pixels: bytes) -> bytes:
|
||||||
"""Encode flat 0x00/0xFF bytes → Photon Workshop BW RLE."""
|
|
||||||
out = bytearray()
|
out = bytearray()
|
||||||
i = 0
|
i, n = 0, len(pixels)
|
||||||
n = len(pixels)
|
|
||||||
while i < n:
|
while i < n:
|
||||||
colour = pixels[i]
|
colour = pixels[i]
|
||||||
nibble = 0xF if colour >= 0x80 else 0x0
|
nibble = 0xF if colour >= 0x80 else 0x0
|
||||||
@@ -55,76 +153,20 @@ def encode_rle(pixels: bytes) -> bytes:
|
|||||||
while j < n and pixels[j] == colour and (j - i) < MAX_RUN:
|
while j < n and pixels[j] == colour and (j - i) < MAX_RUN:
|
||||||
j += 1
|
j += 1
|
||||||
run = j - i
|
run = j - i
|
||||||
encoded = run - 1
|
enc = run - 1
|
||||||
out.append((nibble << 4) | ((encoded >> 8) & 0x0F))
|
out.append((nibble << 4) | ((enc >> 8) & 0x0F))
|
||||||
out.append(encoded & 0xFF)
|
out.append(enc & 0xFF)
|
||||||
i = j
|
i = j
|
||||||
return bytes(out)
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
def decode_rle(data: bytes, expected_pixels: int) -> bytes:
|
|
||||||
"""Decode PW RLE → raw pixel bytes (used for verification)."""
|
|
||||||
out = bytearray()
|
|
||||||
i = 0
|
|
||||||
while i + 1 < len(data):
|
|
||||||
b0, b1 = data[i], data[i + 1]
|
|
||||||
nibble = (b0 >> 4) & 0x0F
|
|
||||||
colour = 0xFF if nibble == 0xF else 0x00
|
|
||||||
run = (((b0 & 0x0F) << 8) | b1) + 1
|
|
||||||
out.extend([colour] * run)
|
|
||||||
i += 2
|
|
||||||
return bytes(out[:expected_pixels])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# pm4n binary surgery
|
# Gerber → PIL Image at LCD resolution
|
||||||
#
|
|
||||||
# Photon Workshop file = sequence of tagged sections:
|
|
||||||
# tag:4 length:4 payload:length
|
|
||||||
#
|
|
||||||
# Sections we care about:
|
|
||||||
# HEAD – contains exposure time as a float somewhere in a packed struct
|
|
||||||
# LAYE – layer definition table: count:u32 then N × entry(28 bytes)
|
|
||||||
# entry[0:4] = absolute file offset of RLE blob
|
|
||||||
# entry[4:8] = RLE blob length in bytes
|
|
||||||
# After the sections: raw RLE layer image blobs (referenced by LAYE offsets)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SECTION_HDR = 8 # 4-byte tag + 4-byte length
|
|
||||||
|
|
||||||
def read_sections(data: bytes) -> list:
|
|
||||||
sections = []
|
|
||||||
i = 0
|
|
||||||
while i + SECTION_HDR <= len(data):
|
|
||||||
tag = data[i:i+4]
|
|
||||||
length = struct.unpack_from('<I', data, i+4)[0]
|
|
||||||
sections.append((tag, i + SECTION_HDR, length))
|
|
||||||
i += SECTION_HDR + length
|
|
||||||
return sections
|
|
||||||
|
|
||||||
def find_section(data: bytes, tag: bytes) -> tuple:
|
|
||||||
for t, off, ln in read_sections(data):
|
|
||||||
if t == tag:
|
|
||||||
return off, ln
|
|
||||||
raise ValueError(f"Section {tag!r} not found in file")
|
|
||||||
|
|
||||||
def patch_u32(data: bytearray, offset: int, value: int):
|
|
||||||
struct.pack_into('<I', data, offset, value)
|
|
||||||
|
|
||||||
def patch_f32(data: bytearray, offset: int, value: float):
|
|
||||||
struct.pack_into('<f', data, offset, value)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Gerber → PIL Image
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def render_gerber(gbr_path: Path, dpmm: float,
|
def render_gerber(gbr_path: Path, dpmm: float,
|
||||||
invert: bool, mirror: bool,
|
invert: bool, mirror: bool,
|
||||||
pos_mm: tuple | None) -> Image.Image:
|
pos_mm: tuple | None) -> Image.Image:
|
||||||
"""
|
|
||||||
Render a Gerber file to a binary PIL image sized to the Photon Mono 4 LCD.
|
|
||||||
copper = white on black background before any transforms.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
from pygerber.gerberx3.api.v2 import (
|
from pygerber.gerberx3.api.v2 import (
|
||||||
GerberFile, ColorScheme, PixelFormatEnum, ImageFormatEnum
|
GerberFile, ColorScheme, PixelFormatEnum, ImageFormatEnum
|
||||||
@@ -132,93 +174,117 @@ def render_gerber(gbr_path: Path, dpmm: float,
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
sys.exit(
|
sys.exit(
|
||||||
"ERROR: pygerber not found.\n"
|
"ERROR: pygerber not found.\n"
|
||||||
"Activate the venv first: source .venv/bin/activate\n"
|
"Activate the venv: source .venv/bin/activate\n"
|
||||||
"Or install: pip install pygerber Pillow numpy"
|
"Or install: pip install pygerber Pillow"
|
||||||
)
|
)
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
(GerberFile
|
GerberFile.from_file(str(gbr_path)).parse().render_raster(
|
||||||
.from_file(str(gbr_path))
|
|
||||||
.parse()
|
|
||||||
.render_raster(
|
|
||||||
buf,
|
buf,
|
||||||
dpmm=int(round(dpmm)),
|
dpmm=int(round(dpmm)),
|
||||||
color_scheme=ColorScheme.DEFAULT_GRAYSCALE,
|
color_scheme=ColorScheme.DEFAULT_GRAYSCALE,
|
||||||
pixel_format=PixelFormatEnum.RGB,
|
pixel_format=PixelFormatEnum.RGB,
|
||||||
image_format=ImageFormatEnum.PNG,
|
image_format=ImageFormatEnum.PNG,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
layer_img = Image.open(buf).convert('L')
|
layer_img = Image.open(buf).convert('L')
|
||||||
|
|
||||||
# Place onto full LCD canvas
|
|
||||||
cw, ch = layer_img.size
|
cw, ch = layer_img.size
|
||||||
canvas = Image.new('L', (LCD_W_PX, LCD_H_PX), 0)
|
canvas = Image.new('L', (LCD_W_PX, LCD_H_PX), 0)
|
||||||
|
|
||||||
if pos_mm is not None:
|
if pos_mm is not None:
|
||||||
px = int(round(pos_mm[0] * dpmm))
|
px = max(0, int(round(pos_mm[0] * dpmm)))
|
||||||
py = int(round(pos_mm[1] * dpmm))
|
py = max(0, int(round(pos_mm[1] * dpmm)))
|
||||||
else:
|
else:
|
||||||
px = (LCD_W_PX - cw) // 2
|
px = (LCD_W_PX - cw) // 2
|
||||||
py = (LCD_H_PX - ch) // 2
|
py = (LCD_H_PX - ch) // 2
|
||||||
|
|
||||||
canvas.paste(layer_img, (max(0, px), max(0, py)))
|
canvas.paste(layer_img, (px, py))
|
||||||
|
|
||||||
if mirror:
|
if mirror:
|
||||||
canvas = ImageOps.mirror(canvas)
|
canvas = ImageOps.mirror(canvas)
|
||||||
if invert:
|
if invert:
|
||||||
canvas = ImageOps.invert(canvas)
|
canvas = ImageOps.invert(canvas)
|
||||||
|
|
||||||
# Hard-binarise: no antialiasing artefacts in the RLE stream
|
# Hard-binarise to strict 0/255
|
||||||
canvas = canvas.point(lambda v: 255 if v >= 128 else 0)
|
canvas = canvas.point(lambda v: 255 if v >= 128 else 0)
|
||||||
|
|
||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# pm4n surgery
|
# pm4n surgery — rewrite with exact format knowledge
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def patch_pm4n(dummy_path: Path, image: Image.Image,
|
def patch_pm4n(dummy_path: Path, image: Image.Image,
|
||||||
exposure_sec: float, output_path: Path):
|
exposure_sec: float, output_path: Path):
|
||||||
"""Replace layer RLE + exposure time in a dummy .pm4n, write output."""
|
|
||||||
raw = bytearray(dummy_path.read_bytes())
|
raw = bytearray(dummy_path.read_bytes())
|
||||||
|
|
||||||
# Encode new layer image
|
# --- encode new RLE ---
|
||||||
pixels = image.convert('L').tobytes()
|
new_rle = encode_rle(image.convert('L').tobytes())
|
||||||
new_rle = encode_rle(pixels)
|
new_rle_size = len(new_rle)
|
||||||
|
|
||||||
# Patch exposure time: scan HEAD for any float in 0.5–600 s range
|
# --- locate LAYE section ---
|
||||||
hdr_off, hdr_len = find_section(raw, b'HEAD')
|
laye_off = find_tag(raw, LAYE_TAG)
|
||||||
for off in range(hdr_off, hdr_off + hdr_len - 3):
|
n_entries = count_laye_entries(raw, laye_off)
|
||||||
val = struct.unpack_from('<f', raw, off)[0]
|
print(f" LAYE at 0x{laye_off:06X}, {n_entries} layer entries")
|
||||||
if 0.5 <= val <= 600.0:
|
|
||||||
patch_f32(raw, off, exposure_sec)
|
|
||||||
|
|
||||||
# Locate layer image via LAYERDEF
|
# Read composite image offset and original block size
|
||||||
ld_off, _ = find_section(raw, b'LAYE')
|
composite_off = unpack_u32(raw, laye_off + 0x14)
|
||||||
layer_count = struct.unpack_from('<I', raw, ld_off)[0]
|
old_block_size = unpack_u32(raw, laye_off + 0x18)
|
||||||
if layer_count != 1:
|
print(f" Image blocks: first=0x{composite_off:06X}, old_size={old_block_size}, new_size={new_rle_size}")
|
||||||
print(f"WARNING: dummy has {layer_count} layers; only layer 0 will be replaced.")
|
|
||||||
entry_off = ld_off + 4
|
|
||||||
img_offset = struct.unpack_from('<I', raw, entry_off)[0]
|
|
||||||
img_len = struct.unpack_from('<I', raw, entry_off + 4)[0]
|
|
||||||
|
|
||||||
print(f" Dummy RLE: offset=0x{img_offset:08X} {img_len} bytes")
|
# --- Read all existing image offsets from LAYE entries ---
|
||||||
print(f" New RLE: {len(new_rle)} bytes")
|
# Block 0 is the composite (at composite_off), blocks 1..N from entries
|
||||||
|
old_offsets = [composite_off]
|
||||||
|
for i in range(n_entries):
|
||||||
|
entry_base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
|
||||||
|
old_offsets.append(unpack_u32(raw, entry_base + 0x18))
|
||||||
|
|
||||||
# Splice new RLE in place
|
# All blocks should be contiguous and equal-sized; verify
|
||||||
old_end = img_offset + img_len
|
expected = composite_off
|
||||||
raw[img_offset:old_end] = new_rle
|
for i, off in enumerate(old_offsets):
|
||||||
|
if off != expected:
|
||||||
|
print(f" WARNING: block {i} offset 0x{off:06X} != expected 0x{expected:06X}")
|
||||||
|
expected = off + old_block_size
|
||||||
|
|
||||||
# Update LAYERDEF length field
|
# --- Patch exposure time in all LAYE entries ---
|
||||||
patch_u32(raw, entry_off + 4, len(new_rle))
|
for i in range(n_entries):
|
||||||
|
entry_base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
|
||||||
|
struct.pack_into('<f', raw, entry_base + 0x04, exposure_sec)
|
||||||
|
print(f" Patched exposure to {exposure_sec}s in {n_entries} entries")
|
||||||
|
|
||||||
# Update any enclosing section's length field
|
# --- Build new image data section ---
|
||||||
for tag, sec_off, sec_len in read_sections(bytes(raw)):
|
# All N+1 blocks (composite + N layers) get the SAME new RLE
|
||||||
if tag not in (b'HEAD', b'LAYE', b'PREV') and sec_off <= img_offset < sec_off + sec_len:
|
# (single-layer exposure: every layer shows the same image)
|
||||||
patch_u32(raw, sec_off - 4, sec_len + len(new_rle) - img_len)
|
n_blocks = n_entries + 1
|
||||||
break
|
new_image_section = new_rle * n_blocks
|
||||||
|
|
||||||
|
# --- Reconstruct file ---
|
||||||
|
# Everything before the first image block stays unchanged
|
||||||
|
prefix = bytes(raw[:composite_off])
|
||||||
|
|
||||||
|
# --- Update LAYE: block size field ---
|
||||||
|
struct.pack_into('<I', raw, laye_off + 0x18, new_rle_size)
|
||||||
|
|
||||||
|
# --- Update Mode section: first_image_size field ---
|
||||||
|
# Mode sub-header: tag 'Mode' + u32(108) + ... + 'SUBIMGS' + ...
|
||||||
|
# first_image_size is at Mode_off + 0x48
|
||||||
|
mode_off = find_tag(raw, MODE_TAG)
|
||||||
|
struct.pack_into('<I', raw, mode_off + 0x48, new_rle_size)
|
||||||
|
print(f" Mode at 0x{mode_off:06X}, patched first_image_size")
|
||||||
|
|
||||||
|
# --- Update LAYE entries: image offsets and sizes ---
|
||||||
|
new_composite_off = composite_off # composite block stays at same position
|
||||||
|
for i in range(n_entries):
|
||||||
|
entry_base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
|
||||||
|
new_layer_off = composite_off + (i + 1) * new_rle_size
|
||||||
|
struct.pack_into('<I', raw, entry_base + 0x18, new_layer_off)
|
||||||
|
struct.pack_into('<I', raw, entry_base + 0x1C, new_rle_size)
|
||||||
|
|
||||||
|
# --- Splice new image data ---
|
||||||
|
old_images_end = composite_off + n_blocks * old_block_size
|
||||||
|
raw[composite_off:old_images_end] = new_image_section
|
||||||
|
|
||||||
output_path.write_bytes(raw)
|
output_path.write_bytes(raw)
|
||||||
print(f" Written: {output_path} ({len(raw):,} bytes)")
|
print(f" Written: {output_path} ({len(raw):,} bytes)")
|
||||||
@@ -231,17 +297,15 @@ def patch_pm4n(dummy_path: Path, image: Image.Image,
|
|||||||
def parse_args():
|
def parse_args():
|
||||||
p = argparse.ArgumentParser(
|
p = argparse.ArgumentParser(
|
||||||
description='Convert Gerber → Anycubic Photon Mono 4 .pm4n PCB exposure file',
|
description='Convert Gerber → Anycubic Photon Mono 4 .pm4n PCB exposure file',
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog=__doc__,
|
|
||||||
)
|
)
|
||||||
p.add_argument('dummy', help='Dummy .pm4n template (from Photon Workshop)')
|
p.add_argument('dummy', help='Dummy .pm4n template (from Photon Workshop)')
|
||||||
p.add_argument('gerber', help='Input Gerber file')
|
p.add_argument('gerber', help='Input Gerber file')
|
||||||
p.add_argument('-o', '--output', default=None, help='Output .pm4n path')
|
p.add_argument('-o', '--output', default=None)
|
||||||
p.add_argument('--invert', action='store_true', help='Invert image (positive-working resist)')
|
p.add_argument('--invert', action='store_true', help='Invert image (positive-working resist)')
|
||||||
p.add_argument('--mirror', action='store_true', help='Mirror X axis (copper-side-down placement)')
|
p.add_argument('--mirror', action='store_true', help='Mirror X (copper-side-down placement)')
|
||||||
p.add_argument('--exposure', type=float, default=60.0, help='Exposure seconds (default: 60)')
|
p.add_argument('--exposure', type=float, default=60.0, help='Exposure seconds (default: 60)')
|
||||||
p.add_argument('--dpmm', type=float, default=NATIVE_DPMM, help=f'Dots/mm (default: {NATIVE_DPMM:.3f})')
|
p.add_argument('--dpmm', type=float, default=NATIVE_DPMM)
|
||||||
p.add_argument('--pos', default=None, help='Board position X,Y mm from top-left')
|
p.add_argument('--pos', default=None, help='Board X,Y mm from top-left')
|
||||||
return p.parse_args()
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -277,6 +341,7 @@ def main():
|
|||||||
preview = out.with_suffix('.preview.png')
|
preview = out.with_suffix('.preview.png')
|
||||||
img.resize((img.size[0] // 4, img.size[1] // 4), Image.NEAREST).save(preview)
|
img.resize((img.size[0] // 4, img.size[1] // 4), Image.NEAREST).save(preview)
|
||||||
print(f"Preview: {preview}")
|
print(f"Preview: {preview}")
|
||||||
|
print()
|
||||||
|
|
||||||
print("Patching .pm4n...")
|
print("Patching .pm4n...")
|
||||||
patch_pm4n(dummy, img, args.exposure, out)
|
patch_pm4n(dummy, img, args.exposure, out)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
front=output/gerbers/Flow_Controller_Panel-Front.gtl
|
front=output/gerbers/Flow_Controller_Panel-Front.gtl
|
||||||
back=output/gerbers/Flow_Controller_Panel-Back.gbl
|
back=output/gerbers/Flow_Controller_Panel-Back.gbl
|
||||||
drill=output/gerbers/Flow_Controller_Panel.drl
|
drill=output/gerbers/Flow_Controller_Panel.drl
|
||||||
|
outline=output/gerbers/Flow_Controller_Panel-Edge_Cuts.gm1
|
||||||
|
|
||||||
# Use the 'User-Eco1' layer instead as it contains panelized board's slot outlines only.
|
# Use the 'User-Eco1' layer instead as it contains panelized board's slot outlines only.
|
||||||
outline=output/gerbers/Flow_Controller_Panel-User-Eco1.gbr
|
#outline=output/gerbers/Flow_Controller_Panel-User-Eco1.gbr
|
||||||
# Do not use the 'Edge_Cuts' layer as it contains also the panel outline.
|
|
||||||
#outline=output/gerbers/Flow_Controller_Panel-Edge_Cuts.gm1
|
|
||||||
|
|
||||||
# Generic
|
# Generic
|
||||||
metric=true # use metric units for parameters
|
metric=true # use metric units for parameters
|
||||||
@@ -12,8 +12,8 @@ metricoutput=true # use metric units for output
|
|||||||
nog64=true # do not set an explicit g64
|
nog64=true # do not set an explicit g64
|
||||||
#nom6=true # do not emit m6
|
#nom6=true # do not emit m6
|
||||||
zsafe=2 # The height in mm at which the bit can move freely without obstruction
|
zsafe=2 # The height in mm at which the bit can move freely without obstruction
|
||||||
zchange=2 # Tool changing height in mm
|
zchange=35 # Tool changing height in mm
|
||||||
output-dir=gcode
|
output-dir=output/gcode
|
||||||
|
|
||||||
# Place a 5x7cm board in the lower right quadrant of the coordinate system
|
# Place a 5x7cm board in the lower right quadrant of the coordinate system
|
||||||
# This will allow you to probe the fixed jaw of the vise for (0,0) on the CNC.
|
# This will allow you to probe the fixed jaw of the vise for (0,0) on the CNC.
|
||||||
@@ -23,7 +23,7 @@ mirror-axis=80 # set this to half of your board width
|
|||||||
|
|
||||||
# Drilling
|
# Drilling
|
||||||
zdrill=-2.2 # drilling depth
|
zdrill=-2.2 # drilling depth
|
||||||
drill-feed=200 # Vertical mm/min feed
|
drill-feed=400 # Vertical mm/min feed
|
||||||
drill-speed=24000 # Spindle RPM
|
drill-speed=24000 # Spindle RPM
|
||||||
#onedrill=true # Use a single drill for all holes
|
#onedrill=true # Use a single drill for all holes
|
||||||
nog81=true # replace G81 with G0+G1 (no G81 in GRBL)
|
nog81=true # replace G81 with G0+G1 (no G81 in GRBL)
|
||||||
|
|||||||
Reference in New Issue
Block a user