Files
furniture/CLAUDE.md
T

15 KiB

Furniture Optimizer (hackathon project)

A web tool for venues (events, banquets, conferences) to lay out furniture in a room and, eventually, auto-optimize the layout.

Current scope: Phases 1-5 (UI, scoring, optimizer)

Layout and editing UI. No optimizer yet.

Layout (Phase 1)

  • Left sidebar (palette): one tile per furniture type (built-in chair, table, screen + any custom) and a dashed "Custom" tile. Resizable by dragging its right edge (default 264px, min 152, max 600); tiles live in an auto-fill grid and stretch in width only (84px tall). The sidebar scrolls vertically.
  • Room in the center (fixed 10m x 8m), scaled to fill the space (pxPerM from fitScale, recomputed by a ResizeObserver in App.tsx), with a "1 m" scale bar. No grid, NO snapping: items land exactly where dropped.
  • Drag a tile into the room to create an item (tile stays). Click (<5px movement) a tile to open its type menu (takes over the sidebar; Escape / Back closes): "Available" wheel (min = placed) and, if the type has seats, a seats wheel. Badge = available - placed; at 0 the tile greys out and can't be dragged.
  • Items are free-placed, clamped to the room, no overlap (touching is fine). Red outline while the position is invalid, green when valid; invalid drop snaps back (or cancels a new add).
  • Wheels (components/Wheel.tsx) are iOS-timer style: 1:1 drag, momentum fling, press to stop, spring settle, mouse wheel/trackpad, tap a row, arrow keys, and hover + type a number (Enter, leaving, or 1.5 s idle commits; Escape cancels). Props: step, wrap, rows, disabled, format. If the parent rejects a change the wheel glides back to the real value.

Editing (Phase 2)

  • Click an item to select it (blue outline); click empty floor to deselect (Escape/Done too). Dragging still moves. Selecting opens the item panel in the sidebar (replaces tiles / type menu).
  • Item panel (chairs have no rotation controls; every other type does): a circular angle dial (AngleDial: drag the knob around the ring, Shift snaps to 15°, arrows/scroll nudge 1°, hover + type an angle); Lock/Unlock; Delete (also Delete/Backspace key, ignored while typing in an input); "Seats at this table" wheel for tables (per-item override, "Reset to default" link when overridden); Size wheels (0.3-6 m, 0.1 steps; screens show only Length).
  • Rotation turns around the item's center and collision/wall clamping use the rotated bounding box (boundsOf). A rotate/size change is pushed back inside the walls, then rejected if it would overlap (tryUpdate).
  • Locked items can't be dragged or rotated and show a padlock. (Size, seats and delete are still allowed on locked items.)
  • Delete frees inventory automatically (remaining is derived from items).
  • Screens: while selected, unlocked and unrotated, left/right end handles change their length (resizeLength, min 0.5 m). Thickness is only changed by size wheels, never handles (top-down view).
  • Custom furniture: "Custom" tile opens a form (name, Seating/Table, width, length, seats, available). Saving adds a config + palette tile. Custom seating draws as a chair, custom tables as a table.

