RasmahRasmah

Assembly Mates and Kinematic Joints

Overview

Mating constraints relate datum geometry on two components — a DatumPlane, DatumAxis, or DatumPoint in each component’s local frame — and solve_assembly drives the free components’ rigid poses until the stacked residual is zero (Gauss–Newton / Levenberg–Marquardt, the same shared solver as the sketch constraints). This is the CAD-assembly analogue of sketch constraints. The worked example at the end draws a real M8 bolted joint — standard fasteners from scratch — and mates them into a checked assembly.

using Rasmah
using LinearAlgebra
 Downloading artifact: vtkjs
     Failure artifact: vtkjs
 Downloading artifact: vtkjs

Datum references and the basic mates

mate_ref(component, datum) references a datum in a component’s local frame. The basic mates are coincident, parallel, perpendicular, concentric, distance, and angle_mate:

a = assembly(
    "Coincide",
    component("Base", box(2.0, 2.0, 2.0)),
    component("Post", box(1.0, 1.0, 1.0); translate=[0.0, 0.0, 3.0]),
)
b = solve_assembly(a, [
    coincident(
        mate_ref("Post", datum_plane([0.0, 0.0, -0.5], [0.0, 0.0, -1.0])),
        mate_ref("Base", datum_plane([0.0, 0.0, 1.0], [0.0, 0.0, 1.0])),
    ),
]; fixed=["Base"])

post = b.components[2]
println("post translation: ", post.placement.t)        # [0, 0, 1.5]
println("base untouched:   ", b.components[1].placement.t)
post translation: [0.0, 0.0, 1.50000000000045]
base untouched:   [0.0, 0.0, 0.0]

fixed keeps the named components in place (plus any ground-marked ones); only the free components move. Let’s exercise the other mates:

# concentric axes
ca = assembly("Axis",
    component("Base", cylinder(1.0, 2.0)),
    component("Pin", cylinder(0.5, 1.0); translate=[2.0, 0.0, 0.0]))
cb = solve_assembly(ca, [
    concentric(
        mate_ref("Pin", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
        mate_ref("Base", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
    )]; fixed=["Base"])
println("pin x,y: ", cb.components[2].placement.t[1:2])   # [0, 0]

# parallel: rotate a tilted post until its top face is parallel to the base top
pa = assembly("Tilt",
    component("Base", box(2.0, 2.0, 2.0)),
    component("Post", box(1.0, 1.0, 1.0); rotate=rotation_x(0.4)))
pb = solve_assembly(pa, [
    parallel(
        mate_ref("Post", datum_plane([0.0, 0.0, 0.5], [0.0, 0.0, 1.0])),
        mate_ref("Base", datum_plane([0.0, 0.0, 1.0], [0.0, 0.0, 1.0])),
    )]; fixed=["Base"])
n = pb.components[2].placement.R * [0.0, 0.0, 1.0]
println("post normal ≈ +z: ", n ≈ [0.0, 0.0, 1.0])

# distance: hold the post bottom 1 unit above the base top
da = assembly("Gap",
    component("Base", box(2.0, 2.0, 2.0)),
    component("Post", box(1.0, 1.0, 1.0); translate=[0.0, 0.0, 3.0]))
db = solve_assembly(da, [
    distance(
        mate_ref("Base", datum_plane([0.0, 0.0, 1.0], [0.0, 0.0, 1.0])),
        mate_ref("Post", datum_plane([0.0, 0.0, -0.5], [0.0, 0.0, 1.0])),
        1.0,
    )]; fixed=["Base"])
println("gap: ", db.components[2].placement.t[3])          # 2.5

# angle: tilt the post to 60° from the base normal
ga = assembly("Angle",
    component("Base", box(2.0, 2.0, 2.0)),
    component("Post", box(1.0, 1.0, 1.0); rotate=rotation_x(0.1)))
gb = solve_assembly(ga, [
    angle_mate(
        mate_ref("Post", datum_plane([0.0, 0.0, 0.5], [0.0, 0.0, 1.0])),
        mate_ref("Base", datum_plane([0.0, 0.0, 1.0], [0.0, 0.0, 1.0])),
        π / 3,
    )]; fixed=["Base"])
an = gb.components[2].placement.R * [0.0, 0.0, 1.0]
println("cos(angle): ", dot(an, [0.0, 0.0, 1.0]) ≈ cos(π / 3))
pin x,y: [5.999992199415694e-13, 0.0]
post normal ≈ +z: true
gap: 2.50000000000015
cos(angle): true

The additional mates

Beyond the basic six there are tangent_mate (sphere/cylinder vs plane), flush_mate (coincident with aligned normals), lock_mate (zero relative pose), width_mate (centre between two planes), symmetry_mate (mirror about a plane), and bounded limit_distance / limit_angle (zero residual inside [lo, hi], positive outside):

ra = mate_ref("A", datum_point([0.0, 0.0, 0.0]))
rb = mate_ref("B", datum_point([0.0, 0.0, 0.0]))

# tangent: sphere (DatumPoint) vs plane, radius 2
tm = tangent_mate(ra, mate_ref("P", datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])), 2.0)
println(tm isa TangentMate)

