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:
- a stepped shaft with a machined keyseat —
revolvefrom a profile, - a drilled pipe flange — cylinder CSG plus a
circular_patternof bolt holes, - a corner bracket — a fully constrained sketch,
extrude, mounting holes, and an edgefillet.
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³")
endremoved (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
- Sketches, extrusion, and revolution — the sweep features behind the shaft and the bracket.
- Sketch constraints — arcs, ellipses, and the full constraint set of the sketch solver.
- Feature operations — shell, mirror, patterns, loft, and the exact BRep fillet/chamfer.
- CSG and meshing — the boolean and transform features these parts are built from.
- Boundary representation — the exact topology behind the flange’s STEP export.
