RasmahRasmah

Parametric CAD: Engineering Parts from Scratch

println("Julia ", VERSION)
Julia 1.12.6

Overview

In parametric CAD, you never type a mesh. You draw a part the way a draughtsman would: a 2D profile with dimensions and constraints, a revolution around an axis, a drilled hole on a pitch circle, a rounded edge. Each feature remembers how it was made, so the drawing stays editable — change one dimension and every dependent feature follows — and the part’s area, volume, and mass come out as formulas, not pixel counts.

This tutorial draws three real parts, feature by feature, and checks each one against closed-form engineering formulas:

  1. a stepped shaft with a machined keyseat — revolve from a profile,
  2. a drilled pipe flange — cylinder CSG plus a circular_pattern of bolt holes,
  3. a corner bracket — a fully constrained sketch, extrude, mounting holes, and an edge fillet.

Along the way we verify every volume twice (analytic formula vs. a meshed measurement) and finish with a part mass in grams. (For the individual features themselves, see Sketches, extrusion, revolution and Feature operations.)

A note on units

Rasmah’s geometry is unit-agnostic: numbers are just lengths, and volumes come back in length-cubed. Engineers think in millimetres, so throughout this tutorial we interpret every length as mm — areas are then mm², volumes mm³ — and convert mass with a density in g/mm³:

using Rasmah

ρ_steel = 7.85e-3   # g/mm³  (7.85 g/cm³ = 7850 kg/m³)
ρ_alu   = 2.70e-3   # g/mm³  (2.70 g/cm³ = 2700 kg/m³)
 Downloading artifact: vtkjs
     Failure artifact: vtkjs
 Downloading artifact: vtkjs
0.0027

(The Units tutorial covers attaching real unit systems to simulations.)

Part 1 — a stepped shaft with a keyseat

A shaft is the classic turned part: a 2D profile — one line per shoulder — swept around the axis. revolve(profile) spins the sketch around the z axis, taking the profile’s x coordinate as the radial distance and its y coordinate as the axial position.

1. The profile

Our shaft is 100 mm long: Ø10 journals of 25 mm at each end, a Ø15 journal of 50 mm in the middle. The profile is a polygon in the half-plane x \ge 0 — each segment is one turned surface:

# 1. profile in the (radius, axial) half-plane: three shoulders
pts = [
    [0.0, -50.0], [5.0, -50.0], [5.0, -25.0], [7.5, -25.0],
    [7.5,  25.0], [5.0,  25.0], [5.0,  50.0], [0.0,  50.0],
]
shaft = revolve(polygon_2d(pts))
Revolve{Polygon2D{Float64}, Float64, Int64}(Polygon2D{Float64}([[0.0, -50.0], [5.0, -50.0], [5.0, -25.0], [7.5, -25.0], [7.5, 25.0], [5.0, 25.0], [5.0, 50.0], [0.0, 50.0]]), 6.283185307179586, 3)
vtk(surface_mesh(shaft; n = 48))

2. Checking the volume against the handbook

A turned part is a stack of cylinders, so its volume is V = \pi\sum r_i^2 h_i. Rasmah’s closed-form volume for the revolved polygon should match to machine precision:

V = ustrip(volume(shaft))                                  # exact
V_handbook = π * (5.0^2 * 25.0 + 7.5^2 * 50.0 + 5.0^2 * 25.0)

println("revolved volume  = ", round(V; digits = 2), " mm³")
println("handbook π Σ r²h = ", round(V_handbook; digits = 2), " mm³")
println("match            = ", isapprox(V, V_handbook; rtol = 1e-12))
revolved volume  = 12762.72 mm³
handbook π Σ r²h = 12762.72 mm³
match            = true

3. The keyseat

A keyseat is a slot milled into the journal to lock a pulley or gear with a square key. For our Ø15 journal a 3 mm wide × 2.5 mm deep × 50 mm long seat is typical. We model it as a small rectangular bar overlapping the journal — the part that sticks out beyond the shaft surface is ignored by the boolean difference:

# 2. keyseat: 3 wide × 2.5 deep, cut along the 50 mm middle journal
key = extrude(translate(rect_2d(2.5, 3.0), 6.25, 0.0), 50.0)
keyed_shaft = difference(shaft, key)
Difference{Revolve{Polygon2D{Float64}, Float64, Int64}, Extrude{Translate2D{Rect2D{Float64, Float64}, Float64, Float64}, Float64}}(Revolve{Polygon2D{Float64}, Float64, Int64}(Polygon2D{Float64}([[0.0, -50.0], [5.0, -50.0], [5.0, -25.0], [7.5, -25.0], [7.5, 25.0], [5.0, 25.0], [5.0, 50.0], [0.0, 50.0]]), 6.283185307179586, 3), Extrude{Translate2D{Rect2D{Float64, Float64}, Float64, Float64}, Float64}(Translate2D{Rect2D{Float64, Float64}, Float64, Float64}(Rect2D{Float64, Float64}(1.25, 1.5), 6.25, 0.0), 50.0))
vtk(surface_mesh(keyed_shaft; n = 48))

