Skip to content

Instantly share code, notes, and snippets.

@devrim
Last active April 17, 2026 02:53
Show Gist options
  • Select an option

  • Save devrim/f483b9cd42dacbcc835e45b7fb7c7c22 to your computer and use it in GitHub Desktop.

Select an option

Save devrim/f483b9cd42dacbcc835e45b7fb7c7c22 to your computer and use it in GitHub Desktop.
Compose architecture doc — /compose-2 procedural floor plan system

Compose Architecture

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.


Table of Contents

  1. System Overview
  2. End-to-End Pipeline
  3. BSP Floor Plan Generation
  4. Room Classification
  5. Furniture Placement Engine
  6. Door + Window Computation
  7. Decoration System
  8. Dynamic Objects
  9. Validation and Auto-Fix
  10. Three.js Rendering
  11. YAML Output
  12. API Surface
  13. Frontend UI
  14. Determinism and Reproducibility
  15. Performance Characteristics
  16. Future Additions

1. System Overview

                        ┌──────────────────────────────────┐
                        │  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

2. End-to-End Pipeline

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.


3. BSP Floor Plan Generation

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:

3.1 Basic BSP — generateBSPFloorPlan(config)

Algorithm:

  1. Start with a root rectangle (e.g. 20 m × 15 m).
  2. Recursively split each rectangle along its longer axis. Split position is randomized within [minSplitRatio, maxSplitRatio] of the dimension.
  3. Stop splitting when a rectangle would be too small (< 2 × minRoomSize) or recursion depth exceeds maxDepth.
  4. Each leaf becomes a room. Rooms tile the BSP space exactly (no inset — wall thickness is purely a render concern, see §10).
  5. 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')
}

3.2 Hallway Spine — generateBSPFloorPlanWithSpine(config)

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.

3.3 Seeded PRNG

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)
}

4. Room Classification

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.

4.1 Deterministic Classifier (default)

classifyRoomsDeterministic(floorPlan) uses area-based heuristics:

  1. Sort rooms by area descending.
  2. Largest → living_room.
  3. Second largest → bedroom_master (if ≥ 12 m²) else bedroom.
  4. 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, laundry based 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.

4.2 Ollama LLM Classifier (opt-in, currently dead path)

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.

4.3 Forced Types

Three layers can override the classifier output:

  1. BSPRoom.forcedType — used by spine carving to mark hallway rooms.
  2. body.outdoor: true — converts the largest non-essential rooms to backyard + patio.
  3. body.forceTypes: { roomIndex: typeName } — explicit per-room overrides via API.
  4. Auto kids_bedroom — the smallest plain bedroom is automatically promoted to kids_bedroom so houses get toys.

4.4 Room Type Colors

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.


5. Furniture Placement Engine

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.

5.1 Furniture Catalog F

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.

5.2 WallOccupancy Tracker

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).

5.3 Per-Room Placement Strategies

Each room type has a placement function. They follow a common pattern:

  1. Place anchor item on the longest clear wall (e.g. bed in bedroom, sofa in living room, counter in kitchen).
  2. Place companion items relative to the anchor (nightstands flank bed, TV on wall opposite sofa, stove next to counter).
  3. Place secondary items on remaining walls (wardrobe, desk, bookshelf, etc.).
  4. Run validateRoom() to snap items flush + remove anything blocking doors.
  5. 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

5.4 Collision Resolution

resolveCollisions(items, room) runs up to 20 iterations:

  1. For each pair of items, check AABB overlap (using rotation-aware effective dimensions).
  2. If overlapping, push the smaller item along the axis with less overlap.
  3. Clamp the moved item back inside room bounds.
  4. 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.

6. Door + Window Computation

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.

6.1 Doors Between Adjacent Rooms

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.

6.2 wallFlags Deduplication

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.

6.3 Windows on Exterior Walls

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.

6.4 Main Entrance

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.


