src/walls/README.md in the repository — edit it there.Walls — Perimeter (Wall) Generation
This module turns each layer's raw mesh cross-section contours into extrusion beads (wall paths with per-path widths). It is the single place the slicing pipeline calls for walls, and it is switchable: the algorithm is chosen per slice by SlicingParams::wall_generator.
The one rule the rest of the pipeline relies on: whichever generator runs, it replaces the layer's OuterWall / InnerWall contours with beads and preserves every non-perimeter path (surfaces, infill) in its original order after the walls. Everything downstream is therefore generator-agnostic.
Why switchable
The mature slicers (PrusaSlicer, OrcaSlicer, Bambu Studio, CuraEngine) all ship two wall generators and let the user pick:
- Classic — fixed-width concentric perimeters plus a thin-wall gap fill. Deterministic, fast, dependency-free. Descends from Slic3r's
PerimeterGenerator+MedialAxis. - Arachne — variable-width extrusion built on a medial-axis skeletal trapezoidation (Kuipers et al. 2020). Beads taper continuously along their length with graded bead-count transitions.
This module ships both, selected per slice:
Arachne— the default. Medial-axis offset loops (constant-dperimeters whose count adapts locally) plus variable-width medial gap fill of the residual. Each inner loop is offset from the morphologically opened remaining region (open(region, d)), so a loop can never trace a sub-2dneck on top of itself — the coincident-bead seam that reads as over-extrusion. Those necks fall through to the variable-width medial gap fill instead, andcut_sliver_partstakes the same care of the outer ring, which cannot be opened without bevelling the model's own corners — see invariant #7. Overlap is resolved by geometry, not by post-hoc flow compensation (wall_overlap_compensationis off by default; see flow). The residual medial skeleton is de-noised (short facet spurs pruned) before the gap beads are walked, so a curved wall's residual ring prints as a few long continuous beads instead of a cloud of stubs that wander layer-to-layer — see invariant #5.Classic— fixed-width concentric perimeters plus a thin-wall gap fill. Deterministic, fast, dependency-free. Descends from Slic3r'sPerimeterGenerator+MedialAxis.
Public API
| Item | Purpose |
|---|---|
generate_walls | Dispatch on wall_generator; replace contours with beads for every layer (parallel via rayon on native). Returns [WallTimings]. |
generate_walls_debug | Sequential debug counterpart; captures intermediate geometry into DebugGeometry. Native only. |
WallParams | Resolved per-run config (mm-absolute) built from SlicingParams. Shared by every generator. |
Bead | One emitted bead: closed centerline Path + extrusion width_mm + is_outer flag. |
WallTimings | CPU-time breakdown (collapse search vs. bead shrinks). |
fuzzy_skin::apply | Cosmetic outer-wall texture, gated on SlicingParams::fuzzy_skin. Called by [crate::core::pipeline], not by generate_walls — see below. |
Selecting the generator: set wall_generator = "arachne" (default) or "classic" in the settings / config, or via the schema-driven UI dropdown.
Classic generator (classic.rs)
For each closed shell contour produced by [crate::core::slice_mesh]:
- Normalise input. A Clipper2
union(…, FillRule::EvenOdd)over the raw contours resolves arbitrary winding and overlaps into the canonical representation (CCW outer rings, CW holes). See invariant #1. - Standard beads. Place up to
wall_countfull-width beads at centerline depthsd/2, 3d/2, …(d = nozzle_diameter_mm) via successive negativeinflate. Each bead has widthd. - Thin-wall residual. If the space remaining after the standard beads is ≥
wall_line_width_min × d, add one variable-width bead at its center (clamped towall_line_width_max × d). - Width distribution. A sub-minimum residual is instead absorbed by the innermost
wall_distribution_countbeads, slightly widening them.
The Clipper2 negative inflate is the fundamental primitive; one call per bead gives the centerline path.
Perimeter routing & ordering options
Three of these are wall-generation concerns handled in this module; the other two live downstream (surfaces, G-code travel) but are listed here for a complete picture of the "perimeter routing" family.
| Parameter | Default | Where it acts | Effect |
|---|---|---|---|
external_perimeters_first | false | this module (beads::order_beads, arachne::generate) | Print order only. false = inner walls first, outer wall last (ecosystem default, cleanest surface); true = outer first. |
thin_walls | true | this module (classic only) | Print thin features (material too narrow for one full perimeter) as a single centered bead. Arachne does this by construction and ignores the flag; the schema hides it unless wall_generator = classic. |
extra_perimeters | false | this module | Fill a uniformly thin residual core (narrower than extra_perimeters_max_gap × d) with extra concentric loops instead of leaving a gap for infill. A core with any part wider than the threshold stays infill's job, so a solid body is never turned into loops. |
ensure_vertical_shell_thickness | false | core::surfaces | Grow each layer's own top/bottom surface inward so a sloped side wall keeps a continuous perpendicular solid shell. |
avoid_crossing_perimeters | false | gcode::travel | Detour travel moves around outer walls (visibility-graph shortest path) instead of dragging the nozzle across a finished surface. |
Ordering is applied at bead assembly, not in the pipeline. Both generators compute beads outermost-first, then flush them in the configured order (order_beads reverses to inner-first for Classic; Arachne buffers each island's loops and flushes them reversed). The pipeline's greedy-TSP path ordering preserves the resulting per-role group order, so the choice survives to G-code. Reordering never changes extrusion amounts — a closed loop has the same perimeter regardless of where it starts — so the slicing-quality baselines are unaffected.
extra_perimeters shares the offset walk. Rather than a separate pass, the generators simply lift the loop cap when the post-wall_count core is uniformly thin, so extra loops reuse the same distance field and never overlap the medial fill (Arachne) or the standard beads (Classic).
A generator-specific option must be gated in the schema. A wall option only one generator honours has to carry an x-relevant-when rule, or the UI shows a control that silently does nothing — or worse, silently changes output:
| Option | Honoured by | Schema gate |
|---|---|---|
thin_walls | classic | wall_generator = classic |
wall_distribution_count | classic | wall_generator = classic |
gap_fill_min_length_mm | arachne | wall_generator = arachne |
extra_perimeters_max_gap | both | extra_perimeters = true |
external_perimeters_first, extra_perimeters and wall_count are honoured by both generators and stay ungated. generator_specific_wall_options_are_gated_in_the_schema (and its over-gating counterpart) pin this in src/settings/params.rs.
thin_walls is a classic-generator option. Arachne fills thin features from the medial axis by construction — that is the generator — so it always prints them and ignores the flag, matching PrusaSlicer/OrcaSlicer where the equivalent option is likewise classic-only. emit_residual_medial_fill is therefore unconditional.
This matters because that pass fills two physically different things: a bead that is the model geometry (a feature too thin for one perimeter) and ordinary filler in the sliver between the innermost loops. Gating the whole pass on thin_walls — the first implementation — deleted both: on the Filament Card Caddy it removed all 37.7 m of gap fill, wiping out ~50 card-slot fins, opening a void along every wall, and letting sparse infill leak into the freed wall band (8.2 → 12.8 m). Keeping the pass unconditional and gating the option to classic removes that whole class of bug for the default generator.
A thin feature is a wall, not a feature type of its own
The residual pass fills two physically different things, and the generator tells them apart by asking where the residual reaches. A loop lays its band from the model surface to d inward, so anything the loops covered leaves a residual that starts a full d in. A residual that instead reaches within d/4 of the surface is one no loop could cover: a rib, fin, divider or neck too thin for a perimeter — the model itself.
| Residual reaches the surface | It is | Emitted as |
|---|---|---|
| yes — the bead is the feature | the wall, there | an open OuterWall bead, ;TYPE:Outer wall |
| no — filler between perimeters | filler | GapFill, ;TYPE:Gap infill |
There is deliberately no separate thin-wall feature type. Covering a sub-perimeter feature from the medial axis is what this generator is for, and the bead it lays there is the outer wall of that feature — it prints at wall speed and acceleration because it is a wall, not because a role was invented to say so. An earlier attempt added a ThinWall role and was removed: it labelled quantisation artefacts rather than features, and a pure 45° turn of the card caddy moved two thirds of its dividers out of it.
The d/4 margin is the point. Three quarters of a bead separates the two answers, so neither rotation, re-facetting nor a different nozzle moves a bead across the line.
The distinction decides the width floor, not just the label. A gap thinner than the minimum bead is left empty — the flanking beads squish into it (see invariant #6) — while a feature thinner than the minimum bead is widened to it, down to half the minimum, and only dropped below that. Dropping it instead is what the generator used to do, and it deleted every slot of the card caddy the moment the nozzle was wider than its 0.4 mm dividers.
Which region each pass walks, and why neither is clipped against the other, is invariant #8.
Because the two roles are now different, no geometric test may name one. Bead footprint, surface trim and redundancy pruning all ask SliceLayer::is_medial_bead, which asks the path — an open polyline carrying per-vertex widths — rather than its role.
Output topology
Both generators emit centerline paths, not filled polygons. Each path is a closed polygon whose vertices are the center of the extrusion bead.
OuterWallpaths sit at inward depthd/2from the raw mesh contour.InnerWallpaths sit at3d/2,5d/2, …path_widths[i]carries the actual extrusion width (the G-code generator uses it to compute the correct E values per move).- A hole boundary also receives an
OuterWall-tagged bead (it is the outermost bead of that contour's shrink sequence). Distinguish solid islands from holes by signed area, not by role:
path.signed_area() | Topology |
|---|---|
| Positive (CCW) | Solid island |
| Negative (CW) | Hole |
Pipeline integration
Walls sit between raw slicing and surface/infill generation:
slice_mesh() — raw mesh → OuterWall contours per layer
generate_walls() — replaces those contours with bead paths
pre_strip_infill_regions snapshot — taken before wall stripping
apply_single_wall_restrictions() — strips inner walls from first/top layers
interior_regions computed — post-strip, used by surfaces
generate_top_bottom_surfaces_with_interior() — top/bottom solid infill within interior
add_infill_to_layers() — sparse infill = pre_strip region − solid_regionsOrder matters: crate::core::surfaces runs after walls so calculate_interior_region sees the correct bead geometry. See ../core/README.md for the full invariant list.
fuzzy_skin::apply runs much later — after path ordering and flow compensation, before bed adhesion — so it perturbs only the final outer-wall geometry and never feeds back into interior-region, surface or infill math. It carries along any per-vertex widths flow compensation already wrote, and never touches skirt/brim (also tagged OuterWall, but generated by adhesion, which runs after it).
It also fuzzes OverhangPerimeter and open OuterWall arcs — the pieces classify_overhang_perimeters leaves behind when it splits a loop at an air boundary — leaving only their two endpoints untouched, since an adjacent run from the same split shares that exact coordinate and jittering it would tear a gap. Skipping open arcs entirely was the original (buggy) behaviour: it left straight, un-textured segments wherever a wall happened to cross unsupported air next to a fuzzed one.
Critical invariants
These have all been hit as bugs. Read before changing the module.
1. Input must be normalised before offsetting
Raw contours from chain_segments have arbitrary winding and may overlap, duplicate, or nest (engraved text, near-degenerate triangles). Passing them straight to inflate(-d, …) produces fragmented, self-intersecting output — hundreds of micro-loops, or a near-total collapse. The union(…, EvenOdd) in classic.rs fixes this.
2. Degenerate beads must be filtered
Thin slivers survive the negative offset as zero-area stubs. drop_degenerate_beads removes any centerline whose area is below 0.01 × d². The collapse-detection branch must test the raw shrink result, not the filtered one.
3. Do not normalise wall paths to CCW elsewhere
calculate_interior_region consumes OuterWall paths directly and must preserve winding. Hole beads are legitimately CW; flipping them makes Clipper2 treat holes as solid and infill fills the void.
4. Bead union with EvenOdd is wrong
Tightly nested concentric paths under EvenOdd produce alternating in/out bands. If you ever union the bead set, use NonZero — and only after making every input path CCW.
5. De-noise the residual skeleton before walking gap beads
A segment Voronoi grows a short spur at every faceted boundary vertex of a thin residual band. Those spurs turn a continuous gap spine into a chain per junction, and since the junctions sit at facet vertices that shift with the sloped wall thickness, the emitted stubs land in a different place on every layer — the gap fill visibly boils. prune_short_leaf_chains removes sub-2d spurs so the junctions collapse to degree 2 and chains reassembles the spine into a few long beads. Do not raise that floor much further: past ~2d it starts eroding genuine sub-millimetre features (small embossed-logo detail) into voids. The radius-ratio prune_boundary_spurs alone does not suffice — it only catches spurs that dive toward the boundary, not the uniform-radius facet spurs of a constant-thickness band.
6. Drop isolated gap-fill runs shorter than 2d
Separate from the spur prune above: emit_medial_beads and the residual pre-filter emit_residual_medial_fill both discard any emitted gap-fill run shorter than gap_fill_min_run_len_mm — gap_fill_min_length_mm when the user set it (> 0), otherwise 2d (0.8 mm at a 0.4 mm nozzle).
The old auto-default was d, which left ~270 sub-2d beads on the 3DBenchy: each an isolated dab of material that still costs a full retract → travel → un-retract to reach. That is the "tiny inner-body splat" — wasted time and a filament-grinding risk.
Dropping them is safe because the residual such a splat would fill is bridged by the squish of the flanking wall beads. classic, which has no gap fill at all, leaves the same curved and tapering wall corners bead-free with no measurable wall-zone void (voids.py). Matching the spur floor is deliberate: a run below the same 2d that separates a real gap spine from facet noise is facet noise once isolated as its own bead.
A dip is not an end. The floor applies to a run, and a run no longer breaks at every node whose thickness leaves the printable band: a dip shorter than one bead is carried (BRIDGE_DIP_FACTOR), because an interruption shorter than the line being drawn cannot be drawn — the two ends squeeze into it anyway, and all the break achieves is a pair of loose ends and a travel between them. A tapering chamfer is where this shows: its thickness crosses the minimum width back and forth as the facets under it change. A dip above the maximum is never bridged — that one is the chain leaving the thin material for a cavity, which infill fills.
7. Quantisation noise is removed where it enters, not where it hurts
Clipper works on a Centi (0.01 mm) integer grid, so every boundary that is not axis-aligned arrives as a staircase of 0.01 mm steps. A segment Voronoi answers a staircase with a spur per step and a spine that zigzags between them: a 5 mm rib that gives one straight medial edge at 0° gave a ten-millimetre chain of junctions at 45°. Everything downstream — spur pruning, chain assembly, the run length floor — was then being asked to tell the model's shape from the grid's, one consequence at a time, and it could not. That is the whole of this generator's old rotation-dependence: the card caddy printed 42 % of its dividers at 45° and doubled the rest, and no threshold anywhere could have fixed it.
medial_fill therefore simplifys its region by two grid steps before the Voronoi sees it. That is far below anything printable — the minimum bead is seventeen times it at a 0.4 mm nozzle — and just above the noise it exists to erase. With it in place the prunes are back to doing the job they were written for, on geometry that is the model's.
Test a change here at more than one rotation, and on a field of ribs. One rib in isolation is not the failure mode: what a rotation breaks is a few ribs on a few layers, which a single rib never samples and an aggregate filament figure hides. a_rib_is_sliced_the_same_at_every_angle covers three widths at 0° and 45°; a_field_of_ribs_survives_being_turned covers twelve ribs at 15°, 30°, 45° and 60°. Measure rib-bead length per layer, never a total.
8. One definition of thin, and neither residual region carved out of the other
thin — the island's material narrower than 1.5·d, from one morphological opening — is computed once per island and read by both passes. emit_offset_loops subtracts it from the outer ring, because a ring in material that thin runs down both flanks and lays 2·d into t + d of space; emit_residual_medial_fill uses it to tell a feature from a gap. When the two answered that question separately — the ring by measuring its own sliver, the medial pass by asking how close the residual came to the surface — they disagreed, and a rib would be traced by one and skipped by the other depending on the layer.
The two residual regions overlap and are never clipped against each other:
| Pass | Region | Discards |
|---|---|---|
| feature | thin, from the model | a run mostly under a ring's band — the crumb an opening leaves at a convex corner |
| gap | uncovered, from the deposition | a run mostly inside thin — that is the feature pass's work |
Each discards the overlap at the level of a finished run, so every surviving bead is exactly what the medial axis drew. Clipping first is what broke this: a rib is a clean rectangle in the model, and intersecting it with the complement of some ring's band replaces two of its sides with that ring's offset curve, which shortens the spine and studs the boundary with vertices the Voronoi answers with spurs.
Mostly, by length, and not wholly, because the regions genuinely meet at the ends: a rib's collar where it joins the wall is covered, and the bead wants to reach the wall it stands on.
9. A pass that rewrites a layer's paths carries the per-vertex widths along
classify_overhang_perimeters and clip_walls_against_bridge_region both rebuild a layer's parallel path arrays, and both used to clear path_vertex_widths wholesale — correct for the arcs they re-cut, wrong for every other path in the layer. A medial bead that passed through untouched lost the taper the generator had just computed for it, and, since a medial bead is now recognised by that taper, also lost its place in the gap-fill footprint: sparse infill was laid straight over it (12 mm² of bead-on-bead on one hinge layer, overlap.py). Clear them for the split arcs only.
Non-goals (deliberately not done here)
- No continuously variable-width outer walls. Arachne varies the wall count locally and fills the thin residual with a variable-width medial bead, but the main perimeter loops themselves stay constant width
d. A skeletal-trapezoidation walk that gives every bead a per-vertex width was prototyped (ArachneWalk) and removed: it fragmented walls into open runs that broke overhang / speed / flow classification for marginal benefit. Thewall_transition_threshold/wall_transition_lengthparams exist for it but are not consumed. - No medial-axis gap fill in Classic. The thin-wall residual is a single offset bead, not a true medial-axis fill (that also needs a Voronoi).
- No parallel placement path outside this module. Walls are generated only here; do not offset perimeters elsewhere in the pipeline.
Related files
- src/core/slicer.rs — produces the raw contours fed here
- src/core/walls.rs — per-island first/top-layer single-wall restriction (runs after this module)
- src/core/infill.rs — infill boundary derived from
OuterWallcenterlines - src/gcode/generator.rs — consumes
(path, role, width)triples; variable widths come frompath_widths[i] - src/settings/params.rs —
SlicingParams,WallGenerator - ../core/README.md — pipeline-wide invariants and Clipper2 fill-rule guidance