4. How much material did the keyseat remove?

The machinist’s rule of thumb says V = w\cdot d\cdot L = 3 \cdot 2.5 \cdot 50 = 375 mm³. But the slot floor is flat while the journal is round, so the true removal is a little less — the exact cross-section is the area between the chord at x = r - d and the circle x^2 + y^2 = r^2:

V_\text{removed} = L \cdot 2\int_0^{w/2} \Bigl(\sqrt{r^2 - y^2} - (r - d)\Bigr)\, dy
= L\Bigl[y\sqrt{r^2 - y^2} + r^2 \arcsin\tfrac{y}{r} - 2(r-d)\,y\Bigr]_0^{w/2}
# 3. exact keyseat removal (chord under the circular journal)
r, w, d, L = 7.5, 3.0, 2.5, 50.0
V_rule  = w * d * L                                          # machinist's estimate
V_exact = L * (w / 2 * sqrt(r^2 - (w / 2)^2) + r^2 * asin(w / 2 / r) - 2 * (r - d) * w / 2)

println("machinist's w·d·L       = ", round(V_rule; digits = 1), " mm³")
println("exact (curved journal)  = ", round(V_exact; digits = 1), " mm³")
machinist's w·d·L       = 375.0 mm³
exact (curved journal)  = 367.5 mm³

A mesh measurement of the same cut — marching_tetrahedra over the part’s bounding box — converges onto the exact value as the grid refines, which is a useful sanity check that the boolean did what the drawing intended:

sdf = keyed_shaft
lo, hi = bounds(sdf)

for n in (64, 128, 256)
    vm = ustrip(volume(marching_tetrahedra(sdf, lo, hi, n)))
    println("removed (n = ", n, ") = ", round(V - vm; digits = 1), " mm³")
end
removed (n = 64) = 375.5 mm³
removed (n = 128) = 369.4 mm³
removed (n = 256) = 367.9 mm³

5. Mass

The finished shaft weighs the stack of cylinders minus the seat:

V_final = V - V_exact
println("keyed shaft volume = ", round(V_final; digits = 1), " mm³")
println("mass (steel)       = ", round(V_final * ρ_steel; digits = 1), " g")
keyed shaft volume = 12395.3 mm³
mass (steel)       = 97.3 g

Part 2 — a drilled pipe flange

A flange is a ring with a circle of bolt holes — the classic “drawing” for a pressure boundary. Every feature is a separate boolean operation, which is exactly how a machinist sees the job: turn the blank, then drill eight holes on a pitch circle.

1. The blank

# 4. blank: Ø100 × 20 disk with a Ø40 bore
ring = difference(cylinder(50.0, 20.0), cylinder(20.0, 20.0))
Difference{Cylinder{Float64, Float64}, Cylinder{Float64, Float64}}(Cylinder{Float64, Float64}(50.0, 20.0), Cylinder{Float64, Float64}(20.0, 20.0))
vtk(surface_mesh(ring; n = 48))

The ring’s volume is textbook — \pi (R^2 - r^2) t — and a meshed measurement of the same solid agrees to well under a percent:

vm = ustrip(volume(surface_mesh(ring; n = 96)))
println("meshed volume = ", round(vm; digits = 1), " mm³")
println("π (R² − r²) t = ", round(π * (50.0^2 - 20.0^2) * 20.0; digits = 1), " mm³")
meshed volume = 131869.9 mm³
π (R² − r²) t = 131946.9 mm³

2. Eight bolt holes on a pitch circle

Eight M8 clearance holes (Ø9) sit on a Ø70 pitch circle. circular_pattern places one translated hole eight times around the axis — no trigonometry needed:

# 5. one Ø9 hole at radius 35, repeated 8× about the bore axis
holes = circular_pattern(translate(cylinder(4.5, 21.0), 35.0, 0.0, 0.0), 8)
flange = difference(ring, holes)
Difference{Difference{Cylinder{Float64, Float64}, Cylinder{Float64, Float64}}, CircularPattern{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Int64, Int64}}(Difference{Cylinder{Float64, Float64}, Cylinder{Float64, Float64}}(Cylinder{Float64, Float64}(50.0, 20.0), Cylinder{Float64, Float64}(20.0, 20.0)), CircularPattern{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Int64, Int64}(Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}(Cylinder{Float64, Float64}(4.5, 21.0), 35.0, 0.0, 0.0), 8, 3))
vtk(surface_mesh(flange; n = 48))

