Three.js From Zero · Article s11-06

S11-06 Modular Furniture Configurator with Snap-Points

Season 11 · Article 06

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:

  1. Will it fit in my room?
  2. Will the modules I want connect properly?
  3. What's the total cost when I add the chaise + ottoman?
  4. 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.

3D view (live)
floor plan (drag pieces)

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

VendorWhat's specialStack notes
RoomleBest-in-class snap rules; modular kitchensCustom WebGL + parametric DSL; SaaS pricing
IKEA KreativPhone-captured room as background; AI room resetThree.js + custom; depth-segmentation tech
Crate & Barrel (Threekit)Material variants per moduleThreekit platform; AWS Batch back-end
West Elm360-spinner first, 3D on capableCylindo + progressive enhancement
ArticleOutdoor sectionals; weather visualizationCylindo + 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:

  1. Mobile = read-only 3D viewer. User builds on mobile, hits "open on desktop" with QR code or email link.
  2. 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.