Compare commits
3 Commits
ed1c4c4fd6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 35fed71971 | |||
| eada18d3f0 | |||
| 3b423af5b5 |
1
CAD/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.FCBak
|
||||||
BIN
CAD/Case.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
CAD/Case_exploded.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
CAD/Flow_Controller_Case.FCStd
Normal 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)
|
(at 44.45 49.53 0)
|
||||||
(effects
|
(effects
|
||||||
(font
|
(font
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ A fail-safe water flow monitoring module designed to protect CNC spindles and la
|
|||||||
|
|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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.
|
* **Output:** 3-pin CNC interface (+24V, GND, ALARM_CNC). Connects directly to standard CNC active-low sinking inputs.
|
||||||
|
|
||||||
## Panel Design and GCode
|
## 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)
|
## Microcontroller Pin Mapping (Per Schematic)
|
||||||
| Pin | Schematic Net | Function | MCU Port (SOP8) |
|
| Pin | Schematic Net | Function | MCU Port (SOP8) |
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 740 KiB After Width: | Height: | Size: 808 KiB |
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 780 KiB |
|
Before Width: | Height: | Size: 250 KiB |
5
scripts/.gitignore
vendored
@@ -1,5 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
panel/
|
|
||||||
output/
|
|
||||||
gcode/
|
|
||||||
.venv/
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||

|
|
||||||
|
|
||||||
# Install kikit
|
|
||||||
Install `kikit`:
|
|
||||||
```bash
|
|
||||||
pipx install --system-site-packages kikit
|
|
||||||
```
|
|
||||||
|
|
||||||
# KiKit Fixture Processor
|
|
||||||
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
|
|
||||||
# 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)
|
|
||||||
## Exporting gcode files from KiCad
|
|
||||||
|
|
||||||
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!!!*
|
|
||||||
|
|
||||||
Run the export by providing the `.kicad_pcb` file as a first argument:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Input layers/filenames and all milling/drilling parameters are taken from the config file: 'millproject'.
|
|
||||||
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.
|
|
||||||
* Load the `gcode/drill.ngc` file for drilling holes.
|
|
||||||
* Load the `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 `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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
# 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).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
### 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`.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
./export.sh \
|
|
||||||
--layers Front \
|
|
||||||
--invert Front \
|
|
||||||
--mirror Front \
|
|
||||||
--exposure 60 \
|
|
||||||
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.
|
|
||||||
|
|
||||||
`--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
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
**`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` |
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
Open the `.pm4n` in Chitubox 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 30–120s depending on board vintage and storage.
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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})")
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# export.sh — KiCad Gerber export + pm4n generation for Anycubic Photon Mono 4
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./export.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, e.g. F.Cu,B.Mask)
|
|
||||||
# --mirror LAYER,LAYER,... Layers to mirror (comma-separated, e.g. F.Cu,F.Mask)
|
|
||||||
# --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)
|
|
||||||
# -h, --help Show this help
|
|
||||||
#
|
|
||||||
# Example:
|
|
||||||
# ./export.sh --invert F.Cu,B.Mask --mirror F.Cu,F.Mask panel/Flow_Controller_Panel.kicad_pcb
|
|
||||||
#
|
|
||||||
# Layer name → Gerber filename mapping (KiCad default):
|
|
||||||
# F.Cu → <board>-F_Cu.gbr
|
|
||||||
# B.Cu → <board>-B_Cu.gbr
|
|
||||||
# F.Mask → <board>-F_Mask.gbr
|
|
||||||
# B.Mask → <board>-B_Mask.gbr
|
|
||||||
# F.SilkS → <board>-F_Silkscreen.gbr
|
|
||||||
# (etc.)
|
|
||||||
|
|
||||||
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=""
|
|
||||||
|
|
||||||
# ---- helpers ----
|
|
||||||
usage() {
|
|
||||||
sed -n '/^# Usage/,/^[^#]/{ /^#/{ s/^# \{0,1\}//; p } }' "$0"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
contains() { # contains <list> <item> — comma-separated list membership
|
|
||||||
local list="$1" item="$2"
|
|
||||||
echo "$list" | tr ',' '\n' | grep -qx "$item"
|
|
||||||
}
|
|
||||||
|
|
||||||
layer_to_filename() { # KiCad layer name → Gerber filename stem
|
|
||||||
local layer="$1"
|
|
||||||
echo "$layer" | sed 's/\./_/g'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- 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 ;;
|
|
||||||
-h|--help) usage ;;
|
|
||||||
-*) echo "Unknown option: $1"; exit 1 ;;
|
|
||||||
*) PCB_FILE="$1"; shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -z "$PCB_FILE" ]]; then
|
|
||||||
echo "ERROR: no .kicad_pcb file specified"
|
|
||||||
echo "Usage: $0 [OPTIONS] <board.kicad_pcb>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f "$PCB_FILE" ]]; then
|
|
||||||
echo "ERROR: file not found: $PCB_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f "$DUMMY" ]]; then
|
|
||||||
echo "ERROR: dummy .pm4n not found: $DUMMY"
|
|
||||||
echo "Place Dummy.pm4n next to export.sh, or pass --dummy <path>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- derive names ----
|
|
||||||
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 via kicad-cli ----
|
|
||||||
echo "=== Exporting Gerbers from KiCad ==="
|
|
||||||
echo " Board: $PCB_FILE"
|
|
||||||
echo " Layers: $LAYERS"
|
|
||||||
echo " Output: $GERBERS_DIR"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
kicad-cli pcb export gerbers \
|
|
||||||
--output "$GERBERS_DIR" \
|
|
||||||
--layers "$LAYERS" \
|
|
||||||
--no-protel-ext \
|
|
||||||
--subtract-soldermask \
|
|
||||||
"$PCB_FILE"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ---- Step 2: convert each layer to .pm4n ----
|
|
||||||
echo "=== Converting Gerbers 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: expected Gerber not found: $GBR_FILE — skipping"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
OUT_PM4N="$PM4N_DIR/${BOARD_NAME}-${LAYER_STEM}.pm4n"
|
|
||||||
|
|
||||||
# Build flags
|
|
||||||
FLAGS=()
|
|
||||||
if contains "$INVERT_LAYERS" "$LAYER"; then FLAGS+=(--invert); fi
|
|
||||||
if contains "$MIRROR_LAYERS" "$LAYER"; then FLAGS+=(--mirror); fi
|
|
||||||
[[ -n "$DPMM" ]] && FLAGS+=(--dpmm "$DPMM")
|
|
||||||
[[ -n "$POS" ]] && FLAGS+=(--pos "$POS")
|
|
||||||
|
|
||||||
echo " Layer: $LAYER"
|
|
||||||
echo " Gerber: $GBR_FILE"
|
|
||||||
echo " pm4n: $OUT_PM4N"
|
|
||||||
echo " Flags: ${FLAGS[*]:-<none>} exposure=${EXPOSURE}s"
|
|
||||||
|
|
||||||
"$PYTHON" "$CONVERTER" \
|
|
||||||
"$DUMMY" \
|
|
||||||
"$GBR_FILE" \
|
|
||||||
--output "$OUT_PM4N" \
|
|
||||||
--exposure "$EXPOSURE" \
|
|
||||||
"${FLAGS[@]}"
|
|
||||||
echo ""
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "=== Done ==="
|
|
||||||
echo "pm4n files in: $PM4N_DIR"
|
|
||||||
@@ -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()
|
|
||||||
@@ -1,287 +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 Layer exposure time in seconds [default: 60]
|
|
||||||
--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)
|
|
||||||
--help Show this message
|
|
||||||
|
|
||||||
Photon Mono 4 specs: 9024 x 5120 px | 153.408 x 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)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Photon Workshop RLE (BW — 2 bytes per run)
|
|
||||||
#
|
|
||||||
# Byte0 [7:4] = colour nibble (0x0 = black, 0xF = white)
|
|
||||||
# Byte0 [3:0] = high 4 bits of run length (bits 11:8)
|
|
||||||
# Byte1 = low 8 bits of run length (bits 7:0)
|
|
||||||
# Run length encodes (n-1): 0x000 = 1 pixel, 0xFFF = 4096 pixels
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
MAX_RUN = 4096
|
|
||||||
|
|
||||||
def encode_rle(pixels: bytes) -> bytes:
|
|
||||||
"""Encode flat 0x00/0xFF bytes → Photon Workshop BW RLE."""
|
|
||||||
out = bytearray()
|
|
||||||
i = 0
|
|
||||||
n = 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
|
|
||||||
encoded = run - 1
|
|
||||||
out.append((nibble << 4) | ((encoded >> 8) & 0x0F))
|
|
||||||
out.append(encoded & 0xFF)
|
|
||||||
i = j
|
|
||||||
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
|
|
||||||
#
|
|
||||||
# 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,
|
|
||||||
invert: bool, mirror: bool,
|
|
||||||
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:
|
|
||||||
from pygerber.gerberx3.api.v2 import (
|
|
||||||
GerberFile, ColorScheme, PixelFormatEnum, ImageFormatEnum
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
sys.exit(
|
|
||||||
"ERROR: pygerber not found.\n"
|
|
||||||
"Activate the venv first: source .venv/bin/activate\n"
|
|
||||||
"Or install: pip install pygerber Pillow numpy"
|
|
||||||
)
|
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
# Place onto full LCD canvas
|
|
||||||
cw, ch = layer_img.size
|
|
||||||
canvas = Image.new('L', (LCD_W_PX, LCD_H_PX), 0)
|
|
||||||
|
|
||||||
if pos_mm is not None:
|
|
||||||
px = int(round(pos_mm[0] * dpmm))
|
|
||||||
py = int(round(pos_mm[1] * dpmm))
|
|
||||||
else:
|
|
||||||
px = (LCD_W_PX - cw) // 2
|
|
||||||
py = (LCD_H_PX - ch) // 2
|
|
||||||
|
|
||||||
canvas.paste(layer_img, (max(0, px), max(0, py)))
|
|
||||||
|
|
||||||
if mirror:
|
|
||||||
canvas = ImageOps.mirror(canvas)
|
|
||||||
if invert:
|
|
||||||
canvas = ImageOps.invert(canvas)
|
|
||||||
|
|
||||||
# Hard-binarise: no antialiasing artefacts in the RLE stream
|
|
||||||
canvas = canvas.point(lambda v: 255 if v >= 128 else 0)
|
|
||||||
|
|
||||||
return canvas
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# pm4n surgery
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def patch_pm4n(dummy_path: Path, image: Image.Image,
|
|
||||||
exposure_sec: float, output_path: Path):
|
|
||||||
"""Replace layer RLE + exposure time in a dummy .pm4n, write output."""
|
|
||||||
raw = bytearray(dummy_path.read_bytes())
|
|
||||||
|
|
||||||
# Encode new layer image
|
|
||||||
pixels = image.convert('L').tobytes()
|
|
||||||
new_rle = encode_rle(pixels)
|
|
||||||
|
|
||||||
# Patch exposure time: scan HEAD for any float in 0.5–600 s range
|
|
||||||
hdr_off, hdr_len = find_section(raw, b'HEAD')
|
|
||||||
for off in range(hdr_off, hdr_off + hdr_len - 3):
|
|
||||||
val = struct.unpack_from('<f', raw, off)[0]
|
|
||||||
if 0.5 <= val <= 600.0:
|
|
||||||
patch_f32(raw, off, exposure_sec)
|
|
||||||
|
|
||||||
# Locate layer image via LAYERDEF
|
|
||||||
ld_off, _ = find_section(raw, b'LAYE')
|
|
||||||
layer_count = struct.unpack_from('<I', raw, ld_off)[0]
|
|
||||||
if layer_count != 1:
|
|
||||||
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")
|
|
||||||
print(f" New RLE: {len(new_rle)} bytes")
|
|
||||||
|
|
||||||
# Splice new RLE in place
|
|
||||||
old_end = img_offset + img_len
|
|
||||||
raw[img_offset:old_end] = new_rle
|
|
||||||
|
|
||||||
# Update LAYERDEF length field
|
|
||||||
patch_u32(raw, entry_off + 4, len(new_rle))
|
|
||||||
|
|
||||||
# Update any enclosing section's length field
|
|
||||||
for tag, sec_off, sec_len in read_sections(bytes(raw)):
|
|
||||||
if tag not in (b'HEAD', b'LAYE', b'PREV') and sec_off <= img_offset < sec_off + sec_len:
|
|
||||||
patch_u32(raw, sec_off - 4, sec_len + len(new_rle) - img_len)
|
|
||||||
break
|
|
||||||
|
|
||||||
output_path.write_bytes(raw)
|
|
||||||
print(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',
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog=__doc__,
|
|
||||||
)
|
|
||||||
p.add_argument('dummy', help='Dummy .pm4n template (from Photon Workshop)')
|
|
||||||
p.add_argument('gerber', help='Input Gerber file')
|
|
||||||
p.add_argument('-o', '--output', default=None, help='Output .pm4n path')
|
|
||||||
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('--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('--pos', default=None, help='Board position X,Y mm from top-left')
|
|
||||||
return p.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = parse_args()
|
|
||||||
dummy = Path(args.dummy)
|
|
||||||
gbr = Path(args.gerber)
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
print(f"Gerber: {gbr}")
|
|
||||||
print(f"Dummy: {dummy}")
|
|
||||||
print(f"Output: {out}")
|
|
||||||
print(f"Invert: {args.invert} Mirror: {args.mirror} Exposure: {args.exposure}s dpmm: {args.dpmm:.3f}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("Rendering Gerber...")
|
|
||||||
img = render_gerber(gbr, dpmm=args.dpmm, invert=args.invert,
|
|
||||||
mirror=args.mirror, pos_mm=pos_mm)
|
|
||||||
print(f"Canvas: {img.size[0]}×{img.size[1]} px")
|
|
||||||
|
|
||||||
preview = out.with_suffix('.preview.png')
|
|
||||||
img.resize((img.size[0] // 4, img.size[1] // 4), Image.NEAREST).save(preview)
|
|
||||||
print(f"Preview: {preview}")
|
|
||||||
|
|
||||||
print("Patching .pm4n...")
|
|
||||||
patch_pm4n(dummy, img, args.exposure, out)
|
|
||||||
print("Done.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
# Use the 'User-Eco1' layer instead as it contains panelized board's slot outlines only.
|
|
||||||
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
|
|
||||||
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=2 # Tool changing height in mm
|
|
||||||
output-dir=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=200 # 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)
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||