src/gcode/README.md in the repository — edit it there.gcode — G-code generation
Converts Vec<SliceLayer> → a firmware-ready G-code String.
Module layout
gcode/
├── mod.rs re-exports; module-level docs
├── flavor.rs GcodeFlavor enum (Marlin | Klipper)
├── dialect.rs GcodeDialect trait + WarnFn + header()
├── generator.rs GcodeGenerator façade + generate_gcode()
├── stats.rs SliceStatistics + metadata/settings header lines
├── simplify.rs output-stage path simplification (micro-segment merge + RDP)
├── time_estimate.rs acceleration-aware print-time estimator
├── source.rs resolve_gcode_source() file/string resolver
└── dialects/
├── mod.rs re-exports
├── marlin.rs MarlinDialect (M104/M109/M140/M190; HEADER_BLOCK header)
└── klipper.rs KlipperDialect (START_PRINT / END_PRINT; KLIPPER_HEADER)Call flow
Dialect abstraction
Object markers
Three trait methods let a firmware attribute every move to a named part and cancel one mid-print. The defaults implement M486 — the RepRap standard, understood by Marlin 2.0.9.3+, RepRapFirmware and Prusa — and KlipperDialect overrides them with EXCLUDE_OBJECT_*, which also carries the footprint so the firmware knows where a cancelled object lives.
| Method | Default (M486) | Klipper |
|---|---|---|
object_definitions | M486 T<n> + comments | EXCLUDE_OBJECT_DEFINE NAME= CENTER= POLYGON= |
object_start | M486 S<i> [A"name"] | EXCLUDE_OBJECT_START NAME= |
object_end | M486 S-1 | EXCLUDE_OBJECT_END NAME= |
The generator drives them from the per-path tags in SliceLayer::path_objects (see src/core/objects.rs); with no objects attached the output is unchanged. Three placement rules matter:
- Definitions precede the start script. Klipper's
[exclude_object]module and Moonraker both expect to meet every object before the print begins. - The block switches before a path's travel, so the hop between two parts is charged to the one it is heading for — the PrusaSlicer / OrcaSlicer convention, and what lets a firmware skip a cancelled object's approach moves too.
- A
Nonetag closes the block without opening one. Bed adhesion belongs to the plate, so it must still print when a single part is cancelled.
Two more, about what goes inside the markers:
M486 A"name"is spent once per object (first_use). Repeating the name on every layer would bloat the file for no gain.- Klipper's exclusion polygon is a convex hull, resampled to ≤ 64 points. A turned cylinder hulls to one vertex per facet, which would push a multi-kilobyte line into the file for no added precision.
In by_object (sequential) order the generator additionally hands over between objects before the layer's own Z move: close the block → retract → lift above the tallest thing already printed → travel across → between_objects_gcode. Doing any of that afterwards would lower the nozzle into the part just finished.
Spiral (vase) mode
The pipeline hands over a plain single-wall slice; the spiralization happens here. A spiral layer is one at index ≥ bottom_layers.max(1) — layer 0 is always flat, since a spiral cannot climb from Z = 0 — that exposes exactly one outermost closed OuterWall loop.
detect_spiral_loop finds it. Hole sub-loops are ignored by a winding-independent point-in-polygon containment test, so a solid island with holes still spiralizes as one contour.
For those layers the discrete per-layer move_z is skipped and emit_spiral_loop walks the loop once, ramping Z from the previous layer's top to this layer's Z in proportion to the distance travelled (move_extrude_z).
- Flow fades in over the first spiral loop and out over the last, so both ends of the seam disappear. It is applied as a multiplier after
extrusion_for_move— passing zero as that function'sflow_ratiowould trip its "non-positive → 1.0" guard and lay a full bead instead of none. - Each loop is rotated to start nearest the previous nozzle position, keeping the start line aligned and travel minimal.
- A multi-island layer falls back to a normal flat print — all paths, discrete Z — with a single warning. Spiral vase is for solid, single-island models.
Which settings are forced off, and why the normalization is idempotent, is core's half of the contract.
Bed mesh leveling
bed_mesh_mode is off by default: most printers already level inside their own start macro or a one-time manual calibration, and a slicer directive on top of that would either duplicate the work or race it. Turning it on hands the mesh step to the slicer instead, so it survives across different custom start scripts without being copied into each one by hand.
| Mode | Marlin / RepRapFirmware (default) | Klipper |
|---|---|---|
off | (nothing emitted) | (nothing emitted) |
load_profile | M420 S1 | BED_MESH_PROFILE LOAD=<name> |
calibrate | G29 (bounded by L/R/F/B when adaptive), then M420 S1 | BED_MESH_CALIBRATE (bounded by AREA_MIN/AREA_MAX when adaptive) |
bed_mesh_adaptive bounds calibrate to the print's own XY footprint — computed once from every extrusion point across every layer — instead of probing the whole bed, on firmware that honours the bounds. load_profile always restores the named (or default) saved mesh regardless of footprint; there is nothing to bound.
The directive is emitted right after the start script (custom or the dialect default), so homing has already run — probing an unhomed axis either faults or reads garbage. A start script that already probes or loads a mesh owns the job: if it contains G29, M420, BED_MESH_CALIBRATE or BED_MESH_PROFILE, the generator emits a note and suppresses its own directive rather than probing twice or fighting over which mesh ends up active.
Extrusion math
For each XY segment of length L the required filament advance is:
E = L × (layer_height × nozzle_ø) / (π × (filament_ø/2)²)This is the volumetric flow balance: the rectangular cross-section of the deposited bead (layer_height × nozzle_ø) must equal the volume of filament pushed through (π r² × E).
Both diameters are configurable via SlicingParams (fields nozzle_diameter_mm and filament_diameter_mm). Typical defaults: filament ø = 1.75 mm, nozzle ø = 0.40 mm.
Metadata header & config-block footer
Two blocks bracket the program, and they serve different readers.
| Block | Where | Reader |
|---|---|---|
GcodeDialect::header | top of file | humans — flavor-tagged, ; key: value |
GcodeDialect::footer | after the end script | machines — ; prusaslicer_config = begin … end |
Both render from one SliceStatistics built after the body is emitted, so their figures always agree with the moves actually written.
The footer is what printer front-ends parse. Moonraker (Mainsail / Fluidd) identifies us as a PrusaSlicer-family slicer from the ; generated by <name> on <date> line, then scans the last 1 MiB for ; key = value comments — which is why the block is a footer, not part of the header. OctoPrint reads the same shape.
Scope rule: a line earns its place by having a parser. The footer is deliberately not a dump of all ~130 resolved settings — every field in it is read by a front-end in the wild (filament usage / cost, print time, layer count, temperatures, nozzle and filament dimensions, filament and machine identity). Anything purely informational belongs in the human header instead.
Fields sourced from a profile are omitted when unset rather than emitted empty, so a flag-only CLI slice never advertises a blank material or a free print.
Thermal management — cooling & chamber
Two contracts meet here. The filament says how hot the chamber should be and how much airflow the material tolerates; the printer says whether the machine can actually heat a chamber. Neither one alone emits anything.
Chamber heating
chamber_temp used to be a {chamber_temp} placeholder and nothing else. It is now a real directive — but only when the printer profile sets heated_chamber. That gate is not a preference, it is a safety interlock: Klipper aborts the print on an unknown command, and every ABS/ASA/PC filament preset carries a chamber temperature whether or not the machine has a chamber heater.
The set and the soak both run before the start script, and the ordering inside that block is load-bearing:
Two mistakes are avoided here, and both are worth stating because the obvious alternatives hit them:
- Waiting after the start script would park molten filament in a hot end for the length of the soak. The materials that carry a chamber target are exactly the ones that suffer for it — ABS/ASA at 250 °C, PC at 270 °C, Nylon at 260 °C.
- Soaking without arming the bed would never terminate on a bed-heated enclosure. The bed target is therefore emitted first, non-blocking; the start script sets it again and its own
M190returns immediately.
chamber_temp_first_layer (0 = inherit chamber_temp) is the soak target; the layer-2 restore mirrors how nozzle_temp_first_layer / bed_temp_first_layer already work and never blocks.
Klipper has no built-in M141/M191, so KlipperDialect overrides set_chamber_temp with SET_HEATER_TEMPERATURE HEATER=chamber TARGET=… and TEMPERATURE_WAIT SENSOR="heater_generic chamber" MINIMUM=… — needing a [heater_generic chamber] section exactly as SET_RETRACTION needs [firmware_retraction]. (TEMPERATURE_WAIT only waits, which is why the target is armed as its own command rather than folded into the wait.)
A start script that heats the chamber owns the whole job. If the custom start G-code contains M141, CHAMBER=, CHAMBER_TEMP, HEATER=chamber or heater_generic chamber, the generator emits a note and suppresses its own sequence rather than heating and soaking twice. A START_PRINT … CHAMBER={chamber_temp} macro keeps behaving exactly as it did before this feature existed. The tokens deliberately require a heating context: a bare chamber would also match a chamber fan (SET_FAN_SPEED FAN=chamber_fan, M106 P2 ; chamber fan) or a comment, and silently disabling chamber heating is the failure this feature exists to prevent.
The chamber heater is not switched off by the end script: ABS and PC want a slow cool-down, and time_estimate_cooldown_s already exists for that allowance.
Part-cooling fan
fan_configs is the printer-side adaptive table (per-fan layer-time curve, plus AuxFanOverrides for RSCS-style hybrid cooling). On top of it sits the filament-owned material policy, resolved by SlicingParams::part_cooling_speed and applied to the part-cooling fan (fan_index 0) only — hotend, chamber and aux fans keep the raw curve:
| Precedence | Condition | Emitted speed |
|---|---|---|
| 1 | layer_index < disable_fan_first_layers | first_layer_fan_speed (default 0.0 = off) |
| 2 | segment role Bridge | bridge_fan_speed |
| 3 | segment overhang ≥ overhang_fan_threshold | overhang_fan_speed |
| 4 | otherwise | min(fan_configs curve, fan_speed) |
Two properties are load-bearing:
fan_speedis a ceiling, not a duty. Clamping the adaptive curve is what gates high-temperature materials: an ABS preset caps at 30 % so the part fan cannot fight the chamber heater it just asked for. At the default1.0it is a no-op, so an unchanged profile emits unchanged G-code.- Rows 2 and 3 are suppressed while row 1 applies. A single overhang on layer 1 must not defeat the adhesion gate, so the per-segment override is not even armed on pinned layers.
Bridge cooling is unconditional (a bridge is a bridge), while the overhang boost stays tied to enable_overhang_speed; when a segment somehow qualifies for both the higher of the two wins. Both are emitted on change only, so a run of bridge lines toggles the fan at most twice per layer.
Behaviour change. Before this landed,
fan_speed,bridge_fan_speed,first_layer_fan_speedanddisable_fan_first_layerswere read by nothing — the generator drove fans purely fromfan_configs, so the part-cooling fan ran on layer 1 for every material. It no longer does.
Print-time estimation
The header / footer ETA and the viewer's Layer Time colouring come from time_estimate, an acceleration-aware estimator that parses the emitted program rather than the slice geometry. Because it measures the moves the generator actually wrote, it can never drift from them — the same principle behind PrusaSlicer's GCodeProcessor.
Each move is timed with a trapezoidal velocity profile (accelerate → cruise → decelerate). Entry/exit speeds at each corner come from a two-pass planner look-ahead, and cornering speed uses the junction-deviation model (a right angle is taken at the square-corner velocity, a straight join keeps full speed, a reversal stops). Travel moves, Z lifts and the retract/un-retract ceremony are all counted; per-role feedrates and per-role accelerations (M204 / SET_VELOCITY_LIMIT) are read straight from the emitted lines.
Machine kinematics (emitted and estimated)
To keep the estimate honest — it must describe the moves the printer actually runs — the kinematic limits are both emitted by the generator and read back by the estimator from the same SlicingParams:
| Param | Emitted as (Marlin / Klipper) | In the estimate |
|---|---|---|
acceleration | M204 P… / SET_VELOCITY_LIMIT ACCEL=… (per role) | per-move ramp rate |
square_corner_velocity | M205 J<jd> / SET_VELOCITY_LIMIT SQUARE_CORNER_VEL… | junction-deviation corner speed |
max_velocity | M203 X… Y… / SET_VELOCITY_LIMIT VELOCITY=… | per-move nominal-speed cap |
Marlin has no square-corner-velocity command, so it is converted to a junction-deviation distance jd = scv² · (√2 − 1) / accel — the exact relation junction_speed inverts. Each limit is emitted only when set (> 0), so a profile that never touched them produces byte-identical output to before.
Calibration (Bucket B)
Three SlicingParams knobs correct for wall-clock the toolpath physics cannot show. total = warmup_s + (toolpath × scale) + cooldown_s:
| Param | Effect |
|---|---|
time_estimate_scale | multiplies the toolpath portion (systematic-error fudge) |
time_estimate_warmup_s | fixed seconds added before (homing, heat-soak, purge) |
time_estimate_cooldown_s | fixed seconds added after (e.g. chamber cool-off) |
The per-layer ;LAYER_TIME: markers are scaled by scale too (so they stay consistent with the toolpath total) but carry no fixed allowance — those belong to no single layer. The physics module time_estimate stays pure; scale/offset are applied at the generator boundary.
The old naive length ÷ print_speed figure survives only as generator::estimate_layer_time — a cheap pre-move proxy still used for the adaptive fan decision (which must be emitted before a layer's moves are known); its ;LAYER_TIME: placeholder is overwritten afterward with the trapezoidal figure. On a 30 mm calibration cube the naive model under-estimated by ~57 % (≈35 min vs the realistic ≈81 min).
Not modelled: heating waits (a coarse
warmup/cooldownallowance stands in — not a thermal model), arcs, dwell, and per-axis jerk (one scalar square-corner velocity, not an X/Y/E profile). The slicer does not yet slow a layer to meet a minimum layer time, so there is no min-layer-time slowdown to account for — the day that feature lands, the estimator picks it up for free because it reads the emitted moves.
Surface generation
Top and bottom surfaces are generated by generate_top_bottom_surfaces() in src/core.rs. They use solid rectilinear infill at a configurable angle (SlicingParams::surface_infill_angle, default 45°).
Detection algorithm
A region of layer i is treated as a top surface when it is not covered by every one of the top_layers layers above it simultaneously. Formally, the surface region is computed by progressive intersection:
covered = perimeters[i]
for j in 1..=top_layers:
if layer[i+j] does not exist or is empty:
covered = ∅ ← model ends here; whole remaining region is exposed
break
covered = intersect(covered, perimeters[i+j])
if covered = ∅: break
top_region[i] = diff(perimeters[i], covered)top_region[i] is the area of layer i that is not enclosed by all top_layers consecutive layers above it. Bottom surfaces follow the same logic looking downward.
Key behaviours:
- Model top/bottom — when fewer than
Nlayers exist above/below, the intersection yields∅, so the entire current slice becomes a surface. - Mid-model surfaces — ledges, internal floors, cabin roofs, etc. are detected because the layers above/below do not fully cover the current footprint.
- Non-monotonic shapes — a region that is exposed at any of the
Nsuccessor layers (even if theN-th successor covers it) is correctly flagged as a top surface, because the progressive intersection narrows to the smallest coverage found in the window. The old single-comparison approach (diff(layer[i], layer[i+N])) could silently miss such regions.
Infill spacing & flow
Line spacing for solid surface infill uses the libslic3r/Orca/PrusaSlicer extrusion-spacing relation, derived from the solid-surface extrusion width (top_surface_line_width → line_width → nozzle diameter — see solid_surface_nominal_width_mm):
line_spacing = extrusion_width − layer_height × (1 − π/4)At a 0.4 mm nozzle / 0.2 mm layers this is ≈ 0.357 mm — below the 0.4 mm bead width so the rounded bead caps interlock (no gaps), yet well above the earlier over-extruding 1.2 × layer_height rule (0.24 mm).
Crucially, the G-code generator charges each fill line at exactly this spacing — mm³/mm = line_spacing × layer_height — not the wider nominal bead width (see resolve_width_mm). Depositing the full nominal width into the narrower pitch would over-extrude every solid surface by width / spacing (≈ 13 % at nozzle width, ≈ 23 % once line_width > nozzle) — the raised, "blobby" top-surface defect. Matching the flow to the spacing mirrors PrusaSlicer/Orca and fills the surface flat: no gaps, no bulge.
Infill direction
The solid top/bottom fill angle alternates by 90° every layer (cross-hatch), matching CuraEngine's default skin_angles = {45°, 135°}. Even layers use surface_infill_angle; odd layers use surface_infill_angle + 90°. Cross- hatching welds adjacent solid layers and hides the fill direction on the visible top surface. Because the fill lines are laid at the interlocking stadium pitch (with the flow reduced to match — see spacing above), the cross-hatch does not open gaps between beads.
Configurable parameters
SlicingParams field | Default | Description |
|---|---|---|
top_layers | 3 | Number of solid layers above a top face |
bottom_layers | 3 | Number of solid layers below a bottom face |
surface_infill_angle | 45.0° | Angle of rectilinear infill lines |
Travel sequence per path
Every path (closed contour or infill line) is preceded by a travel move from the previous path's end. Whether that travel is wrapped in the retract / z-hop / travel / lower / un-retract guard depends on a smart retract policy that mirrors PrusaSlicer / OrcaSlicer / Cura:
| Travel distance | Role change? | Retract? | Why |
|---|---|---|---|
> max(2 mm, min) | any | yes | Long hops always ooze enough to need a retract |
min – 2 mm | yes | yes | Crossing role boundaries (e.g. infill → outer wall) shows seams without retract |
min – 2 mm | no | no | Same-role short hops oozing is invisible inside infill |
≤ min | any | no | Retract ceremony costs more time than the hop itself |
≤ 5 mm, interior | no | no | The hop never leaves the part, so what it drools lands where nothing shows |
The minimum (min) is the configurable retract_before_travel_mm (default 1.0 mm); travels longer than 2 mm always retract. The role-aware branch eliminates the 99 %+ of pointless retracts that occurred on every wall-loop end on dense benchmarks, while still protecting the visible outer surface from oozing.
Interior hops
The last row is the exception that outranks the distance ceiling. A hop is interior when it crosses no outer wall and its midpoint lies on the island's own cross-section — TravelPlanner::hop_is_interior answers this, and the planner is built for every layer whether or not avoid_crossing_perimeters is on. A hop over a solid (top/bottom) region is excluded, because that region may be the visible top surface.
Containment counts every loop, holes included, not just the outlines. Inside an outline is not the same as over the part: a box's cavity, the slot between two card dividers and the bore of a tube all sit inside an outline and are all open air, and a strand hung across one is the very defect the ceremony prevents.
This matters for a part with a field of thin features — card dividers, fins, a lattice web. Each rib is one short bead, so a rib field is dozens of extrusions per layer joined by hops of a few millimetres, and the ceremony between them costs more wall-clock than the hops it guards while pumping the extruder thousands of times over a print. On a 25-slot card caddy the exemption removes roughly 85 % of the print's retractions.
Arriving at an outer wall
A travel runs at travel_acceleration, the hardest ramp in the profile. A hop that ends where an outer wall begins brakes at the wall's own acceleration instead (gentle_travel_to_outer_wall, on by default): a hard stop leaves the toolhead ringing, and the visible wall laid next would print that shake beside the seam. Hops into any other role keep the full travel acceleration. The rule lives in GcodeGenerator::travel_acceleration_into.
Routing a hop over material
An interior hop still crosses whatever is between its ends, and between two dividers that is an open slot — so the ooze the retraction no longer catches becomes a strand hanging in the slot. MaterialRouter removes that too, by re-routing the hop so it never leaves material in the first place.
- What "material" is: the layer's extrusion footprint — every wall and medial bead's physical area, the same measure surface trimming uses. Fill roles are excluded: a top surface is the last thing to drag a nozzle across.
- Where a route may turn: vertices of that footprint eroded by half a bead, plus the two ends of every open bead. Eroding keeps a route in the middle of the beads it crosses rather than along the outer edge of the wall just printed, and keeps the waypoint count an order of magnitude below the raw centerlines — which is what makes the
O(V²)visibility search affordable. The bead ends are there because erosion deletes a one-nozzle-wide rib entirely, and without them a rib meeting a wall offers nowhere to turn. - The budget is twice the straight-line distance. A longer way round costs more time than the retraction it saves and drags the nozzle further across finished beads than one strand is worth.
- A re-routed hop lifts by
z_hop_mmbefore it travels and lowers after. It runs across beads this layer already laid, and grazing them at print Z is the scar the re-route exists to avoid. The lift is the only part of the ceremony it pays — no retraction, no prime.
Routers are built for every layer in parallel, ahead of the strictly sequential G-code walk: each is a chain of Clipper offset and union calls over every bead on its layer, and building them inline dominated the phase.
Entering an open bead from the end the body can reach
An open bead has no seam, so the path orderer may start it at either end and picks whichever is nearer in a straight line. In a rib field that is the wrong answer for every second rib: the nearest end is the free tip, so the hop runs tip to tip across the slot, and the way round from one tip to the next — down a whole rib, along the body and out another whole rib — is far past the budget. Entering at the root instead is a longer straight line but a much shorter way round, comfortably inside it.
So the generator reverses an open bead when the near end has no route over material and the far end has one (MaterialRouter::prefers_far_entry). Every rib then prints root to tip, and every hop between them dives back into the body. The flip is self-limiting: it needs the near end to be genuinely unreachable, which the consecutive arcs of one split wall loop never are.
Nothing about the bead changes but its direction — same vertices, same widths, same flow. On the card caddy this cuts travel that crosses open air without a retraction from 5.5 m to 0.8 m, and the moves that do it from 2441 to 440, for 3 % on the print estimate and no change to a single extrusion.
When a retract is emitted, the sequence is:
Otherwise a single bare G1 X… Y… F travel_speed_mm_min move is emitted.
The distances and the travel speed are configurable via SlicingParams fields retract_mm (default 1.0 mm), z_hop_mm (default 0.2 mm), retract_speed_mm_min (default 2400 mm/min = 40 mm/s), and travel_speed_mm_min (default 9000 mm/min = 150 mm/s).
Advanced retraction modes
The retract / un-retract steps above adapt to several opt-in modes (all default-off, so the baseline output is unchanged):
| Setting | Effect |
|---|---|
use_firmware_retraction | Emits G10/G11 instead of G1 E moves; syncs the firmware via M207/M208 (Marlin) or SET_RETRACTION (Klipper) in the start section |
use_relative_e_distances | Emits M83 and per-move incremental E (G1 … E<delta>) instead of M82 absolute positions |
retract_before_travel_mm | Minimum travel distance that triggers a retraction (the min above) |
retract_restart_extra_mm | Extra prime length added on un-retract to compensate for travel ooze |
retract_on_layer_change | Forces a retraction before the layer-change Z move |
wipe / wipe_distance_mm | Retraces the tail of the just-printed path while retracting, smearing ooze onto printed material |
retract_before_wipe_percent | Fraction of the retraction performed before the wipe move (the rest is distributed along it) |
Under firmware retraction the slicer still performs the Z-hop with explicit Z moves (the firmware Z-hop component of M207 is set to 0), so hop behaviour is identical across modes; only the filament pull becomes G10/G11.
Path simplification
Sliced contours reach the generator far denser than a printer can use, and noisy with it. Geometry lives on Clipper2's 0.01 mm integer grid, so an offset wall around a finely tessellated curve carries clusters of vertices a hundredth of a millimetre apart whose directions snap to multiples of 45°. Streamed as G1 moves, each one is a kink the firmware's planner has to slow down for — a smooth arc stutters, and the wall shows it. The rule: nothing reaches the printer unsimplified — the spiral loop included.
Where it sits in the pipeline
Geometry calculations always use full mesh precision — simplification happens only at the output stage, so wall offsets, infill clipping, and surface detection are never degraded.
Two passes, and why both
simplify_path runs them in order; variable-width beads use simplify_path_with_widths, which also keeps any vertex where the width steps.
- Merge short segments. A vertex bounding a segment shorter than
MIN_SEGMENT_MMis dropped while everything merged into the resulting chord stays within2 × path_tolerance. This clears the grid noise without eroding a real corner or step. - Ramer-Douglas-Peucker at
path_toleranceremoves the vertices that are now redundant along straight and gently curved runs.
Neither pass does the job alone. RDP by itself cannot tell grid noise from curvature: a tolerance fine enough to keep an arc round keeps the zig-zags, and one coarse enough to drop them prints the arc as flat facets. On a grid-snapped circle, merging then RDP ends up closer to the true curve than the raw contour does.
The spiral loop is a closed contour with a fixed start vertex, so it is simplified including its closing segment (simplify_closed_loop).
Configuration — SlicingParams::path_tolerance
| Value | Effect |
|---|---|
0.0 | Disabled — every vertex, noise included, reaches the printer |
0.0125 ⭐ | Default — arcs stay round; sagitta is invisible at a 0.4 mm nozzle |
0.025–0.05 | Fewer moves, but large arcs print as visible facets |
Future-feature checklist
When adding a new feature that emits paths through GcodeGenerator:
- ✅ Nothing to do. Simplification is applied inside the generator's per-path loop, so any new path source (new infill pattern, support, brim, ironing pass, …) automatically benefits.
- ⚠️ Bypass deliberately when curvature must be preserved point-for-point (e.g. arc-fitting /
G2/G3emission, exact-position commands). Either setpath_tolerance = 0.0for that pass or perform the special-case emission before the generic generator loop. - ⚠️ Don't simplify upstream of geometry ops. Calling
simplify_pathon Clipper2 paths before offset/clip/intersect operations will cascade precision loss into walls and infill. Keep it strictly at the output layer.
Lifecycle markers
When LifecycleMarkerConfig::enabled is true (default), each layer emits a structured block compatible with OrcaSlicer / PrusaSlicer post-processors:
;LAYER_CHANGE
;Z:{z}
;HEIGHT:{height}
;BEFORE_LAYER_CHANGE
;{z} ← bare numeric marker for post-processing scripts
G92 E0 ← extruder reset (E tracking restarts each layer)
G1 Z{z} F9000
;AFTER_LAYER_CHANGE
;{z}
;TYPE:{role} ← emitted once per extrusion-role transition
;WIDTH:{w}mmAll marker strings are templates: {z}, {height}, {type}, {width} are substituted at render time via render_marker(). Per-flavor overrides are stored in GlobalSettings::lifecycle_markers (keyed by flavor name).
The ;TYPE: label comes from ExtrusionRole::type_name() (OrcaSlicer- compatible), and a fresh ;TYPE: / ;WIDTH: pair is emitted whenever the role or the extrusion width changes. Variable-width Arachne beads additionally re-emit ;WIDTH: as the bead tapers past WIDTH_MARKER_STEP_MM.
Extrusion width per role
resolve_width_mm(explicit, has_vertex_widths, role, params) decides the width used for both the extrusion flow and the ;WIDTH: annotation. Precedence, first match wins:
- Per-role override —
outer_wall_line_width,inner_wall_line_width,top_surface_line_width(top and bottom surfaces), orsparse_infill_line_width, when set (> 0) — but only for constant-width paths (no per-vertex widths). This lets a wall-width setting take effect even though the wall generator stamps an explicit, nozzle-derived width on every wall path.OverhangPerimeterfollows the outer-wall override. Variable-width Arachne beads (gap fill, tapered beads) are skipped because their per-vertex widths are authoritative. - Explicit per-path width — Arachne bead width or bridge-flow reduction.
- Generic
line_width— but only for solid infill and surfaces, preserving the rule that walls ignore the global line width (their width comes from the wall generator). - Role default — the nozzle-derived
ExtrusionRole::default_width_mm().
All per-role width fields default to 0.0 ("derive"), so an unset config behaves exactly as before.
Machine Z offset
z_offset_mm (default 0.0) compensates for a Z endstop that does not zero at the bed: a negative value lowers the nozzle (endstop leaves a gap), a positive one raises it. It follows the PrusaSlicer / OrcaSlicer z_offset semantics — the value is added to every Z coordinate written to the file, and no firmware directive (SET_GCODE_OFFSET, M290, M851) is emitted. That keeps the correction firmware-agnostic and makes the importer's mapping of a foreign z_offset a straight rename.
machine_z(z, params) is the single conversion, applied at the emission boundary only:
| Carries the offset | Stays on model Z |
|---|---|
Layer-change G1 Z | SliceLayer::z and the whole slicing pipeline |
Z-hop lift and the matching ; lower move | First-layer detection (is_first_layer) |
| Spiral (vase) Z ramp, both ends | SliceStatistics (max_z_mm, bbox) — it describes the object |
;LAYER_CHANGE / ;Z: / ;HEIGHT bare markers | {z} inside the custom layer-change script |
The last row is the one asymmetry, and it is deliberate: markers describe where the nozzle physically is (PrusaSlicer writes ;Z: from the offset-included print Z), while a user macro reasoning about the object should not be handed an endstop correction — the same split PrusaSlicer draws with its offset-free layer_z placeholder. Start/end G-code is never rewritten either, so a G1 Z5 ; lift nozzle in a machine script means exactly what it says.
Because the offset only reshapes emitted Z, extrusion amounts, path geometry and the print-time estimate are untouched, and the default 0.0 is a byte-for-byte no-op.
Script priority chain
CLI --start-gcode argument
↓ (overrides)
GlobalSettings.start_print_gcode
↓ (overrides)
GcodeDialect::start_script() ← firmware defaultresolve_gcode_source(input) auto-detects whether input is a file path or an inline G-code string (1 MiB file size limit enforced).