Snappable
A THREE.Group subclass that wraps a GLTF block node. It separates sockets from base geometry, handles rotation via a _content sub-group, and maintains a lazy AABB cache for collision detection.
Warning
Meant to be used with ComponentMgr only.
Getting started
const snappable = new Snappable({ object3d })
// add to scene directly
scene.add(snappable)
// or add to component manager
componentMgr.add(snappable)
- Expands any
socketGridchildren into regular socket groups - Separates socket groups from geometry children
- Creates
Socketinstances for each socket group - Wraps all content in a
_contentsub-group for isolated rotation - Extracts
blockIdfromuserData.id
Internal Sub-Group
When ComponentMgr calls snapItemToSocket():
Snappableis reset to origin (position=(0,0,0), identity quaternion)_content.quaternionis reset to identity- The snap matrix is applied to
Snappable - Rotation offset is applied to
_contentaround the computed local axis
Why a sub-group?
The snap matrix calculation in snapItemToSocket needs to know the world position of a socket before any rotation is applied.
It resets the Snappable to the world origin first, then computes the matrix.
If rotation were stored on the Snappable itself, resetting the outer transform would also destroy the rotation, making it impossible to apply both in the correct order. Separating the two concerns.
Snappable holds position/orientation from snapping, _content holds rotation — keeps each layer independent and lets snapItemToSocket always start from a clean slate.
Think of it like a spinning office chair. The wheels stay exactly where they are on the floor — that's the
Snappable, locked to its snapped position. The seat spins freely on top — that's_content. Rotating the seat does not drag the whole chair across the room.
Rotation System
Rotation is applied to the _content sub-group (a THREE.Group child of the Snappable) rather than to the Snappable itself.
Axis selection
| Block state | Rotation axis |
|---|---|
| Root block (no parent) | World Y axis (0, 1, 0) — spinning a free-standing block on its vertical axis |
| Child block (snapped to parent) | Socket forward axis — the world normal of the target socket, converted to the Snappable's local space |
For child blocks, the target socket's world normal is converted to local space because _content.quaternion is set in the Snappable's local coordinate system.
Applying a world-space axis to a local quaternion would produce the wrong rotation once the Snappable has a non-identity orientation.
The conversion (worldAxis → snappable.quaternion.invert() → localAxis) ensures the axis is expressed in the same space as the quaternion being set.
The axis is recalculated each time rotationOffset is set, always from the identity orientation (_content reset to (0,0,0,1) before applying) to avoid accumulated floating-point drift across repeated rotations.
API
Constructor(object3d)
Constructor initialises the new Snappable instance
Parameters
object3d- The GLTF block root node (must haveuserData.type = "block"anduserData.id)
Methods
getWorldAABB() → THREE.Box3
Returns the world-space axis-aligned bounding box of this block, excluding socket meshes. The result is cached and recomputed lazily when _isAABBDirty is true.
invalidateAABB()
Marks the AABB cache as dirty. Called automatically by ComponentMgr.applyRotationOffset() after rotation. Call this yourself if you move the block outside of ComponentMgr.
tick()
Calls socket.tick() on all sockets (animates socket material colours). Called automatically by ComponentMgr.tick().
zFightFix(cameraPosition)
Nudges socket meshes along their normals by a camera-distance-dependent offset to prevent z-fighting. Called automatically by ComponentMgr.tick() after snapping.
Parameters
cameraPosition- The position of the camera
clone(recursive?) → Snappable
Creates a new Snappable from the original block node. Copies rotationOffset and applies the current world matrix to the clone.
Parameters
recursive- Whether to clone the object recursively
dispose()
Disposes all socket instances and clears the content sub-group. Does not remove the object from the scene — call scene.remove(snappable) or componentMgr.remove(snappable) first.