Three.jsWeb 3DInteriorFloor PlanInteractiveFrontend

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

···12 min read

The problem this article solves: you have a 2D floor plan and want a browser 3D view you can orbit and furnish. Every step includes code. The "How to run it" and "Coordinate units" sections get it running locally and map plan sizes to scene coordinates.

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.

How to run it: open it online, or run a minimal example locally

The fastest way is to open the 3D layout lab. There is nothing to install and no account. It works in desktop and mobile browsers.

Open the 3D layout lab

The site repository is not public, so the local version is a minimal example. It does two things: turns plan data into a 3D floor and walls, and shows that 1 unit = 1 meter. Three.js ships inside the kit, so there is nothing to install and no network needed.

Download the floor plan to 3D minimal example (ZIP)
bash
unzip threejs-floor-plan-minimal.zip
cd threejs-floor-plan-minimal
python3 -m http.server 8000

Then open http://localhost:8000. npx serve . also works; use the URL the terminal prints. Double-clicking index.html does not work, because browsers block ES modules over file://.

What success looks like: an L-shaped floor, exterior walls and two interior walls, with low walls on the two camera-facing sides. Each grid cell is 1 m, and the blue cube is 1 m, filling one cell. Hover the floor and the top-left panel shows data coordinates [x, z].

The layout data is in layout.js; edit it for your own plan. The example has no furniture, dragging, image upload or measuring. Try those in the online lab.

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.

Coordinate units: 1 unit = 1 meter, Y is up

The whole scene uses meters, so one Three.js unit is one meter. Exterior walls are 2.72 high, interior walls 1.98, and exterior walls 0.2 thick, all in meters.

Height is the Y axis, positive upward. The floor surface sits at y = 0.12, the FLOOR_Y constant. Furniture and the horizontal drag plane sit at this height.

The layout data stores only plan coordinates [x, z], with the origin at the layout center. x is positive to the right. z follows how you read the plan: positive toward the top of the sheet.

When the data reaches Three.js, z flips sign. A data point [x, z] lands at (x, y, -z), and furniture rotation flips too. In the top view, the top of the screen is the top of the plan.

typescript
const FLOOR_Y = 0.12;               // floor surface height (m)
const WALL_HEIGHT = 2.72;           // exterior wall height
const INTERIOR_WALL_HEIGHT = 1.98;  // interior wall height
const toWorldPoint = ([x, z]: Point2): Point2 => [x, -z];

// Furniture: flip the data z, then place on the floor
item.position.set(preset.x, FLOOR_Y, -preset.z);
item.rotation.y = -preset.rotation;

Converting plan pixels: first decide how many meters the whole image spans. Meters per pixel = image width in meters ÷ image width in pixels. On image upload, the lab assumes a 12 m wide image. Use "Set the scale" to correct it.

There are two ways to set the scale. Usually you click both ends of a wall with a known length. The tool sets the image width to wall length ÷ pixel distance × image width in pixels. Without a known wall, enter the real width of the whole plan.

Pixel to coordinate: the origin is the image center. Image y grows downward, so z is flipped. x = (pixel x − width ÷ 2) × meters per pixel. z = (height ÷ 2 − pixel y) × meters per pixel. Results round to two decimals, which is 1 cm.

Example: a 1200 × 800 px image spanning 12 m gives 0.01 m per pixel. Pixel (900, 300) becomes x = 3.00, z = 1.00 in the data.

To check the units yourself, press Measure in the lab and tap both ends of a wall. The reading shows centimeters and meters.

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: FLOOR_Y,       // slab thickness: 0.12 m
  bevelEnabled: false,
});
geometry.rotateX(Math.PI / 2);      // Shape is drawn in XY; lay it flat
geometry.translate(0, FLOOR_Y, 0);  // slab top at y = FLOOR_Y

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 it to 1.18 m, about 40% of full height:

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.

Example: which outer walls get low in layout A

The snippet above matches the current code in src/pages/InteriorLayoutScene.ts, lines 858-866. The layout data is in src/pages/interiorLayoutData.ts. Layouts A and B both live there, in 263 lines.

The rule compares maxX and minZ in data coordinates. The lowest data z is the bottom edge of the plan, which lands on the +z side of the scene. The default camera takes its direction from VIEWPOINTS.perspective, the (12.8, 11.2, 13.8) on line 76. From the plan center, the camera looks down from the lower right. framedViewpoint() sets the actual distance from the plan outline (from line 238).

Plan view of layout A. Of 22 outer walls, 11 on the right and bottom edges are orange: low walls at 1.18 m, 25.17 m in total. The other 11 are gray: full height at 2.72 m, 27.81 m in total. Two dashed lines mark the threshold: midpoint x above 6.27, z below -3.66. The 22 interior walls are thin gray lines. The default camera is at the lower right
The low-wall rule rerun on the layout A data, 2026-11-05. Diagram drawn by KeepOnFirst.

The rule was rerun on the layout A data. The outline has 22 vertices, so there are 22 outer walls. A wall gets low when its midpoint x is above 6.27, or its midpoint z is below -3.66.

Result: 11 walls drop to 1.18 m, 25.17 m in total. The other 11 stay at 2.72 m, 27.81 m in total. The 22 interior walls are 1.98 m high.

The test uses the midpoint, not the whole segment. In layout B, a 1.1 m outer wall at the lower right has its midpoint at x 4.73, z -3.68. It misses both thresholds, so it stays full height. This follows from the code; no screenshot confirms it.

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.

The drag snippet above is simplified. The site code adds two steps: it rounds the position to 0.1 m, then clamps it to x ±6.65 and z ±4.0 m (InteriorLayoutScene.ts, lines 1318-1322). The range is fixed and does not follow the layout size.

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.

The actual setup is in InteriorLayoutScene.ts, lines 587-606. The fill light is a HemisphereLight at intensity 2.1. The RectAreaLight is 8 m wide and 3 m high, at (0, 3.8, 5.4): above the area just outside the bottom edge of the plan.

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 labTurn room sizes into ping: the ping calculatorTry a real sofa in this tool: three "3-seat" sofas 57 cm apartHow a layout fits in a share URL: deflate-raw measured, then a CRC-32Related: the pipeline that turns a real city into a 3D scene

Sources