RasmahRasmah

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 pitch
Difference2D{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 plate
at 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 × thickness
plate 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]))   # outside
center            : -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))  # ≈ 0
solved 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 z
pulley 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]))  # outside
half-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 teeth
CircularPattern2D{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