Monorepo for the SpatialTimber automatic furniture placement system. The repo has three parts:
| Folder | Role |
|---|---|
furnisher-engine/ |
The library. All placement logic, furniture data, and scoring. |
furnisher-app/ |
React prototype for interactive use in the browser. |
furnisher-for-rhino/ |
Grasshopper plugin that drives the engine from Rhino geometry. |
furnisher-engine/src/ is the authoritative source for all furniture placement logic. It is a pure TypeScript library with one runtime dependency (polygon-clipping). Everything else in this repo is a consumer of it.
Given a room polygon, a list of door points, and a furniture library, the engine:
- Analyses the room walls (length, inward normal, door exclusion zones).
- Tries each furniture piece in pipeline order, placing it against the best available wall.
- Subtracts the placed footprint from the available space before placing the next piece.
- Returns full 2D geometry for each piece (furniture lines, clearance bbox, footprint bbox).
- Scores the result 0–100 based on how many placement options existed.
furnisher-engine/src/
├── engine/ ← placement pipeline, wall analysis, scoring
│ ├── pipeline.ts runRoomPipeline / runRoomPipelineAt
│ ├── placer.ts getAllPlacements, getDoorRectangles
│ ├── scorer.ts scoreRoom (0–100 quality score)
│ ├── subtraction.ts polygon boolean difference
│ └── types.ts PlacedFurniture, PipelineResult, StepOptions …
├── library/ ← furniture data and lookup
│ ├── default/
│ │ ├── furniture_library.json geometry + bboxes per piece per apt type
│ │ └── placement_order.md which pieces go in which rooms, in what order
│ ├── loader.ts loads defaults; handles Vite `?raw` import
│ ├── lookup.ts findFurnitureByName, roomNameToCategory, apartmentLabelToType
│ └── types.ts FurnitureLibrary, FurnitureEntry, FurnitureVariant …
└── layout/ ← shared geometry types only
└── types.ts Point2D, Room, RoomName, Apartment, ApartmentLabel
import { runRoomPipelineAt } from "./src/engine";
import { scoreRoom } from "./src/engine";
import type { Room } from "./src/layout/types";
const room: Room = {
name: "Bedroom",
polygon: [[0,0],[4.2,0],[4.2,3.6],[0,3.6]], // CCW, metres, no repeated first vertex
doors: [[1.8, 0]], // points on the wall edge
};
// Run full pipeline — returns all options + selected placement per step
const result = runRoomPipelineAt(room, 2 /* aptType 1–4 */, [] /* selectedIndices */);
for (const step of result.steps) {
step.furnitureName // e.g. "Bed"
step.allOptions.length // total valid (variant × position) options
step.selected.placed.transformedGeometry // array of { closed, points } polylines
step.selected.placed.transformedBbox // clearance / transition zone polygon
step.selected.placed.transformedSmallBbox // physical footprint polygon
}
// Score the result
const { score, steps } = scoreRoom(room.name, result);
// score: 0–100 | steps: per-piece breakdownaptType maps roughly to apartment size: 1 = studio/1-bed, 2 = 2-bed, 3 = 3-bed, 4 = 4-bed+.
"Bedroom", "Living room", "Kitchen", "Bathroom", "WC", "Children 1" … "Children 4"
- Unit: metres
- Winding: counter-clockwise (CCW). If your source is CW, reverse the array.
- Last vertex must not repeat the first.
doorsis a list of points that lie on polygon edges (≤ 0.4 m from the boundary).- Polygons can be rectangular or L-shaped. Avoid holes or self-intersections.
- Collinear intermediate vertices on straight walls (common from CAD discretisation) should be removed before passing to the engine — they can cause the
polygon-clippingsubtraction to produce incorrect results. See the polygon simplification note below.
furnisher-app/ is a React + Vite single-page app. It is a practical prototype, not a production tool — its purpose is to let architects draw a floor plan interactively in the browser, place door points, and explore automatically generated furniture layouts with variant scrolling.
It imports the engine directly via Vite path aliases (@engine, @layout, @library → ../furnisher-engine/src/*), so both folders must stay siblings.
cd furnisher-app && npm install && npm run dev # http://127.0.0.1:5173The Storybook in furnisher-engine/ (npm run storybook) is a useful companion — it lets you drive the engine room-by-room with sliders without the full app.
furnisher-for-rhino/ is a proof-of-concept Grasshopper plugin for Rhino 8. It demonstrates that the engine can be driven from any CAD environment with no forking or reimplementation. See furnisher-for-rhino/README.md for full usage.
This is the pattern used by furnisher-for-rhino. Follow these steps to integrate the engine into any CAD environment (Revit, ArchiCAD, FreeCAD, Blender, etc.).
The core principle: the engine is TypeScript; most CAD scripting environments are not. Rather than porting the logic, bundle the engine into a self-contained Node CLI and communicate with it over stdin/stdout JSON. Your CAD plugin stays thin — it only reads geometry and deserialises results.
CAD environment ──→ your plugin (any language)
│ JSON request on stdin
▼
node furnisher-cli.cjs ← bundled engine
│ JSON response on stdout
▼
your plugin converts geometry back
The CLI (furnisher-for-rhino/engine-cli/src/cli.ts) defines the JSON shapes. The essential parts:
Request (stdin):
{
"rooms": [
{
"name": "Bedroom",
"polygon": [[0,0],[4.2,0],[4.2,3.6],[0,3.6]],
"windows": []
}
],
"doors": [[1.8, 0]],
"aptType": 2,
"selectionMode": "flat",
"selections": [[0, 0]]
}rooms[].polygon— CCW 2D ring in metres, no repeated closing vertex.doors— flat list of all door points; the CLI assigns each to the nearest room (within 0.4 m).aptType— 1–4. Omit to auto-infer from room names.selections[room][step]— flat index (0 = best) or omit for defaults.selectionMode—"flat"(one int per step) or"variant-position"(two ints per step:[variantIndex, positionIndex]).
Response (stdout):
{
"aptType": 2,
"rooms": [
{
"name": "Bedroom",
"score": 100.0,
"steps": [
{
"furnitureName": "Bed",
"optionCount": 11,
"selectedIndex": 0,
"variantGroups": [{"variantIndex": 0, "startFlatIndex": 0, "count": 6}],
"selectedVariant": 0,
"selectedPosition": 0,
"geometry": [{"closed": true, "points": [[x,y], ...]}],
"bbox": [[x,y],[x,y],[x,y],[x,y]],
"smallBbox": [[x,y],[x,y],[x,y],[x,y]]
}
],
"doors": [
{
"rect": [[x,y],[x,y],[x,y],[x,y]],
"width": 0.9,
"hingeAtIndex": 0
}
],
"warnings": []
}
]
}geometry— draw these polylines as the furniture symbol.bbox— the clearance / transition zone. Adjacent pieces' bboxes may overlap each other, but a piece'ssmallBboxmust not sit inside another piece'sbbox.smallBbox— the physical footprint. Never overlaps anothersmallBbox.variantGroups— how many positions each variant shape has. Use to build navigation UI.doors[].rect—[c0, c1, c2, c3]: c0/c1 on the wall, c2/c3 inward.hingeAtIndextells you which wall point is the hinge (chosen as the one closest to a room corner so the door swings into open space). Panel = hinge → inward point. Arc = quarter circle from inward point back to far wall point.- Options are sorted best-first: index 0 always gives the highest room score.
cd furnisher-for-rhino/engine-cli
npm install # installs esbuild only
npm run build # produces dist/furnisher-cli.cjs (~590 KB, self-contained)The build uses build.mjs which handles the Vite ?raw markdown import that normal bundlers reject. The output is a single CommonJS file; Node.js ≥ 18 is the only runtime requirement.
Critical: the engine writes [placer] debug lines via console.log. The CLI redirects these to no-ops before any imports so stdout stays clean JSON. Make sure any fork of cli.ts preserves this.
Before sending polygons to the engine:
-
Remove collinear intermediate vertices. CAD tools often discretise curves with extra points on straight segments. These cause
polygon-clippingsubtraction to silently fail, producing overlapping furniture. Remove any vertex where the cross-product of its two adjacent edges is < 0.002 m². -
Ensure CCW winding. Compute signed area (shoelace formula). If negative (CW), reverse the vertex array.
-
Remove near-duplicate vertices (< 5 mm apart).
-
Do not repeat the closing vertex — the engine expects
polygon[last] ≠ polygon[0].
The CLI's simplifyPolygon function in cli.ts does all of this and is safe to copy.
Spawn node furnisher-cli.cjs, write the JSON request to stdin, close stdin, read stdout to completion. Read stderr concurrently (not sequentially) — the OS pipe buffer is ~4 KB and the engine can fill it with internal warnings, causing a deadlock if you read stdout first and block waiting for it.
# Python example
import subprocess, json
req = {"rooms": [...], "doors": [...]}
proc = subprocess.run(
["node", "furnisher-cli.cjs"],
input=json.dumps(req),
capture_output=True,
text=True,
timeout=60,
)
response = json.loads(proc.stdout)// C# example (see EngineBridge.cs for full implementation)
var psi = new ProcessStartInfo {
FileName = "node",
Arguments = "\"path/to/furnisher-cli.cjs\"",
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
StandardInputEncoding = new UTF8Encoding(false), // no BOM — Node rejects it
StandardOutputEncoding = new UTF8Encoding(false),
};
using var proc = Process.Start(psi);
var stdoutTask = proc.StandardOutput.ReadToEndAsync(); // read both
var stderrTask = proc.StandardError.ReadToEndAsync(); // concurrently
proc.StandardInput.Write(requestJson);
proc.StandardInput.Close();
proc.WaitForExit(60_000);
Task.WaitAll(stdoutTask, stderrTask);
var response = JsonSerializer.Deserialize<EngineResponse>(stdoutTask.Result);Important for .NET: use new UTF8Encoding(false) (no BOM) for stdin encoding. Encoding.UTF8 prepends a byte-order mark that Node's JSON.parse rejects with "Unexpected token".
Each step in the response has optionCount valid placements. To implement scrolling like the app:
- Show the response at
selections = [](all zeros → best-first default). - For each step, read
variantGroupsto know how many shapes exist and how many positions each shape has. - Present two controls per step: variant (0 … variantGroups.length − 1) and position (0 … group.count − 1).
- Convert to a flat
selectionMode: "variant-position"request: interleave[v0, p0, v1, p1, …]per room. - When step i changes, re-send the full request — the engine recomputes all downstream steps in the space left by the new choice. Options for step i+1 depend on what step i selected.
| Output | How to draw it |
|---|---|
geometry |
Polylines (close the loop if closed: true). This is the furniture symbol. |
smallBbox |
Optionally show as a hatched or shaded rectangle. Physical footprint. |
bbox |
Optionally show lighter. Clearance / transition zone. |
doors[].rect + hingeAtIndex |
Panel line from hinge to inward corner. Quarter-circle arc from inward corner to far wall point, centred on hinge. |
The bbox of adjacent pieces may visually overlap — that is correct by design. Only smallBbox must not overlap another smallBbox.
| Problem | Cause | Fix |
|---|---|---|
| All placements return "no placement found" | Polygon winding is CW or has collinear points that break polygon-clipping | Simplify polygon + enforce CCW |
| Rhino/GH freezes on solve | stdout and stderr read sequentially — stderr fills pipe buffer and deadlocks |
Read both streams with async tasks in parallel |
Unexpected token ',' {"rooms":... |
.NET Encoding.UTF8 prepends a UTF-8 BOM to stdin |
Use new UTF8Encoding(false) |
| Furniture pieces overlap | Polygon has collinear intermediate vertices from CAD discretisation | Remove collinear points before sending |
| Second furniture piece ignores first choice | Downstream options not re-fetched after step 0 change | Always re-send the full request when any step changes |
| Options not sorted by quality | Engine returns options in generation order (wall × variant × position) | Score each option greedily and sort before returning (see sortOptionsByScore in cli.ts) |
cd furnisher-engine && npm install
cd ../furnisher-app && npm install# Browser app
cd furnisher-app && npm run dev # http://127.0.0.1:5173
# Engine Storybook (interactive per-room demo)
cd furnisher-engine && npm run storybook # http://localhost:7007