7. Decoration System

Decorations populate every flat surface and indoor floor with primitive-shape ornaments. Output is purely yaml-driven — no real meshes, just colored boxes.

7.1 Decoration Catalog D

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.

7.2 Variation Helpers

function pickColor(name, parentX, parentZ): string;       // deterministic variant choice
function jitterRotation(name, parentX, parentZ, baseRot): number;  // ±15° rotation jitter for "natural" feel

jitterRotation 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.

7.3 Per-Surface Rules

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

7.4 Floor Decorations

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.

7.5 Wall-Mounted System

Wall-mounted decorations have additional fields:

mountedOnWall: 'north' | 'south' | 'east' | 'west';
wallY: number;  // height above floor

Rather 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.

7.6 Parent References

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.2m

8. Dynamic Objects

placeDynamicObjects(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.


9. Validation and Auto-Fix

Three pieces working together:

9.1 runAutoValidation(data) — Live error markers

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.

9.2 diagnose() — Copyable text report

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.

9.3 fixErrors() — Aggressive five-pass repair

Inside the diagnose modal. Runs five passes:

  1. Snap + OOB — push wall items flush, fix out-of-bounds items by clamping
  2. Door push — slide items along the wall away from any door zone they're blocking
  3. Window push — slide tall items along the wall away from window zones
  4. Iterative collision resolve — 20 iterations of AABB push-apart
  5. 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.


10. Three.js Rendering

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.

10.1 Scene Setup

  • scene — black background (#0d1117)
  • camera — perspective (50° fov), starts at top-down view
  • renderer — antialiased, soft PCF shadows
  • labelRenderer — CSS2DRenderer for HTML text labels above items
  • controls — OrbitControls with damping
  • Lighting: ambient (0.6) + directional (0.8) with shadow mapping

10.2 Per-Room Rendering

For each room:

  1. Floor — colored plane using room.flooring.color (wood / tile / concrete / stone / grass / deck — see §10.5).
  2. WallsrenderWall(room, wallSide) for each of 4 sides. Skips entirely if wallFlags[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).
  3. 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.
  4. Windows — wall split with a translucent blue glass pane; segments above sill and below sill rendered separately.
  5. Furniture — each item rendered as a BoxGeometry. Special cases:
    • Person: capsule (cylinder + sphere head)
    • Dog/cat: rounded box body + sphere head + ears
  6. Decorations — boxes positioned at parent_top + dec.height/2 (using yOffset), or wallY + height/2 for wall-mounted.
  7. LabelsCSS2DObject HTML labels above each item. Color/style varies by category (cyan furniture, gold dynamic, green decoration).

10.3 Wall Rendering Detail

renderWall(room, side) is the most complex piece. It:

  1. Returns immediately if the wall is shared with a lower-index neighbor (wallFlags).
  2. Uses a low height (0.6 m) + railing material if the wall is outdoor + exterior.
  3. Sorts openings on this wall by position.
  4. Walks left-to-right, emitting solid wall segments between openings.
  5. For each door opening: emits a lintel above + door frame + door panel (only if renderPanel: true).
  6. For each window opening: emits sill segment below + lintel above + glass pane in middle.
  7. Emits trailing solid segment after the last opening.

Door panels are tracked in doorPivots[] so they can animate on click.

10.4 Click Interaction

raycaster checks every click:

  1. First test door panels — clicked door toggles open/closed (90° rotation, animated via lerp in animate()).
  2. Else test floor planes — clicked room shows its details panel (room info sidebar).

10.5 Flooring Materials

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.

10.6 Error Markers

runAutoValidation() adds red wireframe boxes + warning text labels (OVERLAP, WALL FAR, BLOCKS DOOR, BLOCKS WINDOW, OUT OF BOUNDS) around problem items in the scene.

10.7 Camera Presets

  • Top Down — orthographic-like overhead view, framed to entire floor plan
  • 3D View — perspective 45° angle from corner

11. YAML Output

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.


12. API Surface

POST /api/compose-2/generate

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;
  };
}

