initial
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
output/
|
||||
|
||||
28
Readme.md
Normal file
28
Readme.md
Normal 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.
|
||||
|
||||

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