This commit is contained in:
cpu
2026-06-14 14:18:55 +02:00
parent eada18d3f0
commit 35fed71971
24 changed files with 530 additions and 1951 deletions

1
CAD/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.FCBak

BIN
CAD/Case.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
CAD/Case_exploded.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -4824,7 +4824,7 @@
)
)
)
(property "Footprint" "Connector_JST:JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical"
(property "Footprint" "Connector_JST:JST_XH_S3B-XH-A_1x03_P2.50mm_Horizontal"
(at 44.45 49.53 0)
(effects
(font

View File

@@ -6,6 +6,10 @@ A fail-safe water flow monitoring module designed to protect CNC spindles and la
![Front](images/Flow_Controller_front.png)
![Back](images/Flow_Controller_back.png)
![Panel](images/Flow_Controller_Panel.png)
![Case](CAD/Case.png)
![Case_exploded](CAD/Case_exploded.png)
![ZJ-S401](images/Sensor_ZJ-S401.png)
@@ -31,7 +35,9 @@ A fail-safe water flow monitoring module designed to protect CNC spindles and la
* **Output:** 3-pin CNC interface (+24V, GND, ALARM_CNC). Connects directly to standard CNC active-low sinking inputs.
## Panel Design and GCode
The [scripts](scripts) folder contains a guide to panelize the board (`Flow_Controller_Panel.kicad_pcb`) and howto generate gcode.
The [../kicad2panel](../kicad2panel) folder contains a guide to panelize the board.
The [../kicad2msla](../kicad2msla) folder contains a guide to use the MSLA for UV exposure.
The [../kicad2gcode](../kicad2gcode) folder contains a guide to generate G-code to use CNC for drilling and cutting (optionally also for the isolation trace routing).
## Microcontroller Pin Mapping (Per Schematic)
| Pin | Schematic Net | Function | MCU Port (SOP8) |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 KiB

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 KiB

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

5
scripts/.gitignore vendored
View File

@@ -1,5 +0,0 @@
__pycache__/
panel/
output/
gcode/
.venv/

Binary file not shown.

View File

@@ -1,164 +0,0 @@
TODO: Add table of content
# KiKit 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.
![Panel](../images/Flow_Controller_Panel.png)
## Install kikit
Install `kikit`:
```bash
pipx install --system-site-packages kikit
```
## Usage
```bash
# Panelize the PCB using the preset defined in `myPreset.json`.
kikit panelize \
-p myPreset.json \
../Flow_Controller.kicad_pcb \
panel/Flow_Controller_Panel.kicad_pcb
```
The new `panel/Flow_Controller_Panel.kicad_pcb` file will contain the panelized PCB with the following feature specified in `myPreset.json`. E.g.: Grid of 1 x 2 with space 2.1 mm, new mounting holes and fiducials.
```json
"layout": {
"type": "grid",
"rows": 1,
"cols": 2,
"hspace": "2.1mm",
"vspace": "2.1mm"
}
```
See all values in [default.json](https://raw.githubusercontent.com/yaqwsx/KiKit/refs/heads/master/kikit/resources/panelizePresets/default.json)
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.
```bash
nano millproject
```
*Make sure to set `mirror-axis` in the `millproject` to half of your board width!!!*
```bash
./export_for_cnc.sh panel/Flow_Controller_Panel.kicad_pcb
```
Launch the `gSender` program.
* Load the `output/gcode/drill.ngc` file for drilling holes.
* Load the `output/gcode/outline.ngc` file for milling the board outlines.
* Load the `output/gcode/back.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
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.
Set up the entire back side as one big GND pour. Then, increase the thermal spoke width to be larger than 1mm. This avoids small features and gives more room for error if a larger drill is used for the holes.
![Thermal spoke width](../images/spoke_width.png)
# MSLA PCB Exposure: KiCad → Photon Mono 4
Convert KiCad PCB layers to `.pm4n` files for direct UV exposure on an **Anycubic Photon Mono 4** (9024×5120 px, 17 µm/px).
---
## Setup
### 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_for_msla.sh`.
This file is reused for every job — it carries the correct LCD resolution metadata.
### 2. Install dependencies
```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)
```bash
./export_for_msla.sh \
--layers Front,Back,F.Mask,B.Mask,F.SilkS,B.SilkS \
--invert Front,Back,F.Mask,B.Mask,F.SilkS,B.SilkS \
--mirror Front,F.Mask,F.SilkS \
--exposure 60 \
panel/Flow_Controller_Panel.kicad_pcb
```
#### Using gerber_to_pm4n.py directly
```bash
python3 gerber_to_pm4n.py Dummy.pm4n output/gerbers/Flow_Controller_Panel-Front.gbr \
--invert --mirror --exposure 60
```
---
## Output structure
```
output/
├── gerbers/
│ ├── Flow_Controller_Panel-Front.gbr
│ └── Flow_Controller_Panel-F_Mask.gbr
└── pm4n/
├── Flow_Controller_Panel-Front.pm4n ← copy to USB, print on Mono 4
├── Flow_Controller_Panel-Front.preview.png ← visual check before printing
├── Flow_Controller_Panel-F_Mask.pm4n
└── Flow_Controller_Panel-F_Mask.preview.png
```
---
## Troubleshooting
**`kicad-cli: command not found`** — add KiCad to PATH:
```bash
export PATH="/usr/lib/kicad/bin:$PATH"
```
Or on Flatpak:
```bash
alias kicad-cli='flatpak run --command=kicad-cli org.kicad.KiCad'
```
**Expected Gerber not found** — KiCad's layer→filename mapping:
| Layer | Filename stem |
|---|---|
| `F.Cu` | `Front` |
| `B.Cu` | `Back` |
| `F.Mask` | `F_Mask` |
| `B.Mask` | `B_Mask` |
| `F.SilkS` | `F_Silkscreen` |
---
First print checklist
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)
Start with `--exposure 60` and bracket from there — Bungard presensitized at 405nm typically lands between 30120s depending on board vintage and storage.

View File

@@ -1,96 +0,0 @@
import pcbnew
from shapely.geometry import MultiPolygon, Polygon
# ---------------------------------------------------------------------------
# KiKit postprocess hook cleanup.py
#
# 1. Copy all Edge.Cuts content (substrate rings) to Eco1_User as segments,
# EXCEPT the outermost panel rectangle.
# ---------------------------------------------------------------------------
TOLERANCE_NM = 1_000 # 1 µm
def _mm(nm_val):
return f"{pcbnew.ToMM(int(nm_val)):.4f} mm"
def _collect_rings(geom):
"""Return all rings as list of (shapely_ring, coord_list)."""
rings = []
if isinstance(geom, Polygon):
rings.append((geom.exterior, list(geom.exterior.coords)))
for interior in geom.interiors:
rings.append((interior, list(interior.coords)))
elif isinstance(geom, MultiPolygon):
for poly in geom.geoms:
rings.extend(_collect_rings(poly))
return rings
def _is_outer_frame(ring_geom, x0, y0, x1, y1, tol=TOLERANCE_NM):
"""True if the ring's bounding box equals the panel bounding box."""
b = ring_geom.bounds
return (abs(b[0] - x0) <= tol and abs(b[1] - y0) <= tol and
abs(b[2] - x1) <= tol and abs(b[3] - y1) <= tol)
def _shapely_to_segments(board, coords, layer, width_mm=0.05):
pts = list(coords)
if not pts:
return 0
if pts[0] != pts[-1]:
pts.append(pts[0])
count = 0
for i in range(len(pts) - 1):
seg = pcbnew.PCB_SHAPE(board)
seg.SetShape(pcbnew.SHAPE_T_SEGMENT)
seg.SetLayer(layer)
seg.SetWidth(pcbnew.FromMM(width_mm))
seg.SetStart(pcbnew.VECTOR2I(int(pts[i][0]), int(pts[i][1])))
seg.SetEnd (pcbnew.VECTOR2I(int(pts[i+1][0]), int(pts[i+1][1])))
board.Add(seg)
count += 1
return count
def kikitPostprocess(panel, arg):
print("=" * 60)
print("[cleanup] START")
board = panel.board
substrate = panel.boardSubstrate
# outer panel bbox
b = substrate.bounds()
x0, y0, x1, y1 = int(b[0]), int(b[1]), int(b[2]), int(b[3])
print(f"[cleanup] Panel bbox: ({_mm(x0)},{_mm(y0)})({_mm(x1)},{_mm(y1)})")
# get substrate geometry
geom = None
for attr in ("substrates", "substrate", "_substrate", "geometry"):
if hasattr(substrate, attr):
geom = getattr(substrate, attr)
break
if geom is None:
print("[cleanup] ERROR: cannot access substrate geometry")
return
rings = _collect_rings(geom)
print(f"[cleanup] Total rings in substrate: {len(rings)}")
total = 0
for i, (ring_geom, coords) in enumerate(rings):
rb = ring_geom.bounds
if _is_outer_frame(ring_geom, x0, y0, x1, y1):
print(f"[cleanup] ring[{i}] pts={len(coords)-1} → OUTER FRAME, skipped")
continue
n = _shapely_to_segments(board, coords, pcbnew.Eco1_User)
total += n
print(f"[cleanup] ring[{i}] pts={len(coords)-1}"
f" bbox=({_mm(rb[0])},{_mm(rb[1])})({_mm(rb[2])},{_mm(rb[3])})"
f"{n} segments on Eco1_User")
print(f"[cleanup] Total Eco1_User segments added: {total}")
print("[cleanup] END")
print("=" * 60)

View File

@@ -1,62 +0,0 @@
#!/usr/bin/env python3
"""Run this on your machine: python3 diagnose_pm4n.py Dummy.pm4n"""
import struct, sys
path = sys.argv[1] if len(sys.argv) > 1 else 'Dummy.pm4n'
data = open(path, 'rb').read()
fsize = len(data)
print(f"File: {path} ({fsize} bytes)")
print()
# Parse ANYCUBIC header
magic = data[0:8]
version = struct.unpack_from('<I', data, 8)[0]
n_sections = struct.unpack_from('<I', data, 12)[0]
header_size = struct.unpack_from('<I', data, 16)[0]
print(f"Magic: {magic}")
print(f"Bytes 8-11: {data[8:12].hex()} (={version})")
print(f"Section count: {n_sections}")
print(f"Header size: {header_size} (0x{header_size:X})")
print()
# Section table: n_sections entries of (offset:u32, length:u32) starting at byte 20
print(f"Section table ({n_sections} entries from offset 20):")
sections = []
for i in range(n_sections):
base = 20 + i * 8
if base + 8 > fsize:
break
off = struct.unpack_from('<I', data, base)[0]
ln = struct.unpack_from('<I', data, base+4)[0]
sections.append((off, ln))
for idx, (off, ln) in enumerate(sections):
tag = data[off:off+4] if off + 4 <= fsize else b'????'
tag_str = tag.decode('ascii', errors='replace')
print(f" [{idx:2d}] offset=0x{off:06X} ({off:7d}) length={ln:7d} tag@offset={tag_str!r}")
print()
# Also peek at each section start for tag-like content
print("Content at each section offset (first 32 bytes):")
for idx, (off, ln) in enumerate(sections):
if off + 16 <= fsize:
chunk = data[off:off+32]
# Try to find sub-tags
for sub_off in range(0, min(32, len(chunk))-3):
sub_tag = chunk[sub_off:sub_off+4]
if all(32 <= b < 127 for b in sub_tag):
sub_len = struct.unpack_from('<I', chunk, sub_off+4)[0] if sub_off+8 <= len(chunk) else 0
print(f" section[{idx}]+0x{sub_off:02X} tag={sub_tag.decode()!r} next_u32={sub_len}")
break
else:
print(f" section[{idx}] hex: {chunk[:16].hex()}")
print()
# Look for exposure-like floats (1.0 to 600.0) across the whole file
print("Float values in range [1.0 .. 600.0] across whole file:")
for off in range(0, fsize - 3, 4):
v = struct.unpack_from('<f', data, off)[0]
if 1.0 <= v <= 600.0 and v == round(v, 1):
# Show context
section_hint = next((f"sec[{i}]+{off-s:d}" for i,(s,l) in enumerate(sections) if s <= off < s+l), "outside")
print(f" 0x{off:06X} {v:.1f} ({section_hint})")

View File

@@ -1,37 +0,0 @@
#!/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

View File

@@ -1,148 +0,0 @@
#!/usr/bin/env bash
# export_for_msla.sh — KiCad Gerber export + pm4n generation for Anycubic Photon Mono 4
#
# Usage:
# ./export_for_msla.sh [OPTIONS] <path/to/board.kicad_pcb>
#
# Options:
# --layers LAYER,LAYER,... KiCad layer names to export (default: F.Cu)
# --invert LAYER,LAYER,... Layers to invert (comma-separated)
# --mirror LAYER,LAYER,... Layers to mirror (comma-separated)
# --exposure SECONDS Exposure time in seconds (default: 60)
# --dummy FILE Dummy .pm4n template (default: Dummy.pm4n beside this script)
# --out DIR Output directory (default: ./output)
# --dpmm N Render resolution in dots/mm (default: native 58.824)
# --pos X,Y Board position on LCD in mm (default: centred)
# --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_for_msla.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
# ---- 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.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 ;;
--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")
[[ $VERBOSE -eq 1 ]] && FLAGS+=(--verbose)
log " $LAYER$OUT_PM4N [${FLAGS[*]:-} exposure=${EXPOSURE}s]"
"$PYTHON" "$CONVERTER" \
"$DUMMY" \
"$GBR_FILE" \
--output "$OUT_PM4N" \
--exposure "$EXPOSURE" \
"${FLAGS[@]}"
done
log "=== Done ==="

View File

@@ -1,44 +0,0 @@
#!/usr/bin/env python3
import argparse
import os
import pcbnew
def main():
parser = argparse.ArgumentParser(
description="Export a single PCB layer to a Gerber file using pcbnew."
)
parser.add_argument("input", help="Input .kicad_pcb file")
parser.add_argument("--output", "-o", required=True, help="Output directory")
parser.add_argument("--layers", "-l", required=True,
help="Layer name to export (e.g. User.Eco1)")
args = parser.parse_args()
board = pcbnew.LoadBoard(args.input)
# Resolve layer name to ID
layer_id = board.GetLayerID(args.layers)
if layer_id == -1:
parser.error(f"Unknown layer: {args.layers!r}. "
f"Run with a known layer name (e.g. User.Eco1, Edge.Cuts).")
# Resolve output directory to absolute path so pcbnew doesn't make it
# relative to the board file location
output_dir = os.path.abspath(args.output)
os.makedirs(output_dir, exist_ok=True)
prefix = args.layers.replace(".", "-")
pc = pcbnew.PLOT_CONTROLLER(board)
po = pc.GetPlotOptions()
po.SetOutputDirectory(output_dir)
po.SetPlotFrameRef(False)
pc.SetLayer(layer_id)
pc.OpenPlotfile(prefix, pcbnew.PLOT_FORMAT_GERBER, args.layers)
print(f"Plotting layer {args.layers!r} (id={layer_id}) to {pc.GetPlotFileName()}")
pc.PlotLayer()
pc.ClosePlot()
print("Done.")
if __name__ == "__main__":
main()

View File

@@ -1,320 +0,0 @@
#!/usr/bin/env python3
"""
gerber_to_pm4n.py Anycubic Photon Mono 4 PCB exposure file generator
Usage:
python3 gerber_to_pm4n.py <dummy.pm4n> <board.gbr> [options]
Options:
-o OUTPUT Output file path [default: <board>.pm4n]
--invert Invert the image (for positive-working resist like Bungard standard)
--mirror Mirror X axis (for copper-side-down placement on FEP)
--exposure SEC Exposure time in seconds [default: 60]
--dpmm N Render resolution in dots/mm [default: 58.824, native 17µm/px]
--pos X,Y Board position mm from top-left (default: centred)
--verbose Print detailed progress
Photon Mono 4 specs: 9024 × 5120 px | 153.408 × 87.040 mm | 17.001 µm/px
"""
import argparse
import struct
import sys
import io
from pathlib import Path
from PIL import Image, ImageOps
# ---------------------------------------------------------------------------
# Printer constants
# ---------------------------------------------------------------------------
LCD_W_PX = 9024
LCD_H_PX = 5120
LCD_W_MM = 153.408
LCD_H_MM = 87.040
NATIVE_DPMM = LCD_W_PX / LCD_W_MM # 58.824 dpmm (1 px ≈ 17.001 µm)
# ---------------------------------------------------------------------------
# pm4n / ANYCUBIC file format constants (reverse-engineered from Dummy.pm4n)
#
# File layout:
# 0x00 ANYCUBIC magic (8 bytes)
# 0x08 unknown u32 (0)
# 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
LAYE_HDR_SIZE = 0x1C # bytes before first entry within LAYE section
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:
"""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('<f', data, pos)[0]
if not (0.0 < v < 1000.0):
break
n += 1
return n
def unpack_u32(data: bytes, off: int) -> int:
return struct.unpack_from('<I', data, off)[0]
# ---------------------------------------------------------------------------
# Photon Workshop RLE (BW — 2 bytes per run)
# Byte0 [7:4] colour nibble: 0x0=black, 0xF=white
# Byte0 [3:0] + Byte1: run length 1 (12-bit, max run=4096)
# ---------------------------------------------------------------------------
MAX_RUN = 4096
def encode_rle(pixels: bytes) -> bytes:
out = bytearray()
i, n = 0, len(pixels)
while i < n:
colour = pixels[i]
nibble = 0xF if colour >= 0x80 else 0x0
j = i + 1
while j < n and pixels[j] == colour and (j - i) < MAX_RUN:
j += 1
run = j - i
enc = run - 1
out.append((nibble << 4) | ((enc >> 8) & 0x0F))
out.append(enc & 0xFF)
i = j
return bytes(out)
# ---------------------------------------------------------------------------
# Gerber → PIL Image at LCD resolution
# ---------------------------------------------------------------------------
def render_gerber(gbr_path: Path, dpmm: float,
invert: bool, mirror: bool,
pos_mm: tuple | None,
verbose: bool = False) -> Image.Image:
try:
from pygerber.gerberx3.api.v2 import (
GerberFile, ColorScheme, PixelFormatEnum, ImageFormatEnum
)
except ImportError:
sys.exit(
"ERROR: pygerber not found.\n"
"Activate the venv: source .venv/bin/activate\n"
"Or install: pip install pygerber Pillow"
)
buf = io.BytesIO()
GerberFile.from_file(str(gbr_path)).parse().render_raster(
buf,
dpmm=int(round(dpmm)),
color_scheme=ColorScheme.DEFAULT_GRAYSCALE,
pixel_format=PixelFormatEnum.RGB,
image_format=ImageFormatEnum.PNG,
)
buf.seek(0)
layer_img = Image.open(buf).convert('L')
cw, ch = layer_img.size
canvas = Image.new('L', (LCD_W_PX, LCD_H_PX), 0)
if pos_mm is not None:
px = max(0, int(round(pos_mm[0] * dpmm)))
py = max(0, int(round(pos_mm[1] * dpmm)))
else:
px = (LCD_W_PX - cw) // 2
py = (LCD_H_PX - ch) // 2
canvas.paste(layer_img, (px, py))
if mirror:
canvas = ImageOps.mirror(canvas)
if invert:
canvas = ImageOps.invert(canvas)
canvas = canvas.point(lambda v: 255 if v >= 128 else 0)
if verbose:
print(f" Gerber rendered: {layer_img.size[0]}×{layer_img.size[1]} px"
f" placed at ({px},{py}) invert={invert} mirror={mirror}")
return canvas
# ---------------------------------------------------------------------------
# pm4n surgery
# ---------------------------------------------------------------------------
def patch_pm4n(dummy_path: Path, image: Image.Image,
exposure_sec: float, output_path: Path,
verbose: bool = False):
def log(*a):
if verbose:
print(*a)
raw = bytearray(dummy_path.read_bytes())
new_rle = encode_rle(image.convert('L').tobytes())
new_rle_size = len(new_rle)
laye_off = find_tag(raw, LAYE_TAG)
n_entries = count_laye_entries(raw, laye_off)
log(f" LAYE at 0x{laye_off:06X}, {n_entries} layer entries")
composite_off = unpack_u32(raw, laye_off + 0x14)
old_block_size = unpack_u32(raw, laye_off + 0x18)
log(f" Image blocks: first=0x{composite_off:06X}, "
f"old_size={old_block_size}, new_size={new_rle_size}")
# Patch exposure in all entries
for i in range(n_entries):
base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
struct.pack_into('<f', raw, base + 0x04, exposure_sec)
log(f" Exposure patched to {exposure_sec}s in {n_entries} entries")
# Update block size in LAYE header and Mode header
struct.pack_into('<I', raw, laye_off + 0x18, new_rle_size)
mode_off = find_tag(raw, MODE_TAG)
struct.pack_into('<I', raw, mode_off + 0x48, new_rle_size)
log(f" Mode at 0x{mode_off:06X}")
# Update image offsets and sizes in all entries
for i in range(n_entries):
base = laye_off + LAYE_HDR_SIZE + i * ENTRY_STRIDE
struct.pack_into('<I', raw, base + 0x18, composite_off + (i + 1) * new_rle_size)
struct.pack_into('<I', raw, base + 0x1C, new_rle_size)
# Splice new image data (composite block + one block per layer, all identical)
n_blocks = n_entries + 1
old_end = composite_off + n_blocks * old_block_size
raw[composite_off:old_end] = new_rle * n_blocks
output_path.write_bytes(raw)
log(f" Written: {output_path} ({len(raw):,} bytes)")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args():
p = argparse.ArgumentParser(
description='Convert Gerber → Anycubic Photon Mono 4 .pm4n PCB exposure file',
)
p.add_argument('dummy', help='Dummy .pm4n template')
p.add_argument('gerber', help='Input Gerber file')
p.add_argument('-o', '--output', default=None)
p.add_argument('--invert', action='store_true')
p.add_argument('--mirror', action='store_true')
p.add_argument('--exposure', type=float, default=60.0)
p.add_argument('--dpmm', type=float, default=NATIVE_DPMM)
p.add_argument('--pos', default=None)
p.add_argument('--verbose', action='store_true')
return p.parse_args()
def main():
args = parse_args()
dummy = Path(args.dummy)
gbr = Path(args.gerber)
v = args.verbose
for p, label in [(dummy, 'dummy'), (gbr, 'gerber')]:
if not p.exists():
sys.exit(f"ERROR: {label} file not found: {p}")
out = Path(args.output) if args.output else gbr.with_suffix('.pm4n')
pos_mm = None
if args.pos:
try:
x, y = map(float, args.pos.split(','))
pos_mm = (x, y)
except Exception:
sys.exit("ERROR: --pos must be X,Y e.g. --pos 10.5,8.0")
if v:
print(f"Gerber: {gbr}")
print(f"Dummy: {dummy}")
print(f"Output: {out}")
print(f"Invert: {args.invert} Mirror: {args.mirror}"
f" Exposure: {args.exposure}s dpmm: {args.dpmm:.3f}")
img = render_gerber(gbr, dpmm=args.dpmm, invert=args.invert,
mirror=args.mirror, pos_mm=pos_mm, verbose=v)
preview = out.with_suffix('.preview.png')
img.resize((img.size[0] // 4, img.size[1] // 4), Image.NEAREST).save(preview)
patch_pm4n(dummy, img, args.exposure, out, verbose=v)
# Always print the output path (quiet mode only output)
print(out)
if __name__ == '__main__':
main()

View File

@@ -1,376 +0,0 @@
"""
=============================================================================
MACRO: CNC Slot Skeletonizer (Centerlines Only)
=============================================================================
DESCRIPTION:
This macro is specifically designed for CAM/CNC routing where the router bit
diameter matches the slot width. It reads a DXF-style slot sketch, calculates
the mathematical centerlines, and produces a sketch containing ONLY the
toolpaths (centerlines).
WHAT IT DOES:
1. Safely duplicates the selected sketch.
2. Mathematically calculates true Arcs from faceted DXF micro-segments.
3. Calculates intersections for Straight, L-shape, and T-shape junctions.
4. Merges collinear centerline segments.
5. ADVANCED CAM OPTIMIZATION: Groups connected lines into continuous chains so
the tool doesn't lift unnecessarily. Starts the job at the origin (0,0) and
takes the shortest rapid paths between cut groups.
6. WIPES THE SKETCH COMPLETELY CLEAN of all original geometry.
7. Draws ONLY the pure skeleton centerlines.
=============================================================================
"""
import FreeCAD as App
import FreeCADGui as Gui
import Part
import math
try:
from PySide6.QtWidgets import QMessageBox
except ImportError:
try:
from PySide2.QtWidgets import QMessageBox
except ImportError:
from PySide.QtGui import QMessageBox
# =================================================================
# CONFIGURATION
MAX_SEGMENT_LENGTH = 0.5
# =================================================================
def get_circumcenter(p1, p2, p3):
temp = p2.x**2 + p2.y**2
bc = (p1.x**2 + p1.y**2 - temp) / 2.0
cd = (temp - p3.x**2 - p3.y**2) / 2.0
det = (p1.x - p2.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p2.y)
if abs(det) < 1e-6: return None, None
cx = (bc * (p2.y - p3.y) - cd * (p1.y - p2.y)) / det
cy = ((p1.x - p2.x) * cd - (p2.x - p3.x) * bc) / det
center = App.Vector(cx, cy, 0)
radius = (p1 - center).Length
return center, radius
def prepare_working_sketch():
doc = App.ActiveDocument
if not doc: return None
target = None
edit_view = Gui.ActiveDocument.getInEdit()
if edit_view and edit_view.Object.isDerivedFrom("Sketcher::SketchObject"):
target = edit_view.Object
Gui.ActiveDocument.resetEdit()
if not target:
sel = Gui.Selection.getSelection()
if sel and sel[0].isDerivedFrom("Sketcher::SketchObject"):
target = sel[0]
if not target: return None
new_sketch = doc.copyObject(target, False)
new_sketch.Label = target.Label + "_Toolpath"
if hasattr(target, "ViewObject") and target.ViewObject:
target.ViewObject.Visibility = False
if hasattr(new_sketch, "ViewObject") and new_sketch.ViewObject:
new_sketch.ViewObject.Visibility = True
doc.recompute()
return new_sketch
def merge_collinear_lines(lines):
merged = True
while merged:
merged = False
for i in range(len(lines)):
for j in range(i + 1, len(lines)):
l1 = lines[i]
l2 = lines[j]
shared_pt, other1, other2 = None, None, None
tol = 1e-3
if (l1[0] - l2[0]).Length < tol: shared_pt, other1, other2 = l1[0], l1[1], l2[1]
elif (l1[0] - l2[1]).Length < tol: shared_pt, other1, other2 = l1[0], l1[1], l2[0]
elif (l1[1] - l2[0]).Length < tol: shared_pt, other1, other2 = l1[1], l1[0], l2[1]
elif (l1[1] - l2[1]).Length < tol: shared_pt, other1, other2 = l1[1], l1[0], l2[0]
if shared_pt:
v1 = (other1 - shared_pt)
v2 = (other2 - shared_pt)
if v1.Length > tol and v2.Length > tol:
v1.normalize()
v2.normalize()
if abs(v1.dot(v2) + 1.0) < 1e-4:
lines.pop(j)
lines.pop(i)
lines.append((other1, other2))
merged = True
break
if merged: break
return lines
def optimize_toolpath_order(lines):
if not lines: return []
# Phase 1: Build continuous chains (Polylines)
unvisited = lines.copy()
chains = []
tol = 1e-3
while unvisited:
current_chain = [unvisited.pop(0)]
growing = True
while growing:
growing = False
chain_start = current_chain[0][0]
chain_end = current_chain[-1][1]
for i, line in enumerate(unvisited):
l_start, l_end = line[0], line[1]
if (l_start - chain_end).Length < tol:
current_chain.append(unvisited.pop(i))
growing = True; break
elif (l_end - chain_end).Length < tol:
current_chain.append((l_end, l_start)) # Flip
unvisited.pop(i)
growing = True; break
elif (l_end - chain_start).Length < tol:
current_chain.insert(0, unvisited.pop(i))
growing = True; break
elif (l_start - chain_start).Length < tol:
current_chain.insert(0, (l_end, l_start)) # Flip
unvisited.pop(i)
growing = True; break
chains.append(current_chain)
# Phase 2: Traveling Salesperson between chains
unvisited_chains = chains.copy()
optimized_lines = []
# Start with the chain closest to global Origin (0,0,0)
start_idx = 0
best_origin_dist = float('inf')
for i, chain in enumerate(unvisited_chains):
d1 = chain[0][0].Length
d2 = chain[-1][1].Length
if min(d1, d2) < best_origin_dist:
best_origin_dist = min(d1, d2)
start_idx = i
current_chain = unvisited_chains.pop(start_idx)
# Ensure it starts at the point closer to origin
if current_chain[-1][1].Length < current_chain[0][0].Length:
current_chain.reverse()
current_chain = [(l[1], l[0]) for l in current_chain]
optimized_lines.extend(current_chain)
current_pos = current_chain[-1][1] # Position after cutting first chain
while unvisited_chains:
best_dist = float('inf')
best_idx = -1
reverse_chain = False
for i, chain in enumerate(unvisited_chains):
c_start = chain[0][0]
c_end = chain[-1][1]
dist_to_start = (c_start - current_pos).Length
dist_to_end = (c_end - current_pos).Length
if dist_to_start < best_dist:
best_dist = dist_to_start
best_idx = i
reverse_chain = False
if dist_to_end < best_dist:
best_dist = dist_to_end
best_idx = i
reverse_chain = True
next_chain = unvisited_chains.pop(best_idx)
if reverse_chain:
next_chain.reverse()
next_chain = [(l[1], l[0]) for l in next_chain]
optimized_lines.extend(next_chain)
current_pos = next_chain[-1][1]
return optimized_lines
def show_message(title, message):
msg = QMessageBox()
msg.setIcon(QMessageBox.Information)
msg.setWindowTitle(title)
msg.setText(message)
try:
msg.exec()
except AttributeError:
msg.exec_()
def process_sketch():
sketch = prepare_working_sketch()
if not sketch:
App.Console.PrintError("Could not find a sketch. Select one in the tree view and run.\n")
return
geo = sketch.Geometry
# --- STEP 1: IDENTIFY MICRO-SEGMENTS TO INFER ARC DATA ---
graph = {}
pt_dict = {}
def get_pt_key(pt): return (round(pt.x, 3), round(pt.y, 3))
for i, g in enumerate(geo):
if isinstance(g, Part.LineSegment) and not sketch.GeometryFacadeList[i].Construction:
if (g.EndPoint - g.StartPoint).Length < MAX_SEGMENT_LENGTH:
k1 = get_pt_key(g.StartPoint)
k2 = get_pt_key(g.EndPoint)
if k1 not in graph: graph[k1] = []
if k2 not in graph: graph[k2] = []
graph[k1].append((k2, i))
graph[k2].append((k1, i))
pt_dict[k1] = g.StartPoint
pt_dict[k2] = g.EndPoint
paths = []
visited_edges = set()
for node, edges in graph.items():
if len(edges) == 1:
first_edge = edges[0][1]
if first_edge in visited_edges: continue
path_nodes = [node]
curr = node; prev = None
while True:
neighbors = graph[curr]
next_node = None; next_idx = None
for n, idx in neighbors:
if n != prev:
next_node = n; next_idx = idx
break
if next_node is None: break
path_nodes.append(next_node)
visited_edges.add(next_idx)
if len(graph[next_node]) > 2: break
prev = curr; curr = next_node
if len(path_nodes) >= 3:
paths.append(path_nodes)
# --- STEP 2: CALCULATE TRUE ARC CENTERS & DIRECTIONS ---
arc_data = []
for path_nodes in paths:
p1 = pt_dict[path_nodes[0]]
p2 = pt_dict[path_nodes[len(path_nodes)//2]]
p3 = pt_dict[path_nodes[-1]]
center, radius = get_circumcenter(p1, p2, p3)
if center:
v1 = p1 - center
v3 = p3 - center
if v1.Length > 1e-4 and v3.Length > 1e-4:
dot = max(-1.0, min(1.0, v1.dot(v3) / (v1.Length * v3.Length)))
angle = math.acos(dot)
if angle > 2.0:
chord = p3 - p1
perp = App.Vector(-chord.y, chord.x, 0)
if perp.Length > 1e-6:
perp.normalize()
if perp.dot(center - p2) < 0: perp = -perp
arc_data.append((center, perp))
# Existing native Arcs
for i, g in enumerate(geo):
if isinstance(g, Part.ArcOfCircle) and not sketch.GeometryFacadeList[i].Construction:
p1 = g.StartPoint
p3 = g.EndPoint
center = g.Center
v1 = p1 - center
v3 = p3 - center
if v1.Length > 1e-4 and v3.Length > 1e-4:
dot = max(-1.0, min(1.0, v1.dot(v3) / (v1.Length * v3.Length)))
angle = math.acos(dot)
if angle > 2.0:
chord = p3 - p1
perp = App.Vector(-chord.y, chord.x, 0)
if perp.Length > 1e-6:
perp.normalize()
mid_u = (g.FirstParameter + g.LastParameter) / 2.0
p2 = g.value(mid_u)
if perp.dot(center - p2) < 0: perp = -perp
arc_data.append((center, perp))
# --- STEP 3: WIPE THE SKETCH CLEAN ---
for i in range(sketch.ConstraintCount - 1, -1, -1):
sketch.delConstraint(i)
for i in range(sketch.GeometryCount - 1, -1, -1):
sketch.delGeometry(i)
# --- STEP 4: CALCULATE TOOLPATHS ---
raw_lines = []
for i, (c1, dir1) in enumerate(arc_data):
min_t = float('inf')
best_stop = None
for j, (c2, dir2) in enumerate(arc_data):
if i == j: continue
cross = dir1.x * dir2.y - dir1.y * dir2.x
if abs(cross) < 1e-4:
vec = c2 - c1
dist_cross = vec.x * dir1.y - vec.y * dir1.x
if abs(dist_cross) < 1e-2:
t1 = vec.dot(dir1)
t2 = (-vec).dot(dir2)
if t1 > 1e-2 and t2 > 1e-2:
if t1 < min_t:
min_t = t1
best_stop = c2
else:
dx = c2.x - c1.x
dy = c2.y - c1.y
t1 = (dx * dir2.y - dy * dir2.x) / cross
t2 = (dx * dir1.y - dy * dir1.x) / cross
if t1 > 1e-2 and t2 > -1e-2:
if t1 < min_t:
min_t = t1
best_stop = c1 + dir1 * t1
if best_stop is not None:
if (c1 - best_stop).Length > 1e-3:
pts = sorted([(round(c1.x, 3), round(c1.y, 3)), (round(best_stop.x, 3), round(best_stop.y, 3))])
pA = App.Vector(pts[0][0], pts[0][1], 0)
pB = App.Vector(pts[1][0], pts[1][1], 0)
if not any((l[0]-pA).Length < 1e-3 and (l[1]-pB).Length < 1e-3 for l in raw_lines):
raw_lines.append((pA, pB))
# --- STEP 5: MERGE AND OPTIMIZE PATHS FOR CNC ---
merged_lines = merge_collinear_lines(raw_lines)
optimized_lines = optimize_toolpath_order(merged_lines)
# Draw the final optimized lines sequentially
for pA, pB in optimized_lines:
sketch.addGeometry(Part.LineSegment(pA, pB), False)
App.ActiveDocument.recompute()
App.Console.PrintMessage(f"Skeletonizer successful: Created '{sketch.Label}'.\n")
show_message("CAM Toolpath Complete", f"Successfully generated continuous, optimized CNC toolpaths in:\n\n{sketch.Label}")
# Run it
process_sketch()

View File

@@ -1,55 +0,0 @@
front=output/gerbers/Flow_Controller_Panel-Front.gtl
back=output/gerbers/Flow_Controller_Panel-Back.gbl
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.
#outline=output/gerbers/Flow_Controller_Panel-User-Eco1.gbr
# Generic
metric=true # use metric units for parameters
metricoutput=true # use metric units for output
nog64=true # do not set an explicit g64
#nom6=true # do not emit m6
zsafe=2 # The height in mm at which the bit can move freely without obstruction
zchange=35 # Tool changing height in mm
output-dir=output/gcode
# 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.
# For two-sided boards, the PCB needs to be flipped along the axis x=VALUE
mirror-axis=80 # set this to half of your board width
# Drilling
zdrill=-2.2 # drilling depth
drill-feed=400 # Vertical mm/min feed
drill-speed=24000 # Spindle RPM
#onedrill=true # Use a single drill for all holes
nog81=true # replace G81 with G0+G1 (no G81 in GRBL)
drill-side=back
# Milling
zwork=-0.1 # V-bit plunge depth
#mill-diameters=0.11 # 60 deg V-bit dia at -0.1 plunge depth
#mill-diameters=0.08 # 45 deg V-bit dia at -0.1 plunge depth
mill-diameters=0.05 # 30 deg V-bit dia at -0.1 plunge depth
mill-speed=24000 # Spindle RPM
mill-feed=600 # Horizontal feedrate in mm/min
mill-vertfeed=100 # Plunge rate in mm/min
voronoi=true # cuts the milling time significantly, but check with this on and off if everything looks ok
preserve-thermal-reliefs = true # has effect only if voronoi=true
# Cutting
zcut=-4
cutter-diameter=2.1
cut-feed=400
cut-vertfeed=50
cut-infeed=4
cut-speed=24000
cut-side=back
# Tabs
#bridgesnum=4 # Total 4 tabs
#bridges=0.5 # Tab width 0.5 mm
#zbridges=0 # bridges height (default to zsafe)

View File

@@ -1,106 +0,0 @@
{
"layout": {
"type": "grid",
"rows": 1,
"cols": 2,
"hspace": "2.1mm",
"vspace": "2.1mm"
},
"tabs": {
"type": "fixed",
"hcount": 1,
"vcount": 1,
"hwidth": "3mm",
"vwidth": "3mm"
},
"cuts": {
"type": "mousebites",
"offset": "0.2mm",
"prolong": "0.7mm",
"drill": "0.5mm",
"spacing": "0.8mm"
},
"framing": {
"type": "tightframe",
"copperFill": true,
"slotwidth": "2.1mm",
"mintotalheight": "87mm",
"mintotalwidth": "153.4mm",
"maxtotalheight": "87mm",
"maxtotalwidth": "153.4mm"
},
"tooling": {
"type": "3hole",
"layout": "3hole",
"hoffset": "6mm",
"voffset": "6mm",
"size": "2mm",
"paste": true,
"soldermaskmargin": "0mm"
},
"text": {
"type": "simple",
"text": "Front",
"anchor": "mt",
"hoffset": "0mm",
"voffset": "10mm",
"orientation": "0deg",
"width": "3.5mm",
"height": "3.5mm",
"hjustify": "center",
"vjustify": "center",
"thickness": "0.3mm",
"layer": "F.SilkS"
},
"text2": {
"type": "simple",
"text": "Back",
"anchor": "mt",
"hoffset": "0mm",
"voffset": "10mm",
"orientation": "0deg",
"width": "3.5mm",
"height": "3.5mm",
"hjustify": "center",
"vjustify": "center",
"thickness": "0.3mm",
"layer": "B.SilkS"
},
"copperfill": {
"type": "solid",
"clearance": "0.5mm",
"edgeclearance": "0.5mm",
"layers": "F.Cu,B.Cu"
},
"post": {
"type": "auto",
"copperfill": false,
"reconstructarcs": false,
"millradius": "1mm",
"millradiusouter": "0mm",
"script": "cleanup.py",
"scriptarg": "",
"origin": "tl",
"refillzones": false,
"dimensions": true,
"edgewidth": "0.1mm"
},
"page": {
"type": "inherit",
"anchor": "tl",
"posx": "0mm",
"posy": "0mm",
"width": "1000mm",
"height": "1000mm"
},
"debug": {
"type": "none",
"drawPartitionLines": false,
"drawBackboneLines": false,
"drawboxes": false,
"trace": false,
"deterministic": false,
"drawtabfail": false,
"drawTabFillet": false
}
}

View File

@@ -1,131 +0,0 @@
from kikit.plugin import ToolingPlugin
import pcbnew
class CustomTooling(ToolingPlugin):
def buildTooling(self, panel):
board = panel.board
# panelBBox() -> (xmin, ymin, xmax, ymax)
xmin, ymin, xmax, ymax = panel.panelBBox()
min_x = pcbnew.ToMM(xmin)
min_y = pcbnew.ToMM(ymin)
max_x = pcbnew.ToMM(xmax)
max_y = pcbnew.ToMM(ymax)
margin_x_mm = 10
margin_y_mm = 2.5
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
holes = [
(min_x + margin_x_mm, min_y + margin_y_mm), # top left
(center_x, min_y + margin_y_mm), # top center
(max_x - margin_x_mm, min_y + margin_y_mm), # top right
(min_x + margin_x_mm, max_y - margin_y_mm), # bottom left
(center_x, max_y - margin_y_mm), # bottom center
(max_x - margin_x_mm, max_y - margin_y_mm), # bottom right
]
hole_d = pcbnew.FromMM(3.172)
for x_mm, y_mm in holes:
fp = pcbnew.FOOTPRINT(board)
fp.SetReference("")
pos = pcbnew.VECTOR2I(
pcbnew.FromMM(x_mm),
pcbnew.FromMM(y_mm)
)
fp.SetPosition(pos)
pad = pcbnew.PAD(fp)
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
pad.SetAttribute(pcbnew.PAD_ATTRIB_NPTH)
pad.SetSize(pcbnew.VECTOR2I(hole_d, hole_d))
pad.SetDrillSize(pcbnew.VECTOR2I(hole_d, hole_d))
pad.SetPosition(pos)
fp.Add(pad)
board.Add(fp)
# =========================
# SCREEN RECTANGLE
# =========================
screen_w = 153.4
screen_h = 87.0
# Panel center
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
# Screen rectangle corners
screen_x0 = center_x - (screen_w / 2)
screen_y0 = center_y - (screen_h / 2)
screen_x1 = center_x + (screen_w / 2)
screen_y1 = center_y + (screen_h / 2)
screen = pcbnew.PCB_SHAPE(board)
screen.SetShape(pcbnew.SHAPE_T_RECT)
screen.SetLayer(pcbnew.Dwgs_User)
screen.SetStart(
pcbnew.VECTOR2I(
pcbnew.FromMM(screen_x0),
pcbnew.FromMM(screen_y0)
)
)
screen.SetEnd(
pcbnew.VECTOR2I(
pcbnew.FromMM(screen_x1),
pcbnew.FromMM(screen_y1)
)
)
screen.SetWidth(pcbnew.FromMM(0.2))
board.Add(screen)
# =========================
# FIXTURE RECTANGLE
# =========================
fixture_w = 200.0
fixture_h = 130.0
# Fixture rectangle corners
fixture_x0 = center_x - (fixture_w / 2)
fixture_y0 = center_y - (fixture_h / 2)
fixture_x1 = center_x + (fixture_w / 2)
fixture_y1 = center_y + (fixture_h / 2)
fixture = pcbnew.PCB_SHAPE(board)
fixture.SetShape(pcbnew.SHAPE_T_RECT)
fixture.SetLayer(pcbnew.Dwgs_User)
fixture.SetStart(
pcbnew.VECTOR2I(
pcbnew.FromMM(fixture_x0),
pcbnew.FromMM(fixture_y0)
)
)
fixture.SetEnd(
pcbnew.VECTOR2I(
pcbnew.FromMM(fixture_x1),
pcbnew.FromMM(fixture_y1)
)
)
fixture.SetWidth(pcbnew.FromMM(0.2))
board.Add(fixture)