RasmahRasmah

Feature Operations: Shell, Mirror, Patterns, Loft, Fillet, and Chamfer

using Rasmah

# Geometry is stored in SI (metres); these helpers render millimetres.
mm(v)  = round(uconvert(u"mm", v); digits = 1)
mm3(v) = round(uconvert(u"mm^3", v); digits = 0)
mm3 (generic function with 1 method)

Overview

Beyond the boolean and transform features, Rasmah ships seven higher-level modeling operations that are common in parametric CAD (computer-aided design) but rarely differentiable:

  • shell — hollow a solid into a thin-walled shell.
  • mirror — reflect a solid across a plane.
  • linear_pattern — replicate a shape in a row along an axis.
  • circular_pattern — replicate a shape around an axis.
  • loft — morph between two 2D sketches.
  • fillet — round convex edges/corners with a circular arc.
  • chamfer — cut convex edges/corners with a flat 45° plane.

This tutorial applies each one to a real part: a plastic electronics enclosure (shell + fillet), a rod-end clevis (mirror), a heat sink (linear pattern), a pipe flange (circular pattern), and a round-to-square HVAC transition duct (loft). All seven are ordinary features: they compose with parameters, booleans, and transforms, and each carries an analytic signed distance field (so they stay differentiable). Dimensions are in millimetres; the code writes them as metres.

Hollow shells

shell(g, t) turns a solid into a shell of half-thickness t around its surface — the SDF is |g(x)| − t, so the wall is 2t thick. It is cheap, exact, and differentiable. For a sphere the closed-form volume is the outer ball (r + t) minus the inner ball (r − t), which makes a handy test case:

hollow = shell(sphere(0.010), 0.001)   # Ø20 sphere, 2 mm wall

println("at the centre (the void): ", field(hollow, [0.0, 0.0, 0.0]))   # +9 mm → outside
println("mid-wall:                 ", field(hollow, [0.010, 0.0, 0.0]))  # −1 mm → inside wall
println("outer surface:            ", field(hollow, [0.011, 0.0, 0.0]))  # ≈ 0
at the centre (the void): 0.009000000000000001
mid-wall:                 -0.001
outer surface:            -8.673617379884035e-19
println("shell volume                 = ", mm3(volume(shell(sphere(0.010), 0.001))))
println("analytic 4/3 π (11³ − 9³) mm³ = ",
        round(4π / 3 * (11.0^3 - 9.0^3); digits = 0))
shell volume                 = 2522.0
analytic 4/3 π (11³ − 9³) mm³ = 2522.0
vtk(surface_mesh(hollow; n=48); opacity=0.5)

In practice shell is the operation behind an injection-moulded enclosure: a single sketch extruded to the outer size, shelled to the wall stock, and filleted to the mould’s corner radius:

ext = extrude(rect_2d(0.100, 0.060), 0.030)          # 100 × 60 × 30 mm blank
enc = shell(ext, 0.002)                              # 4 mm walls

lo, hi = bounds(enc)
println("enclosure bounds: ", mm.(lo), " .. ", mm.(hi))   # ±52 × ±32 × ±17 (walls +2t)
enclosure bounds: [-52.0, -32.0, -17.0] .. [52.0, 32.0, 17.0]

Mirroring

mirror(g, axis) reflects a solid across a coordinate plane: axis = 1, 2, or 3 selects the x = 0, y = 0, or z = 0 plane. It is a pure reflection, so volume is unchanged — only the position of the solid moves.

Symmetric parts are drawn once and mirrored. A rod-end clevis is a web with a pin lug on each side; draw half the web plus one lug, drill one Ø8 pin hole, and mirror across the x = 0 plane for the second lug:

half = union(translate(box(0.015, 0.010, 0.008), 0.0075, 0.0, 0.0),   # web half, 15 × 10 × 8 mm
             translate(box(0.010, 0.020, 0.008), 0.020, 0.010, 0.0))  # 10 × 20 mm lug
half = difference(half, translate(cylinder(0.004, 0.008), 0.020, 0.010, 0.0))  # Ø8 pin hole

clevis = union(half, mirror(half, 1))
lo, hi = bounds(clevis)
println("clevis bounds: ", mm.(lo), " .. ", mm.(hi))
println("clevis volume: ", mm3(volume(surface_mesh(clevis; n=64))),
        " ≈ 2 × ", mm3(volume(surface_mesh(half; n=64))))
clevis bounds: [-25.0, -5.0, -4.0] .. [25.0, 20.0, 4.0]
clevis volume: 4773.0 ≈ 2 × 2394.0
vtk(surface_mesh(clevis; n=48))

Linear pattern

linear_pattern(g, axis, spacing, n) places n copies of g along a coordinate axis, spaced spacing apart. The classic use is a heat sink: a base plate plus a row of extruded fins. union welds the fins to the base:

