RasmahRasmah

CNC Toolpaths and 3D-Print Slicing

Overview

A design on its own is geometry; a fabricated part is geometry plus a plan for the machine that makes it. CNC milling removes material with a spinning cutter that must be steered over every region to be cleared; 3D printing deposits material layer by layer, so the plan is a stack of 2D perimeters and fill patterns. Both are, at heart, the same idea: turn a solid into an ordered sequence of tool positions — a toolpath — and then serialize it to the machine’s G-code.

Rasmah models this with one neutral data structure, the ToolPath: a tool plus an ordered list of motions (rapid, linear, arc, helical), each carrying a cutting position. A milling strategy (pocketing, profiling, drilling) produces a ToolPath; a post-processor consumes one to write G-code; the stock simulator consumes one to subtract the swept tool volume from the workpiece.

using Rasmah
using LinearAlgebra

A part to machine

Take a box and slice its top face to get the pocket region — the 2D loop(s) a 2.5D tool has to clear. slice_contours intersects a mesh with a horizontal plane and returns closed perimeters.

part = box_mesh([-0.01, -0.01, -0.01], [0.01, 0.01, 0.01])   # 20 mm cube
region = slice_contours(part, 0.01)                          # outline at the top

println("loops:      ", length(region))
println("loop area:  ", contour_area(region[1]), " m²")       # 0.0004 = 20×20 mm
loops:      1
loop area:  0.0004 m²

The region is one square loop. vtk shows the solid we will machine:

vtk(part)

A pocket: clear the interior

A pocket removes everything inside the region down to a floor. pocket_zigzag does this with a rectilinear raster, stepping down in stepdown increments and finishing with a pass along the wall. A machinist first sets the speeds and feeds: for a 4 mm carbide end mill in aluminum, the catalog gives about 16 000 rpm and 3.2 m/min — a 50% stepover is a roughing pass:

tool = EndMill(0.004)   # 4 mm flat end mill

rpm, feed_mmin, sfm = recommended_cut(:aluminum, 0.004)

tp = pocket_zigzag(region, tool; depth=0.002, stepover=0.002, stepdown=0.002,
                   feedrate=feed_mmin / 60, spindle=rpm)

println("motions:     ", num_motions(tp))
println("path length: ", round(ustrip(toolpath_length(tp)); digits=4), " m")
println("rpm:         ", round(rpm; digits=0), ", feed: ", round(feed_mmin * 1000; digits=0), " mm/min")
println("cutting time: ", round(toolpath_time(tp); digits=1), " s")
motions:     49
path length: 0.521 m
rpm:         15915.0, feed: 3183.0 mm/min
cutting time: 10.6 s

The feed follows from the chip-load kinematics — v = π d N for the surface speed, feed = chip load × N × teeth — applied to the catalog values for the material; see the Feeds and Speeds tutorial for the full machining plan.

Every motion is either a RapidMove (position at speed) or a LinearMove (cutting). The first and last motions are retracts to a safe height:

println(first(tp.motions) isa RapidMove)   # true
println(last(tp.motions) isa RapidMove)    # true
true
true

G-code for any controller

The same toolpath serializes to any dialect. Fanuc uses O1000 + (...) comments, Marlin uses ; comments and absolute extrusion:

g = gcode(tp; dialect=:fanuc)
print(first(split(g, "\n"), 6))
SubString{String}["%", "O1000", "G90", "G21", "G17", "( Rasmah )"]

A drilling cycle on the same part: drill emits a peck cycle (full retract every peck):

d = Drill(0.002)
holes = [[0.005, 0.005, 0.01]]
td = drill(holes, d; depth=0.008, peck=0.004)

println("peck motions: ", num_motions(td))
println("bottom z:     ", minimum(m.target[3] for m in td.motions))
peck motions: 5
bottom z:     0.002

Verify by removing material

The stock simulator subtracts the tool’s swept volume from the workpiece, so the removed volume is exactly the material the tool cleared. A single plunge of a flat end mill removes a cylinder, so removed_volume matches π r² h:

stock = stock_box([-0.01, -0.01, -0.01], [0.01, 0.01, 0.01])
t = EndMill(0.004)
plunge = ToolPath(t)
push_rapid!(plunge, [0.0, 0.0, 0.02])
push_cut!(plunge, [0.0, 0.0, 0.008])     # 2 mm into the top

rv = removed_volume(stock.mesh, plunge; n=64)
println("removed:      ", rv, " m³")
println("π r² h:       ", π * 0.002^2 * 0.002, " m³")
removed:      2.4869802775864293e-8 m³
π r² h:       2.5132741228718345e-8 m³

3D printing: slice and print

The additive side slices a solid into layers, then writes perimeters and infill with an extrusion axis. print_plan slices; print_gcode emits the full Marlin program (home, heat, then G1 … E… extrusion moves). zmin/zmax restrict the print to the middle 10 mm band of the 20 mm cube; at a realistic 0.2 mm layer height that is 51 layers:

plan = print_plan(part; layer_height=0.2e-3, zmin=-0.005, zmax=0.005,
                  perimeters=2, infill_density=0.2)

pg = print_gcode(plan; bed_temp=60, hotend_temp=210)
println("layers:       ", length(plan.layers))
println("lines of g-code: ", length(split(pg, "\n")))
println(occursin("M109", pg), " (wait hotend), ", occursin("E", pg), " (extrusion)")
layers:       51
lines of g-code: 2767
true (wait hotend), true (extrusion)

vase_mode instead prints a single continuous spiral wall, useful for vases and thin shells — one 0.2 mm lap at a time:

vase = vase_mode(part; layer_height=0.2e-3)
println("vase motions: ", num_motions(vase))
vase motions: 801

Summary

  • A ToolPath (tool + RapidMove/LinearMove/ArcMove/HelicalMove) is the neutral representation; strategies produce it, gcode serializes it, and simulate_stock verifies it.
  • Milling strategies (pocket_zigzag, profile_milling, drill, adaptive_clearing, parallel_finish, turning_*, wire_edm, laser_profile) and printing (print_plan, print_gcode, vase_mode) all emit the same ToolPath.
  • Verification is closed-form: pocket area, π r² h removed volume, extrusion volume balance — not just “it runs”.

Next steps