# flush / lock / width / symmetry / bounded
println(flush_mate(ra, rb) isa FlushMate)
println(lock_mate(ra, rb) isa LockMate)
println(width_mate(ra, rb, rb) isa WidthMate)
println(symmetry_mate(ra, rb, mate_ref("P", datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]))) isa SymmetryMate)
println(limit_distance(ra, rb, 1.0, 2.0) isa LimitDistanceMate)
println(limit_angle(ra, rb, 0.0, π / 4) isa LimitAngleMate)
true
true
true
true
true
true
true

Kinematic (mechanical) mates

Beyond the geometric mates, Rasmah provides mechanical joints that also expose their kinematics (the driven motion for a drive input), not just the static pose:

axa = mate_ref("A", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]))
axb = mate_ref("B", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]))

# spur gear: parallel axes a distance ra+rb apart, counter-rotation
gm = gear_mate(axa, axb, 1.0, 2.0)
println(gear_ratio(gm))           # 0.5
println(gear_rotation(gm, 1.0))   # -0.5

g = assembly("G",
    component("A", box(1.0, 1.0, 1.0)),
    component("B", box(1.0, 1.0, 1.0); translate=[5.0, 0.0, 0.0]))
bg = solve_assembly(g, [gm]; fixed=["A"])
println("gear centre distance: ", norm(bg.components[2].placement.t))   # 3.0

# screw: coincident axes + helical coupling (axial travel = pitch·θ / 2π)
sm = screw_mate(axa, axb, 2.0)
println(screw_axial(sm, 2π))      # 2.0

# rack-and-pinion: perpendicular axes at pitch radius r
rp = rack_pinion_mate(
    mate_ref("A", datum_axis([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])),
    mate_ref("B", datum_axis([0.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
    1.0)
println(rack_offset(rp, 1.0))     # 1.0

# cam: a follower point (with optional roller offset) stays on a cam solid
cam = sphere(1.0)
cm = cam_mate(mate_ref("F", datum_point([0.0, 0.0, 0.0])), cam)
println(cm isa CamMate)
cc = assembly("Cam",
    component("Cam", box(0.2, 0.2, 0.2)),
    component("Follower", sphere(0.05); translate=[3.0, 0.0, 0.0]))
bc = solve_assembly(cc, [cam_mate(mate_ref("Follower", datum_point([0.0, 0.0, 0.0])), cam)]; fixed=["Cam"])
println("follower pulled onto cam: ", norm(bc.components[2].placement.t))   # 1.0
0.5
-0.5
gear centre distance: 3.0000000000006
2.0
1.0
true
follower pulled onto cam: 1.0000000000006

Path and segmented-limit mates

path_mate slides a point along a 3D Curve3D; the segmented limit mates allow a union of allowed [lo, hi] bands:

path = spline_path([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 1.0, 0.0], [3.0, 1.0, 0.0]])
pa = assembly("P", component("Ball", sphere(0.1); translate=[0.0, 0.0, 5.0]))
pb = solve_assembly(pa, [path_mate(mate_ref("Ball", datum_point([0.0, 0.0, 0.0])), path)])
t = pb.components[1].placement.t
println("on path: ", minimum(norm(path_point(path, i / 128) .- t) for i in 0:128) < 1e-6)

ml = limit_distance(ra, rb, [(1.0, 2.0), (4.0, 5.0)])
println(ml isa SegmentedLimitDistanceMate)
println(limit_angle(ra, rb, [(0.0, π / 4)]) isa SegmentedLimitAngleMate)
on path: true
true
true

The mate types

mate_ref(component, datum) returns a MateRef; each mate constructor returns its matching <: Mate type — coincident → CoincidentMate, parallel → ParallelMate, perpendicular → PerpendicularMate, concentric → ConcentricMate, distance → DistanceMate, angle_mate → AngleMate, the kinematic joints → GearMate / RackPinionMate / ScrewMate / CamMate / PathMate, and so on:

ra = mate_ref("A", datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]))
rb = mate_ref("B", datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]))
println(ra isa MateRef)
println(coincident(ra, rb) isa CoincidentMate)
println(parallel(ra, rb) isa ParallelMate)
println(perpendicular(ra, rb) isa PerpendicularMate)
println(concentric(
    mate_ref("A", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
    mate_ref("B", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]))) isa ConcentricMate)