base = box(0.060, 0.040, 0.004)                          # 60 × 40 × 4 mm base
fin  = translate(box(0.002, 0.040, 0.020), 0.0, 0.0, 0.012)  # 2 mm fin, 20 mm tall
fins = translate(linear_pattern(fin, 1, 0.006, 9), -0.024, 0.0, 0.0)  # 9 fins at 6 mm pitch
sink = union(base, fins)

lo, hi = bounds(sink)
println("heat sink bounds: ", mm.(lo), " .. ", mm.(hi))  # 60 × 40 × 24 mm overall
heat sink bounds: [-30.0, -20.0, -2.0] .. [30.0, 20.0, 22.0]
vtk(surface_mesh(sink; n=32))

Circular pattern

circular_pattern(g, n; axis=3) places n copies of g rotated about a coordinate axis (default the z axis). Offset a shape from the axis and you get a ring of holes or teeth.

The standard pipe flange is the textbook case. An EN 1092-1 PN16 DN50 flange is an OD 165 × 18 mm plate with a Ø61 bore and four Ø18 bolt holes on a 125 mm pitch-circle diameter:

flange = tube(0.0825, 0.0305, 0.018)                      # OD 165, bore Ø61, 18 mm thick
hole   = translate(cylinder(0.009, 0.018), 0.0625, 0.0, 0.0)  # Ø18 at the PCD
fl = difference(flange, circular_pattern(hole, 4; axis=3))    # 4 holes, 90° apart

lo, hi = bounds(fl)
println("flange bounds: ", mm.(lo), " .. ", mm.(hi))     # ±82.5 in xy, ±9 in z
flange bounds: [-82.5, -82.5, -9.0] .. [82.5, 82.5, 9.0]

The machined flange volume — the analytic value (solid minus four hole volumes) against the mesh measurement:

analytic = π * (82.5^2 - 30.5^2) * 18 - 4 * π * 9^2 * 18   # mm³
println("analytic volume: ", round(analytic; digits = 0), " mm³")
println("mesh volume   : ", mm3(volume(surface_mesh(fl; n=64))), " mm³")
analytic volume: 313958.0 mm³
mesh volume   : 313165.0 mm³
vtk(surface_mesh(fl; n=48))

Loft

loft(bottom, top, height) morphs between two 2D sketches as z sweeps from −height/2 (bottom) to +height/2 (top). circle_2d → circle_2d is an exact cone/frustum (with an analytic volume); circle_2d → rect_2d morphs through rounded intermediates — the round-to-square transition duct that joins a fan to a ductwork system:

frustum = loft(circle_2d(0.020), circle_2d(0.040), 0.040)
println("loft volume (frustum) = ",
        mm3(volume(loft(circle_2d(0.020), circle_2d(0.040), 0.040))))
println("analytic frustum       = ",
        round(π * 40 / 3 * (20^2 + 20 * 40 + 40^2); digits = 0), " mm³")

vtk(surface_mesh(frustum; n=48))
loft volume (frustum) = 117286.0
analytic frustum       = 117286.0 mm³
duct = loft(circle_2d(0.040), rect_2d(0.060, 0.060), 0.080)
lo, hi = bounds(duct)
println("duct bounds: ", mm.(lo), " .. ", mm.(hi))

vtk(surface_mesh(duct; n=48))
duct bounds: [-40.0, -40.0, -40.0] .. [40.0, 40.0, 40.0]

Fillet

fillet(g, r) rounds convex edges and corners with a circular arc of radius r. The SDF is the classic cheap rounding g(x) − r — a Minkowski dilation by a ball of radius r — so sharp corners round off while flat faces offset outward by r. It is exact for a sphere (R → R + r) and for a box (the rounded box), with a closed-form volume:

rounded = fillet(box(0.020, 0.020, 0.020), 0.002)
println("fillet bounds = ", mm.(bounds(rounded)[1]), " .. ", mm.(bounds(rounded)[2]))  # ±12 mm
println("fillet volume = ", mm3(volume(fillet(box(0.020, 0.020, 0.020), 0.002))))

ball = fillet(sphere(0.010), 0.002)
println("filleted sphere radius = ",
        round(1000 * (ustrip(volume(ball)) / (4π / 3))^(1 / 3); digits = 1), " mm")

vtk(surface_mesh(rounded; n=64))
fillet bounds = [-12.0, -12.0, -12.0] .. [12.0, 12.0, 12.0]
fillet volume = 13587.0
filleted sphere radius = 12.0 mm

Applied to the enclosure, the same op adds the mould’s corner radius — here 6 mm, visible as the enclosure growing from ±52 to ±58 in the plane:

enc_f = fillet(enc, 0.006)
lo, hi = bounds(enc_f)
println("filleted enclosure bounds: ", mm.(lo), " .. ", mm.(hi))
filleted enclosure bounds: [-58.0, -38.0, -23.0] .. [58.0, 38.0, 23.0]
vtk(surface_mesh(enc_f; n=48))

