Three.jsWeb 3DInteriorFloor PlanInteractiveFrontend

Turning a floor plan into a 3D space you can walk into: the complete Three.js approach

·6 min read

House hunting, renovation, renting — what you get is always the same artifact: a 2D floor plan.

It is the most universal language of space, and honestly, most people cannot read it. Wall thickness, room proportions, the walking line from entry to master bedroom — the information is all on the drawing, but turning it into a mental 3D model takes spatial imagination not everyone has.

So I ran an experiment: take a real three-bedroom layout and turn it into a browser 3D space — orbit the camera, switch configurations, drag furniture. No app, no account; open the page and walk in.

The takeaway: this is far cheaper than it looks. No interior-design software, no Blender. The whole scene's source is a data file under three hundred lines.

First decision: what a floor plan looks like as data

Reduced to essentials, a floor plan contains exactly five kinds of things:

typescript
export type LayoutPreset = {
  readonly floorOutline: readonly Point2[];   // exterior outline (polygon)
  readonly walls: readonly WallSegment[];      // interior walls (segment + thickness)
  readonly roomLabels: readonly RoomLabel[];   // room names and positions
  readonly finishes: readonly RoomFinish[];    // per-room floor finishes
  readonly furniture: readonly {               // initial furniture placement
    readonly type: string;
    readonly x: number; readonly z: number;
    readonly rotation: number;
  }[];
};

Convert the drawing's dimensions to meters, move the origin to the layout's center, and type the vertices in one by one. It is the most tedious step, and it buys the most important property: data fully separated from rendering. The same types hold any layout — the lab's A/B configuration switch is just swapping presets.

One deliberate design decision deserves a note: the data file keeps only anonymized, normalized geometry. The source drawing's layer names, annotations, and original coordinate system never enter the repository. This is a real home's layout — the geometry itself carries no privacy risk, but "where is this and where did the drawing come from" does. Drawing that line in the data model up front is far easier than scrubbing later.

The floor: one polygon, extruded

With outline vertices in hand, the floor is the easiest part of the scene. Three.js's Shape takes a run of 2D vertices and ExtrudeGeometry pushes it down into a slab:

typescript
const shape = new THREE.Shape();
// moveTo / lineTo along the floorOutline vertices...
const geometry = new THREE.ExtrudeGeometry(shape, {
  depth: 0.22,          // slab thickness
  bevelEnabled: false,
});

This is not a rectangular slab with walls on top — the floor is the layout's shape. Recessed balconies, protruding utility areas: wherever the outline goes, the slab follows. It makes the model read as "that apartment" at first glance rather than as building blocks.

Walls: segments become boxes, exterior walls come free

The wall construction is almost comically plain: every wall segment is one BoxGeometry — length of the segment, width of the wall thickness, height of the wall — rotated to the segment's angle.

The more interesting part: exterior walls are never declared. They are the floor outline itself — one wall auto-generated along each outline edge. Only interior walls are listed in the data:

typescript
// Exterior walls: generated along the outline
floorOutline.forEach((point, index) => {
  const next = floorOutline[(index + 1) % floorOutline.length];
  this.createWall(point, next, 0.2, outerMaterial, wallHeight);
});
// Interior walls: declared per segment (with individual thickness)
walls.forEach((wall) => {
  this.createWall(wall.from, wall.to, wall.thickness ?? 0.14, innerMaterial, interiorHeight);
});

The dollhouse cutaway: camera-facing walls shrink automatically

The first version had a fatal problem: all walls the same height meant the camera saw a ring of walls from outside — and nothing of the interior.

Interior-design software solves this with section cuts. My version is simpler — check whether each exterior wall's midpoint falls on the two camera-facing sides, and if so drop its height to about a third:

typescript
const midpointX = (point[0] + next[0]) / 2;
const midpointZ = (point[1] + next[1]) / 2;
const isCameraFacing = midpointX > maxX - 0.82 || midpointZ < minZ + 0.82;
this.createWall(point, next, 0.2, outerMaterial,
  isCameraFacing ? 1.18 : WALL_HEIGHT);   // camera-facing: low wall

Low walls rather than no walls is deliberate: removing them entirely loses the sense that a boundary exists there. A stub of wall keeps the enclosure legible while letting the eye in. Dollhouses have done exactly this for a century, and it works.

Room labels: draw text on a canvas, skip 3D text

Labels like "Master Bedroom" need to appear on the floor. Three.js's TextGeometry needs font files and geometry generation — especially heavy for CJK text, where one font file runs to megabytes.

The cheap way: open a 2D canvas, draw the text with the browser's native fillText, and turn the whole canvas into a texture (CanvasTexture) on a plane. The browser already knows how to render CJK text; there is no reason to make a 3D engine relearn it.

Furniture dragging: one Raycaster and one invisible plane

Dragging furniture sounds like a physics-engine feature. There is not one line of physics in it. The core is a Raycaster plus an invisible plane at floor height:

typescript
private readonly raycaster = new THREE.Raycaster();
private readonly dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -FLOOR_Y);
private readonly dragPoint = new THREE.Vector3();

// On pointermove: cast the screen ray onto the horizontal plane;
// the intersection is the furniture's new position
if (!this.raycaster.ray.intersectPlane(this.dragPlane, this.dragPoint)) return;
item.position.set(this.dragPoint.x, item.position.y, this.dragPoint.z);

Press to pick with the ray, drag to follow the ray-plane intersection. Furniture cannot fly up or sink underground because its motion is inherently locked to that plane — the constraint is not computed; the geometry provides it.

Mobile needs one extra resolution: dragging furniture and orbiting the camera are both one-finger drags, and the gestures fight. The fix is mutual exclusion — touching furniture pauses OrbitControls, releasing restores it. The tool panel also collapses on small screens and respects the safe area; no algorithms there, just changes made after actually using it on a phone once.

Light: one sun, one fake window

Interior lighting is easy to overdo. This scene has exactly three things: a warm DirectionalLight as the sun (with shadows), a cool RectAreaLight placed where the window is as daylight, and a touch of ambient fill.

RectAreaLight is underrated: it is a glowing rectangle — natively window-shaped. No HDRI, no baking; one light makes the interior read as daytime.

If you want to build one

In this order — every step produces something visible:

One — translate the plan into data first: outline polygon, wall segments, labels. Most boring step; everything after it is assembly.

Two — Shape + ExtrudeGeometry for the floor, auto-generate exterior walls from the outline. At this point it already reads as "that apartment".

Three — add the dollhouse cutaway (shrink camera-facing walls); without it you cannot see in.

Four — labels via CanvasTexture; do not touch 3D text.

Five — furniture dragging via Raycaster plus a horizontal plane; do not touch a physics engine.

Six — if the data comes from a real home, anonymize at the data-model layer, not by remembering to scrub later.

The result, ready to walk into: the 3D layout labRelated: the pipeline that turns a real city into a 3D scene

Sources