RasmahRasmah

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.componentFunction
component(name, model, placement::Placement) -> Component
component(name, model; translate = [0.0, 0.0, 0.0], rotate = I) -> Component

Construct a named Component from a model and a placement, or from a translate vector and rotate matrix.

source
Rasmah.assemblyFunction
assembly(name, components::Component...) -> Assembly

Construct an Assembly named name from the given Components.

source
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_distanceFunction
surface_distance(f, g; n = 64) -> SurfaceDistance

Pointwise 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
true

See also: chamfer_distance, register.

source
surface_distance(to_sdf(sphere(1.0)), to_sdf(sphere(1.0)); n = 32).rms
6.674007774418102e-17

Two identical spheres have distance zero (to machine precision). Move one and the symmetric distance grows:

Rasmah.chamfer_distanceFunction
chamfer_distance(f, g; n = 64) -> Real

Symmetric chamfer distance between surfaces f and g (the average of the two one-sided mean distances).

source
chamfer_distance(to_sdf(sphere(1.0)), to_sdf(translate(sphere(1.0), 0.2, 0.0, 0.0)); n = 32)
0.09997827031639743

Recovering 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.registerFunction
register(src, ref, θ0; objective = :surface, n = 48, σ = nothing, store_trace = false) -> RigidAlignment

Find 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: :surface minimizes the RMS surface distance; :field minimizes 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 :field objective.
  • store_trace: when true, store the optimizer trace (for optimization_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]
true

See also: assemble, surface_distance.

source
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.0

Starting 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.assembleFunction
assemble(ref, pieces, θ0; n=48, soft=true, k=0.05, objective=:both, …) -> AssemblyResult

Register 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.

source

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.DatumPointType
DatumPoint{T}

A datum point: a single reference location.

Fields

  • p::T: the point coordinates.
source
Rasmah.DatumAxisType
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.
source
Rasmah.DatumPlaneType
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.
source
Rasmah.datum_axisFunction
datum_axis(point::AbstractVector, dir::AbstractVector) -> DatumAxis

Construct a DatumAxis through point along dir (normalized).

source
Rasmah.datum_planeFunction
datum_plane(point::AbstractVector, normal::AbstractVector) -> DatumPlane
datum_plane(normal::AbstractVector, offset::Real) -> DatumPlane
datum_plane(p1::AbstractVector, p2::AbstractVector, p3::AbstractVector) -> DatumPlane

Construct a DatumPlane from a point and normal, from the Hessian normal form n̂·x = offset, or from three points.

source

A mate_ref names the component and the datum that participates in a constraint (in the component's local frame):

Rasmah.mate_refFunction
mate_ref(component, datum) -> MateRef

Build a mate reference: the named component (or datum) that a mate constraint acts on.

source

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_mateFunction
angle_mate(a::MateRef, b::MateRef, θ::Real) -> AngleMate

Fix the angle between two datums to θ (radians).

source
Rasmah.tangent_mateFunction
tangent_mate(a::MateRef, b::MateRef, radius::Real) -> TangentMate

Make a sphere or cylinder tangent to a plane at the given radius.

source
Rasmah.flush_mateFunction
flush_mate(a::MateRef, b::MateRef) -> FlushMate

Make two planes coincident with aligned normals.

source
Rasmah.lock_mateFunction
lock_mate(a::MateRef, b::MateRef) -> LockMate

Fix two components together with zero relative pose.

source

ground marks components fixed in place, and solve_assembly drives the free components' poses until every mate is satisfied at once:

Rasmah.groundFunction
ground(a::Assembly, names::AbstractString...) -> Assembly

Return a copy of the assembly with the named components marked grounded (fixed).

source
Rasmah.solve_assemblyFunction
solve_assembly(
    a::Assembly,
    mates::AbstractVector{<:Mate};
    fixed = String[],
    tol = 1e-10,
    max_iter = 200,
    method = :lm,
    damp = 1e-6,
) -> Assembly

Solve 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 (:lm Levenberg–Marquardt, or Gauss–Newton).
  • damp: Levenberg–Marquardt damping.

Returns

A new Assembly with the solved component placements.

source

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.0

The 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.interferenceFunction
interference(a::Assembly, i, j; n = 64, k = 10.0) -> Quantity

Overlap volume (a Quantity of volume dimension) of the two named components, 0 when they are disjoint.

source
Rasmah.clearanceFunction
clearance(a::Assembly, i, j; n = 64) -> Quantity

Minimum surface gap (a Quantity of length dimension) between the two named components.

source
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 m

Two 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.