GET /compose-2

Serves the inline HTML page with embedded Three.js client. Authenticates via ?key=, header X-API-Key, or cookie library_api_key.


13. Frontend UI

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: true for 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.


14. Determinism and Reproducibility

The full pipeline is deterministic given the same input parameters. Sources of randomness:

  1. SeededRandom (LCG) — seeded by config.seed for BSP splits + corridor placements.
  2. decorRng(x, z, name) — seeded by item position + name for decoration color variants and rotation jitter.
  3. Math.random() — used only by placeDynamicObjects (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.


15. Performance Characteristics

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).


16. Future Additions

Roadmap items, in approximate priority order:

16.1 Wall-mounted shelves with sub-items

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).

16.2 Multi-floor houses

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

16.3 Exterior shell + roof

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).

16.4 Real-asset matching

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.

16.5 Animation for dynamic objects

People + pets walk between rooms with simple lerp paths. Pathfinding through doorways using the adjacency graph. Requires per-frame state in the client.

16.6 Style profiles

Each generation gets a style (modern, rustic, industrial, scandi, etc.) that affects:

  • Furniture color palettes (existing variants arrays 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)

16.7 Custom prompts

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.

16.8 Bathroom counter as first-class furniture

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.

16.9 Entry foyer as a room type

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.

16.10 BSP weighting for room variety

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.

16.11 Determinism for dynamic objects

Replace Math.random() calls in placeDynamicObjects with a seeded RNG so people + pets are reproducible across calls with the same seed.

16.12 Diagonal walls and non-rectangular rooms

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.

16.13 Plumbing + electrical as data layers

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.

16.14 Material variation

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.

16.15 Output formats beyond YAML

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.

16.16 Sharing + permalinks

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.

16.17 Saved scenes + named houses

Persist generated houses to D1 with user-editable names, notes, and tags. Build a library of curated houses for reuse / comparison / iteration.

16.18 Smart Ollama prompts

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

Critical Files Reference

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.


Revision Request — Refactor Plan (2026-04-17)

Opus 4.7 review of the above architecture, grounded in file:line evidence. Ordered by priority.

High 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 / ollamaModel API params
  • UI checkbox + classifyRoomsWithOllama call 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.

Medium priority

6. Split furniture-rules.ts (1662 lines) into natural modules:

  • data/furniture-catalog.tsF (lines 25-91)
  • data/decoration-catalog.tsD (lines 1172-1252)
  • utils/wall-occupancy.tsWallOccupancy class (lines 131-300)
  • strategies/{bedroom,kitchen,living,bathroom,...}.ts — 15 per-room placers (~420-1000)
  • decoration/decorate.tsdecorateSurface / decorateFloor (1300-1650)

Each module < 400 lines, independently testable.

7. Fix type ownership.

  • FurnitureItem currently lives in ollama.ts:19-36 but is used everywhere. Move to types.ts.
  • FURNITURE_DEFAULTS (ollama.ts:40-87) duplicates F (furniture-rules.ts:25-91). Single source.
  • WALL_ITEMS / TALL_ITEMS (furniture-rules.ts:104-129) vs WALL_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.

Low priority

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.

Estimated effort

High items: ~8 hours. Full cleanup: ~23 hours.

Shipping order

  1. Remove Ollama (#1) — clears the biggest chunk before other refactors compound.
  2. SeededRandom fix (#2) — 30-min win.
  3. Move FurnitureItem + unify catalogs (#7) — foundational.
  4. Extract client HTML/JS to R2 static (#3) — biggest readability gain.
  5. Split furniture-rules.ts (#6) — needs #7 done first.
  6. Extract computeOpenings (#4 part a) — independent, can be earlier.
  7. Consolidate validators (#5) — needs #3 + #6 done first.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment