RasmahRasmah

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 Rasmah

1. 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