Cleaning Assemblies: Filling Gaps and Smooth BReps
println("Julia ", VERSION)Julia 1.12.6
Overview
Reassembling a solid from fragments — sliced pieces, scan data, or a part that arrived in two — usually leaves thin gaps where the pieces should meet: the recovery converges to “almost touching”, but a fraction of a millimetre of residual error remains, and the hard union of the pieces is a set of disjoint solids rather than one watertight body. Before the result can go to CAM or another CAD system as a STEP file, those cracks must be closed.
This tutorial walks through the cleaning step on a real part — an 80 × 40 × 30 mm aluminium mounting block with a Ø12 through-hole, fractured into three fragments with 0.1 mm gaps — by dilating the signed-distance field to close the gaps, then rebuilding a smooth BRep approximation of the filtered field. The result is a single, watertight, faceted BRep that flows through the rest of the kernel (STEP export, booleans, meshing).
using Rasmah1. The part and its fragments
The part is drawn from scratch the usual way — a block with a drilled hole:
block = difference(box(80.0, 40.0, 30.0), cylinder(6.0, 32.0))
V_an = 80.0 * 40.0 * 30.0 - π * 6.0^2 * 30.0
println("original volume = ", round(V_an; digits = 1), " mm³")
println("mass (aluminum) = ", round(V_an * 2.70e-3; digits = 1), " g")original volume = 92607.1 mm³
mass (aluminum) = 250.0 g
The “fractures” are three fragments cut along x — each is the part intersected with a thin slab, and the slabs are placed 0.1 mm apart to simulate the recovery error:
B = to_sdf(block)
frag(xc, hx) = intersection(B, translate(to_sdf(box(2hx, 40.0, 30.0)), xc, 0.0, 0.0))
pieces = [frag(-26.7, 13.3), frag(0.0, 13.3), frag(26.7, 13.3)]
assembled = reduce(union, pieces)UnionSDF{UnionSDF{IntersectionSDF{DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}, TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}}, IntersectionSDF{DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}, TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}}}, IntersectionSDF{DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}, TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}}}(UnionSDF{IntersectionSDF{DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}, TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}}, IntersectionSDF{DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}, TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}}}(IntersectionSDF{DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}, TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}}(DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}(BoxSDF{Float64, Float64, Float64}(40.0, 20.0, 15.0), CylinderSDF{Float64, Float64}(6.0, 32.0)), TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}(BoxSDF{Float64, Float64, Float64}(13.3, 20.0, 15.0), [-26.7, 0.0, 0.0])), IntersectionSDF{DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}, TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}}(DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}(BoxSDF{Float64, Float64, Float64}(40.0, 20.0, 15.0), CylinderSDF{Float64, Float64}(6.0, 32.0)), TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}(BoxSDF{Float64, Float64, Float64}(13.3, 20.0, 15.0), [0.0, 0.0, 0.0]))), IntersectionSDF{DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}, TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}}(DifferenceSDF{BoxSDF{Float64, Float64, Float64}, CylinderSDF{Float64, Float64}}(BoxSDF{Float64, Float64, Float64}(40.0, 20.0, 15.0), CylinderSDF{Float64, Float64}(6.0, 32.0)), TranslateSDF{BoxSDF{Float64, Float64, Float64}, Vector{Float64}}(BoxSDF{Float64, Float64, Float64}(13.3, 20.0, 15.0), [26.7, 0.0, 0.0])))
The raw union is three disjoint fragments — its volume is short of the original by the crack material, and the slits show up as thin dark seams:
raw = surface_mesh(assembled; n = 48)
println("fragmented volume = ", round(volume(raw); digits = 1), " mm³ (original ", round(V_an; digits = 1), ")")
vtk(raw)fragmented volume = 92328.0 m^3 mm³ (original 92607.1)
The missing ≈280 mm³ is the two crack planes — 0.1 × 40 × 30 mm each, plus the hole-wall slivers. More importantly, the union is three separate solids, and no downstream consumer will accept it as one part.
2. The filter: morphological dilation
An SDF’s dilation (the Minkowski sum with a ball of radius r) is just the field offset g − r:
\[D_r(g)(x) = g(x) - r.\]
It is exact for any signed-distance field, and it grows the solid by r — so any gap or hole narrower than 2r is closed over. dilate wraps it, and the crack closes from +0.05 (outside) to −0.05 (inside):
r = 0.1
filtered = dilate(assembled, r)
println("field at a crack plane: ", assembled([13.35, 15.0, 0.0]), " → ", filtered([13.35, 15.0, 0.0]))
println("bounds grew by r: ", bounds(assembled)[1], " → ", bounds(filtered)[1])field at a crack plane: 0.049999999999998934 → -0.05000000000000107
bounds grew by r: [-40.0, -20.0, -15.0] → [-40.1, -20.1, -15.1]
A natural question is “why not dilate then erode back, so the size is unchanged?”. Because the two offsets are exact inverses of each other — (g − r) + r = g — a dilate-then-erode (a morphological closing) is the identity for a signed-distance field. Filling the gaps necessarily adds the missing material, so the cleaned solid is inflated by r; for sub-millimetre cracks this is a percent-or-so change in volume, which is the standard tradeoff for a guaranteed watertight result.
3. Re-meshing the filtered SDF
Marching cubes on the filtered field now yields one connected surface (the grown fragments overlap, so there are no more slits). The raw marching-cubes mesh emits a vertex per grid crossing, so coincident vertices along shared edges are duplicated — weld_vertices merges them into a clean, indexed mesh:
m = surface_mesh(filtered; n = 48)
println("raw marching-cubes mesh: ", size(m.vertices, 2), " vertices")
mw = weld_vertices(m)
println("after welding: ", size(mw.vertices, 2), " vertices")
println("welded volume = ", round(volume(mw); digits = 1), " mm³ (gaps closed, inflated by r)")raw marching-cubes mesh: 12672 vertices
after welding: 12672 vertices
welded volume = 93790.8 m^3 mm³ (gaps closed, inflated by r)
4. A smooth BRep approximation
Finally, smooth the welded mesh while snapping each vertex back onto the filtered field (Taubin’s alternating λ|μ filter plus a level-set projection), then build a faceted BRep. The projection keeps the mesh on the filtered SDF, so the smoothing smooths the surface without shrinking it away:
sm = smooth_surface(mw, filtered; iterations = 5, λ = 0.5, μ = -0.8)
br = faceted_brep(sm)
println("cleaned volume = ", round(volume(surface_mesh(br; n = 48)); digits = 1), " mm³")
println("cleaned watertight = ", is_watertight(br))
vtk(surface_mesh(br; n = 40))cleaned volume = 93860.2 m^3 mm³
cleaned watertight = true
The cleaned part is ≈1.4% larger than the original — the surface area times the 0.1 mm inflation, as promised. It is also one watertight body, so it round-trips to STEP for handoff to CAM or another CAD package:
path = tempname() * ".step"
write_step(br, path)
println("STEP round trip: ", read_step(path) isa BRep)STEP round trip: true
faceted_brep recovers the mesh as planar faces (it is not a primitive/analytic-surface recovery), which is exactly the “smooth approximation” the cleaning step promises.
5. The whole pipeline in one line
The four steps — dilate, weld, smooth-with-projection, facet — compose into a one-liner:
clean(g, r; n = 48, iterations = 5, λ = 0.5, μ = -0.8) =
faceted_brep(
smooth_surface(
weld_vertices(surface_mesh(dilate(g, r); n = n)),
dilate(g, r);
iterations = iterations, λ = λ, μ = μ,
),
)
repaired = clean(assembled, 0.1)
println("repaired watertight = ", is_watertight(repaired))repaired watertight = true
Next steps
- Shape Similarity, Registration, and Self-Assembly — the
assembleoptimization that produces the gappy pieces in the first place. - Boundary Representation (BRep) — the topological model
faceted_brepbuilds. - Tolerance and Robustness —
is_watertightand the edge-consistency checks.