println(gear_mate(
    mate_ref("A", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
    mate_ref("B", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])), 1.0, 2.0) isa GearMate)
println(rack_pinion_mate(
    mate_ref("A", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
    mate_ref("B", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])), 1.0) isa RackPinionMate)
println(screw_mate(
    mate_ref("A", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
    mate_ref("B", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])), 2.0) isa ScrewMate)
println(path_mate(
    mate_ref("A", datum_point([0.0, 0.0, 0.0])),
    spline_path([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]])) isa PathMate)
true
true
true
true
true
true
true
true
true

mirror_component(a, name, plane) reflects a component across a datum plane as a new component (named <name>_mirror):

ma = assembly("M", component("Part", box(1.0, 1.0, 1.0); translate=[2.0, 0.0, 0.0]))
mm = mirror_component(ma, "Part", datum_plane([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]))
println(length(mm.components), " components after mirroring")
2 components after mirroring

Collections (the outliner)

Collection groups component names with show/hide state — the kernel side of a Blender-style outliner. visible_assembly drops the hidden members:

c = collection("Group", "Base", "Post")
println(visible(c), "  ", member_names(c))

h = hide(c)
println(!visible(h), "  ", visible(reveal(h)))

println(member_names(add_to_collection(c, "Pin")))
println(member_names(remove_from_collection(add_to_collection(c, "Pin"), "Post")))
println(member_names(reorder_collection(c, ["Post", "Base"])))

va = visible_assembly(assembly("A",
    component("Base", box(1.0, 1.0, 1.0)),
    component("Post", box(1.0, 1.0, 1.0); translate=[0.0, 0.0, 2.0])),
    collection("G", "Base"))
println(length(va.components), " visible component(s)")
true  ["Base", "Post"]
true  true
["Base", "Post", "Pin"]
["Base", "Pin"]
["Post", "Base"]
1 visible component(s)

A worked example: an M8 bolted joint

Mates earn their keep on real hardware. This section draws an M8 bolted joint — a plate, a hex-head bolt, a washer, and a nut — as four components, drops them in space at deliberately wrong poses, and lets the mate solver drag them into a valid, checked assembly. Every dimension is millimetres.

1. Drawing the four standard fasteners

A bolt is two features: a hexagonal prism head (13 mm across flats, so circumradius 13/√3, 5.5 mm tall) unioned with the Ø8 shank. The washer is a hollow tube (DIN 125: OD 16, ID 8.4, 1.6 mm thick). The nut is a hex prism with the Ø8 tapped hole modelled as a full bore. The plate is a 60 × 40 × 10 block with a Ø9 clearance hole:

head = prism(6, 13 / √3, 5.5)
bolt = union(translate(head, 0.0, 0.0, 2.75),    # hex head, z ∈ [0, 5.5]
             translate(cylinder(4.0, 21.0), 0.0, 0.0, -10.5))   # shank, z ∈ [-21, 0]
washer = tube(8.0, 4.2, 1.6)
nut = difference(prism(6, 13 / √3, 6.8), cylinder(4.0, 6.8))
plate = difference(box(60.0, 40.0, 10.0), cylinder(4.5, 11.0))   # Ø9 hole at the centre

println("bolt   = ", round(ustrip(volume(bolt)); digits = 0), " mm³")
println("washer = ", round(ustrip(volume(washer)); digits = 0), " mm³")
println("nut    = ", round(ustrip(volume(nut)); digits = 0), " mm³")
println("plate  = ", round(ustrip(volume(plate)); digits = 0), " mm³")
bolt   = 1859.0 mm³
washer = 233.0 mm³
nut    = 653.0 mm³
plate  = 23364.0 mm³

Every volume is an exact formula per feature — the prism is \frac{n}{2}R^2\sin(2\pi/n)\,h, the cylinder \pi r^2 h — and the boolean parts integrate numerically to a fraction of a percent, so the bill of materials comes for free:

vtk(bolt; n = 64)

2. Scatter the parts, then declare the joint

The four components start at deliberately wrong poses — translated away and tilted — exactly as if they had just been imported into an empty scene:

a = assembly(
    "M8 joint",
    component("Plate", plate),
    component("Bolt", bolt; translate = [40.0, -25.0, 18.0], rotate = rotation_x(0.3)),
    component("Washer", washer; translate = [-30.0, 20.0, -12.0], rotate = rotation_y(0.2)),
    component("Nut", nut; translate = [15.0, 10.0, -35.0], rotate = rotation_z(0.9)),
)
Assembly(M8 joint: 4 components)