3. Volume and mass

Each hole removes \pi \cdot 4.5^2 \cdot 20 mm³, eight of them in total:

V_blank = π * (50.0^2 - 20.0^2) * 20.0
V_holes = 8 * π * 4.5^2 * 20.0

sdf = flange
lo, hi = bounds(sdf)
vm = ustrip(volume(marching_tetrahedra(sdf, lo, hi, 128)))

println("blank volume  = ", round(V_blank; digits = 1), " mm³")
println("holes volume  = ", round(V_holes; digits = 1), " mm³")
println("closed form   = ", round(V_blank - V_holes; digits = 1), " mm³")
println("meshed volume = ", round(vm; digits = 1), " mm³")
println("mass (steel)  = ", round((V_blank - V_holes) * ρ_steel; digits = 1), " g")
blank volume  = 131946.9 mm³
holes volume  = 10178.8 mm³
closed form   = 121768.1 mm³
meshed volume = 121760.9 mm³
mass (steel)  = 955.9 g

4. The exact boundary model

The blank is built purely from cylinders, so Rasmah keeps an exact, watertight boundary representation of it — four analytic faces — that round-trips to STEP for hand-off to other CAD/CAM systems:

b = brep(ring)
println("faces: ", length(b.faces), ", watertight: ", is_watertight(b))

path = tempname() * ".step"
write_step(b, path)
b2 = read_step(path)
println("STEP: ", round(filesize(path) / 1e3; digits = 1), " kB, reads back: ", b2 isa BRep)
faces: 4, watertight: true
STEP: 4.3 kB, reads back: true

Part 3 — a corner bracket from a constrained sketch

Plate parts are sketched, not stacked. The professional way to draw one is to lay down points only approximately, then declare what the part should be — this edge horizontal, that gap 60 mm — and let the constraint solver snap the sketch to shape. Change a dimension later and the whole outline re-solves.

1. A sketch that solves itself

The bracket is an L: a 60 × 15 mm base leg and a 15 × 40 mm upright leg. We place six points deliberately off-target, then impose the constraints — two perpendicular legs, four right-angle corners, and the four driving dimensions:

# 6. six points, placed only approximately
p1 = sketch_point(1); p2 = sketch_point(3); p3 = sketch_point(5)
p4 = sketch_point(7); p5 = sketch_point(9); p6 = sketch_point(11)
l1 = SketchLine(p1, p2); l2 = SketchLine(p2, p3); l3 = SketchLine(p3, p4)
l4 = SketchLine(p4, p5); l5 = SketchLine(p5, p6); l6 = SketchLine(p6, p1)

sketch = Sketch(
    [0.0, 0.0, 58.0, 2.0, 61.0, 16.0, 14.0, 14.0, 16.0, 39.0, -1.0, 41.0],
    [
        fixed(p1, 0.0, 0.0),          # anchor one corner
        horizontal(l1), vertical(l2), horizontal(l3),
        vertical(l4), horizontal(l5), vertical(l6),   # perpendicular legs
        distance(p1, p2, 60.0),       # base leg length
        distance(p2, p3, 15.0),       # base leg width
        distance(p3, p4, 45.0),       # upright leg width
        distance(p4, p5, 25.0),       # upright leg height
    ],
)

θ = solve_sketch(sketch)
println("residual: ", round(maximum(abs.(residuals(sketch, θ))); sigdigits = 2))
residual: 7.7e-13

2. Area, checked by hand

The solved outline is an exact polygon, and its area matches the L broken into two rectangles — 60 \cdot 15 + 15 \cdot 25 = 1275 mm²:

profile = polygon_from_points([p1, p2, p3, p4, p5, p6], θ)

println("outline area = ", round(ustrip(area(profile)); digits = 2), " mm²")
println("two legs     = ", round(60.0 * 15.0 + 15.0 * 25.0; digits = 2), " mm²")
outline area = 1275.0 mm²
two legs     = 1275.0 mm²

3. Extrude, then drill

The plate is the sketch pushed 10 mm; the two Ø6 mounting holes are booleans:

