Assembly & registration
Overview
Two of the most common things you do with shapes are put them together and line them up. This chapter covers both. Assembly composes named parts into one model by placing each part in space; registration recovers the rigid motion that best aligns one shape with another.
The two ideas share the same machinery from the previous chapters: a part is a signed distance field, an assembly is the union of its parts' fields, and "how well do these line up?" is a differentiable distance that a quasi-Newton optimizer can drive to zero.
Placing parts in an assembly
A part becomes a component when it is given a name and a rigid placement, and an assembly is a named collection of components inspected as one model:
Rasmah.component — Function
component(name, model, placement::Placement) -> Component
component(name, model; translate = [0.0, 0.0, 0.0], rotate = I) -> ComponentConstruct a named Component from a model and a placement, or from a translate vector and rotate matrix.
Rasmah.assembly — Function
assembly(name, components::Component...) -> AssemblyConstruct an Assembly named name from the given Components.
base = component("base", box(2.0, 1.0, 1.0))
top = component("top", translate(sphere(0.5), 0.0, 0.0, 1.0))
part = assembly("part", base, top)
vtk(evaluate(part, SDFBackend()))The assembly evaluates to the union of its components' signed distance fields, so everything downstream — meshing, simulation, differentiation — sees one solid.
How close are two shapes?
Before you can align two shapes you have to measure their mismatch. The surface distance samples one surface and evaluates the samples against the other's signed distance field:
Rasmah.surface_distance — Function
surface_distance(f, g; n = 64) -> SurfaceDistancePointwise surface distances from implicit surface f to g, aggregated into the mean, RMS, one-sided maximum, symmetric chamfer, and symmetric Hausdorff distances. Samples both isosurfaces at marching-cubes resolution n.
Theory
The distance from a point on $f$ to the surface $g$ is simply $|g(\mathbf{x})|$ (the point is evaluated against g's signed distance field). surface_distance samples f's surface, evaluates each sample against g, and aggregates the result in both directions. Two coincident surfaces therefore have distance zero, and the gap grows as the shapes separate — which is why this is the standard loss for shape matching and pose registration.
Returns
A SurfaceDistance with mean, rms, max (one-sided), chamfer, and hausdorff (symmetric) fields.
Example
julia> using Rasmah
julia> d = surface_distance(to_sdf(sphere(1.0)), to_sdf(sphere(1.0)); n = 32);
julia> d.rms < 1e-10
trueSee also: chamfer_distance, register.
surface_distance(to_sdf(sphere(1.0)), to_sdf(sphere(1.0)); n = 32).rms6.674007774418102e-17Two identical spheres have distance zero (to machine precision). Move one and the symmetric distance grows:
Rasmah.chamfer_distance — Function
chamfer_distance(f, g; n = 64) -> RealSymmetric chamfer distance between surfaces f and g (the average of the two one-sided mean distances).
chamfer_distance(to_sdf(sphere(1.0)), to_sdf(translate(sphere(1.0), 0.2, 0.0, 0.0)); n = 32)0.09997827031639743Recovering the pose that aligns two shapes
Registration turns that distance into a pose: parametrize the rigid motions of one shape by six scalars and descend the gradient of the surface distance.
Rasmah.register — Function
register(src, ref, θ0; objective = :surface, n = 48, σ = nothing, store_trace = false) -> RigidAlignmentFind the rigid pose θ = [tx, ty, tz, ωx, ωy, ωz] (translation + axis-angle rotation) that moves ref onto src, by minimizing a differentiable geometric loss with quasi-Newton descent and automatic differentiation.
Theory
Two shapes are aligned when their surfaces coincide. register poses this as an optimization problem: parametrize the rigid motions of ref by six scalars, measure the mismatch between the moved ref and src with a surface (or field) distance, and descend its gradient. Because the distance is differentiable in the pose, the gradient is automatic — no finite differences.
Keyword arguments
objective::surfaceminimizes the RMS surface distance;:fieldminimizes the band-masked squared field difference over the bounding box of both shapes.n: marching-cubes resolution for surface sampling.σ: Gaussian band width for the:fieldobjective.store_trace: whentrue, store the optimizer trace (foroptimization_history).
Returns
A RigidAlignment with translation, rotation, final_loss, and result.
Example
julia> using Rasmah
julia> src = to_sdf(sphere(1.0));
julia> ref = to_sdf(translate(sphere(1.0), 0.3, 0.4, 0.0));
julia> reg = register(src, ref, zeros(6); n = 32);
julia> round.(reg.translation; digits = 3) == [-0.3, -0.4, -0.0]
trueSee also: assemble, surface_distance.
src = to_sdf(sphere(1.0))
ref = to_sdf(translate(sphere(1.0), 0.3, 0.4, 0.0))
reg = register(src, ref, zeros(6); n = 32)
round.(reg.translation; digits = 3)3-element Vector{Float64}:
-0.3
-0.4
-0.0Starting from the identity pose, register recovers the $(-0.3, -0.4, 0)$ translation that undoes the offset, driving the surface distance back to zero.
Putting a jigsaw back together
Self-assembly is the same idea with many pieces at once: optimize the stacked poses of every piece so their union re-forms a reference shape.
Rasmah.assemble — Function
assemble(ref, pieces, θ0; n=48, soft=true, k=0.05, objective=:both, …) -> AssemblyResultRegister pieces to re-form the reference geometry ref by minimizing a surface (and, when soft=true, a soft-union + Dice) distance, optimizing one pose per piece (θ0 stacks 6 dof per piece). Returns the optimized poses and an optional trace.
Constraining parts with mates
Placing parts by explicit transform works when you already know exactly where each part goes. A real assembly is usually built the other way: you declare how the parts relate — "these two faces touch", "these axes line up", "this gap is 5 mm" — and let the solver place them. That declaration is a mate, a geometric constraint between the datum geometry of two components.
Datum geometry is the reference geometry a mate acts on — a point, an axis, or a plane:
Rasmah.DatumPoint — Type
DatumPoint{T}A datum point: a single reference location.
Fields
p::T: the point coordinates.
Rasmah.DatumAxis — Type
DatumAxis{P,D}A datum axis: an infinite line through point along the unit dir.
Fields
point::P: a point on the axis.dir::D: unit direction vector.
Rasmah.DatumPlane — Type
DatumPlane{P,N}A datum plane through point with unit outward normal.
Fields
point::P: a point on the plane.normal::N: unit normal vector.
Rasmah.datum_point — Function
datum_point(p::AbstractVector)
datum_point(x, y, z)Construct a DatumPoint from a coordinate vector or its three components.
Rasmah.datum_axis — Function
datum_axis(point::AbstractVector, dir::AbstractVector) -> DatumAxisConstruct a DatumAxis through point along dir (normalized).
Rasmah.datum_plane — Function
datum_plane(point::AbstractVector, normal::AbstractVector) -> DatumPlane
datum_plane(normal::AbstractVector, offset::Real) -> DatumPlane
datum_plane(p1::AbstractVector, p2::AbstractVector, p3::AbstractVector) -> DatumPlaneConstruct a DatumPlane from a point and normal, from the Hessian normal form n̂·x = offset, or from three points.
A mate_ref names the component and the datum that participates in a constraint (in the component's local frame):
Rasmah.mate_ref — Function
mate_ref(component, datum) -> MateRefBuild a mate reference: the named component (or datum) that a mate constraint acts on.
The mates themselves are small constructors. The core set makes two datums coincident (coincident), parallel (parallel), perpendicular (perpendicular), or concentric (concentric), or holds a fixed distance or angle_mate between them; the extended set handles tangency, flushing, locking, width centring, and symmetry:
Rasmah.angle_mate — Function
angle_mate(a::MateRef, b::MateRef, θ::Real) -> AngleMateFix the angle between two datums to θ (radians).
Rasmah.tangent_mate — Function
tangent_mate(a::MateRef, b::MateRef, radius::Real) -> TangentMateMake a sphere or cylinder tangent to a plane at the given radius.
Rasmah.flush_mate — Function
flush_mate(a::MateRef, b::MateRef) -> FlushMateMake two planes coincident with aligned normals.
Rasmah.lock_mate — Function
lock_mate(a::MateRef, b::MateRef) -> LockMateFix two components together with zero relative pose.
ground marks components fixed in place, and solve_assembly drives the free components' poses until every mate is satisfied at once:
Rasmah.ground — Function
ground(a::Assembly, names::AbstractString...) -> AssemblyReturn a copy of the assembly with the named components marked grounded (fixed).
Rasmah.solve_assembly — Function
solve_assembly(
a::Assembly,
mates::AbstractVector{<:Mate};
fixed = String[],
tol = 1e-10,
max_iter = 200,
method = :lm,
damp = 1e-6,
) -> AssemblySolve the assembly's mating constraints by driving the free components' rigid poses to zero the stacked residual. Fixed components (named in fixed, or marked ground in a) keep their stored placement. Returns a new Assembly; the input is not mutated.
Arguments
a: the assembly to solve.mates: the vector of mating constraints.
Keyword arguments
fixed: component names to hold fixed.tol: residual-norm convergence tolerance.max_iter: maximum solver iterations.method: solver method (:lmLevenberg–Marquardt, or Gauss–Newton).damp: Levenberg–Marquardt damping.
Returns
A new Assembly with the solved component placements.
A worked example: sitting one block on another
block = component("block", box(2.0, 2.0, 1.0))
slab = component("slab", translate(box(1.0, 1.0, 1.0), 0.0, 0.0, 3.0))
assm = assembly("assm", block, slab)
block_top = mate_ref("block", datum_plane([0.0, 0.0, 0.5], [0.0, 0.0, 1.0]))
slab_bot = mate_ref("slab", datum_plane([0.0, 0.0, -0.5], [0.0, 0.0, -1.0]))
solved = solve_assembly(ground(assm, "block"), [coincident(block_top, slab_bot)])
round.(solved.components[2].placement.t; digits=3)3-element Vector{Float64}:
0.0
0.0
1.0The slab started at $z = 3$; the coincident mate pulled its bottom face down onto the block's top face, so its placement is now $(0, 0, 1)$ — the solver placed it, and you only declared the relation:
vtk(evaluate(solved, SDFBackend()))Measuring interference and clearance
Whether parts are placed by hand or by mates, you can ask how much two of them overlap or how far apart they are:
Rasmah.interference — Function
interference(a::Assembly, i, j; n = 64, k = 10.0) -> QuantityOverlap volume (a Quantity of volume dimension) of the two named components, 0 when they are disjoint.
Rasmah.clearance — Function
clearance(a::Assembly, i, j; n = 64) -> QuantityMinimum surface gap (a Quantity of length dimension) between the two named components.
gap = assembly("gap",
component("a", box(1.0, 1.0, 1.0)),
component("b", translate(box(1.0, 1.0, 1.0), 2.0, 0.0, 0.0)),
)
clearance(gap, "a", "b")1.0 mTwo unit boxes moved $2$ units apart leave a $1$-unit gap between their faces. interference reports the overlap volume the other way: $0$ for disjoint parts, and the overlapping volume when they intersect.
Next steps
Registration leans on the optimization and nonlinear-solving machinery — see Optimization and Nonlinear solvers — and an assembled model feeds straight into meshing and simulation.
