Reference / Datum Geometry
println("Julia ", VERSION)Julia 1.12.6
Overview
Most Rasmah features build solids — a box, a difference, a fillet — with a signed-distance field (SDF) and an exact boundary representation (BRep). Datum geometry is the opposite: zero- or infinite-size reference data (points, axes, planes, and coordinate systems) that never produces a solid, but anchors where and how other features are placed. In this tutorial we construct the four datum types, measure against them, convert between coordinate systems, and then use them as anchors for mirror and the pattern features.
Datum geometry lives in plain values, is type-generic — its coordinates may be AD duals — and is fully differentiable.
Datum points, axes, and planes
A DatumPoint is a single position, a DatumAxis a line (a point plus a unit direction), and a DatumPlane an infinite plane (a point plus a unit normal). The constructors normalise the direction/normal for you:
using Rasmah
p = datum_point([1.0, 2.0, 3.0]) # a point
a = datum_axis([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]) # x-axis (dir normalised to [1,0,0])
n = datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 3.0]) # z = 0 plane
println("axis direction: ", a.dir)
println("plane normal: ", n.normal)axis direction: [1.0, 0.0, 0.0]
plane normal: [0.0, 0.0, 1.0]
All four concrete types — DatumPoint, DatumAxis, DatumPlane, and DatumCoordinateSystem — subtype the abstract DatumGeometry, so you can write generic code that accepts any anchor:
println(DatumPoint([0.0, 0.0, 0.0]) isa DatumGeometry) # true
println(datum_axis([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]) isa DatumGeometry) # true
println(datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]) isa DatumGeometry) # true
println(datum_csys([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]) isa DatumGeometry) # truetrue
true
true
true
A plane can also be defined by the Hessian form n̂·x = offset or by three points it passes through:
hessian = datum_plane([0.0, 1.0, 0.0], 2.0) # the y = 2 plane
tri = datum_plane([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
println("hessian point: ", hessian.point, " normal: ", hessian.normal)
println("three-point norm: ", tri.normal) # [1,1,1]/√3hessian point: [0.0, 2.0, 0.0] normal: [0.0, 1.0, 0.0]
three-point norm: [0.5773502691896258, 0.5773502691896258, 0.5773502691896258]
Measuring against a datum
distance reports the unsigned distance from a query point to any datum, and signed_distance the signed distance to a plane (positive on the side the normal points toward):
origin = datum_point([0.0, 0.0, 0.0])
axis = datum_axis([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
plane = datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 1.0])
println("point distance: ", distance(origin, [3.0, 4.0, 0.0])) # 5
println("axis distance: ", distance(axis, [0.0, 3.0, 4.0])) # 5 (the 3-4-5 rule)
println("plane signed: ", signed_distance(plane, [0.0, 0.0, 5.0])) # +5
println("plane distance: ", distance(plane, [1.0, 2.0, 3.0])) # 3point distance: 5.0
axis distance: 5.0
plane signed: 5.0
plane distance: 3.0
project drops a point orthogonally onto a plane or axis — the basis of snapping geometry to a reference:
println("project onto plane: ", project(plane, [1.0, 2.0, 5.0])) # [1,2,0]
println("project onto axis: ", project(axis, [1.0, 2.0, 3.0])) # [1,0,0]project onto plane: [1.0, 2.0, 0.0]
project onto axis: [1.0, 0.0, 0.0]
Coordinate systems
A DatumCoordinateSystem pairs an origin with a right-handed orthonormal basis R (its columns are the local x/y/z axes in global coordinates). to_global / to_local convert points between the two frames:
c = datum_csys([1.0, 2.0, 3.0], [0.0, 0.0, 1.0]) # +z = global z, origin shifted
q = [0.7, -1.3, 2.9]
println("origin → local zero: ", to_local(c, c.origin))
println("round-trip: ", to_global(c, to_local(c, q)))origin → local zero: [0.0, 0.0, 0.0]
round-trip: [0.7, -1.2999999999999998, 2.9]
For a rotated frame, datum_csys(origin, normal) builds the basis with +z pointing along normal:
tilted = datum_csys([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]) # +z = +y
println("local +z → global: ", to_global(tilted, [0.0, 0.0, 1.0])) # [0,1,0]local +z → global: [0.0, 1.0, 0.0]
Datum planes as mirror anchors
mirror accepts either an integer coordinate axis (1/2/3) or an arbitrary DatumPlane. Reflect an off-centre box across a tilted 45° plane through the origin:
tilted = datum_plane([0.0, 0.0, 0.0], [1.0, 1.0, 0.0])
m = mirror(translate(box(1.0, 1.0, 1.0), 2.0, 0.0, 0.0), tilted)
println("reflected centre: ", field(m, [0.0, -2.0, 0.0])) # −0.5 → inside
println("volume preserved: ", volume(m))reflected centre: -0.49999999999999956
volume preserved: 1.0 m^3
vtk(surface_mesh(m; n=48))Datum axes as pattern anchors
linear_pattern and circular_pattern likewise accept a DatumAxis in place of the integer coordinate axis. A circular_pattern about the hub axis turns a single blade into a six-blade fan rotor: 24 × 4 × 3 mm blades standing at a 20 mm radius around a Ø8 mm hub:
using LinearAlgebra
hub_axis = datum_axis([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]) # the rotor's spin axis
blade = translate(box(0.024, 0.004, 0.003), 0.020, 0.0, 0.0)
rotor = union(circular_pattern(blade, 6; axis = hub_axis), cylinder(0.004, 0.003))
fan = rotor
lo, hi = bounds(fan)
println("rotor bounds: ", round.(lo .* 1000; digits = 1), " .. ", round.(hi .* 1000; digits = 1), " mm")
println("hub centre (solid): ", field(fan, [0.0, 0.0, 0.0]) < 0)
# blade tips: one blade every 60° about the datum axis
for i in 0:5
a = i * π / 3
p = [0.020 * cos(a), 0.020 * sin(a), 0.0]
println("tip at ", round(rad2deg(a); digits = 0), "° : ", field(fan, p) < 0 ? "inside a blade" : "in the gap")
endrotor bounds: [-32.0, -28.7, -1.5] .. [32.0, 28.7, 1.5] mm
hub centre (solid): true
tip at 0.0° : inside a blade
tip at 60.0° : inside a blade
tip at 120.0° : inside a blade
tip at 180.0° : inside a blade
tip at 240.0° : inside a blade
tip at 300.0° : inside a blade
vtk(surface_mesh(fan; n = 48))A linear_pattern along a diagonal datum axis places copies along that direction — a row of rivet heads:
diag = datum_axis([0.0, 0.0, 0.0], [1.0, 1.0, 0.0])
row = linear_pattern(sphere(0.0025), diag, 0.006, 3)
println("copy at (1/√2, 1/√2) × 6 mm: ", field(row, [1 / sqrt(2) * 0.006, 1 / sqrt(2) * 0.006, 0.0])) # −r → insidecopy at (1/√2, 1/√2) × 6 mm: -0.0025
A real part: an angled passage from two datum points
Datum points anchor the hardest call-out on a drawing: the axis of an angled hole. A 40 × 20 × 5 mm manifold plate needs a Ø5 mm oil passage entering the left face at A = (−20, 0, 0) mm and leaving the top face at B = (0, 0, +2.5) mm — the two points the print specifies, nothing else. The axis is the direction B − A, and a z-aligned cylinder is rotated onto it with Rodrigues’ formula (a rotation by the angle between z and the axis about their cross product):
A = datum_point([-0.020, 0.0, 0.0]) # entry, on the left face
B = datum_point([0.0, 0.0, 0.0025]) # exit, on the top face
dir = (B.p .- A.p); dir ./= norm(dir) # the passage axis
z = [0.0, 0.0, 1.0]
v = cross(z, dir) # rotation axis
c = dot(z, dir) # cos of the tilt angle
V = [0.0 -v[3] v[2]; v[3] 0.0 -v[1]; -v[2] v[1] 0.0]
R = I + V + V^2 * ((1 - c) / dot(v, v)) # Rodrigues: R·z = dir
println("R·z == dir : ", R * z ≈ dir)
plate = box(0.040, 0.020, 0.005)
mid = (A.p .+ B.p) ./ 2
passage = translate(rotate(cylinder(0.0025, norm(B.p .- A.p) + 0.002), R), mid...)
part = difference(plate, passage)R·z == dir : true
Difference{Box{Float64, Float64, Float64}, Translate{Rotate{Cylinder{Float64, Float64}, Matrix{Float64}}, Float64, Float64, Float64}}(Box{Float64, Float64, Float64}(0.04, 0.02, 0.005), Translate{Rotate{Cylinder{Float64, Float64}, Matrix{Float64}}, Float64, Float64, Float64}(Rotate{Cylinder{Float64, Float64}, Matrix{Float64}}(Cylinder{Float64, Float64}(0.0025, 0.022155644370746373), [0.12403473458920844 0.0 0.9922778767136676; 0.0 1.0 0.0; -0.9922778767136676 0.0 0.12403473458920844]), -0.01, 0.0, 0.00125))
The SDF answers the inspection questions a machinist asks: is the passage open end-to-end along the axis, and is the surrounding plate still solid?
println("mid-axis (removed) : ", round(field(part, mid); digits = 4)) # +r → empty
println("A + 2 mm along axis : ", round(field(part, A.p .+ 0.002 .* dir); digits = 4))
println("off-axis (material) : ", round(field(part, [-0.012, 0.006, 0.0]); digits = 4)) # < 0 → solidmid-axis (removed) : 0.0025
A + 2 mm along axis : 0.0025
off-axis (material) : -0.0025
vtk(surface_mesh(part; n = 64))Inside the passage the field reads +r — the distance to the passage wall — because the difference has removed that material; a point 6 mm off the axis is solid plate (−r). The whole part was driven by two datum points, exactly the way the drawing dimensioned it.
Differentiability
Because datum geometry is type-generic, a datum driven by an optimization parameter differentiates cleanly. Here the distance from a fixed point to a parameterised axis position is |3 − d|, whose derivative is −1 for d < 3:
f(d) = distance(datum_axis([d, 0.0, 0.0], [0.0, 0.0, 1.0]), [3.0, 0.0, 0.0])
println("∂ distance/∂ d at d = 1: ", derivative(f, 1.0)) # −1∂ distance/∂ d at d = 1: -1.0
The same holds for signed_distance to a moving plane — the origin sits on the negative side of a plane at z = d, so the signed distance is −d:
g(d) = signed_distance(datum_plane([0.0, 0.0, d], [0.0, 0.0, 1.0]), [0.0, 0.0, 0.0])
println("∂ signed/∂ d at d = 2: ", derivative(g, 2.0)) # −1∂ signed/∂ d at d = 2: -1.0
A datum scheme on a real part
Datums earn their keep as the measuring reference of a drawing: GD&T (geometric dimensioning and tolerancing) fixes the part against three datum planes — primary A, secondary B, tertiary C — the machinist’s “3-2-1” locating scheme. Every tolerance on the drawing is measured from those planes, which is exactly what distance and signed_distance do.
Take a 100 × 60 × 8 mm plate with two Ø6 holes drawn at (30, 20) and (70, 40). The datum system is the bottom face (A), the long side (B), and the short side (C), and the two hole axes are datum axes:
A = datum_plane([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]) # primary: bottom face z = 0
B = datum_plane([0.0, 60.0, 0.0], [0.0, 1.0, 0.0]) # secondary: long side y = 60
C = datum_plane([100.0, 0.0, 0.0], [1.0, 0.0, 0.0]) # tertiary: short side x = 100
h1 = datum_axis([30.0, 20.0, 0.0], [0.0, 0.0, 1.0]) # hole 1 axis
h2 = datum_axis([70.0, 40.0, 0.0], [0.0, 0.0, 1.0]) # hole 2 axisDatumAxis{Vector{Float64}, Vector{Float64}}([70.0, 40.0, 0.0], [0.0, 0.0, 1.0])
A CMM reports a hole position as its distance from the datum planes. The signed distance to B is the hole’s y coordinate measured from the long side, and to C its x coordinate from the short side — here hole 1 sits 40 mm in from B and 70 mm in from C:
println("hole 1 → B (y): ", signed_distance(B, h1.point))
println("hole 1 → C (x): ", signed_distance(C, h1.point))
println("hole 2 → B (y): ", signed_distance(B, h2.point))
println("hole 2 → C (x): ", signed_distance(C, h2.point))hole 1 → B (y): -40.0
hole 1 → C (x): -70.0
hole 2 → B (y): -20.0
hole 2 → C (x): -30.0
The pitch — centre-to-centre distance between the two holes — is the position-tolerance check: the distance between the two axes must match the drawing’s \sqrt{40^2 + 20^2}:
println("hole pitch = ", round(distance(h1, h2.point); digits = 3))
println("analytic = ", round(sqrt(40.0^2 + 20.0^2); digits = 3))hole pitch = 44.721
analytic = 44.721
And a perpendicularity check: a hole drilled square to the face has its axis parallel to A’s normal:
println("hole 1 square to A: ", h1.dir ≈ A.normal) # [0,0,1] == [0,0,1]hole 1 square to A: true
The same three lines of measurement work on any part — swap the datum planes for the faces of your part and signed_distance/distance are the CMM readout.
Next steps
- Feature operations — the
mirror, pattern,fillet, andchamferfeatures that accept datum anchors. - Constructive solid geometry — the boolean and transform features datum geometry places in space.
- Sketches, extrusion, revolution — 2D sketches, which datum planes anchor in a full parametric model.