Chamfer

chamfer(g, d) cuts convex edges with a flat 45° plane of setback d — the L1-metric analog of the fillet’s circular arc. It is exact for an axis-aligned box (faces at ±(b + d), 45° chamfers), with a closed-form volume. A machine base plate gets chamfered top edges so it deburrs cleanly and drops into a fixture:

cut = chamfer(box(0.020, 0.040, 0.060), 0.0025)
println("chamfer bounds = ", mm.(bounds(cut)[1]), " .. ", mm.(bounds(cut)[2]))  # ±(12.5, 22.5, 32.5) mm
println("chamfer volume = ", mm3(volume(chamfer(box(0.020, 0.040, 0.060), 0.0025))))

vtk(surface_mesh(cut; n=64))
chamfer bounds = [-12.5, -22.5, -32.5] .. [12.5, 22.5, 32.5]
chamfer volume = 71521.0

Exact BRep fillet / chamfer / offset

The SDF fillet / chamfer above dilate. The exact (analytic-surface) BRep variants instead round selected edges by a rolling ball — material is removed, as a machinist would do on the mill. Pass edges as topological names ("EdgeN" or "Body.EdgeN"); brep(...) builds the exact result:

# fillet one edge of a 20 mm block: removes a (r² − πr²/4) strip along it
r = 0.0025
removed = (r^2 - π * r^2 / 4) * 0.020
bf = brep(fillet(box(0.020, 0.020, 0.020), r; edges=["Edge6"]))
println("volume          : ", mm3(volume(surface_mesh(bf; n=64))), " ≈ ",
        round(8000 - removed * 1e9; digits = 0), " mm³")

# two non-adjacent edges fillet independently
b2 = brep(fillet(box(0.020, 0.020, 0.020), r; edges=["Edge6", "Edge4"]))
println("two edges       : ", mm3(volume(surface_mesh(b2; n=64))), " ≈ ",
        round(8000 - 2 * removed * 1e9; digits = 0), " mm³")

# chamfer one edge: removes a right-triangular prism (d²/2 per unit length)
d = 0.0025
bc = brep(chamfer(box(0.020, 0.020, 0.020), d; edges="Edge6"))
println("chamfer volume  : ", mm3(volume(surface_mesh(bc; n=64))), " ≈ ",
        round(8000 - d^2 / 2 * 0.020 * 1e9; digits = 0), " mm³")
println("watertight      : ", is_watertight(bc))
volume          : 7973.0 ≈ 7973.0 mm³
two edges       : 7946.0 ≈ 7946.0 mm³
chamfer volume  : 7937.0 ≈ 7938.0 mm³
watertight      : true

offset(g, d) displaces a solid’s surface outward by d — exact for primitives of the same family (sphere R → R + d, box s → s + 2d, cylinder (r, h) → (r + d, h + 2d)), and a faceted BRep of the offset SDF otherwise:

os = offset(sphere(0.010), 0.002)
println("offset sphere   : ", volume(os) ≈ 4π / 3 * 0.012^3)
println("offset bounds   : ", mm.(bounds(os)[2]))

ob = brep(offset(box(0.020, 0.020, 0.020), 0.001))
println("offset box BRep : ", ob isa BRep)
offset sphere   : true
offset bounds   : [12.0, 12.0, 12.0]
offset box BRep : true

shell(g, t) is exactly difference(offset(g, t), offset(g, −t)) — a solid of wall thickness 2t.

Datum (reference) geometry

mirror and the patterns accept arbitrary datum anchors — a DatumPlane for mirroring or a DatumAxis for patterns — in addition to the integer coordinate axes 1/2/3. Datum geometry is plain construction data (a point, axis, plane, or coordinate system), not a solid:

# mirror an off-centre block across a 45° plane through the origin
tilted = datum_plane([0.0, 0.0, 0.0], [1.0, 1.0, 0.0])
m = mirror(translate(box(0.010, 0.010, 0.010), 0.020, 0.0, 0.0), tilted)
println("mirrored volume = ", mm3(volume(m)))

# a circular pattern about an off-axis datum axis (6 bosses on a 10 mm radius)
ring_axis = datum_axis([0.010, 0.0, 0.0], [0.0, 0.0, 1.0])   # z-axis through x = 10
fan = circular_pattern(translate(box(0.004, 0.002, 0.002), 0.010, 0.0, 0.0), 6; axis=ring_axis)
lo, hi = bounds(fan)
println("fan bounds = ", mm.(lo), " .. ", mm.(hi))

vtk(surface_mesh(fan; n=48))
mirrored volume = 1000.0
fan bounds = [8.0, -2.2, -1.0] .. [12.0, 2.2, 1.0]

The datum types carry the usual reference queries — distance, signed_distance (planes), project, and to_global/to_local for a coordinate system — and are differentiable, so a datum can itself be driven by optimization parameters.

Next steps