RasmahRasmah

Snapping and Coincidence Inference

Overview

Every draughtsman knows the difference between looking right and being right: a hole centre clicked by hand at “about 15 mm” is never at exactly 15 mm. Snapping is the set of magnets that turn an approximate cursor position into an exact drawing: pull the click to the nearest vertex > edge > midpoint > face > grid target, and feed the result to the sketch or mate solver as a coincidence constraint. snap_point is Blender-style snapping for 2D sketching and 3D part placement, snap_grid and angle_snap snap scalars to a grid/multiple, and infer_coincident decides whether two points are the same.

To see what they buy, this tutorial draws a standard part the way a draughtsman would: an 80 × 60 × 8 mm adapter plate with four Ø6 mounting holes, laid out entirely with snap aids, then checked against closed-form volume and mass. Dimensions are millimetres.

using Rasmah

Grid snapping for a hole pattern

The four mounting holes sit on a clean pattern — 25 mm in from the x edges and 15 mm in from the y edges of the plate, on a 5 mm grid. The clicks are deliberate but human, off by a few tenths; snap_grid rounds each coordinate to the nearest multiple of the spacing:

clicks = [[24.7, 14.4], [-25.3, 15.2], [24.9, -15.2], [-24.6, -14.9]]
centres = [snap_grid(c, 5.0) for c in clicks]

println("snapped centres: ", centres)
println("pitch: ", centres[1][1] - centres[2][1], " mm in x,  ",
        centres[1][2] - centres[3][2], " mm in y")
snapped centres: [[25.0, 15.0], [-25.0, 15.0], [25.0, -15.0], [-25.0, -15.0]]
pitch: 50.0 mm in x,  30.0 mm in y

Four approximate clicks became the exact (±25, ±15) pattern — the 50 × 30 mm hole pitch is now a declared quantity, not a measured one.

Vertex, edge, and midpoint magnets

A click near existing geometry snaps to it. The plate’s outline supplies the targets — its four corners as points, its four edges as (a, b) segments:

corners = [[-40.0, -30.0], [40.0, -30.0], [40.0, 30.0], [-40.0, 30.0]]
edges = [(corners[i], corners[mod1(i + 1, 4)]) for i in 1:4]

rv = snap_point([39.4, -28.8]; points = corners, radius = 2.0)
println("corner click  → ", snap_kind(rv), " at ", snapped(rv))

rm = snap_point([0.0, 30.4]; segments = edges, radius = 2.0)
println("middle hover  → ", snap_kind(rm), " at ", snapped(rm))

re = snap_point([-38.6, 11.2]; segments = edges, radius = 2.0)
println("edge click    → ", snap_kind(re), " at ", snapped(re))

r0 = snap_point([-25.0, 15.0]; points = corners, radius = 2.0)
println("far from any  → ", snap_kind(r0), " (cursor unchanged: ", snapped(r0), ")")
corner click  → vertex at [40.0, -30.0]
middle hover  → midpoint at [0.0, 30.0]
edge click    → edge at [-40.0, 11.2]
far from any  → none (cursor unchanged: [-25.0, 15.0])

The corner magnet returns the exact corner, the edge magnet projects the click onto the outline, and the midpoint magnet engages whenever the cursor is within the magnet radius of the middle of an edge. Outside the magnet radius nothing moves — the kind is :none and snapped returns the cursor itself.

Angle snapping for a bolt circle

angle_snap rounds an angle to the nearest multiple of a step — the same idea on the circle. A 6-hole bolt circle needs its holes at 60° steps; six approximate clicks become exactly 0°, 60°, …, 300°:

θs = [angle_snap(θ, π / 3) for θ in (1.15, 2.2, 3.1, 4.3, 5.2, 0.1)]
println("snapped to multiples of π/3: ", round.(θs ./ (π / 3); digits = 0))

c = [25.0 * cos(θs[1]), 25.0 * sin(θs[1])]
println("first hole on the Ø50 pitch circle: ", round.(c; digits = 1))
snapped to multiples of π/3: [1.0, 2.0, 3.0, 4.0, 5.0, 0.0]
first hole on the Ø50 pitch circle: [12.5, 21.7]

The snapped angles are exactly 1, 2, 3, 4, 5, 0 multiples of π/3, so the first hole lands at (12.5, 21.7) — the difference between a bolt circle that is symmetric and one that only looks symmetric. (For repeating a hole about an axis, circular_pattern does the same job without the clicks — see the Feature operations tutorial.)

Coincidence inference

infer_coincident(a, b, tol) is the predicate the solvers use to decide whether two points are the same — the difference between a double-click and two features. A re-click a fraction of a micron off is one hole; two centres 0.5 mm apart are two holes:

println("re-click 0.5 µm away → merged:  ", infer_coincident([25.0, 15.0], [25.0 + 5e-7, 15.0]))
println("0.5 mm apart          → distinct: ", !infer_coincident([25.0, 15.0], [25.5, 15.0]))
re-click 0.5 µm away → merged:  true
0.5 mm apart          → distinct: true

The plate itself

The point of all this: the snapped data is the drawing. Extrude the 80 × 60 outline 8 mm and cut one Ø6 hole at each snapped centre:

plate = difference(
    extrude(rect_2d(80.0, 60.0), 8.0),
    reduce(union, [translate(cylinder(3.0, 9.0), c[1], c[2], 0.0) for c in centres]),
)
Difference{Extrude{Rect2D{Float64, Float64}, Float64}, FeatureUnion{FeatureUnion{FeatureUnion{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}}, Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}}, Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}}}(Extrude{Rect2D{Float64, Float64}, Float64}(Rect2D{Float64, Float64}(40.0, 30.0), 8.0), FeatureUnion{FeatureUnion{FeatureUnion{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}}, Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}}, Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}}(FeatureUnion{FeatureUnion{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}}, Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}}(FeatureUnion{Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}, Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}}(Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}(Cylinder{Float64, Float64}(3.0, 9.0), 25.0, 15.0, 0.0), Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}(Cylinder{Float64, Float64}(3.0, 9.0), -25.0, 15.0, 0.0)), Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}(Cylinder{Float64, Float64}(3.0, 9.0), 25.0, -15.0, 0.0)), Translate{Cylinder{Float64, Float64}, Float64, Float64, Float64}(Cylinder{Float64, Float64}(3.0, 9.0), -25.0, -15.0, 0.0)))
vtk(plate; n = 64)

Because every dimension came out of a snap, the part’s volume is the textbook value — the plate less four through-holes — and the mesh measurement of the same solid agrees to within a few hundredths of a percent:

lo, hi = bounds(plate)
vm = ustrip(volume(marching_tetrahedra(plate, lo, hi, 96)))

V_analytic = 80.0 * 60.0 * 8.0 - 4 * π * 3.0^2 * 8.0
println("analytic volume = ", round(V_analytic; digits = 1), " mm³")
println("meshed volume   = ", round(vm; digits = 1), " mm³")
println("mass (aluminum) = ", round(V_analytic * 2.70e-3; digits = 1), " g")
analytic volume = 37495.2 mm³
meshed volume   = 37497.9 mm³
mass (aluminum) = 101.2 g

Next steps