Three.js From Zero · Article s11-06
S11-06 Modular Furniture Configurator with Snap-Points
Modular Furniture with Snap-Points — the Roomle Pattern
Roomle, IKEA Kreativ, Crate & Barrel's Threekit deployment. The convergent pattern for modular furniture: the user drags pieces on a 2D floor plan; pieces snap edges; total dimensions update live; "fits in your room" badge confirms before checkout. Snap-points + parametric assembly + BOM accumulation, all in one self-contained tutorial.
1. Why this is the highest-conversion-lift home category
Modular furniture (sectionals, sofas, modular kitchens, shelving systems) sits in the worst spot for traditional e-commerce. The customer needs to know:
- Will it fit in my room?
- Will the modules I want connect properly?
- What's the total cost when I add the chaise + ottoman?
- How does it actually look in 3D?
A flat product page can answer none of these. Roomle's case studies show 40-60% conversion lift on modular furniture when a configurator is added. IKEA Kreativ's beta put modular sectional sales up 38% in the first 90 days.
2. Live demo — drag furniture in 2D, see it in 3D, fit a room
Left pane: live 3D view. Right pane: top-down floor plan, drag pieces around, walls reject overlap. Pick from four module types. Watch the snap-points snap. Watch the BOM update.
3. The snap-point primitive
A snap-point is a piece of metadata attached to a furniture module: a position (in module-local space), an orientation (which way out of the piece does it face), and a type tag (so a "sofa-arm" only snaps to a "sofa-arm").
// A simple snap point
{
pos: new THREE.Vector3(0.95, 0, 0), // local-space
dir: new THREE.Vector3(1, 0, 0), // outward normal
type: 'sofa-side', // matches sofa-side or chaise-side
taken: false, // runtime: is something snapped here?
}
For a 3-seat sofa, you typically have 4 snap points: one on each side ("sofa-side") and two on the back ("sofa-back" — for putting things behind it like a console table). The chaise has 1 snap-side and 1 snap-foot. The ottoman has 4 sides and 1 top (cushion-stack).
4. Snapping logic — proximity + type match
When the user drags a piece, you scan all other pieces' snap points. For each candidate, check: (a) is the type compatible? (b) is the dragged piece's snap point within snap-radius (typically 30cm world / 15px floor-plan)? (c) when you'd snap it, does the piece overlap a wall or another piece?
function findSnap(draggedPiece, allPieces) {
let best = null, bestDist = SNAP_RADIUS;
for (const piece of allPieces) {
if (piece === draggedPiece) continue;
for (const sa of draggedPiece.snaps) {
for (const sb of piece.snaps) {
if (!compatible(sa.type, sb.type)) continue;
if (sa.taken || sb.taken) continue;
const wa = draggedPiece.local2world(sa.pos);
const wb = piece.local2world(sb.pos);
const d = wa.distanceTo(wb);
if (d < bestDist) {
best = { sa, sb, piece };
bestDist = d;
}
}
}
}
return best;
}
When a match is found: align the dragged piece so its snap-point sits exactly on the partner's snap-point, with opposite outward normals. This is two transforms: a translation, plus a quaternion that rotates one outward direction onto the negation of the other.
5. Collision rejection
Even with snap-points, two pieces can overlap if the user drops them into the middle of an existing arrangement. AABB (axis-aligned bounding box) overlap test is enough for furniture — orientation is grid-snapped to 90° in most production configurators.
function aabbOverlap(a, b) {
return (a.maxX > b.minX && a.minX < b.maxX
&& a.maxZ > b.minZ && a.minZ < b.maxZ);
}
// Walls treated as unwalkable AABBs
const wallAABBs = roomWalls.map(w => w.aabb);
function isPlacementValid(newPiece, allPieces) {
for (const w of wallAABBs)
if (aabbOverlap(newPiece.aabb, w)) return false;
for (const p of allPieces)
if (p !== newPiece && aabbOverlap(newPiece.aabb, p.aabb))
return false;
return true;
}
If the placement is invalid, you can do one of three things: (a) snap-back to last valid position, (b) ghost-render at semi-transparent + show a "won't fit here" indicator, or (c) auto-adjust the position by smallest distance to a valid spot. Roomle uses (a). IKEA Kreativ uses (b). The ghosting pattern is more forgiving for novice users.
6. Floor plan ↔ 3D mapping
Two coordinate spaces:
- World 3D: meters, X right, Y up, Z forward (Three.js convention).
- Plan 2D: pixels, x right, y down (canvas convention).
Map = (planX, planY) → (worldX, worldZ) with a single scale factor and an offset to center the room. Y is always 0 on the floor (or the floor of the floor plan).
const pxPerM = 80; // 80 pixels = 1 meter on the plan
function planToWorld(planX, planY) {
return new THREE.Vector3(
(planX - planWidth / 2) / pxPerM,
0,
(planY - planHeight / 2) / pxPerM
);
}
function worldToPlan(world) {
return {
x: world.x * pxPerM + planWidth / 2,
y: world.z * pxPerM + planHeight / 2,
};
}
Pieces have position3D and positionPlan — the same data, expressed in two coords. Updates flow either direction; the snap/drag handlers update both.
7. "Fits in your room" — live total dimensions
This is the highest-impact UX feature in the entire pattern. After the user has placed 4-6 pieces, they want to know: does this whole assembly fit in my actual living room?
function totalAABB(allPieces) {
const min = new THREE.Vector3(Infinity, 0, Infinity);
const max = new THREE.Vector3(-Infinity, 0, -Infinity);
for (const p of allPieces) {
min.min(new THREE.Vector3(p.aabb.minX, 0, p.aabb.minZ));
max.max(new THREE.Vector3(p.aabb.maxX, 0, p.aabb.maxZ));
}
return { width: max.x - min.x, depth: max.z - min.z };
}
const total = totalAABB(allPieces);
const fits = total.width <= roomWidth && total.depth <= roomDepth;
showBadge(fits ? 'fits' : 'too large');
showDimensions(`${total.width.toFixed(2)}m × ${total.depth.toFixed(2)}m`);
Production tip: also show the user's specific input ("for a 4.5m × 4.0m room"). You're answering a specific question, not displaying a measurement.
8. BOM accumulation
Bill of Materials = the list of items that will end up in the user's cart. Each module has a SKU, a price, and a description. As pieces are added/removed, the BOM updates and the total price recomputes.
const catalog = {
sofa3: { sku: 'STR-3-CHR', name: '3-seat sofa', price: 1499 },
sofa2: { sku: 'STR-2-CHR', name: '2-seat sofa', price: 1199 },
chaise: { sku: 'STR-CH-CHR', name: 'Chaise', price: 899 },
ottoman: { sku: 'STR-OT-CHR', name: 'Ottoman', price: 449 },
};
function buildBOM(allPieces) {
const counts = {};
for (const p of allPieces) counts[p.type] = (counts[p.type] || 0) + 1;
const lines = Object.entries(counts).map(([type, n]) => ({
sku: catalog[type].sku,
name: catalog[type].name,
qty: n,
unit: catalog[type].price,
total: n * catalog[type].price,
}));
const total = lines.reduce((s, l) => s + l.total, 0);
return { lines, total };
}
9. Save state to URL
The user spent 8 minutes arranging their dream sectional. Now they want to share it with their partner. The URL needs to encode the state — without an account, without a backend.
function saveToURL(allPieces) {
const compact = allPieces.map(p => [
p.type,
Math.round(p.position3D.x * 100),
Math.round(p.position3D.z * 100),
Math.round(p.rotationY * 180 / Math.PI),
]);
const json = JSON.stringify(compact);
const b64 = btoa(json);
history.replaceState(null, '', '?c=' + b64);
}
function loadFromURL() {
const b64 = new URLSearchParams(location.search).get('c');
if (!b64) return [];
const compact = JSON.parse(atob(b64));
return compact.map(([type, x, z, deg]) => spawn(type, x / 100, z / 100, deg * Math.PI / 180));
}
For a 6-piece arrangement, the resulting URL parameter is ~80 characters. Easily fits inside a tweet, an email, or a print receipt QR code. Production tip: also persist into localStorage so refreshing doesn't lose state.
10. Rotation: 90° grid snap
Most modular furniture in the consumer market only supports 90° rotation increments. This is great for snap-points (orientations are discrete, math is clean) and great for users (no fiddly tilting). Hold a key or click a "rotate" button on a selected piece, increment by 90°.
function rotatePiece(piece) {
piece.rotationY = (piece.rotationY + Math.PI / 2) % (Math.PI * 2);
// Re-derive AABB after rotation
piece.aabb = computeAABB(piece);
// Validate; if invalid, undo
if (!isPlacementValid(piece, allPieces)) {
piece.rotationY = (piece.rotationY - Math.PI / 2 + Math.PI * 2) % (Math.PI * 2);
piece.aabb = computeAABB(piece);
}
}
11. Production deployments — the comparison
| Vendor | What's special | Stack notes |
|---|---|---|
| Roomle | Best-in-class snap rules; modular kitchens | Custom WebGL + parametric DSL; SaaS pricing |
| IKEA Kreativ | Phone-captured room as background; AI room reset | Three.js + custom; depth-segmentation tech |
| Crate & Barrel (Threekit) | Material variants per module | Threekit platform; AWS Batch back-end |
| West Elm | 360-spinner first, 3D on capable | Cylindo + progressive enhancement |
| Article | Outdoor sectionals; weather visualization | Cylindo + Threekit hybrid |
12. The mobile constraint
Furniture configuration is a desktop-leaning behavior — large screens make the spatial reasoning easier. But 60% of cold traffic to furniture pages comes via mobile (Threekit / Roomle aggregated data). Two options:
- Mobile = read-only 3D viewer. User builds on mobile, hits "open on desktop" with QR code or email link.
- Mobile = AR placement. Bypass the configurator entirely; user uses Quick Look / Scene Viewer to place individual pieces in their actual room.
Most production deployments do (1). It's simpler and the conversion data supports it.
13. Takeaways
- Snap-points are (position, direction, type, taken) tuples. Match types, find nearest under threshold, align two coordinate frames.
- Floor plan and 3D are two views of the same data. Map them via a single scale factor.
- Rotation is 90° grid for sanity. AABB collision is sufficient because of the 90° constraint.
- "Fits in your room" badge is the highest-impact UX feature. Show specific dimensions, specific room.
- BOM updates live. Each module = SKU + price + description.
- Save state to URL via base64 of compact JSON. ~80 chars for a 6-piece arrangement.
- Mobile is read-only or AR-only; desktop is the configuration surface.