diff --git a/Dummy.pm4n b/Dummy.pm4n index e54b8b4..3b51a25 100644 Binary files a/Dummy.pm4n and b/Dummy.pm4n differ diff --git a/Readme.md b/Readme.md index 2170911..93ccb95 100644 --- a/Readme.md +++ b/Readme.md @@ -6,19 +6,51 @@ KiCad PCB layers need to be converted to `.pm4n` files for direct UV exposure on --- -## Setup +## Preparation -### 1. KiCad: Set your design grid +### KiCad: Set your design grid In KiCad's PCB editor, go to `Preferences → Preferences → PCB Editor → Grids` and add a custom grid of 0.017 mm. This ensures trace edges land on pixel boundaries and avoids the sub-pixel rounding that causes the ±1px size error people see with arbitrary grids. -### 2. Create the `Dummy.pm4n` file +### Create the `Dummy.pm4n` file - -Open **CHITUBOX Basic** slicer (or any other slicer that works for your resin printer), select printer **Anycubic Photon Mono 4**, slice any tiny STL (e.g.: 1×1×0.05 mm box), and save as `Dummy.pm4n` in the same directory as `export.sh`. +Open **CHITUBOX Basic** slicer (or any other slicer that works for your resin printer), select printer **Anycubic Photon Mono 4**, slice any tiny STL (e.g.: 1×1×0.05 mm box), and save as `Dummy.pm4n`. This file is reused for every job — it carries the correct LCD resolution metadata. -### 3. Install dependencies +## Manual processing + +`UVtools` has a dedicated `PCB Exposure` tool that converts a Gerber file to a pixel-perfect image given your printer's LCD resolution, specifically for exposing copper traces. + +### 1. Install `UVtools` + +```bash +sudo apt-get install -y curl +sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/sn4k3/UVtools/master/Scripts/install-dependencies.sh)" +``` + +### 2. Run `UVtools` + +```bash +cd UVtools +./UVtools +``` + +### 3. Export `.pm4n` file from Gerber + +- You export Gerber from KiCad (not SVG), which natively gives you positive/negative control per layer. +- You open the `Dummy.pm4n` file in `UVtools` (a minimal valid `.pm4n` sliced by `Chitubox` or `Photon Workshop` with any tiny model), then use `Tools → PCB Exposure` to inject your Gerber layer e.g.: `Flow_Controller_Panel-Front.gbr`. +- You control `inversion`, `invert` and the `exposure time` at the bottom of the `PCB exposure` dialog. +- You save it as e.g.: `Flow_Controller_Panel-Front.pm4n` file and print it. + +![PCB_Exposure_Dialog_1](images/PCB_Exposure_Dialog_1.png) + +![PCB_Exposure_Dialog_2](images/PCB_Exposure_Dialog_2.png) + +--- + +## Automated script + +### 1. Install dependencies ```bash python3 -m venv .venv @@ -28,7 +60,7 @@ pip install pygerber Pillow numpy Or activate the `venv` once and put `source .venv/bin/activate` in your shell profile. -### 4. Export multiple layers (e.g. copper + soldermask + silkscreen) +### 2. Export multiple layers (e.g. copper + soldermask + silkscreen) ```bash ./export.sh \ @@ -59,19 +91,18 @@ 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 ``` -#### 4.1. Check the layer preview +#### 2.1. Check the layer preview Check the `output/pm4n/Flow_Controller_Panel-*.preview.png` images — traces should appear black on white background. - background = UV exposed = resist removed = etched away - traces = dark = resist kept = copper stays -#### 4.2. Check the printer exposure +#### 2.2. Check the printer exposure Open the `.pm4n` in `Chitubox Basic` slicer to visually verify before printing. -#### 4.3. Adjust exposure +#### 2.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. - -#### Export single layer (e.g. copper) +### 3. Export single layer (e.g. copper) Export the front layer as gerber ```bash diff --git a/diagnose/Dummy.FCStd b/diagnose/Dummy.FCStd new file mode 100644 index 0000000..5143794 Binary files /dev/null and b/diagnose/Dummy.FCStd differ diff --git a/diagnose/Dummy.stl b/diagnose/Dummy.stl new file mode 100644 index 0000000..67b40a1 Binary files /dev/null and b/diagnose/Dummy.stl differ diff --git a/diagnose/decode_prev.py b/diagnose/decode_prev.py new file mode 100644 index 0000000..4c08ec9 --- /dev/null +++ b/diagnose/decode_prev.py @@ -0,0 +1,71 @@ +#!/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 new file mode 100644 index 0000000..70d85e0 --- /dev/null +++ b/diagnose/decode_rle_blocks.py @@ -0,0 +1,76 @@ +#!/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('= 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(' int: @@ -56,21 +76,8 @@ def find_tag(data: bytes, tag: bytes, start: int = 0) -> int: def count_laye_entries(data: bytes, laye_off: int) -> int: - """Count layer entries by scanning until EXTR tag or implausible float.""" - 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] - if word in (b'EXTR', b'MACH', b'Mode', b'HEAD', b'PREV', b'LAYE'): - break - v = struct.unpack_from(' int: @@ -183,10 +190,10 @@ def patch_pm4n(dummy_path: Path, image: Image.Image, 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 + # 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('