A slicer for 3D printing: perimeters and infill

Before a print, a slicer reads the part, cuts it into layers, and turns every layer into the paths that the nozzle follows. The example is a slicer for a chess rook: OpenCASCADE reads a STEP file and cuts the solid with a plane at each layer’s height. planex2d takes each section as a face and makes the toolpaths: five perimeters as offsets of the layer’s outline, and infill lines clipped to what is left inside, turned by 90° from layer to layer. Each toolpath, offset by half the line width, is the bead of plastic the nozzle lays down. Lengths are in mm.

A chess rook sliced into layers with perimeters and infill, built up layer by layer

[1]:
import math
import time
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from IPython.display import HTML
import netgen.occ as occ
from ngsolve_webgpu.jupyter import Draw
import planex as px

The part

The part is a chess rook, turned from a profile, with four slots in its crown, a bore from the top and a window through its waist. It is written to a STEP file and read back from it, as a slicer would receive it.

[2]:
profile = [(0, 0), (15, 0), (15, 3), (9, 8), (9, 28), (12, 31), (12, 40), (7, 40), (7, 25), (0, 25)]
side = px.QuickSketch(px.Plane(normal=(0, -1, 0), axis=(1, 0, 0))).Move(profile[0])
for p in profile[1:]:
    side.Line(p)
body = side.Close().Face().ToOCCFace().Revolve(occ.Axis((0, 0, 0), occ.Z), 360)
slot = occ.Box(occ.Pnt(-2.5, -20, 35), occ.Pnt(2.5, 20, 41))
window = occ.Cylinder(occ.Pnt(-20, 0, 18), occ.X, 2.5, 40)
rook = body - slot - slot.Rotate(occ.Axis((0, 0, 0), occ.Z), 90) - window
rook.WriteStep("rook.step")
part = occ.OCCGeometry("rook.step").shape
print("volume", round(part.mass, 3), "mm³,", len(part.faces), "faces")
Draw(part, euler_angles=[-64.62, 0.29, 72.18])
volume 11471.181 mm³, 25 faces
3D view
▶ Click to interact

The part as the slicer read it: rook.step

A layer

Cutting the part with a plane at the layer’s mid-height gives the section’s faces, and projected onto the xy plane, they are planex2d faces. Where the plane cuts the cone at the rook’s foot, OpenCASCADE returns each circle as two half circles, and planex2d reads them as one exact circle edge. Check: the layer’s area is the section’s area as OpenCASCADE measures it.

[3]:
def section(z):
    plane = px.QuickSketch(px.XY.Offset(z)).RectangleC(200, 200).Face().ToOCCFace()
    return part * plane


def layer(z):
    return px.Unify([px.Face(px.Project(f)) for f in section(z).faces])


for z in (1.0, 4.0, 18.0, 30.0, 37.0):
    print("z =", z, len(layer(z).faces), "pieces, planex2d", round(layer(z).mass, 6), "OCC",
          round(sum(f.mass for f in section(z).faces), 6))
z = 1.0 1 pieces, planex2d 706.858347 OCC 706.858347
z = 4.0 1 pieces, planex2d 598.284905 OCC 598.284905
z = 18.0 2 pieces, planex2d 165.640192 OCC 165.640192
z = 30.0 1 pieces, planex2d 226.194671 OCC 226.194671
z = 37.0 4 pieces, planex2d 197.162995 OCC 197.162995

Toolpaths

A 0.25 nozzle lays lines width = 0.25 wide in layers height = 0.2 high. The rules:

  • the k-th perimeter runs along the outline of the layer shrunk by (k + 1/2) line widths, which is the layer minus a band around its outline, the outline’s offset;

  • the infill fills the layer shrunk by the five perimeters with parallel lines spaced for 20 %, five line widths apart, found by intersecting each line with the outline and keeping the pieces inside;

  • the bottom three layers are filled solid.

Every toolpath offset by half a line is its bead, and a compound keeps the beads as they are, overlapping where the nozzle passes twice.

[4]:
width = 0.25
height = 0.2


def outlines(shape):
    return [w for f in shape.faces for w in (f.outer, *f.inner)]


def shrunk(shape, d):
    return shape - px.Unify([w.Offset(d) for w in outlines(shape)])


def infill(region, angle, gap):
    if region.mass < 1e-9:
        return []
    (x0, y0), (x1, y1) = region.bounding_box
    cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
    reach = math.hypot(x1 - x0, y1 - y0) / 2 + 1
    ux, uy = math.cos(math.radians(angle)), math.sin(math.radians(angle))
    lines = []
    for n in range(-int(reach / gap), int(reach / gap) + 1):
        x, y = cx - uy * n * gap, cy + ux * n * gap
        a, b = (x - reach * ux, y - reach * uy), (x + reach * ux, y + reach * uy)
        hits = sorted({round((v.h - a[0]) * ux + (v.v - a[1]) * uy, 9)
                       for f in region.faces for v in f.Intersections(px.Line(a, b))})
        for s0, s1 in zip(hits, hits[1:]):
            mid = (a[0] + (s0 + s1) / 2 * ux, a[1] + (s0 + s1) / 2 * uy)
            if any(f.Contains(mid) for f in region.faces):
                lines.append(px.Line((a[0] + s0 * ux, a[1] + s0 * uy), (a[0] + s1 * ux, a[1] + s1 * uy)))
    return lines


