Free-Form Curves and Sweeping Along a Path
println("Julia ", VERSION)Julia 1.12.6
Overview
extrude and revolve sweep a 2D sketch along a straight axis. This tutorial adds the third, most general sweep: sweep(profile, path), which moves a 2D sketch along an arbitrary free-form 3D path. To feed it, Rasmah provides
- free-form 3D curves (
spline_path,helix) — the guide path, and - free-form 2D sketches (
spline_2d,polygon_2d) — the cross-section.
Everything is a differentiable SDF (signed-distance field): the path and profile are continuous parameters, so the swept solid’s geometry, volume, and meshes can all be differentiated.
The two real parts we draw this way — a pipe elbow and a compression spring — are exactly the shapes that are awkward as stacked primitives and trivial as a profile travelling along a path. Those two parts are dimensioned in millimetres; the remaining sections use unit-free numbers where only the shape matters.
Free-form paths
A path is a 3D curve C(t), t ∈ [0, 1], with path_point, path_tangent, and path_length:
using Rasmah
# a smooth (Catmull–Rom) spline path that interpolates its control points
path = spline_path([[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [2.0, 0.0, 0.0]])
println("start : ", path_point(path, 0.0))
println("middle : ", path_point(path, 0.5))
println("end : ", path_point(path, 1.0))
println("tangent : ", path_tangent(path, 0.5))
println("length : ", round(path_length(path); digits=4))start : [0.0, 0.0, 0.0]
middle : [1.0, 1.0, 0.0]
end : [2.0, 0.0, 0.0]
tangent : [2.0, 0.0, 0.0]
length : 2.8864 m
A helix(radius, pitch, turns) is the classic 3D guide curve (used for springs, threads, and coils), with an analytic arc length:
h = helix(1.0, 0.5, 2) # radius 1, rise 0.5 per turn, 2 turns
println("helix point : ", path_point(h, 0.25))
println("helix length : ", round(path_length(h); digits=4))
println("analytic length: ", round(2 * sqrt((2π)^2 + 0.5^2); digits=4))helix point : [-1.0, 1.2246467991473532e-16, 0.25]
helix length : 12.6061 m
analytic length: 12.6061
Free-form 2D sketches
Beyond circle_2d and rect_2d, a sketch can be any closed free-form curve. polygon_2d(points) is a straight-edged polygon; spline_2d(points) is a smooth closed spline through the same points. Both carry a signed distance (negative inside) and an analytic area (the shoelace formula on a dense sample):
triangle = polygon_2d([[0.0, 0.0], [2.0, 0.0], [0.0, 2.0]])
blob = spline_2d([[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0]])
println("triangle area = ", area(triangle))
println("triangle sdf = ", triangle(0.5, 0.5)) # < 0 inside
println("spline sdf = ", blob(0.0, 0.0)) # < 0 insidetriangle area = 2.0 m^2
triangle sdf = -0.5
spline sdf = -0.8836056594734294
They compose with extrude and revolve exactly like the built-in sketches:
prism = extrude(triangle, 1.0)
println("prism volume = ", volume(prism), " = area × height = ", area(triangle) * 1.0)prism volume = 2.0 m^3 = area × height = 2.0 m^2
Sweeping along a path
sweep(profile, path) holds the profile perpendicular to the path tangent and traces out the swept solid (closed by flat caps at both ends):
# a Ø50 circle swept 100 mm along a straight path is a capped cylinder
line = spline_path([[0.0, 0.0, 0.0], [100.0, 0.0, 0.0]])
tube = sweep(circle_2d(25.0), line)
println("centerline : ", field(tube, [50.0, 0.0, 0.0])) # -25 → inside
println("lateral wall : ", field(tube, [50.0, 25.0, 0.0])) # 0 → boundary
println("past the end : ", field(tube, [101.0, 0.0, 0.0])) # > 0 → outside
println("bounds : ", bounds(tube))centerline : -25.0
lateral wall : 0.0
past the end : 1.0
bounds : ([0.0, -25.0, -25.0], [100.0, 25.0, 25.0])
The swept volume follows Pappus’s rule — cross-section area times path length — which is exact for a circular profile along any smooth path (a pipe of constant circular section) and for any profile along a straight path:
println("tube volume = ", round(ustrip(volume(sweep(circle_2d(25.0), line))); digits=1), " mm³")
println("analytic π r² L = ", round(π * 25.0^2 * 100.0; digits=1), " mm³")tube volume = 196349.5 mm³
analytic π r² L = 196349.5 mm³
A pipe elbow
The elbow that turns a pipe through 90° is the same sweep around a curved path. The path is a Catmull–Rom spline through five points on a quarter circle of 40 mm centreline radius; the profile is the Ø24 pipe cross-section:
Rc, r = 40.0, 12.0 # centreline radius, pipe radius (mm)
arc_pts = [[Rc * cos(θ), Rc * sin(θ), 0.0] for θ in range(0, π / 2; length = 5)]
elbow = sweep(circle_2d(r), spline_path(arc_pts))
println("path length = ", round(ustrip(path_length(spline_path(arc_pts))); digits = 2), " mm")
println("true quarter arc = ", round(π / 2 * Rc; digits = 2), " mm")
println("elbow volume = ", round(ustrip(volume(elbow)); digits = 0), " mm³")
println("Pappus π r² L = ", round(π * r^2 * ustrip(path_length(spline_path(arc_pts))); digits = 0), " mm³")path length = 62.7 mm
true quarter arc = 62.83 mm
elbow volume = 28363.0 mm³
Pappus π r² L = 28363.0 mm³
vtk(surface_mesh(elbow; n = 48))The spline approximates the true quarter circle to 0.2% in length, and the swept volume is exactly Pappus’s rule against its own path length — the same check a pipe-fitter would run with \pi r^2 L to order stock.
A compression spring (helix sweep)
A circular profile swept along a helix is a spring. Take a real one: Ø3 music wire wound into 8 active coils on a Ø25 mean diameter, at 5 mm pitch (a 40 mm free length). The spring index C = D/d = 8.3 is the ratio that tells a spring designer how tightly the wire is wrapped:
d, D, ncoils, pitch = 3.0, 25.0, 8.0, 5.0 # wire Ø3, mean coil Ø25, 8 coils
spring = sweep(circle_2d(d / 2), helix(D / 2, pitch, ncoils))
Lwire = ncoils * sqrt((π * D)^2 + pitch^2)
println("wire length = ", round(Lwire; digits = 1), " mm")
println("spring index C = ", round(D / d; digits = 1))
println("spring volume = ", round(ustrip(volume(spring)); digits = 1), " mm³")
println("analytic π(d/2)²L = ", round(π * (d / 2)^2 * Lwire; digits = 1), " mm³")wire length = 629.6 mm
spring index C = 8.3
spring volume = 4450.3 mm³
analytic π(d/2)²L = 4450.3 mm³
The wire length — what you actually order from the coil supplier — is the analytic helix arc length, and the swept volume agrees with Pappus’s rule \pi (d/2)^2 L exactly: volume of a sweep uses the path’s closed-form arc length. The mass follows directly:
ρ = 7.85e-3 # g/mm³ (steel: 7.85 g/cm³)
println("mass of one spring = ", round(π * (d / 2)^2 * Lwire * ρ; digits = 1), " g")mass of one spring = 34.9 g
vtk(surface_mesh(spring; n = 48))The spring rate
The number a spring designer sells is the rate — force per millimetre of deflection. For a round-wire helical spring it is the closed form k = G d^{4} / (8 D^{3} n) with the wire’s shear modulus G (≈ 79.3 GPa for music wire). Our spring is therefore a 6.4 N/mm spring, and compressing it 12 mm takes about 77 N:
G = 79.3e3 # N/mm² (music wire shear modulus)
k = G * d^4 / (8 * D^3 * ncoils)
δ = 12.0
println("spring rate k = ", round(k; digits = 2), " N/mm")
println("force at δ = 12 mm = ", round(k * δ; digits = 1), " N")spring rate k = 6.42 N/mm
force at δ = 12 mm = 77.1 N
An auger flight
A rectangular profile swept along the same helix makes a flat, twisted ribbon — the flight of a screw conveyor (auger). The strip runs from the shaft outward, so its centroid sits at the helix radius: 30 × 3 mm strip centred 15 mm off-axis, swept along a radius-15 helix, welded onto a Ø20 shaft:
strip = translate(rect_2d(30.0, 3.0), 15.0, 0.0) # spans r ∈ [0, 30]
flight = sweep(strip, helix(15.0, 40.0, 3)) # 3 turns, 40 mm pitch
auger = union(cylinder(10.0, 160.0), flight)
println("flight volume = ", round(ustrip(volume(flight)); digits = 0), " mm³")
println("Pappus A·L = ", round(30.0 * 3.0 * 3 * sqrt((2π * 15.0)^2 + 40.0^2); digits = 0), " mm³")flight volume = 27644.0 mm³
Pappus A·L = 27644.0 mm³
vtk(surface_mesh(auger; n = 48))The flight volume agrees with Pappus’s rule (area × centroid-path length) to the printed digit.
helix_solid(radius, pitch, turns, wire) is the packaged spring (a circular wire swept along a helix), and thread(radius, height, pitch) builds a threaded rod — a cylinder plus a helical ridge wound around it (a cosmetic thread). An M10 × 60 thread is radius 5, height 60, pitch 1.5:
spring2 = helix_solid(D / 2, pitch, ncoils, d / 2)
println(volume(spring2) ≈ volume(spring))
rod = thread(5.0, 60.0, 1.5)true
Difference{Difference{Cylinder{Float64, Float64}, Translate{Rasmah.ThreadSweep{Polygon2D{Float64}}, Float64, Float64, Float64}}, Translate{Cone{Float64, Float64}, Float64, Float64, Float64}}(Difference{Cylinder{Float64, Float64}, Translate{Rasmah.ThreadSweep{Polygon2D{Float64}}, Float64, Float64, Float64}}(Cylinder{Float64, Float64}(5.0, 60.0), Translate{Rasmah.ThreadSweep{Polygon2D{Float64}}, Float64, Float64, Float64}(Rasmah.ThreadSweep{Polygon2D{Float64}}(Polygon2D{Float64}([[0.0, 0.65625], [0.8118993245269451, 0.18749970642949293], [0.8567393358225467, 0.15309303107920133], [0.891146185750236, 0.10825311191309475], [0.9127752344860552, 0.056035931564544096], [0.9201524999999999, 0.0], [0.9127752344860552, -0.056035931564544096], [0.891146185750236, -0.10825311191309475], [0.8567393358225467, -0.15309303107920133], [0.8118993245269451, -0.18749970642949293], [0.0, -0.65625]]), 5.0, 1.5, 60.0, true, 1), 0.0, 0.0, -30.0)), Translate{Cone{Float64, Float64}, Float64, Float64, Float64}(Cone{Float64, Float64}(5.0, 5.0), 0.0, 0.0, 32.5))
A bent exhaust tube
The profile can travel along a composite path, so a single sweep can carry a pipe through several bends. An exhaust tube of Ø40 runs 80 mm straight, turns through 90° on a 30 mm centreline radius, and continues 50 mm more — composite_path joins the straight and curved segments into one guide curve:
p1 = spline_path([[0.0, 0.0, 0.0], [80.0, 0.0, 0.0]])
p2 = spline_path([[80.0 + 30.0 * cos(θ), 30.0 + 30.0 * sin(θ), 0.0] for θ in range(-π / 2, 0.0; length = 5)])
p3 = spline_path([[110.0, 30.0, 0.0], [110.0, 80.0, 0.0]])
tube_path = composite_path(p1, composite_path(p2, p3))
bent = sweep(circle_2d(20.0), tube_path)
L = ustrip(path_length(tube_path))
println("path length = ", round(L; digits = 2), " mm (analytic 80 + 30π/2 + 50 = ", round(80 + 30 * π / 2 + 50; digits = 2), ")")
println("tube volume = ", round(ustrip(volume(bent)); digits = 0), " mm³")
println("Pappus π r² L = ", round(π * 20.0^2 * L; digits = 0), " mm³")path length = 177.02 mm (analytic 80 + 30π/2 + 50 = 177.12)
tube volume = 222451.0 mm³
Pappus π r² L = 222451.0 mm³
vtk(surface_mesh(bent; n = 48))The path length matches the hand computation to 0.06% and the swept volume satisfies Pappus’s rule to four significant figures — the material take-off for bending the real tube from straight stock.
Differentiability
The swept solid is differentiable in the profile, the path, and the path’s parameters. Here a scalar parameter drives the profile radius, and the swept volume is differentiated through the Pappus formula:
vol(r) = volume(sweep(circle_2d(r), line))
println("d(volume)/dr at r = 25 : ", round(derivative(vol, 25.0); digits=2))
println("analytic 2π r L : ", round(2π * 25.0 * 100.0; digits=2))
# …and the swept SDF differentiates in a helix radius:
sdf(a) = field(sweep(circle_2d(0.2), helix(a, 0.5, 1)), [1.0, 0.6, 0.0])
println("d(sdf)/da at a = 1.0 : ", round(derivative(sdf, 1.0); digits=4))d(volume)/dr at r = 25 : 15707.96 m^3
analytic 2π r L : 15707.96
d(sdf)/da at a = 1.0 : -0.9662
More curve tools (project, composite, intersection)
Curves are values, so they compose: project_path drops a curve onto a datum plane, composite_path joins curves end-to-end, and intersection_path traces the curve where two implicit fields meet:
plane = datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]) # the xy plane
proj = project_path(h, plane) # helix → circle (r = 1)
println("projected helix at t = 0.25 : ", path_point(proj, 0.25))
corner = composite_path(
spline_path([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]),
spline_path([[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]),
)
println("composite midpoint : ", path_point(corner, 0.5))
s = to_sdf(sphere(1.0))
cy = to_sdf(cylinder(0.5, 3.0))
loop = intersection_path(s, cy, [-1.2, -1.2, -1.2], [1.2, 1.2, 1.2], 24)
println("sphere ∩ cylinder curve : ", loop !== nothing)projected helix at t = 0.25 : [-1.0, 1.2246467991473532e-16, 0.0]
composite midpoint : [1.0, 0.0, 0.0]
sphere ∩ cylinder curve : true
Placing paths in space (translate / rotate)
A Curve3D is a value, so translate / rotate place it in space — the tangent moves with the curve, so a sweep placed on a moved path follows it:
moved = translate(spline_path([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), [0.0, 1.0, 0.0])
println(path_point(moved, 0.5)) # [0.5, 1.0, 0.0]
spun = rotate(helix(0.3, 0.4, 2.0), rotation_z(π / 2))
println(path_tangent(spun, 0.0)) # rotated tangent[0.5, 1.0, 0.0]
[-3.7699111843077517, 2.3084048324661477e-16, 0.8]
The exact BRep sweep builds the envelope of a ball carried along a circular-profile path (or the swept surface of a closed polygonal profile), with planar end caps — brep(sweep(circle_2d(r), path)) and brep(sweep(polygon_2d(...), path)) respectively.
Split body and draft angle
Two more modeling operations: split_body keeps one side of a solid relative to a datum plane, and draft tapers a solid’s cross-section along a pull axis (a differentiable deformation, like fillet/chamfer):
b = to_sdf(box(2.0, 2.0, 2.0))
pl = datum_plane([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
pos = split_body(b, pl; keep = :positive)
neg = split_body(b, pl; keep = :negative)
lo, hi = [-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]
println("split halves ≈ ", round(volume(marching_tetrahedra(pos, lo, hi, 32)); digits=2),
" + ", round(volume(marching_tetrahedra(neg, lo, hi, 32)); digits=2))
tapered = draft(b, 0.3; axis = 3, ref = -1.0)
println("bottom corner (unchanged) : ", tapered([1.0, 1.0, -1.0]))
println("top corner (narrowed) : ", tapered([1.0, 1.0, 1.0]))split halves ≈ 4.0 m^3 + 4.0 m^3
bottom corner (unchanged) : 0.0
top corner (narrowed) : 2.294445161368431
Next steps
- Sketches, extrusion, and revolution — the two straight-axis sweeps this generalizes.
- Engineering parts from scratch — stepped shaft, drilled flange, and corner bracket built feature by feature.
- Feature operations — shell, mirror, patterns, loft, fillet, and chamfer.
- NURBS — the exact rational-spline curves and surfaces behind the BRep.