The joint is then six mate declarations, each a datum pair: the bolt’s axis concentric with the plate’s hole axis, the head’s underside flush on the plate top, the washer stacked under the plate, the nut stacked under the washer, and an angle_mate that clocks the nut so a hex flat faces the plate’s front edge (the hexagon’s facet midpoints sit at \pi/6, so that is the flat’s local normal):

solved = solve_assembly(a, [
    concentric(
        mate_ref("Bolt", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
        mate_ref("Plate", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
    ),
    coincident(
        mate_ref("Bolt", datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),    # head underside
        mate_ref("Plate", datum_plane([0.0, 0.0, 5.0], [0.0, 0.0, 1.0])),   # plate top
    ),
    concentric(
        mate_ref("Washer", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
        mate_ref("Bolt", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
    ),
    coincident(
        mate_ref("Washer", datum_plane([0.0, 0.0, 0.8], [0.0, 0.0, 1.0])),  # washer top
        mate_ref("Plate", datum_plane([0.0, 0.0, -5.0], [0.0, 0.0, -1.0])), # plate bottom
    ),
    concentric(
        mate_ref("Nut", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
        mate_ref("Bolt", datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
    ),
    coincident(
        mate_ref("Nut", datum_plane([0.0, 0.0, 3.4], [0.0, 0.0, 1.0])),     # nut top
        mate_ref("Washer", datum_plane([0.0, 0.0, -0.8], [0.0, 0.0, -1.0])),
    ),
    angle_mate(
        mate_ref("Nut", datum_plane([0.0, 0.0, 0.0], [cos(π / 6), sin(π / 6), 0.0])),
        mate_ref("Plate", datum_plane([0.0, 20.0, 0.0], [0.0, 1.0, 0.0])),
        0.0,
    ),
]; fixed = ["Plate"])
Assembly(M8 joint: 4 components)

3. Inspect the solved joint

The solver returns the same assembly with each free component’s placement filled in. Reading them off is the assembly drawing, in numbers: the bolt sits with its head underside on z = 5, the washer is centred at z = -5.8, the nut at z = -10 — the stack is exactly plate (10) + washer (1.6) + nut (6.8):

for c in solved.components
    println(rpad(c.name, 8), " t = ", round.(c.placement.t; digits = 3))
end

n_flat = solved.components[4].placement.R * [cos(π / 6), sin(π / 6), 0.0]
println("nut flat normal = ", round.(n_flat; digits = 3), "   (want [0, 1, 0])")
Plate    t = [0.0, 0.0, 0.0]
Bolt     t = [0.0, 0.0, 5.0]
Washer   t = [-0.0, -0.0, -5.8]
Nut      t = [-0.0, -0.0, -10.0]
nut flat normal = [0.0, 1.0, -0.0]   (want [0, 1, 0])

Two mechanical checks close the loop. The shank sticks out 21 − (10 + 1.6 + 6.8) = 2.6 mm below the nut — just over two M8 thread pitches, the textbook “two threads proud”. clearance reports the smallest surface gap between two components (0: the head sits flush on the plate), and interference the overlap volume (≈10 mm³ of smooth-overlap smear at the contact plane, against an ≈880 mm³ union — the parts touch, they do not interpenetrate):

println("shank protrusion = ", 21.0 - (10.0 + 1.6 + 6.8), " mm  (2.6 / 1.25 = ", round(2.6 / 1.25; digits = 1), " pitches)")
println("clearance bolt–plate    = ", round(ustrip(clearance(solved, "Bolt", "Plate")); digits = 3), " mm")
println("interference nut–washer = ", round(ustrip(interference(solved, "Nut", "Washer")); digits = 1), " mm³")
shank protrusion = 2.6000000000000014 mm  (2.6 / 1.25 = 2.1 pitches)
clearance bolt–plate    = 0.0 mm
interference nut–washer = 10.1 mm³

Finally, the joined assembly as one solid, and the joint mass (summing the feature volumes times steel density, 7.85 g/cm³):

vtk(solved; n = 64)
ρ = 7.85e-3                       # g/mm³
m_tot = ustrip(volume(bolt)) + ustrip(volume(washer)) +
        ustrip(volume(nut)) + ustrip(volume(plate))
println("joint mass = ", round(m_tot * ρ; digits = 1), " g (steel)")
joint mass = 205.0 g (steel)

Because each part is a feature recipe, the whole joint is parametric: change the plate thickness and the washer and nut mates re-solve the stack onto the new face — the placement numbers above update automatically.

Next steps