Room features (Phase 3)

  • A Layout / Room toggle sits at the top of the sidebar. Switching closes menus and deselects. In Room mode furniture is visible but can't be moved or selected.
  • Room panel: width and length wheels (3-60 m, step 0.1; the room grows/shrinks from its top-left). It can't shrink past the bounding box of placed furniture, obstacles and wall items (minRoomSize; anything else that would clash is rejected via tryResizeRoom and the wheel glides back). Add door / Add screen (max 2) / Add obstacle, Export room, Import room.
  • Walls are drawn inside the canvas (WALL_PX); a door is a gap in the wall plus a quarter-circle swing arc. Doors and wall screens slide along their own wall only (no corner-hopping to adjacent walls). Door panel: width 0.7-4 m, "Loading door" toggle, delete (the last door can't be deleted). Wall-screen panel: width 1-12 m, delete. Obstacles (default 0.6 x 0.6 m, 0.2-10 m) drag anywhere; width/length wheels, delete. Delete/Backspace also deletes the selected room element.
  • Keep-clear zones: the door swing square and the 0.6 m strip inside the opening, the 0.3 m strip under a wall screen, and obstacles. getBlockedRects(room) returns them all and isValidPlacement reads it, so furniture can't overlap them (red outline, drop blocked). Doors/screens can't sit on furniture or obstacles or share wall length; obstacles can't overlap furniture, each other, or door/screen zones.
  • Export downloads room.json (width, height, doors, screens, obstacles; no furniture). Import validates with parseRoom (roomSchema.ts) and shows a clear error; if furniture is placed it asks for confirmation first, and if the placed furniture doesn't fit the imported room it clears all furniture and warns.

Rules and scoring (Phase 4) - measures the layout.

  • All scoring is pure TS in src/scoring/ (no React/Konva): scoreLayout(room, items, configs, rules, weights, {debug}) returns the five sub-scores (0..1 or null = n/a), the 0-100 overall (overallScore, weighted average of the non-null scores by the 0-10 priority weights), issues (text + item ids), rule violations and optional overlay data. Roughly 12 ms for a 10x8 room, 85 ms for 60x60 with 120 items.
  • seats.ts getSeatPoints: N points evenly around a table's perimeter (seatsOverride, else type default); standalone seating = its center, or seats points along its long side; a single chair within 0.8 m of a table edge is that table's seat and isn't counted again.
  • occupancy.ts: internal 0.1 m grid (never drawn, nothing snaps to it) from getBlockedRects + the true rotated furniture, with a chamfer clearance transform and a Dial's-algorithm reachFrom. Cached per variant and rebuilt only when the room or items arrays change. Door keep-clear zones are walkable for pathfinding.
  • loadingScore: paths from loading doors (any door if none is marked) through >= 0.9 m clear width; 0.6 * fraction of items reachable + 0.4 * (1 - normalized avg path length). screenScore: per seat, angle off the screen normal (ideal <= 45 deg), distance (ideal 1.5-6x screen width), obstacles block line of sight (furniture doesn't); null with no screen. accessScore: per table, fraction of perimeter with 0.6 m free clearance reachable from a door; null with no tables. comfortScore: seat spacing (0.9 m), door distance (1.5 m), not flush to obstacles, blended with crowding (> 0.9 seats/m2).
  • Rules mode (third tab): list editor for keepApart, keepNear, screenDistance, doorDistance, againstWall, each with a priority (Required, High 3, Medium 2, Low 1). evaluateRules returns violations (rule id, item ids, meters off); rulesScore = 1 - weighted violated fraction over non-required rules. Required violations outline items in orange and are listed in the score panel but never block dragging.
  • Score panel (top of the sidebar in Layout mode, collapsible): overall number, five labeled bars with priority wheels, required violations, top issues (click to highlight items), and overlay toggles (loading paths, sight lines, table clearance; off by default). Weights and rules live in the store. During a drag the score recomputes at most ~10x/s.
  • Tests: npm test (vitest) covers seat points, empty room, reachable vs walled-off table, blocked line of sight, comfort, rules and the weighted overall.

Optimizer (Phase 5)

  • Simulated annealing in a Web Worker (src/optimizer/worker.ts); pure core in anneal.ts (no React/Konva). State = x, y, rotation of every unlocked item; locked items never move or rotate. Objective = the weighted overall score. Moves: nudge (shrinking with temperature), relocate, rotate 90 (rotatable types only), swap two items of one type, shift a table with the chairs within 0.8 m. Hard constraints (constraints.ts): inside the room, no overlap with furniture, none with getBlockedRects, no Required-rule violations. If the start breaks Required rules, a repair phase (up to 20% of the budget) minimizes the violation count first and the result reports what remains. Geometric cooling with 3 restarts from the best state; seeded RNG (rng.ts, mulberry32) so the same seed + iteration budget gives the same result.
  • Speed: search scores at a 0.2 m occupancy grid (ScoreOpts.res), the final before/after at 0.1 m. About 5,000 steps/s on a 10x8 room (idle machine); no incremental rescoring, every step rescoring everything.
  • Auto-fill (autofill.ts): with "Seats needed" > 0, tables (most seats first) then loose chairs are placed from remaining inventory at random valid spots until seats >= needed; if inventory/space runs out it says so. "Only place remaining inventory" makes only those new items movable.
  • Optimize tab: duration 5-60 s (default 15), seats needed, seed (blank = random), Optimize / Cancel (terminates the worker and discards). While running: progress bar, best score, steps; the canvas shows the best layout (posted every ~300 ms), not draggable. On finish: before/after for the overall and the five sub-scores, locked-items-skipped count, auto-fill message, unresolved Required violations, Apply / Revert. Leaving the tab cancels or drops a pending result.
  • Undo/redo: snapshot history (past[], future[] of items + room) in the store; Apply is one undo step. Add/move/update/remove/room changes are undoable too (rapid repeats on the same item coalesce for 700 ms). Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z (or Ctrl+Y), and Undo/Redo buttons in the Optimize tab.

Out of scope: phone scanning, templates, export to image, non-rectangular rooms, neural networks, incremental rescoring, floor plan image import, phone scanning, placement rules, scoring, optimization. Do not design in a way that blocks the roadmap below.

Tech stack

  • React + TypeScript, Vite
  • react-konva (Konva) for the canvas
  • State: single top-level store (useState/useReducer). Keep it centralized so an optimizer can read and write it later.

Data model

type FurnitureType = string; // config id: "chair" | "table" | "screen" | "custom-1" ...
type Shape = "chair" | "table" | "screen"; // how a type is drawn

interface FurnitureItem {
  id: string;
  type: FurnitureType; // config id
  x: number;        // meters, top-left of the UNROTATED rect, room coordinates
  y: number;
  width: number;    // meters
  height: number;
  rotation: number; // degrees, around the rect's center
  locked: boolean;
  seatsOverride?: number; // tables: overrides config.seatsPerUnit
}

type Wall = "top" | "bottom" | "left" | "right";
// offset = meters from the wall's start (top/bottom start at the left, left/right at the top) to the near edge
interface Door { id: string; wall: Wall; offset: number; width: number; isLoading: boolean; }
interface RoomScreen { id: string; wall: Wall; offset: number; width: number; } // spec calls it "Screen"; renamed to avoid clashing with the screen furniture type / DOM Screen
interface Obstacle { id: string; x: number; y: number; width: number; height: number; }
interface Room { width: number; height: number; doors: Door[]; screens: RoomScreen[]; obstacles: Obstacle[]; } // meters

interface FurnitureTypeConfig {
  id: FurnitureType; name: string; shape: Shape;
  defaultWidth: number; defaultHeight: number; // meters
  isTable: boolean;
  rotatable: boolean;    // chairs: false (no rotation controls)
  available: number;     // inventory count
  seatsPerUnit?: number; // type-level seats
}
// remaining = available - items.filter(i => i.type === type).length   (derived, never stored)
// seats for an item = item.seatsOverride ?? config.seatsPerUnit        (effectiveSeats)

Rules/weights: type Priority = "required"|"high"|"medium"|"low"; Rule { id; kind: keepApart|keepNear|screenDistance|doorDistance|againstWall; priority; typeA; typeB?; min?; max? }; ScoreWeights { loading, screen, access, comfort, rules } (0-10, default 5).

Store (store.ts; setRoom replaces the room, optionally clearing furniture): { room, items, configs: Record<id, config>, typeOrder: id[], rules, weights }. Defaults live in the configs (chair 0.5x0.5, table 1.8x0.8 w/ 8 seats, screen 2x0.2). Chairs and table seats are independent.

Conventions

  • Store everything in meters. Convert to pixels only at render time using the dynamic pxPerM scale (fitScale in geometry.ts).
  • Collision = axis-aligned bounding boxes of the rotated rect (boundsOf). Keep collision and clamping logic in pure functions (no React or Konva imports) so the optimizer can reuse them.
  • Keep geometry/rules logic separate from rendering components.
  • Small, typed, readable code. No new dependencies without a good reason.
  • Keep the UI minimal: light grey app background, room centered in the remaining space.

File layout

  • src/types.ts; src/geometry.ts (pure: boundsOf, clampToRoom, overlaps, isValidPlacement, tryUpdate, resizeLength, fitScale, plus room rules: getBlockedRects, isValidWallItem, isValidObstacle, isLayoutValid, minRoomSize, tryResizeRoom, findWallSpot, findObstacleSpot); src/roomSchema.ts (pure: parseRoom, exportRoom); src/inventory.ts (pure: placedCount, remaining, effectiveSeats); src/store.ts (reducer + built-in configs).
  • src/optimizer/: anneal.ts, autofill.ts, constraints.ts, rng.ts, worker.ts, messages.ts, useOptimizer.ts (React hook that drives the worker), anneal.test.ts.
  • src/scoring/: index.ts (scoreLayout, overallScore), seats.ts, occupancy.ts, loading.ts, screen.ts, access.ts, comfort.ts, rules.ts, geom.ts, types.ts, scoring.test.ts.
  • src/App.tsx: owns the store, selection, sidebar mode, palette drag, room scale, Delete key.
  • src/components/: Palette, TypeMenu, ItemPanel, CustomForm, ModeToggle, RoomPanels, ScorePanel, RulesPanel, OptimizePanel, Wheel, Room (Konva), FurnitureShape, Icons, ScaleBar, panelStyles.

Roadmap (not built yet, don't block)

Venue-focused features planned for later phases:

  • Furniture inventory: set X amount of each furniture type
  • Semi-custom furniture: couches, chairs, tables of any size and seating count
  • Room import
  • Furniture placement rules
  • Locking furniture in place
  • Custom seating limits (per table or global)
  • Optimizers for: room loading/unloading flow, screen visibility, max comfort, max table access
  • Optimization approach idea: user-set rule priorities, possibly a neural net with weights and biases to score "optimal placements" (undecided; start with hard constraints like no overlap, then weighted scoring)
  • Input ideas: phone scanning of a room, manual entry

Commands

npm install
npm run dev      # start dev server
npm run build    # production build
npx tsc --noEmit # type check

Working agreement

  • Ask before expanding scope past Phase 5. Commit and push after each finished change (a teammate works in the same repo): fetch first, rebase if needed. Don't commit dist/ or node_modules/ changes.
  • After changes, make sure the app builds and type checks.
  • Hackathon pace: prefer simple and working over clever.