The /compose-2 system generates complete residential floor plans procedurally — including layout, furniture, decorations, and dynamic objects — and renders them as an interactive 3D scene with primitive geometry. Every spatial decision is captured in a deterministic pipeline driven by a seeded PRNG, so the same seed produces the same house. The output is fully described in YAML so the data is portable to other consumers (game engines, simulators, asset pipelines).
This document describes the architecture in depth, including the data flow, the constraint system, the rendering strategy, the validation pipeline, and a roadmap of future additions.
- System Overview
- End-to-End Pipeline
- BSP Floor Plan Generation
- Room Classification
- Furniture Placement Engine
- Door + Window Computation
- Decoration System
- Dynamic Objects
- Validation and Auto-Fix
- Three.js Rendering
- YAML Output
- API Surface
- Frontend UI
- Determinism and Reproducibility
- Performance Characteristics
- Future Additions
┌──────────────────────────────────┐
│ Cloudflare Worker (Hono) │
│ library.luckyrobots.com │
│ │
│ GET /compose-2 → HTML page │
│ POST /api/compose-2/generate │
└──────────────┬───────────────────┘
│
┌────────────────────────┼─────────────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────────┐ ┌───────▼───────┐
│ bsp.ts │ │ furniture-rules │ │ ollama.ts │
│ Floor plan │ │ .ts │ │ Classification│
│ generation │ │ Placement + │ │ (det + LLM) │
│ │ │ Decoration │ │ │
└─────────────┘ └─────────────────┘ └───────────────┘
│ │ │
└────────────────────────┼─────────────────────────┘
│
┌────────▼─────────┐
│ compose-2.ts │
│ (route + Three.js│
│ inline HTML) │
└──────────────────┘
Module responsibilities:
| Module | Lines | Responsibility |
|---|---|---|
worker/src/routes/compose-2.ts |
2002 | HTTP route, room shaping, opening computation, inline HTML/Three.js client, validation, fix-errors, diagnose, YAML rendering |
worker/src/services/bsp.ts |
461 | BSP algorithms — basic dungeon split + hallway-spine carving; seeded PRNG |
worker/src/services/furniture-rules.ts |
1632 | Furniture catalog (F), wall-occupancy tracker, per-room placement strategies, collision resolution, decoration catalog (D), per-surface decoration rules, dynamic object placement |
worker/src/services/ollama.ts |
448 | FurnitureItem interface, deterministic room classifier, optional Ollama LLM classifier, room-type colors |
worker/src/types.ts |
485 | RoomStandard definitions per room type (min/typical/max + required/optional furniture), wall-hugging item set, center item set |
A single POST /api/compose-2/generate request executes the following pipeline. Each stage produces structured data consumed by the next stage; nothing is wasted.
┌─ Request ─────────────────────────────────────────────────────────────┐
│ { seed, width, depth, minRoomSize, maxDepth, useSpine, outdoor, │
│ decorate, useOllama, forceTypes } │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ Stage 1: BSP Floor Plan Generation ─────────────────────────────────┐
│ generateBSPFloorPlan(config) — recursive rectangle split │
│ OR generateBSPFloorPlanWithSpine(config) — carve hallway first │
│ Output: { rooms[], corridors[], adjacency[][] } │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ Stage 2: Room Classification ───────────────────────────────────────┐
│ classifyRoomsDeterministic(floorPlan) — area-based heuristics │
│ OR classifyRoomsWithOllama(...) — LLM (qwen3:0.6b) when configured │
│ Then preserve forcedType from BSP (e.g. 'hallway' from spine) │
│ Apply forceTypes overrides + outdoor promotion + kids_bedroom │
│ Output: ClassifiedRoom[] with type assignments │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ Stage 3: Build RoomResult Records ──────────────────────────────────┐
│ For each classified room: assemble RoomResult with floor color, │
│ flooring material, wallFlags, exteriorWalls, is_outdoor flag │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ Stage 4: Compute Openings ──────────────────────────────────────────┐
│ computeOpenings(rooms, adjacency, fpW, fpD) │
│ • Doors at midpoint of shared walls between adjacent rooms │
│ • Windows on exterior walls (skipped for outdoor rooms) │
│ • Main entrance on widest south-facing exterior wall │
│ • wallFlags assigned (lower-index renders shared wall, higher skips)│
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ Stage 5: Furniture Placement ───────────────────────────────────────┐
│ For each room: placeFurnitureWithRules(room) │
│ → switch on room.type → per-room strategy (placeBedroom, etc.) │
│ → WallOccupancy tracker prevents overlaps with doors/windows │
│ → validateRoom() snaps wall items flush, removes door blockers │
│ → resolveCollisions() iterates push apart, removes unresolvable │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ Stage 6: Dynamic Objects (always) ──────────────────────────────────┐
│ placeDynamicObjects(rooms) — people + pets, free-roaming │
│ • ~30% chance per major room for one person (max 4 total) │
│ • ~70% chance for one dog or cat per house │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ Stage 7: Decoration (opt-in via decorate=true) ─────────────────────┐
│ decorateScene(rooms) │
│ • For each surface furniture (bed, sofa, desk, …): decorateSurface │
│ • Floor decorations per room (rugs to 80%, plants, entry items) │
│ • Wall-mounted decor (frames, art, mirrors) at proper wall surface │
│ • Color variation + rotation jitter for natural feel │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ Response ────────────────────────────────────────────────────────────┐
│ { seed, floorPlan: { width, depth, rooms[], corridors[], adjacency }, │
│ classification_source, ollama_available, timing } │
└────────────────────────────────────────────────────────────────────────┘
Total runtime is typically 1–10 ms for the entire pipeline (deterministic path). Ollama path adds 1–5 s per LLM call.
Source: worker/src/services/bsp.ts.
The core spatial primitive is a Binary Space Partition (BSP) — a recursive split of a rectangle into rooms. There are two flavors:
Algorithm:
- Start with a root rectangle (e.g. 20 m × 15 m).
- Recursively split each rectangle along its longer axis. Split position is randomized within
[minSplitRatio, maxSplitRatio]of the dimension. - Stop splitting when a rectangle would be too small (
< 2 × minRoomSize) or recursion depth exceedsmaxDepth. - Each leaf becomes a room. Rooms tile the BSP space exactly (no inset — wall thickness is purely a render concern, see §10).
- After the tree is built,
connectNodes()walks the tree adding L-shaped corridors between sibling subtrees, and records the adjacency pairs.
Initial: After split: After two splits:
┌──────────┐ ┌─────┬────┐ ┌──┬──┬─────┐
│ │ │ │ │ │ │ │ │
│ │ → │ L │ R │ → │ A│ B│ R │
│ │ │ │ │ │ │ │ │
└──────────┘ └─────┴────┘ └──┴──┴─────┘
Output:
interface BSPFloorPlan {
width: number; // total floor plan width
depth: number; // total floor plan depth
rooms: BSPRoom[]; // all leaf rectangles
corridors: BSPCorridor[]; // overlay strips connecting rooms
adjacency: [number, number][]; // pairs of connected room indices
}
interface BSPRoom {
index: number;
x: number; z: number; // room top-left corner (BSP space)
width: number; depth: number;
forcedType?: string; // optional pre-assigned type (used by spine for 'hallway')
}A 1.2 m wide hallway is carved through the center along the longer axis FIRST. The two remaining halves are BSPed independently. The hallway becomes a real room (forcedType: 'hallway'). Adjacency between rooms and the spine is computed by detecting walls that touch the spine boundary.
Without spine: With spine (horizontal floor plan):
┌────┬────┬────┐ ┌────┬────┬────┐
│ A │ B │ C │ │ A │ B │ C │
├────┼────┼────┤ ├────┴────┴────┤
│ D │ E │ F │ │ HALLWAY │ 1.2m wide spine
└────┴────┴────┘ ├────┬────┬────┤
│ D │ E │ F │
└────┴────┴────┘
This makes the layout architecturally realistic — every room reaches the front door via the hallway, eliminating room-to-room adjacency for most pairs. The spine version is the default in the UI.
SeededRandom is a simple Linear Congruential Generator (LCG) seeded from config.seed. Same seed → same floor plan. The user can change seed to explore variants while keeping all other parameters fixed.
class SeededRandom {
next(): number; // [0, 1)
range(min, max): number; // [min, max)
}Source: worker/src/services/ollama.ts.
After BSP, each leaf rectangle needs a type (bedroom, kitchen, living_room, etc.). The classifier accepts the floor plan and returns a ClassifiedRoom[] with type assignments.
classifyRoomsDeterministic(floorPlan) uses area-based heuristics:
- Sort rooms by area descending.
- Largest →
living_room. - Second largest →
bedroom_master(if ≥ 12 m²) elsebedroom. - For each remaining room, in order of area:
- Smallest unfilled essential roles fill first: kitchen (area ≥ 8 m²), bathroom (area ≤ 10 m²).
- Long thin rooms become
hallway. - Remaining medium rooms →
bedroom,dining_room,office,laundrybased on size + counts.
Constraint: every house ends up with at least one kitchen + bathroom, plus a living room and master bedroom when room count permits.
classifyRoomsWithOllama(ollamaUrl, model, floorPlan) posts a structured prompt to a local Ollama server and parses the JSON response. Falls back to the deterministic classifier on failure (network error, malformed JSON).
The endpoint expects OLLAMA_URL to be set (e.g. via Cloudflare Tunnel exposing a GPU workstation as ollama.luckyrobots.com). Currently OLLAMA_URL is unset in production, so this path is unused. The deterministic classifier produces clean results for the current scope; the Ollama path is wired for future style/variety experiments.
Three layers can override the classifier output:
BSPRoom.forcedType— used by spine carving to mark hallway rooms.body.outdoor: true— converts the largest non-essential rooms tobackyard+patio.body.forceTypes: { roomIndex: typeName }— explicit per-room overrides via API.- Auto kids_bedroom — the smallest plain bedroom is automatically promoted to
kids_bedroomso houses get toys.
ROOM_TYPE_COLORS in ollama.ts maps each room type to a hex color used as the room's floor_color. This was historically used as the floor fill but is now used only as the rug color (see §10), giving each room a distinguishable accent on top of the realistic flooring material.
Source: worker/src/services/furniture-rules.ts.
The placement engine takes a typed room and produces a list of FurnitureItems positioned within the room. It is purely deterministic, rule-based, and respects door clearance + window blocking + collision constraints.
Every furniture type has fixed dimensions, color, and category:
const F: Record<string, { w: number; d: number; h: number; color: string; cat: string }> = {
bed: { w: 1.6, d: 2.0, h: 0.5, color: '#4169e1', cat: 'bed' },
sofa: { w: 2.2, d: 0.9, h: 0.85, color: '#2e8b57', cat: 'seating' },
refrigerator: { w: 0.7, d: 0.7, h: 1.8, color: '#c0c0c0', cat: 'appliance' },
// …~70 entries covering all room types + outdoor + workshop
};Categories: bed, seating, table, storage, appliance, bathroom, lighting, decor, electronics, vehicle, structure, water, dynamic, misc.
Two related sets:
WALL_ITEMS— furniture that must hug a wall (bed, sofa, wardrobe, counter, sink, toilet, etc.).TALL_ITEMS— items taller than window sill (0.9 m): wardrobe, fridge, bookshelf, TV. These are blocked from window zones in addition to door zones.
Class WallOccupancy(room) tracks occupied segments per wall + door clearance zones + (optional) window zones. Methods:
| Method | Purpose |
|---|---|
pickWall(itemWidth, avoid?, isTall?) |
Pick the best wall for an anchor item — longest clear stretch, prefer no-door walls |
findSpace(wall, itemWidth, preferred?, isTall?) |
Find a clear segment center on a wall, given item width and optional preferred position |
occupy(wall, center, width) |
Mark a segment as occupied |
getWall(name), getAllWalls() |
Inspect wall metadata |
Door clearance zone: 1.0 m extension on each side of every door, blocked for all items.
Window zone: 0.1 m extension, blocked only for tall items (when isTall=true).
Each room type has a placement function. They follow a common pattern:
- Place anchor item on the longest clear wall (e.g. bed in bedroom, sofa in living room, counter in kitchen).
- Place companion items relative to the anchor (nightstands flank bed, TV on wall opposite sofa, stove next to counter).
- Place secondary items on remaining walls (wardrobe, desk, bookshelf, etc.).
- Run
validateRoom()to snap items flush + remove anything blocking doors. - Run
resolveCollisions()to push overlapping items apart, then delete unresolvable cases.
| Strategy | Room |
|---|---|
placeBedroom(room, isMaster) |
bedroom, bedroom_master, kids_bedroom |
placeKitchen(room) |
kitchen — L-shaped counter+stove+sink, optional island |
placeLivingRoom(room) |
living_room — sofa, TV (opposite wall), coffee table, armchair, floor lamp |
placeBathroom(room) |
bathroom — toilet far from door, sink near door, bathtub remaining wall |
placeDiningRoom(room) |
dining_room — central table + 4 chairs + sideboard |
placeOffice(room) |
office — desk facing window, chair, bookshelf, filing cabinet |
placeLaundry(room) |
washer + dryer side by side, shelf |
placeHallway(room) |
console table |
placeGarage(room) |
single-car: workbench, storage shelf |
placeGarageNCar(room, count) |
2- or 3-car: cars side by side, workbench against back wall |
placeWorkshop(room) |
workbench + tool cabinet + drill press + table saw |
placePatio(room) |
central table + bistro chairs + BBQ + planters |
placeDeck(room) |
outdoor sofa + deck chairs + fire pit |
placeBackyard(room) |
pool (dominant) + lounge chairs + BBQ + trees |
placeBalcony(room) |
bistro chairs + planters |
resolveCollisions(items, room) runs up to 20 iterations:
- For each pair of items, check AABB overlap (using rotation-aware effective dimensions).
- If overlapping, push the smaller item along the axis with less overlap.
- Clamp the moved item back inside room bounds.
- After iterations, any remaining overlaps cause the smaller item to be removed.
This is paired with validateRoom() which:
- Snaps wall-hugging items flush against the nearest wall (3 cm gap).
- Removes items that sit inside a door clearance zone.
Source: worker/src/routes/compose-2.ts:81-225.
After classification, each room is wrapped in a RoomResult with empty openings: []. Then computeOpenings(rooms, adjacency, fpW, fpD) populates them.
For each [a, b] pair in the BSP adjacency list, the function checks all four possible shared-wall configurations:
- A's east wall ≈ B's west wall (vertical shared wall, x match)
- B's east wall ≈ A's west wall
- A's south wall ≈ B's north wall (horizontal shared wall, z match)
- B's south wall ≈ A's north wall
If walls are within 0.5 m of each other AND the overlap on the perpendicular axis exceeds the door width (0.9 m), a door is added at the midpoint of the overlap to BOTH rooms. The connects_to field references the partner room.
Each RoomResult carries wallFlags: { north, south, east, west }. When two rooms share a wall, the lower-index room renders the wall (wallFlags[side] = true); the higher-index room skips rendering it (wallFlags[side] = false). This eliminates double walls at no geometric cost — the data model still records both rooms' walls, but only one is drawn.
Same pattern applies to door panels via renderPanel: true/false — doors render once per shared wall.
For each non-outdoor room, walls that touch the floor plan boundary (exteriorWalls.{north,south,east,west}) get windows. addWindowsToWall() places 1–2 windows per wall (depending on length) at evenly spaced positions, skipping any position that would overlap a door.
Window dimensions: 1.2 m wide × 1.4 m tall, sill at 0.9 m.
The room with the widest south-facing exterior wall (preferring the hallway when present) gets a special door with isMainEntrance: true and connects_to: 'EXTERIOR'. Rendered with a distinct red material to visually mark the front door.
Decorations populate every flat surface and indoor floor with primitive-shape ornaments. Output is purely yaml-driven — no real meshes, just colored boxes.
furniture-rules.ts:1172-1252 defines ~50 decoration items:
| Group | Items |
|---|---|
| Soft goods | pillow, throw_pillow, blanket, throw_blanket |
| Lighting | table_lamp, desk_lamp |
| Wall art | picture_frame, wall_frame, wall_art, wall_clock, wall_mirror |
| Books + paper | book, book_stack, notebook, magazine, pen |
| Tech | laptop, monitor, keyboard |
| Containers | pen_cup, organizer, tray, basket |
| Drinkware | mug, glass, wine_glass |
| Decor | figurine, vase, small_vase, candle, candle_holder, plant_small, succulent, coaster |
| Dining | plate, bowl, silverware, placemat, table_runner, centerpiece |
| Kitchen | cutting_board, knife_block, fruit_bowl, coffee_maker, toaster, spice_rack, jar, utensil_holder |
| Bedroom | jewelry_box, perfume_bottle |
| Bathroom | soap_dispenser, folded_towel, toiletry_kit |
| Entry | key_tray, shoe_pair, umbrella_stand |
| Kids | toy_blocks, stuffed_animal, toy_car |
| Floor | rug, floor_rug_small |
Most items have variants arrays of 3–6 alternate colors. The renderer uses pickColor() (deterministic PRNG seeded by item position + name) to choose a variant — same room generates the same colors.
function pickColor(name, parentX, parentZ): string; // deterministic variant choice
function jitterRotation(name, parentX, parentZ, baseRot): number; // ±15° rotation jitter for "natural" feeljitterRotation only applies to items in JITTER_ITEMS: books, magazines, mugs, figurines, candles, picture_frames, plants, toys, etc. Items like beds and desks stay axis-aligned.
decorateSurface(parent, items, room) switches on parent furniture name and places decorations using local coordinates relative to the parent's center, then rotates them by the parent's rotation to get world coordinates.
| Parent | Decorations placed |
|---|---|
| bed | 2 pillows + throw_pillow at headboard end + throw_blanket at foot + wall_frame above |
| sofa | 2 pillows + 2 throw_pillows + throw_blanket + wall_art above |
| armchair | 1 throw_pillow |
| nightstand | table_lamp + alarm_clock + picture_frame + book |
| dresser | 2 picture_frames + jewelry_box + 2 perfume + candle |
| sideboard | vase + 2 picture_frames + 2 candle_holders + 2 candles + book_stack |
| console table | table_lamp + small_vase + picture_frame + tray + key_tray + wall_mirror above |
| dining table | table_runner + centerpiece + 2 candles + 4 settings (placemat + plate + silverware + glass) |
| desk | laptop + monitor + keyboard + desk_lamp + pen_cup + pen + mug + figurine + notebook + organizer |
| coffee table | book_stack + magazine + 2 coasters + small_vase + 2 candles |
| counter | cutting_board + knife_block + coffee_maker + 2 jars + toaster + spice_rack + utensil_holder |
| island | fruit_bowl + cutting_board + 2 jars + plant_small |
| bookshelf | per shelf (4 heights): 3 books + book_stack + alternating small_vase / figurine + frame |
| sink (bathroom) | soap_dispenser + toiletry_kit + folded_towel + wall_mirror above |
| workbench | organizer + pen_cup + book |
decorateFloor(room) adds room-level items:
- Rugs in every indoor room (min dim ≥ 1.5 m). Sized to 80% of room dimensions. Hallway gets a long thin runner.
- Plants in living room corners and master bedroom corner.
- Kids bedroom: toy_blocks, toy_car, 2 stuffed_animals, basket.
- Hallway entry: shoe_pairs + umbrella_stand + basket near console table.
- Laundry: basket.
Wall-mounted decorations have additional fields:
mountedOnWall: 'north' | 'south' | 'east' | 'west';
wallY: number; // height above floorRather than sitting on a parent's top surface, they attach to the wall surface. wallSurfacePos(parent, room) computes the proper wall position from the parent's rotation (which wall the parent hugs). The renderer uses wallY as the y coordinate.
This pattern lets art appear above beds, mirrors above sinks, and frames above sideboards — at the wall, not floating above the parent furniture.
Every decoration carries parentName?: string referencing its anchor furniture. Floor decorations have no parent. The YAML generator uses this to nest decorations under their parent visually:
furniture:
- bed: [4.8, 1.5] # 1.6×2×0.5m
decorations:
- pillow: [4.45, 0.80] # 0.45×0.45×0.15m
- pillow: [5.15, 0.80]
- throw_pillow: [4.80, 0.85]
- throw_blanket: [4.80, 2.40]
- wall_frame: [0.02, 1.50] # 0.4×0.04×0.5m
floor_decorations:
- rug: [3.0, 2.5] # 4.8×3.2mplaceDynamicObjects(rooms) adds people + pets after furniture. They're free-roaming — no wall rules, no collision detection, excluded from validation/diagnose.
- Up to 4 people total, ~30% chance per major room (
living_room,kitchen,dining_room,bedroom_master,backyard,patio,outdoor_deck). - ~70% chance of one pet (50/50 dog or cat) per house, preferring living room / backyard / kitchen.
Items get category: 'dynamic' so they bypass wall-occupancy logic and collision resolution. The renderer draws them with custom geometry (capsule for person, rounded box + ears for pets) instead of plain boxes.
Three pieces working together:
Runs after every render. Detects:
- Overlap — any two non-decoration / non-dynamic items with overlapping AABBs
- Wall-far — wall-hugging items more than 0.2 m from any wall
- Out of bounds — items extending past room boundaries
- Door blocked — items inside door clearance zone (0.8 m)
- Window blocked — TALL items in front of windows
For each issue, draws a red wireframe marker around the problem item and a floating red label.
Opens a modal with a structured text report listing every issue, plus user-flagged items (clicked in the YAML panel). Includes:
- Room dimensions
- All openings (doors with
connects_to, windows) - Every item with position, size, rotation
- Issues marked with
>>for visual scanning
The report is one-click copyable for sharing in chat / debugging.
Inside the diagnose modal. Runs five passes:
- Snap + OOB — push wall items flush, fix out-of-bounds items by clamping
- Door push — slide items along the wall away from any door zone they're blocking
- Window push — slide tall items along the wall away from window zones
- Iterative collision resolve — 20 iterations of AABB push-apart
- Final sweep — remove any item still violating after all passes (OOB, in door zone, far from wall)
After fixing, the scene rebuilds, the YAML refreshes, and if the diagnose modal is open, it re-runs to show remaining issues. Items that can't be saved get deleted entirely.
Source: compose-2.ts:411-2000 (inline HTML/JS embedded in the route).
The page is served from the route as inline HTML — Three.js + OrbitControls + CSS2DRenderer loaded from a CDN via importmap. This keeps the entire frontend self-contained in one file.
scene— black background (#0d1117)camera— perspective (50° fov), starts at top-down viewrenderer— antialiased, soft PCF shadowslabelRenderer— CSS2DRenderer for HTML text labels above itemscontrols— OrbitControls with damping- Lighting: ambient (0.6) + directional (0.8) with shadow mapping
For each room:
- Floor — colored plane using
room.flooring.color(wood / tile / concrete / stone / grass / deck — see §10.5). - Walls —
renderWall(room, wallSide)for each of 4 sides. Skips entirely ifwallFlags[wallSide] === false. Outdoor rooms use a 0.6 m railing instead of 2.8 m wall on exterior sides only (interior shared walls stay full height). - Doors — for each door opening, splits the wall around it (left segment + lintel + right segment) and adds an interactive door panel. Main entrance doors use a red material; others brown. Doors pivot from the hinge edge and animate to 90° on click.
- Windows — wall split with a translucent blue glass pane; segments above sill and below sill rendered separately.
- Furniture — each item rendered as a
BoxGeometry. Special cases:- Person: capsule (cylinder + sphere head)
- Dog/cat: rounded box body + sphere head + ears
- Decorations — boxes positioned at
parent_top + dec.height/2(usingyOffset), orwallY + height/2for wall-mounted. - Labels —
CSS2DObjectHTML labels above each item. Color/style varies by category (cyan furniture, gold dynamic, green decoration).
renderWall(room, side) is the most complex piece. It:
- Returns immediately if the wall is shared with a lower-index neighbor (wallFlags).
- Uses a low height (0.6 m) + railing material if the wall is outdoor + exterior.
- Sorts openings on this wall by position.
- Walks left-to-right, emitting solid wall segments between openings.
- For each door opening: emits a lintel above + door frame + door panel (only if
renderPanel: true). - For each window opening: emits sill segment below + lintel above + glass pane in middle.
- Emits trailing solid segment after the last opening.
Door panels are tracked in doorPivots[] so they can animate on click.
raycaster checks every click:
- First test door panels — clicked door toggles open/closed (90° rotation, animated via lerp in
animate()). - Else test floor planes — clicked room shows its details panel (room info sidebar).
Each room has flooring: { type, color } set server-side (getFloorMaterial(roomType)):
| Room type | Flooring |
|---|---|
| bedroom, bedroom_master, living_room, dining_room, office, hallway, kids_bedroom | wood |
| kitchen, bathroom, laundry, balcony | tile |
| garage, garage_2car, garage_3car, workshop | concrete |
| patio | stone |
| outdoor_deck | deck (wood planks) |
| backyard | grass |
The realistic floor material renders underneath; the room-type accent color appears as the rug on top. This gives both functional realism and visual room distinction.
runAutoValidation() adds red wireframe boxes + warning text labels (OVERLAP, WALL FAR, BLOCKS DOOR, BLOCKS WINDOW, OUT OF BOUNDS) around problem items in the scene.
- Top Down — orthographic-like overhead view, framed to entire floor plan
- 3D View — perspective 45° angle from corner
Every generation produces a structured YAML representation in the right-side panel. This is the "source of truth" for downstream consumers — game engine importers, simulation pipelines, asset generation prompts.
Structure:
# BSP Floor Plan — seed 42
# Classification: deterministic
# Generated in 5ms
floor_plan:
size: 20 × 15m
seed: 42
rooms: 13
corridors: 0
rooms:
living_room_8:
type: living_room
position: [5.7, 0.0]
size: 5.7 × 5.6m # 31.4 m²
doors:
- wall: south # → hallway_0
windows:
- wall: north # 1.2m wide
- wall: north # 1.2m wide
furniture:
- sofa: [2.85, 0.50] # 2.2×0.9×0.85m
decorations:
- pillow: [2.15, 0.78]
- throw_pillow: [2.60, 0.83]
- pillow: [3.10, 0.78]
- throw_pillow: [3.55, 0.83]
- throw_blanket: [3.85, 0.50]
- wall_art: [2.85, 0.02] # mounted on north wall
floor_decorations:
- rug: [2.85, 2.80] # 4.56×4.48m
adjacency:
- hallway_0 ↔ living_room_8
- hallway_0 ↔ kitchen_10
...Each furniture line is clickable in the UI — clicking toggles a "PROBLEMATIC" marker on the item. Marked items show up in the diagnose report so the user can flag misplacements for debugging.
Request body (all optional except payload structure):
{
seed?: number; // defaults to random
width?: number; // floor plan width (default 20)
depth?: number; // floor plan depth (default 15)
minRoomSize?: number; // BSP min room dimension (default 3.0)
maxDepth?: number; // BSP recursion depth (default 5)
useOllama?: boolean; // use Ollama LLM classifier
ollamaModel?: string; // Ollama model name (default 'qwen3:0.6b')
outdoor?: boolean; // promote rooms to backyard + patio
useSpine?: boolean; // BSP spine carving (default true in UI)
decorate?: boolean; // add surface + floor decorations
forceTypes?: Record<string, string>; // { roomIndex: roomType }
}Response:
{
seed: number;
floorPlan: {
width: number;
depth: number;
rooms: RoomResult[];
corridors: BSPCorridor[];
adjacency: [number, number][];
};
classification_source: 'ollama' | 'deterministic';
ollama_available: boolean;
timing: {
bsp_ms: number;
classify_ms: number;
furniture_ms: number;
total_ms: number;
};
}Serves the inline HTML page with embedded Three.js client. Authenticates via ?key=, header X-API-Key, or cookie library_api_key.
The page has three regions:
- Left sidebar — controls (seed, dimensions, sliders, checkboxes, generate buttons, camera presets, diagnose, room info panel, legend)
- Center canvas — Three.js 3D viewport
- Right panel — live YAML representation with syntax highlighting
Buttons:
- Generate Floor Plan — runs the pipeline (without decorate)
- Decorate Current Plan — re-fetches with
decorate: truefor current seed - Random (R) — randomizes seed and regenerates
- Top Down / 3D View — camera presets
- Diagnose — opens modal with text report; modal contains Fix Errors + Copy + Close
Checkboxes:
- Use Ollama for classification (off by default; OLLAMA_URL not set)
- Include outdoor rooms (on by default — adds backyard + patio)
- Hallway spine (on by default — uses spine BSP)
Status bar shows room count + door count + window count + decoration count + issue count.
The full pipeline is deterministic given the same input parameters. Sources of randomness:
SeededRandom(LCG) — seeded byconfig.seedfor BSP splits + corridor placements.decorRng(x, z, name)— seeded by item position + name for decoration color variants and rotation jitter.Math.random()— used only byplaceDynamicObjects(people/pets), so dynamic objects vary across calls but everything else is reproducible.
To make dynamic objects deterministic too, replace Math.random() calls in placeDynamicObjects with a SeededRandom instance keyed off the request seed. Pending future change.
Typical timings (deterministic path, 20 m × 15 m, spine + outdoor + decorate):
- BSP: < 1 ms
- Classify: < 1 ms
- Furniture + decoration + dynamic: 1–5 ms
- Total: 1–10 ms
Cloudflare Worker cold start adds ~15 ms. End-to-end response time is typically 20–40 ms on the production edge. The Ollama path adds 1–5 s per LLM call when enabled.
The Three.js scene rebuilds in 50–200 ms for ~150 furniture + 150 decorations. CSS2D labels are the most expensive piece (one DOM element per labeled item).
Roadmap items, in approximate priority order:
A new wall-mounted decoration type wall_shelf that itself acts as a parent for smaller decorations (books, picture_frames, small_vases). Requires extending the parent/child model: a wall-mounted item must be addressable as a parent for surface decorations (with yOffset relative to its wallY instead of a floor-resting parent's height). Touched files: furniture-rules.ts (new D entries + decorate function), compose-2.ts (renderer needs to handle wall-mounted parents).
Currently every house is single-story. A second floor would require:
- BSP for the upstairs footprint (subset of downstairs footprint to leave roof area)
- Stairs as a real furniture type connecting floors
- Y-offset for upper floor rendering
- Two-level YAML structure
A house exterior — pitched or flat roof, exterior wall material (brick, siding), front yard, driveway, garage door visible from outside. This pairs naturally with Option 2 from the outdoor decision (separate house + lot).
Replace primitive boxes with actual GLB files from the asset library. The existing services/db.ts already provides asset enrichment. Each FurnitureItem would gain optional glb_url field, and the renderer would load Draco-compressed GLBs via Three.js GLTFLoader. Style matching (modern / rustic / industrial) becomes meaningful at this point.
People + pets walk between rooms with simple lerp paths. Pathfinding through doorways using the adjacency graph. Requires per-frame state in the client.
Each generation gets a style (modern, rustic, industrial, scandi, etc.) that affects:
- Furniture color palettes (existing
variantsarrays grouped by style) - Decoration choices (more candles for rustic, more electronics for modern)
- Wall + floor material (light wood + white walls for scandi vs dark wood + brick for industrial)
A free-text input ("a 3-bedroom modern home with a home office and 2-car garage") parsed by Ollama to produce structured forceTypes + outdoor + size constraints. The current API accepts these structured inputs; the prompt parser would translate natural language to API params.
Today the bathroom sink doubles as the surface for bathroom decor. Adding a proper vanity or bathroom_counter furniture type would let bathrooms have a wider counter span with sink + mirror + toiletries arranged on it.
Front-door rooms today are whichever room has the widest south frontage — usually a kitchen or hallway. Adding foyer / entry as a recognized type, prioritized for the front door connection, would produce more architectural layouts.
The current BSP splits randomly. A weighted version could bias splits toward producing a target room mix (e.g. "always one large room for living, several medium for bedrooms, a few small for bath/laundry"). This avoids degenerate cases like 19 small bedrooms.
Replace Math.random() calls in placeDynamicObjects with a seeded RNG so people + pets are reproducible across calls with the same seed.
The BSP model is purely orthogonal. Architecturally interesting features (bay windows, angled walls, curved kitchen islands) would require a different geometric primitive — probably a polygon-based room with explicit wall segments, replacing the implicit rectangle model.
Each room could carry plumbing endpoints (sink, toilet positions) and electrical outlet positions. Used by downstream simulators or asset generators to know where to place sockets, light switches, junction boxes.
Each catalog entry currently has one color (with optional variants). Adding texture maps (wood grain, fabric pattern, tile pattern) would dramatically improve visual fidelity without needing real meshes. Three.js supports texture maps natively.
JSON, glTF (with primitive geometry), Houdini HDA, USD, MuJoCo MJCF — each downstream consumer wants a different format. A single export module that emits the chosen format from the canonical RoomResult[] structure.
The seed + parameters fully describe a layout. A shareable URL like /compose-2?seed=42&useSpine=1&decorate=1&outdoor=1 would let users bookmark or share specific houses.
Persist generated houses to D1 with user-editable names, notes, and tags. Build a library of curated houses for reuse / comparison / iteration.
The current Ollama integration is dead code. A real activation would:
- Run on the GPU workstation behind a Cloudflare Tunnel (
ollama.luckyrobots.com) - Use a larger model (qwen3 or similar) with better spatial reasoning
- Be invoked only for novel cases the deterministic classifier struggles with (very large houses, unusual room counts)
- Use structured JSON output mode with tight prompt + post-validation
| Path | Purpose |
|---|---|
worker/src/routes/compose-2.ts |
Route handler, computeOpenings, RoomResult shaping, inline HTML/Three.js client, validation, fix-errors, diagnose, YAML renderer |
worker/src/services/bsp.ts |
generateBSPFloorPlan, generateBSPFloorPlanWithSpine, SeededRandom |
worker/src/services/furniture-rules.ts |
F catalog, WallOccupancy, all place* functions, D catalog, decorateScene, validateRoom, resolveCollisions, placeDynamicObjects |
worker/src/services/ollama.ts |
FurnitureItem interface, classifyRoomsDeterministic, classifyRoomsWithOllama, ROOM_TYPE_COLORS |
worker/src/types.ts |
ROOM_STANDARDS per room type (min/typical/max + required/optional), WALL_HUGGING_ITEMS, CENTER_ITEMS |
worker/src/nav.ts |
Top navigation bar (single 'Compose' link → /compose-2) |
The system is intentionally self-contained — no external services at runtime (apart from optional Ollama). All catalogs, rules, and rendering live in five files. New room types, furniture, and decorations are added by editing those files.
Opus 4.7 review of the above architecture, grounded in file:line evidence. Ordered by priority.
1. Remove Ollama entirely. Doc §4.2 admits the path is dead (OLLAMA_URL unset in prod). The deterministic classifier covers the current scope. Delete:
worker/src/services/ollama.ts(448 lines)useOllama/ollamaModelAPI params- UI checkbox +
classifyRoomsWithOllamacall path - Future Ollama items (§16.18, §16.7) — drop or defer to a new doc
2. Fix dynamic object determinism. §14 flags it. placeDynamicObjects uses Math.random() at 5 callsites (furniture-rules.ts:1030, 1054, 1058, 1068, 1069, 1075, 1081). Replace with a SeededRandom keyed off request seed. Same seed → same house, including people + pets.
3. Extract inline HTML/JS from compose-2.ts. 1414 lines of browser JS + 170 lines of CSS/HTML embedded in the route (compose-2.ts:415-2000). Move to /public/compose-2.html + /public/compose-2-client.js, served from R2 like index.html. Route shrinks to ~400 lines.
4. Split compose-2.ts seams.
computeOpenings()(compose-2.ts:81-225) →worker/src/services/openings.ts. Pure geometry, unrelated to HTTP.runAutoValidation/fixErrors/diagnose(compose-2.ts:1302-1879) → client module (after extraction in #3).
5. Consolidate constraint solvers. validateRoom / resolveCollisions in furniture-rules.ts:1092 is reimplemented inside client fixErrors() (compose-2.ts:1430-1690, 260 lines). Two sources of truth for the same door/wall/collision rules. Server exports validators; new POST /api/compose-2/fix calls them; client only draws markers.
6. Split furniture-rules.ts (1662 lines) into natural modules:
data/furniture-catalog.ts—F(lines 25-91)data/decoration-catalog.ts—D(lines 1172-1252)utils/wall-occupancy.ts—WallOccupancyclass (lines 131-300)strategies/{bedroom,kitchen,living,bathroom,...}.ts— 15 per-room placers (~420-1000)decoration/decorate.ts—decorateSurface/decorateFloor(1300-1650)
Each module < 400 lines, independently testable.
7. Fix type ownership.
FurnitureItemcurrently lives in ollama.ts:19-36 but is used everywhere. Move totypes.ts.FURNITURE_DEFAULTS(ollama.ts:40-87) duplicatesF(furniture-rules.ts:25-91). Single source.WALL_ITEMS/TALL_ITEMS(furniture-rules.ts:104-129) vsWALL_HUGGING_ITEMS/CENTER_ITEMS(types.ts:460-485) — merge.
8. Add post-BSP adjacency validation. Door placement (compose-2.ts:96-169) silently skips rooms that share a wall but aren't in the adjacency list. Add overlap check as safety net + log warning.
9. Test suite. No tests exist for the compose-2 pipeline. Start with BSP determinism, placement bounds, opening positions, collision resolver.
10. API cleanup. ollamaModel becomes obsolete after #1.
High items: ~8 hours. Full cleanup: ~23 hours.
- Remove Ollama (#1) — clears the biggest chunk before other refactors compound.
- SeededRandom fix (#2) — 30-min win.
- Move
FurnitureItem+ unify catalogs (#7) — foundational. - Extract client HTML/JS to R2 static (#3) — biggest readability gain.
- Split
furniture-rules.ts(#6) — needs #7 done first. - Extract
computeOpenings(#4 part a) — independent, can be earlier. - Consolidate validators (#5) — needs #3 + #6 done first.