Snap Maths
This page explains the mathematics behind calculateSnapMatrix and snapItemToSocket — the two functions that physically position one block against another.
The goal is to give you a mental model accurate enough to reason about edge cases, extend the system, or debug unexpected orientations. Basic familiarity with vectors, dot products, cross products, and 4×4 transformation matrices is assumed. Nothing beyond that.
The Goal
When a user clicks a socket, the configurator must move the incoming block (item) so that its chosen socket (itemSocket) presses face-to-face against the stationary socket (targetSocket). "Face-to-face" means:
- The two sockets end up at the same world position.
- The item socket's normal points directly opposite to the target socket's normal (they face each other).
- The up directions of both sockets align, so the block is not twisted around the snap axis.
- An optional rotation offset spins the block around the snap axis by a user-chosen amount.
All four constraints are encoded into a single THREE.Matrix4 that is applied to the Snappable.
Socket Coordinate Frames
Each Socket provides three pieces of data in its local space:
| Property | What it is |
|---|---|
normal | Unit vector pointing away from the block surface — the snap axis |
upNormal | Unit vector perpendicular to normal — the rotational reference direction |
position | World position of the socket origin |
These are stored in local space and must be transformed to world space before any calculation. For a vector and a world quaternion , the world-space vector is:
In Three.js this is expressed as:
const targetNormal = targetSocket.normal.clone().applyQuaternion(targetWorldQuat);
const targetUpNormal = targetSocket.upNormal.clone().applyQuaternion(targetWorldQuat);
After this step, both normal and upNormal are expressed in the same global coordinate system and can be compared and combined.
Building an Orthonormal Frame (Gram-Schmidt)
A coordinate frame is three mutually perpendicular unit vectors. For each socket the code builds one such frame from (normal) and (upNormal).
Step 1 — Remove the component of parallel to .
upNormal as authored is intended to be perpendicular to normal, but floating-point accumulation and imprecise authoring mean it rarely is exactly. The Gram-Schmidt projection removes the leakage:
The scalar is the projection of onto — the amount of that "points in the same direction as ". Subtracting that amount leaves only the part of that is truly perpendicular to .
tU.sub(tN.clone().multiplyScalar(tU.dot(tN)));
Step 2 — Compute the right vector via cross product.
const tR = new THREE.Vector3().crossVectors(tU, tN).normalize();
The cross product of two perpendicular unit vectors produces a third unit vector perpendicular to both, completing a right-handed coordinate system. The three vectors are now mutually orthogonal and all unit-length — an orthonormal frame.
Why is this needed? An orthonormal frame encodes a rotation unambiguously. The three vectors become the columns of a rotation matrix. If the vectors were not exactly perpendicular and unit-length, the matrix would contain shear or scale, and the resulting transformation would distort geometry.
Building the Rotation Matrices
With the three orthonormal vectors in hand, each socket's frame is encoded as a 4×4 rotation matrix with the frame axes as columns:
const targetMat = new THREE.Matrix4().set(
tR.x, tU.x, tN.x, 0,
tR.y, tU.y, tN.y, 0,
tR.z, tU.z, tN.z, 0,
0, 0, 0, 1
);
The same is done for the item socket, producing . Reading a rotation matrix by columns is a useful mental shorthand: the columns tell you where the local X, Y, Z axes end up in world space after the rotation is applied.
The Anti-Parallel Problem
The obvious approach — "just rotate the item's normal to point at the target" — uses setFromUnitVectors. This works for most directions but has a fatal edge case.
When two vectors are anti-parallel (, pointing in exactly opposite directions), the rotation required to map one onto the other is 180°, but the rotation axis is undefined — it can be any vector perpendicular to both. setFromUnitVectors picks one arbitrarily, and that arbitrary choice introduces an unpredictable twist around the snap axis.
Sockets that face each other are always anti-parallel. This means the naive approach would randomly twist blocks every time.
The orthonormal frame approach sidesteps this entirely. Because both frames include an up direction, the 180° flip axis is fully determined:
There is only one rotation that simultaneously anti-aligns the normals and aligns the up directions, and the frame matrices encode it directly.
The Face-to-Face Flip
Both frames are built from their actual (non-inverted) normals. At this point and describe frames with matching columns — they point in the same direction. To make the item socket face toward the target, the item's normal must be reversed.
This is done with a 180° rotation around the item socket's local up axis (Y in local frame before the frame matrix is applied):
const flipQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
const flipMat = new THREE.Matrix4().makeRotationFromQuaternion(flipQuat);
The flip is applied in local space first, then the item's world frame is applied on top:
const flippedItemMat = new THREE.Matrix4().multiplyMatrices(itemMat, flipMat);
Note the multiplication order: acts first (in local space), transforms the result to world space. A 180° rotation around local Y flips the Z axis (normal) while leaving the Y axis (up) unchanged — exactly the face-to-face semantics we want.
Aligning the Frames
Both frames are now in world space and the item frame has its normal flipped. The rotation that maps the item frame onto the target frame is:
const flippedItemMatInv = flippedItemMat.clone().invert();
const alignmentMat = new THREE.Matrix4().multiplyMatrices(targetMat, flippedItemMatInv);
const combinedQuat = new THREE.Quaternion().setFromRotationMatrix(alignmentMat);
Why does this formula work?
Think of as the transformation from world space into the item socket's (flipped) frame. Composing it with gives the full path:
The result is the single rotation that takes the item's (flipped) coordinate frame and lands it exactly on top of the target's coordinate frame.
Rotation Offset
The user can spin the block around the snap axis after alignment. The offset quaternion is built from the target socket's world normal :
const offsetQuat = new THREE.Quaternion().setFromAxisAngle(targetNormal.normalize(), rotationOffsetRadians);
It is applied by pre-multiplying the combined quaternion:
const finalQuat = new THREE.Quaternion().multiplyQuaternions(offsetQuat, combinedQuat);
Pre-multiplication means the offset acts in world space after the alignment is applied — the block is first oriented correctly relative to the socket, then spun around the snap axis. Post-multiplying would apply the spin in the block's local space before alignment, producing a different (and wrong) result.
Computing the Translation
Once the final rotation is known, the block must be repositioned so that itemSocket lands at the same world position as targetSocket.
Step 1 — Rotate the item socket's current world position.
const rotatedItemPos = itemWorldPos.clone().applyMatrix4(rotationMatrix);
This computes where the item socket would end up after the rotation is applied, assuming the block pivots around the world origin.
Step 2 — Find the offset needed to bring it to .
const translation = targetWorldPos.clone().sub(rotatedItemPos);
Why subtract and not ?
The rotation is applied around the world origin. When the block rotates, its socket moves from to . The translation must compensate for where the socket ends up after rotation, not where it started.
Step 3 — Compose the final matrix.
const finalMatrix = new THREE.Matrix4().multiplyMatrices(translationMatrix, rotationMatrix);
In this composition, acts first (rotating around the origin), then moves the rotated block into final position. This is the standard decomposition for a transform that rotates then translates.
Why Reset to Origin First
calculateSnapMatrix assumes the Snappable is at the world origin before the matrix is applied. snapItemToSocket enforces this:
item.position.set(0, 0, 0);
item.quaternion.set(0, 0, 0, 1);
item.scale.set(1, 1, 1);
item._content.quaternion.set(0, 0, 0, 1); // clear content rotation too
item.updateMatrixWorld(true);
Why is this necessary? calculateSnapMatrix samples itemSocket.getWorldPosition() and itemSocket.getWorldQuaternion() to read the socket's current world-space data. If the Snappable is sitting somewhere else in the scene, those values would be offset by the Snappable's old transform :
The returned snap matrix would then implicitly encode an "undo " correction, which would compound incorrectly with re-placement.
Resetting to the identity transform guarantees:
The snap matrix is then purely about geometry alignment, not about history correction.
Content Group Rotation
After placing the Snappable at its snap position, rotationOffset is applied to the _content sub-group — not the Snappable itself. The rotation axis is the target socket's world normal , but it must be expressed in the Snappable's local space.
Let be the Snappable's world quaternion. The local axis is:
const targetNormalWorld = targetSocket.normal.clone().applyQuaternion(targetWorldQuat).normalize();
const snappableWorldQuat = new THREE.Quaternion();
item.getWorldQuaternion(snappableWorldQuat);
snappableWorldQuat.invert();
const localAxis = targetNormalWorld.clone().applyQuaternion(snappableWorldQuat).normalize();
Why convert to local space?
_content.quaternion lives in the Snappable's local coordinate system. Setting a quaternion using a world-space axis produces the wrong rotation once the Snappable has a non-identity orientation (which it always does after snapping). The conversion "undoes" the Snappable's world orientation so the axis is expressed in the same space as the quaternion being set.
Degenerate Fallback
Before building the full orthonormal frames, the code checks whether the Gram-Schmidt step produced a non-zero result:
if (tULen > 0.001 && iULen > 0.001) {
// full orthonormal frame path
} else {
// fallback: normal-only alignment
alignmentQuat.setFromUnitVectors(invertedItemNormal, targetNormal);
}
If drops below 0.001, and were nearly co-linear — the artist placed the up indicator almost exactly on top of the normal axis. In that case the frame-based approach would produce a near-zero right vector and a garbage rotation matrix, so the code falls back to setFromUnitVectors. This fallback has the anti-parallel ambiguity described above, but it is better than corrupted geometry. The ambiguity only manifests as an unknown spin around the snap axis, which the user can correct with rotation controls.
The practical fix is to ensure the up indicator object in Blender is placed off to the side of the socket (perpendicular to the socket's facing direction) rather than in front of or behind it.