Sketches, Extrusion, and Revolution
using Rasmah
# Geometry is stored in SI (metres); these helpers render millimetres.
mm(v) = round(uconvert(u"mm", v); digits = 1)
mm2(v) = round(uconvert(u"mm^2", v); digits = 1)
mm3(v) = round(uconvert(u"mm^3", v); digits = 0)mm3 (generic function with 1 method)
Overview
Every part that goes on a milling machine or a lathe starts life as a 2D drawing: a profile sketched on paper, then swept into the third dimension. Rasmah supports the two fundamental sweeps found in every CAD (computer-aided design) system:
- Extrusion — translate a 2D sketch along a straight axis. A plate with bolt holes is a flat profile pushed through its thickness.
- Revolution — rotate a 2D sketch around an axis. A pulley, a wheel, or a shaft with a shoulder is a half cross-section spun around its centreline.
This tutorial draws two standard parts from scratch with these features — a millimetre-scale mounting bracket (sketch + extrude) and a V-belt pulley (revolve) — and checks the analytic volume of each against the closed-form formulas. The part dimensions are given in millimetres; Rasmah stores lengths in SI, so the code writes them as metres (80 mm = 0.080 m).
The bracket sketch
A sketch is a 2D implicit shape: a function s(x, y) that is negative inside, zero on the boundary, and positive outside. slot_2d(width, height, radius) is a rounded rectangle — the classic base-plate outline — and circle_2d(r) is a circle. The bracket is that plate with two Ø9 bolt holes (M8 clearance) punched through it, which is a 2D boolean on the sketch itself:
plate = slot_2d(0.080, 0.040, 0.008) # 80 × 40 mm plate, 8 mm corners
hole = translate(circle_2d(0.0045), -0.026, 0.0) # one Ø9 mm hole at x = −26
profile = difference(difference(plate, hole),
translate(hole, 0.052, 0.0)) # second hole, 52 mm pitchDifference2D{Difference2D{Slot2D{Float64, Float64, Float64}, Translate2D{Circle2D{Float64}, Float64, Float64}}, Translate2D{Translate2D{Circle2D{Float64}, Float64, Float64}, Float64, Float64}}(Difference2D{Slot2D{Float64, Float64, Float64}, Translate2D{Circle2D{Float64}, Float64, Float64}}(Slot2D{Float64, Float64, Float64}(0.04, 0.02, 0.008), Translate2D{Circle2D{Float64}, Float64, Float64}(Circle2D{Float64}(0.0045), -0.026, 0.0)), Translate2D{Translate2D{Circle2D{Float64}, Float64, Float64}, Float64, Float64}(Translate2D{Circle2D{Float64}, Float64, Float64}(Circle2D{Float64}(0.0045), -0.026, 0.0), 0.052, 0.0))
The sketch field answers the questions a draughtsman would ask with a calliper: is this point inside the plate, or inside a hole?
println("at a bolt-hole centre : ", hole(-0.026, 0.0)) # −4.5 mm → inside the hole
println("mid-plate material : ", profile(0.0, 0.0)) # < 0 → inside the plateat a bolt-hole centre : -0.0045
mid-plate material : -0.02
The 2D booleans and transforms on sketches compose exactly like the 3D ones, so a sketch can be edited with the same difference/union/intersection and translate/rotate/scale calls you already know.
Extruding the bracket
extrude(sketch, distance) sweeps the sketch along z from −distance/2 to +distance/2. Its SDF (signed-distance field) is the maximum of the sketch’s 2D distance and the distance to the two end caps — the classic way to build a prismatic part. The extrusion has a closed-form volume: the sketch area times the thickness, here 8 mm:
bracket = extrude(profile, 0.008)
println("plate area : ", mm2(area(profile))) # shoelace formula minus the holes
println("bracket volume : ", mm3(volume(bracket))) # area × thicknessplate area : 3018.8
bracket volume : 24150.0
An engineer’s first sanity check on a part is its mass. Steel is 7850 kg/m³, and volume returns a unitful quantity, so the conversion is dimensional arithmetic:
println("mass (steel) : ",
round(uconvert(u"kg", 7850u"kg/m^3" * volume(bracket)); digits = 2), " kg")mass (steel) : 0.19 kg
The mesh renders inline in the interactive viewer:
vtk(bracket; n = 48)Extrusion mechanics
The SDF of an extrusion is readable point-by-point. For a solid distance tall, the field is negative on the mid-plane, zero on the caps, and positive beyond them:
ext = extrude(circle_2d(1.0), 2.0)
println("center : ", field(ext, [0.0, 0.0, 0.0])) # inside
println("on the end cap : ", field(ext, [0.0, 0.0, 1.0])) # boundary
println("above the top cap : ", field(ext, [0.0, 0.0, 2.0])) # outsidecenter : -1.0
on the end cap : 0.0
above the top cap : 1.0
The bounds of an extrusion combine the sketch bounds in xy with the half-thickness in z:
lo, hi = bounds(ext)
println("bounds: ", lo, " .. ", hi)bounds: [-1.0, -1.0, -1.0] .. [1.0, 1.0, 1.0]
Parametric sketch constraints
Hand-drawn numbers are guesses; a design engineer constrains a sketch — pin it, dimension it, and let the solver finish the geometry. A Sketch holds a flat parameter vector θ (two (x, y) slots per point, plus a slot for each circle radius) and a set of constraints; solve_sketch drives the residual to zero with Gauss–Newton:
p1 = sketch_point(1); p2 = sketch_point(3)
p3 = sketch_point(5); p4 = sketch_point(7)
l1 = SketchLine(p1, p2); l2 = SketchLine(p2, p3)
l3 = SketchLine(p3, p4); l4 = SketchLine(p4, p1)
sketch = Sketch(
[0.2, -0.3, 9.8, 0.4, 9.6, 4.7, 0.3, 4.9], # a perturbed rectangle
[
fixed(p1, 0.0, 0.0), # pin p1 to the origin
horizontal(l1), vertical(l2), # p2 is due east, p3 due north
horizontal(l3), vertical(l4), # close the rectangle
distance(p1, p2, 10.0), # bottom edge length 10
distance(p2, p3, 5.0), # right edge length 5
],
)
θ = solve_sketch(sketch)
println("solved corners: ", point_pos(p1, θ), ", ", point_pos(p2, θ),
", ", point_pos(p3, θ), ", ", point_pos(p4, θ))
println("point type: ", typeof(p1)) # SketchPoint
println("residual max: ", round(maximum(abs.(residuals(sketch, θ))); digits = 2)) # ≈ 0solved corners: [9.318296998231868e-15, 2.7724922051614694e-15], [10.000000000000016, 5.253150285949233e-15], [10.00000000000002, 5.000000000000007], [1.0751884488779851e-14, 5.000000000000008]
point type: SketchPoint
residual max: 0.0
The solved points feed straight into polygon_2d (via polygon_from_points), so a constrained sketch is an ordinary extrude/revolve profile:
profile2 = polygon_from_points([p1, p2, p3, p4], θ)
println("constrained sketch area = ", area(profile2))
println("extruded volume = ", volume(extrude(profile2, 2.0)))constrained sketch area = 50.00000000000008 m^2
extruded volume = 100.00000000000016 m^3
Constraints include coincident, distance, horizontal, vertical, parallel, perpendicular, tangent (circle–line), and fixed.
A SketchCircle carries a center point and a radius slot, so the tangent constraint can drive the radius to make a circle touch a line:
p1 = sketch_point(1); p2 = sketch_point(3); pc = sketch_point(5)
c = SketchCircle(pc, 7) # θ[7] is the radius slot
l = SketchLine(p1, p2)
tangent_sketch = Sketch(
[0.0, 0.0, 10.0, 0.0, 5.0, 1.0, 2.0],
[
fixed(p1, 0.0, 0.0), horizontal(l), distance(p1, p2, 10.0),
fixed(pc, 5.0, 5.0), tangent(c, l), # centre fixed at (5, 5), line at y = 0
],
)
θc = solve_sketch(tangent_sketch)
println("circle type : ", typeof(c)) # SketchCircle
println("solved radius: ", round(θc[7]; digits = 3)) # → 5.0 (centre-to-line distance)circle type : SketchCircle
solved radius: 5.0
Revolution: turning a pulley
revolve(profile, angle = 2π; axis = 3) rotates the profile around an axis. The profile’s x coordinate becomes the radial distance from the axis and its y coordinate the axial position — so a half cross-section through the centreline is exactly what a lathe drawing specifies.
The V-belt pulley below is one such cross-section, drawn as a polygon and revolved a full turn: a Ø10 bore, a web out to Ø90, and a flat-bottom groove of Ø76 between the two rim flanges:
section_mm = [ # (radius, z) cross-section, in millimetres
[0.0, -10.0], [5.0, -10.0], # bore Ø10, face at z = −10
[5.0, -4.0], [45.0, -4.0], # web out to the rim at z = −4
[45.0, -2.0], [38.0, -2.0], # rim flange top, then into the groove
[38.0, 2.0], [45.0, 2.0], # groove floor Ø76, back up the flange
[45.0, 4.0], [5.0, 4.0], # rim at z = +4, back to the hub
[5.0, 10.0], [0.0, 10.0], # hub face at z = +10
]
section = section_mm ./ 1000 # millimetres → metres (SI storage)
pulley = revolve(polygon_2d(section))
lo, hi = bounds(pulley)
println("pulley bounds: ", mm.(lo), " .. ", mm.(hi)) # ±45 in xy, ±10 in zpulley bounds: [-45.0, -45.0, -10.0] .. [45.0, 45.0, 10.0]
vtk(pulley; n = 48)
### Checking the volume: Pappus's theorem
A revolved solid obeys **Pappus's centroid theorem**: its volume is the profile
area times the distance its centroid travels, `V = 2π·x̄·A`, where `x̄` is the
centroid's radial position. Rasmah's closed-form `volume` for a revolved
polygon is exactly this formula — verified here against an independent
shoelace-formula computation, and against a mesh measurement:
```{julia}
function pappus_volume(pts) # pts in mm → volume in mm³
A = 0.0; Cx = 0.0
for i in eachindex(pts)
p = pts[i]; q = pts[mod1(i + 1, length(pts))]
cr = p[1] * q[2] - q[1] * p[2] # shoelace cross term
A += cr / 2
Cx += (p[1] + q[1]) * cr / 6
end
return 2π * Cx # V = 2π·x̄·A = 2π·Cx
end
println("profile area : ", mm2(area(polygon_2d(section))))
println("volume (Rasmah) : ", mm3(volume(pulley)))
println("Pappus 2π·x̄·A : ", round(pappus_volume(section_mm); digits = 0), " mm³")
println("equal : ", uconvert(u"mm^3", volume(pulley)) ≈ pappus_volume(section_mm))
The meshed fallback converges to the same value — marching_tetrahedra turns any SDF into a TetMesh whose signed tetrahedron sum is the volume:
lo, hi = bounds(pulley)
println("volume (meshed) : ",
mm3(volume(marching_tetrahedra(pulley, lo, hi, 64))))volume (meshed) : 44391.0
A Ø90 × 20 steel pulley from this volume weighs about:
println("mass (steel) : ",
round(uconvert(u"kg", 7850u"kg/m^3" * volume(pulley)); digits = 2), " kg")mass (steel) : 0.35 kg
Partial revolution — angle
Passing an angle sweeps the profile through a sector instead of a full turn — the geometry of a split pulley or a segment keyed onto a shaft. The solid is the profile restricted to the azimuthal wedge [0, angle], and its volume scales linearly with the angle:
half = revolve(polygon_2d(section), π)
println("full-turn pulley volume = ", mm3(volume(pulley)))
println("half-turn sector volume = ", mm3(volume(half)), " (= full / 2)")full-turn pulley volume = 44535.0
half-turn sector volume = 22268.0 (= full / 2)
A point on the +y axis is inside a half-turn wedge (its azimuth is π/2), while its mirror on the −y axis is outside:
sector = revolve(circle_2d(1.0), π)
println("half-turn at (0, 0.5, 0) : ", field(sector, [0.0, 0.5, 0.0])) # inside
println("half-turn at (0, -0.5, 0): ", field(sector, [0.0, -0.5, 0.0])) # outsidehalf-turn at (0, 0.5, 0) : -0.5
half-turn at (0, -0.5, 0): 0.5
Choosing the axis
axis selects the revolution axis — a coordinate axis 1 (x), 2 (y), 3 (z, the default), or a datum_axis. Revolving the same circle about a different axis still gives a sphere (revolution is rotation-invariant), but the bounding box permutes to match the axis:
rev_x = revolve(circle_2d(1.0), 2π; axis = 1)
rev_y = revolve(circle_2d(1.0), 2π; axis = 2)
println("volume (axis = 1) = ", volume(rev_x))
println("volume (axis = 2) = ", volume(rev_y))
println("bounds (axis = 1) = ", bounds(rev_x))
ax = datum_axis([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]) # axis through (1,0,0) along z
rev_d = revolve(circle_2d(0.5), 2π; axis = ax)
println("off-origin sphere volume = ", volume(rev_d))volume (axis = 1) = 4.1887902047863905 m^3
volume (axis = 2) = 4.1887902047863905 m^3
bounds (axis = 1) = ([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0])
off-origin sphere volume = 0.5235987755982988 m^3
2D sketch CSG and patterns
Sketches support the same boolean, transform, and pattern operations as 3D solids, and a compound sketch is still one sketch — sweepable, measurable, and composable:
plate2 = difference(rect_2d(4.0, 2.0), circle_2d(0.5)) # a plate with a hole
strip = linear_pattern(circle_2d(0.4), [1.0, 0.0], 1.0, 4) # 4 holes in a row
gear = circular_pattern(rect_2d(0.3, 1.0), 6) # 6 teethCircularPattern2D{Rect2D{Float64, Float64}, Int64}(Rect2D{Float64, Float64}(0.15, 0.5), 6)
A row of holes is a mounting rail’s drilling pattern; six rectangular teeth around a centre is a gear blank. The compound sketch sweeps and measures like any primitive sketch — extrude it and read off the (sampled-polygon) area and the extrusion volume:
extruded = extrude(plate2, 0.5)
println("plate area: ", round(area(plate2); digits = 4))
println("extruded volume: ", round(volume(extruded); digits = 4))plate area: 7.2129 m^2
extruded volume: 3.6064 m^3
Next steps
- Free-form curves and sweeping along a path — the general
sweepthat traces a sketch along an arbitrary 3D curve. - Boundary representation — inspect the exact topological model behind the same features.
- Feature operations — shell, mirror, patterns, loft, fillet, and chamfer on top of these sweeps.
