This commit is contained in:
cpu
2026-06-11 18:33:07 +02:00
commit 70a48539bb
5 changed files with 498 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
output/

28
Readme.md Normal file
View File

@@ -0,0 +1,28 @@
TODO: Add table of content
# 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.sh ../kicad2panel/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)

37
export.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
set -euo pipefail
GERBERS_DIR="output/gerbers"
GCODE_DIR="output/gcode"
usage() {
echo "Usage: $0 <kicad_pcb_file>"
exit 1
}
PCB_FILE="$1"
mkdir -p "$GERBERS_DIR"
mkdir -p "$GCODE_DIR"
# Export drill, front and back layers as gerber files.
echo "Exporting gerbers..."
kicad-cli pcb export drill -o "$GERBERS_DIR" "$PCB_FILE"
kicad-cli pcb export gerbers -o "$GERBERS_DIR" -l Front "$PCB_FILE"
kicad-cli pcb export gerbers -o "$GERBERS_DIR" -l Back "$PCB_FILE"
kicad-cli pcb export gerbers -o "$GERBERS_DIR" -l Edge.Cuts "$PCB_FILE"
# Export outlines of the penelized board i.e. use the layer 'User.Eco1'.
# echo "Exporting panelized outlines from layer 'User.Eco1'..."
# python3 export_panel_outlines_gerber.py \
# --layers User.Eco1 \
# --output "$GERBERS_DIR" \
# "$PCB_FILE"
# Input layers/filenames and all milling/drilling parameters are taken from the config file: 'millproject'.
echo "Exporting Gcode..."
docker run --rm -i -t \
-u "$(id -u):$(id -g)" \
-v "$(pwd):/data" \
ptodorov/pcb2gcode

View File

@@ -0,0 +1,376 @@
"""
=============================================================================
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()

55
millproject Normal file
View File

@@ -0,0 +1,55 @@
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)