def toolpaths(n):
    z = (n + 0.5) * height
    region = layer(z)
    walls = [w for k in range(5) for w in outlines(shrunk(region, (k + 0.5) * width))]
    lines = infill(shrunk(region, 5 * width), 45 + 90 * n, width if n < 3 else 5 * width)
    return {"z": z, "area": region.mass, "perimeters": walls, "infill": lines}


def beads(paths):
    return {kind: px.Compound([w.Offset(width / 2) for w in paths[kind]]) for kind in ("perimeters", "infill")}

The beads of three layers: in the foot, through the window and in the crown, the perimeters red and the infill yellow.

../_images/examples_slicer_10_0.png

All layers

The part’s height is divided into layers of 0.2. Check: the layers’ areas times the layer height add up to the part’s volume, up to the slicing error at the slopes. The length of the toolpaths gives the print time and the filament.

[6]:
start = time.perf_counter()
layers = [toolpaths(n) for n in range(round(40 / height))]
length = sum(w.mass for paths in layers for w in paths["perimeters"] + paths["infill"])
print(len(layers), "layers in", round(time.perf_counter() - start, 1), "s")
print("layers' volume", round(sum(p["area"] for p in layers) * height, 1), "mm³, part", round(part.mass, 1), "mm³")
print(round(length / 1000, 1), "m of toolpath,", round(length / 40 / 60), "min at 40 mm/s")
print(round(length * width * height / (math.pi * 1.75**2 / 4) / 1000, 2), "m of 1.75 mm filament")
200 layers in 1.6 s
layers' volume 11474.1 mm³, part 11471.2 mm³
115.1 m of toolpath, 48 min at 40 mm/s
2.39 m of 1.75 mm filament

Layer by layer

The figure shows every third layer, with the perimeters’ beads stacking up on the left, the layer’s beads on the right, and the infill turning from one layer to the next.

[7]:

G-code

The output is the simplest G-code that a printer runs, with millimeters, absolute positions, relative extrusion, one feed rate and no retraction. Each toolpath becomes a travel move to its start and extruding moves along its points, with arcs as short chords. The filament for a move is its length times the line’s width and height, divided by the filament’s cross-section. The comments are the ones PrusaSlicer writes, so its G-code viewer shows the layers and which lines are perimeters and which infill. The part sits at the bed’s center. The filament in the file must be what the toolpaths’ length gave above.

[8]:
bed = (125.0, 105.0)
feed = 40 * 60
filament = math.pi * 1.75**2 / 4


def chords(w):
    shape = w.ToShapely(0.01)
    return [list(part.coords) for part in getattr(shape, "geoms", [shape])]


gcode = ["; a chess rook, sliced with planex2d", "G21", "G90", "M83", "M104 S210", "M140 S60", "M190 S60", "M109 S210",
         "G28", f"G1 F{feed}"]
extruded = 0.0
for n, paths in enumerate(layers):
    top = paths["z"] + height / 2
    gcode += [";LAYER_CHANGE", f";Z:{top:.2f}", f";HEIGHT:{height}", f"G1 Z{top:.3f} F{feed}"]
    for kind, label in (("perimeters", "Perimeter"), ("infill", "Solid infill" if n < 3 else "Internal infill")):
        gcode += [f";TYPE:{label}", f";WIDTH:{width}"]
        for w in paths[kind]:
            for points in chords(w):
                gcode.append(f"G0 X{points[0][0] + bed[0]:.3f} Y{points[0][1] + bed[1]:.3f}")
                for p, q in zip(points, points[1:]):
                    e = math.dist(p, q) * width * height / filament
                    extruded += e
                    gcode.append(f"G1 X{q[0] + bed[0]:.3f} Y{q[1] + bed[1]:.3f} E{e:.5f}")
gcode += ["M104 S0", "M140 S0", "M84"]
with open("rook.gcode", "w") as file:
    file.write("\n".join(gcode) + "\n")
print("rook.gcode:", len(gcode), "lines,", round(extruded / 1000, 3), "m of filament")
print("from the toolpaths' length", round(length * width * height / filament / 1000, 3), "m")
rook.gcode: 95342 lines, 2.391 m of filament
from the toolpaths' length 2.392 m

The G-code: rook.gcode

In PrusaSlicer

The file is valid G-code. Opened in PrusaSlicer’s G-code viewer, it shows all 200 layers with the crown’s slots, the bore and the window, and the viewer counts the same 2.39 m (7.16 g) of filament. It can be printed as it is on a printer with a 0.25 mm nozzle and a 250 × 210 bed, whose middle is (125, 105).

The rook's G-code in PrusaSlicer's G-code viewer