# 7. extrude the outline, then drill two Ø6 holes
plate = extrude(profile, 10.0)
hole = translate(cylinder(3.0, 11.0), 7.5, 7.5, 0.0)
bracket = difference(plate, union(hole, translate(hole, 37.5, 0.0, 0.0)))
Difference{Extrude{Polygon2D{Float64}, Float64}, FeatureUnion{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Translate{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Float64, Float64, Float64}}}(Extrude{Polygon2D{Float64}, Float64}(Polygon2D{Float64}([[1.7904573177947262e-14, 7.687721131808256e-13], [60.000000000000036, 1.496455245613573e-12], [60.00000000000004, 15.000000000002142], [15.000000000000055, 15.000000000002665], [15.000000000000059, 40.000000000003034], [1.9556461857632e-14, 40.000000000003226]]), 10.0), FeatureUnion{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Translate{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Float64, Float64, Float64}}(Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}(Cylinder{Float64, Float64}(3.0, 11.0), 7.5, 7.5, 0.0), Translate{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Float64, Float64, Float64}(Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}(Cylinder{Float64, Float64}(3.0, 11.0), 7.5, 7.5, 0.0), 37.5, 0.0, 0.0)))
vtk(surface_mesh(bracket; n = 64))

Extrusion volume is exactly \text{area} \times \text{thickness}, and each hole removes a full cylinder through the 10 mm plate:

V_plate = ustrip(volume(plate))
V_holes = 2 * π * 3.0^2 * 10.0

sdf = bracket
lo, hi = bounds(sdf)
vm = ustrip(volume(marching_tetrahedra(sdf, lo, hi, 96)))

println("plate volume    = ", round(V_plate; digits = 1), " mm³")
println("closed form     = ", round(V_plate - V_holes; digits = 1), " mm³")
println("meshed volume   = ", round(vm; digits = 1), " mm³")
println("mass (aluminum) = ", round((V_plate - V_holes) * ρ_alu; digits = 1), " g")
plate volume    = 12750.0 mm³
closed form     = 12184.5 mm³
meshed volume   = 12179.7 mm³
mass (aluminum) = 32.9 g

4. Round the edges

A cast or cut-and-formed bracket carries a radius on its outside corners to reduce stress concentration. Rasmah’s SDF fillet rounds every convex edge by dilating the part by a ball of radius r — material is added (as in a cast radius), growing the volume by roughly the surface area times the radius:

# 8. 1 mm cast radius on every edge
smooth = fillet(bracket, 1.0)
Fillet{Difference{Extrude{Polygon2D{Float64}, Float64}, FeatureUnion{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Translate{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Float64, Float64, Float64}}}, Float64}(Difference{Extrude{Polygon2D{Float64}, Float64}, FeatureUnion{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Translate{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Float64, Float64, Float64}}}(Extrude{Polygon2D{Float64}, Float64}(Polygon2D{Float64}([[1.7904573177947262e-14, 7.687721131808256e-13], [60.000000000000036, 1.496455245613573e-12], [60.00000000000004, 15.000000000002142], [15.000000000000055, 15.000000000002665], [15.000000000000059, 40.000000000003034], [1.9556461857632e-14, 40.000000000003226]]), 10.0), FeatureUnion{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Translate{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Float64, Float64, Float64}}(Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}(Cylinder{Float64, Float64}(3.0, 11.0), 7.5, 7.5, 0.0), Translate{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Float64, Float64, Float64}(Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}(Cylinder{Float64, Float64}(3.0, 11.0), 7.5, 7.5, 0.0), 37.5, 0.0, 0.0))), 1.0)
vtk(surface_mesh(smooth; n = 64))
sf = smooth
lo, hi = bounds(sf)
vm = ustrip(volume(marching_tetrahedra(sf, lo, hi, 96)))

println("sharp-edge volume = ", round(V_plate - V_holes; digits = 0), " mm³")
println("filleted volume   = ", round(vm; digits = 0), " mm³")
println("growth            = ", round(vm - (V_plate - V_holes); digits = 0), " mm³")
sharp-edge volume = 12185.0 mm³
filleted volume   = 17503.0 mm³
growth            = 5318.0 mm³

For radius applied to selected edges with material removed (the true machined fillet), see the BRep edge fillet in the Feature operations tutorial.

What a drawing gives you

Each of the three parts is a recipe, not a shape:

  • the shaft is a profile plus an axis — swap the profile and you have any turned part;
  • the flange is a ring minus a pattern — change the pitch circle and the holes follow;
  • the bracket is a constrained sketch — change one dimension and the solver re-derives the outline.

Because the features are explicit, the mass properties came from formulas, and the exact BRep parts export to STEP. That is the payoff of parametric CAD: the drawing is the single source of truth for the analysis, the drawing office, and the shop floor.

Next steps