diff --git a/CLAUDE.md b/CLAUDE.md index 1f36452..e169c5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ 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-4 (UI + scoring, no optimizer) +## Current scope: Phases 1-5 (UI, scoring, optimizer) Layout and editing UI. No optimizer yet. @@ -29,7 +29,7 @@ Layout and editing UI. No optimizer yet. - 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 only; there is NO optimizer and nothing moves automatically. +**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. @@ -38,7 +38,14 @@ Layout and editing UI. No optimizer yet. - 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. -Out of scope: the optimizer, auto-moving anything, non-rectangular rooms, floor plan image import, phone scanning, placement rules, scoring, optimization. Do not design in a way that blocks the roadmap below. +**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 @@ -98,9 +105,10 @@ Store (`store.ts`; `setRoom` replaces the room, optionally clearing furniture): ## 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`, `Wheel`, `Room` (Konva), `FurnitureShape`, `Icons`, `ScaleBar`, `panelStyles`. +- `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) @@ -127,6 +135,6 @@ npx tsc --noEmit # type check ## Working agreement -- Ask before expanding scope past Phase 4. 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. +- 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. \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index d3b0fb2..f9b0dab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,8 @@ import { Palette } from "./components/Palette"; import { Room, WALL_PX, type Mode, type Preview, type RoomSel } from "./components/Room"; import { ModeToggle } from "./components/ModeToggle"; import { ScorePanel, type OverlayToggles } from "./components/ScorePanel"; +import { OptimizePanel, type OptimizeFormValues } from "./components/OptimizePanel"; +import { useOptimizer } from "./optimizer/useOptimizer"; import { RulesPanel } from "./components/RulesPanel"; import { DoorPanel, ObstaclePanel, RoomPanel, ScreenPanel } from "./components/RoomPanels"; import { ScaleBar } from "./components/ScaleBar"; @@ -63,6 +65,8 @@ export default function App() { const previewTimer = useRef(0); const lastPreviewAt = useRef(0); const latestPreview = useRef<{ id: string; x: number; y: number } | null>(null); + const opt = useOptimizer(); + const [optForm, setOptForm] = useState({ durationS: 15, seatsNeeded: 0, seed: "", onlyNew: false }); const nextItemId = useRef(1); const nextCustomId = useRef(1); @@ -82,6 +86,7 @@ export default function App() { // Switching modes closes menus and clears every selection. const switchMode = (m: Mode) => { + if (m !== "optimize") opt.cancel(); // leaving Optimize drops a running search or a pending result setMode(m); setSelectedId(null); setRoomSel(null); @@ -119,6 +124,26 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, deleteSelected, roomSel, state.room]); + // Cmd/Ctrl+Z undo, Shift+Cmd/Ctrl+Z (or Ctrl+Y) redo + useEffect(() => { + const key = (e: KeyboardEvent) => { + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + const k = e.key.toLowerCase(); + if (k === "z" || k === "y") { + e.preventDefault(); + if (opt.state.status === "running") return; + dispatch({ kind: k === "y" || e.shiftKey ? "redo" : "undo" }); + setSelectedId(null); + setRoomSel(null); + } + }; + window.addEventListener("keydown", key); + return () => window.removeEventListener("keydown", key); + }, [opt.state.status]); + // Maximise the room inside the center area (minus wall border and the scale bar). useEffect(() => { const el = mainRef.current; @@ -353,9 +378,11 @@ export default function App() { } }, []); + // In Optimize mode the canvas shows the optimizer's best-so-far / result instead of the real layout. + const shownItems = mode === "optimize" && opt.state.items ? opt.state.items : state.items; const scoreItems = useMemo( - () => (dragPreview ? state.items.map((i) => (i.id === dragPreview.id ? { ...i, x: dragPreview.x, y: dragPreview.y } : i)) : state.items), - [state.items, dragPreview], + () => (dragPreview ? shownItems.map((i) => (i.id === dragPreview.id ? { ...i, x: dragPreview.x, y: dragPreview.y } : i)) : shownItems), + [shownItems, dragPreview], ); const wantDebug = mode === "layout" && (overlayToggles.loading || overlayToggles.sight || overlayToggles.clearance); const layoutScore = useMemo( @@ -415,6 +442,39 @@ export default function App() { onExport={exportRoomFile} onImport={importRoomFile} /> ); } + } else if (mode === "optimize") { + panel = ( + setOptForm((f) => ({ ...f, ...p }))} + state={opt.state} + lockedCount={state.items.filter((i) => i.locked).length} + canUndo={state.past.length > 0} + canRedo={state.future.length > 0} + onStart={() => + opt.start({ + room: state.room, + items: state.items, + configs: state.configs, + typeOrder: state.typeOrder, + rules: state.rules, + weights: state.weights, + seed: optForm.seed === "" ? null : Number(optForm.seed), + durationS: optForm.durationS, + seatsNeeded: optForm.seatsNeeded, + onlyNew: optForm.onlyNew, + }) + } + onCancel={opt.cancel} + onApply={() => { + if (opt.state.result) dispatch({ kind: "applyLayout", items: opt.state.result.items }); + opt.clear(); + }} + onRevert={opt.clear} + onUndo={() => dispatch({ kind: "undo" })} + onRedo={() => dispatch({ kind: "redo" })} + /> + ); } else if (mode === "rules") { panel = ( patchObstacle(id, { x, y })} pxPerM={pxPerM} room={state.room} - items={state.items} + items={shownItems} configs={state.configs} preview={preview} floorRef={floorRef} diff --git a/src/components/ModeToggle.tsx b/src/components/ModeToggle.tsx index 12868e0..2a4706c 100644 --- a/src/components/ModeToggle.tsx +++ b/src/components/ModeToggle.tsx @@ -2,7 +2,6 @@ import type { Mode } from "./Room"; export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) { const seg = (m: Mode): React.CSSProperties => ({ - flex: 1, height: 30, border: "none", borderRadius: 6, @@ -13,7 +12,7 @@ export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) boxShadow: mode === m ? "0 1px 3px rgba(0,0,0,0.15)" : "none", }); return ( -
+
@@ -23,6 +22,9 @@ export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) +
); } diff --git a/src/components/OptimizePanel.tsx b/src/components/OptimizePanel.tsx new file mode 100644 index 0000000..8e7f64a --- /dev/null +++ b/src/components/OptimizePanel.tsx @@ -0,0 +1,160 @@ +import type { OptimizerState } from "../optimizer/useOptimizer"; +import type { ScoreKey } from "../scoring"; +import { Wheel } from "./Wheel"; +import { btn, hint, label, panelRoot, title, twoCol } from "./panelStyles"; + +export interface OptimizeFormValues { + durationS: number; + seatsNeeded: number; + seed: string; // blank = random + onlyNew: boolean; +} + +interface Props { + values: OptimizeFormValues; + onChange: (patch: Partial) => void; + state: OptimizerState; + lockedCount: number; + canUndo: boolean; + canRedo: boolean; + onStart: () => void; + onCancel: () => void; + onApply: () => void; + onRevert: () => void; + onUndo: () => void; + onRedo: () => void; +} + +const ROWS: [ScoreKey, string][] = [ + ["loading", "Loading"], + ["screen", "Screen"], + ["access", "Access"], + ["comfort", "Comfort"], + ["rules", "Rules"], +]; + +const pct = (v: number | null) => (v === null ? "n/a" : String(Math.round(v * 100))); +const num = (v: number | null) => (v === null ? "n/a" : String(Math.round(v))); + +export function OptimizePanel({ values, onChange, state, lockedCount, canUndo, canRedo, onStart, onCancel, onApply, onRevert, onUndo, onRedo }: Props) { + const running = state.status === "running"; + const done = state.status === "done" ? state.result : null; + const locked = lockedCount > 0 ? `${lockedCount} locked item${lockedCount > 1 ? "s" : ""} will not move.` : ""; + + const delta = (before: number | null, after: number | null, scale: number) => { + if (before === null || after === null) return { text: "", color: "#888" }; + const d = (after - before) * scale; + return { text: `${d >= 0 ? "+" : ""}${Math.round(d)}`, color: d > 0.5 ? "#2f9e44" : d < -0.5 ? "#e03131" : "#888" }; + }; + + return ( +
+
Optimize
+ +
+
+
Duration (s)
+ onChange({ durationS: v })} /> +
+
+
Seats needed
+ (v === 0 ? "off" : String(v))} onChange={(v) => onChange({ seatsNeeded: v })} /> +
+
+ +
+
Seed
+ onChange({ seed: e.target.value.replace(/[^0-9]/g, "").slice(0, 9) })} + style={{ width: "100%", boxSizing: "border-box", height: 30, padding: "0 10px", border: "1px solid #c4c4c4", borderRadius: 8, font: "13px system-ui, sans-serif" }} + /> +
+ + + {locked &&
{locked}
} + + {running ? ( + <> +
+
+
+
+
+ Best score: {state.best === null ? "n/a" : state.best.toFixed(1)} + {state.iterations.toLocaleString()} steps +
+
+ + + ) : ( + + )} + + {done && ( +
+
+ + before + after + + Overall + {num(done.before.overallWeighted)} + {num(done.after.overallWeighted)} + {delta(done.before.overallWeighted, done.after.overallWeighted, 1).text} + {ROWS.map(([k, name]) => ( + + ))} +
+ {done.autofill &&
{done.autofill.message}
} + {done.remainingViolations > 0 && ( +
+ {done.remainingViolations} Required rule violation{done.remainingViolations > 1 ? "s" : ""} could not be fixed. +
+ )} +
+ {done.skippedLocked} locked item{done.skippedLocked === 1 ? "" : "s"} skipped · seed {done.seed} · {done.iterations.toLocaleString()} steps +
+
+ + +
+
+ )} + +
+ + +
+
+ ); +} + +function Row({ name, before, after, d }: { name: string; before: number | null; after: number | null; d: { text: string; color: string } }) { + return ( + <> + {name} + {pct(before)} + {pct(after)} + {d.text} + + ); +} diff --git a/src/components/Room.tsx b/src/components/Room.tsx index a642f40..3c28ef2 100644 --- a/src/components/Room.tsx +++ b/src/components/Room.tsx @@ -27,7 +27,7 @@ export interface Preview { } export const WALL_PX = 6; // drawn wall thickness around the floor -export type Mode = "layout" | "room" | "rules"; +export type Mode = "layout" | "room" | "rules" | "optimize"; export type RoomSel = { kind: "door" | "screen" | "obstacle"; id: string }; export interface Overlays { diff --git a/src/optimizer/anneal.test.ts b/src/optimizer/anneal.test.ts new file mode 100644 index 0000000..72f2550 --- /dev/null +++ b/src/optimizer/anneal.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { boundsOf, DEFAULT_ROOM, getBlockedRects, isInsideRoom, overlaps } from "../geometry"; +import { remaining } from "../inventory"; +import { evaluateRules } from "../scoring"; +import { initialState } from "../store"; +import type { FurnitureItem, Room, Rule } from "../types"; +import { anneal, type AnnealInput } from "./anneal"; +import { autoFill } from "./autofill"; +import { createRng } from "./rng"; + +const configs = initialState.configs; +const weights = initialState.weights; + +const item = (id: string, type: string, x: number, y: number, extra: Partial = {}): FurnitureItem => { + const c = configs[type]; + return { id, type, x, y, width: c.defaultWidth, height: c.defaultHeight, rotation: 0, locked: false, ...extra }; +}; + +const room: Room = { + ...DEFAULT_ROOM, + screens: [{ id: "s", wall: "top", offset: 3, width: 4 }], + obstacles: [{ id: "o", x: 6, y: 3, width: 1, height: 1 }], +}; + +const layout = (): FurnitureItem[] => [ + item("t1", "table", 1, 2), + item("t2", "table", 3, 5), + item("t3", "table", 7, 5, { locked: true }), + item("c1", "chair", 1.2, 3.1), + item("c2", "chair", 8, 1), + item("c3", "chair", 5, 2), +]; + +const run = (over: Partial = {}, iterations = 1500) => + anneal({ room, items: layout(), configs, rules: [], weights, seed: 7, ...over }, { iterations }); + +const assertValid = (r: Room, items: FurnitureItem[]) => { + const blocked = getBlockedRects(r); + items.forEach((a, i) => { + const b = boundsOf(a); + expect(isInsideRoom(b, r)).toBe(true); + blocked.forEach((z) => expect(overlaps(b, z)).toBe(false)); + items.slice(i + 1).forEach((o) => expect(overlaps(b, boundsOf(o))).toBe(false)); + }); +}; + +describe("anneal", () => { + it("never moves or rotates locked items", () => { + const res = run(); + const before = layout().find((i) => i.id === "t3")!; + expect(res.items.find((i) => i.id === "t3")).toEqual(before); + expect(res.skippedLocked).toBe(1); + }); + + it("never returns an overlapping, out-of-room or blocked layout", () => { + for (const seed of [1, 2, 3]) assertValid(room, run({ seed }).items); + }); + + it("keeps Required rules unbroken when the start is valid", () => { + const rules: Rule[] = [{ id: "r", kind: "againstWall", priority: "required", typeA: "chair" }]; + const start = layout().map((i) => (i.type === "chair" ? { ...i, x: 0, y: i.y } : i)); // chairs on the left wall + expect(evaluateRules(room, start, configs, rules).violations).toHaveLength(0); + const res = run({ items: start, rules }); + expect(evaluateRules(room, res.items, configs, rules).violations).toHaveLength(0); + expect(res.remainingViolations).toBe(0); + }); + + it("repairs a start that breaks a Required rule", () => { + const rules: Rule[] = [{ id: "r", kind: "againstWall", priority: "required", typeA: "chair" }]; + const startViolations = evaluateRules(room, layout(), configs, rules).violations.length; + expect(startViolations).toBeGreaterThan(0); + const res = run({ rules }, 4000); + expect(res.remainingViolations).toBeLessThan(startViolations); + }); + + it("gives the same result for the same seed", () => { + expect(run({ seed: 42 })).toEqual(run({ seed: 42 })); + expect(run({ seed: 42 }).items).not.toEqual(run({ seed: 43 }).items); + }); + + it("never ends worse than it started", () => { + for (const seed of [1, 2, 3, 4]) { + const r = run({ seed }); + expect(r.bestScore).toBeGreaterThanOrEqual(r.startScore - 1e-9); + } + }); + + it("only moves the allowed items", () => { + const res = run({ movableIds: new Set(["c2"]) }); + const start = layout(); + res.items.filter((i) => i.id !== "c2").forEach((i) => expect(i).toEqual(start.find((s) => s.id === i.id))); + }); + + it("runs thousands of iterations per second on a typical room", () => { + // ~5,000/s on an idle machine; best of three and a 1,000/s floor so a busy machine doesn't fail the test + let best = 0; + for (let k = 0; k < 3; k++) { + const t0 = performance.now(); + const res = anneal({ room, items: layout(), configs, rules: [], weights, seed: 1 }, { iterations: 2000 }); + best = Math.max(best, (res.iterations / (performance.now() - t0)) * 1000); + } + console.log(`iterations/s: ${Math.round(best)}`); + expect(best).toBeGreaterThan(1000); + }); +}); + +describe("autoFill", () => { + it("adds tables first, then chairs, from remaining inventory", () => { + const f = autoFill(room, [], configs, initialState.typeOrder, [], 20, createRng(1)); + expect(f.tablesAdded).toBeGreaterThan(0); + expect(f.seats).toBeGreaterThanOrEqual(20); + assertValid(room, f.items); + expect(remaining(f.items, "table", configs.table.available)).toBeGreaterThanOrEqual(0); + }); + + it("uses everything it can and reports a shortfall", () => { + const f = autoFill(room, [], configs, initialState.typeOrder, [], 500, createRng(1)); + expect(f.shortfall).toBeGreaterThan(0); + expect(f.message).toMatch(/Only/); + expect(remaining(f.items, "table", configs.table.available)).toBeGreaterThanOrEqual(0); + expect(remaining(f.items, "chair", configs.chair.available)).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/src/optimizer/anneal.ts b/src/optimizer/anneal.ts new file mode 100644 index 0000000..1a475f2 --- /dev/null +++ b/src/optimizer/anneal.ts @@ -0,0 +1,263 @@ +// Simulated annealing over item positions and rotations. Pure TS: no React, Konva or DOM. +import { boundsOf, clampToRoom } from "../geometry"; +import { distToShape, rectCenter } from "../scoring/geom"; +import { overallScore, scoreLayout } from "../scoring"; +import type { FurnitureItem, FurnitureTypeConfig, Room, Rule, ScoreWeights } from "../types"; +import { makeConstraints, placementOk, type Constraints } from "./constraints"; +import { createRng, type Rng } from "./rng"; + +export interface AnnealInput { + room: Room; + items: FurnitureItem[]; + configs: Record; + rules: Rule[]; + weights: ScoreWeights; + seed: number; + movableIds?: ReadonlySet; // only these may move (default: every unlocked item) + res?: number; // occupancy resolution while searching (default 0.2 m) +} + +export interface Budget { + iterations?: number; // deterministic budget (tests) + ms?: number; // wall-clock budget (the worker) +} + +export interface Progress { + items: FurnitureItem[]; // best layout so far + score: number; // best overall (0-100) + fraction: number; // 0..1 of the budget used + iterations: number; +} + +export interface AnnealResult { + items: FurnitureItem[]; + startScore: number; + bestScore: number; + iterations: number; + remainingViolations: number; // Required-rule violations left in the result + skippedLocked: number; +} + +export interface Hooks { + onProgress?: (p: Progress) => void; + progressEveryMs?: number; // default 300 + now?: () => number; + shouldStop?: () => boolean; +} + +const SEARCH_RES = 0.2; +const T_START = 4; // score points; accepts small drops early +const T_END = 0.05; +const RESTARTS = 3; +const REPAIR_SHARE = 0.2; // max share of the budget for the repair phase +const CHAIR_ATTACH_M = 0.8; // chairs this close to a table edge move with it + +interface Evaluation { + score: number; + required: number; // Required-rule violations +} + +export function anneal(input: AnnealInput, budget: Budget, hooks: Hooks = {}): AnnealResult { + const { room, configs, rules, weights } = input; + const res = input.res ?? SEARCH_RES; + const rng = createRng(input.seed); + const cons = makeConstraints(room); + const now = hooks.now ?? (() => Date.now()); + + const movable = (it: FurnitureItem) => !it.locked && (input.movableIds ? input.movableIds.has(it.id) : true); + const skippedLocked = input.items.filter((i) => i.locked).length; + + const evaluate = (items: FurnitureItem[]): Evaluation => { + const r = scoreLayout(room, items, configs, rules, weights, { res }); + return { score: overallScore(r.scores, weights) ?? 0, required: r.violations.filter((v) => v.priority === "required").length }; + }; + + let items = input.items; + let cur = evaluate(items); + const start = cur; + let best = { items, ...cur }; + + const startedAt = now(); + let iterations = 0; + let lastProgress = startedAt; + const fractionOf = () => { + const byIter = budget.iterations ? iterations / budget.iterations : 0; + const byTime = budget.ms ? (now() - startedAt) / budget.ms : 0; + return Math.min(1, Math.max(byIter, byTime)); + }; + const report = (fraction: number) => hooks.onProgress?.({ items: best.items, score: best.score, fraction, iterations }); + + const anyMovable = items.some(movable); + if (!anyMovable) { + report(1); + return { items, startScore: start.score, bestScore: start.score, iterations: 0, remainingViolations: start.required, skippedLocked }; + } + + const hooksCheck = (fraction: number): boolean => { + const t = now(); + if (t - lastProgress >= (hooks.progressEveryMs ?? 300)) { + lastProgress = t; + report(fraction); + } + return fraction >= 1 || !!hooks.shouldStop?.(); + }; + + /** One random move; returns the new items array, or null if the move wasn't possible or isn't valid. */ + const propose = (s: number): FurnitureItem[] | null => { + const mov = items.filter(movable); + const roll = rng.next(); + if (roll < 0.4) return nudge(items, mov, cons, rng, s); + if (roll < 0.55) return relocate(items, mov, cons, rng); + if (roll < 0.7) return rotate(items, mov, configs, cons, rng); + if (roll < 0.8) return swap(items, mov, cons, rng); + return groupShift(items, mov, configs, cons, rng, s); + }; + + // ---- Phase 1: repair. If the start breaks Required rules, minimize how many it breaks. ---- + let repairEnd = 0; + if (cur.required > 0) { + repairEnd = REPAIR_SHARE; + for (;;) { + const f = fractionOf(); + if (cur.required === 0 || f >= repairEnd || hooks.shouldStop?.()) break; + iterations++; + const next = propose(0.5); + if (next) { + const ev = evaluate(next); + if (ev.required <= cur.required) { + items = next; + cur = ev; + if (ev.required < best.required || (ev.required === best.required && ev.score > best.score)) best = { items, ...ev }; + } + } + if (iterations % 16 === 0) hooksCheck(f); + } + items = best.items; + cur = { score: best.score, required: best.required }; + } + + // ---- Phase 2: anneal, Required rules as hard constraints, restarting from the best state ---- + const limitRequired = cur.required; // never get worse than where repair left us + for (let k = 0; k < RESTARTS; k++) { + items = best.items; + cur = { score: best.score, required: best.required }; + const t0 = T_START * Math.pow(0.5, k); + const segStart = repairEnd + ((1 - repairEnd) * k) / RESTARTS; + const segEnd = repairEnd + ((1 - repairEnd) * (k + 1)) / RESTARTS; + for (;;) { + const f = fractionOf(); + if (f >= segEnd || hooks.shouldStop?.()) break; + const s = Math.min(1, Math.max(0, (f - segStart) / (segEnd - segStart))); // progress inside this restart + iterations++; + const next = propose(s); + if (next) { + const ev = evaluate(next); + if (ev.required <= limitRequired) { + const T = t0 * Math.pow(T_END / t0, s); + const d = ev.score - cur.score; + if (d >= 0 || rng.next() < Math.exp(d / T)) { + items = next; + cur = ev; + if (ev.score > best.score) best = { items, ...ev }; + } + } + } + if (iterations % 16 === 0 && hooksCheck(fractionOf())) break; + } + if (hooks.shouldStop?.()) break; + } + + report(1); + return { + items: best.items, + startScore: start.score, + bestScore: best.score, + iterations, + remainingViolations: best.required, + skippedLocked, + }; +} + +// ---------------- moves ---------------- + +const replace = (items: FurnitureItem[], changed: FurnitureItem[]): FurnitureItem[] => { + const byId = new Map(changed.map((c) => [c.id, c])); + return items.map((i) => byId.get(i.id) ?? i); +}; + +/** Move an item's center by (dx, dy). */ +const shifted = (it: FurnitureItem, dx: number, dy: number): FurnitureItem => ({ ...it, x: it.x + dx, y: it.y + dy }); + +function nudge(items: FurnitureItem[], mov: FurnitureItem[], cons: Constraints, rng: Rng, s: number): FurnitureItem[] | null { + const it = mov[rng.int(mov.length)]; + const sigma = 1.2 * Math.pow(0.05 / 1.2, s); // 1.2 m early, 5 cm late + const moved = clampToRoom(shifted(it, rng.gauss() * sigma, rng.gauss() * sigma), cons.room); + return placementOk(cons, items, [moved]) ? replace(items, [moved]) : null; +} + +function relocate(items: FurnitureItem[], mov: FurnitureItem[], cons: Constraints, rng: Rng): FurnitureItem[] | null { + const it = mov[rng.int(mov.length)]; + const b = boundsOf(it); + const hx = b.width / 2; + const hy = b.height / 2; + if (cons.room.width < b.width || cons.room.height < b.height) return null; + for (let tries = 0; tries < 8; tries++) { + const cx = rng.range(hx, cons.room.width - hx); + const cy = rng.range(hy, cons.room.height - hy); + const moved = { ...it, x: cx - it.width / 2, y: cy - it.height / 2 }; + if (placementOk(cons, items, [moved])) return replace(items, [moved]); + } + return null; +} + +function rotate(items: FurnitureItem[], mov: FurnitureItem[], configs: Record, cons: Constraints, rng: Rng): FurnitureItem[] | null { + const can = mov.filter((i) => configs[i.type]?.rotatable); + if (can.length === 0) return null; + const it = can[rng.int(can.length)]; + const moved = clampToRoom({ ...it, rotation: (it.rotation + 90) % 360 }, cons.room); + return placementOk(cons, items, [moved]) ? replace(items, [moved]) : null; +} + +function swap(items: FurnitureItem[], mov: FurnitureItem[], cons: Constraints, rng: Rng): FurnitureItem[] | null { + const a = mov[rng.int(mov.length)]; + const same = mov.filter((i) => i.id !== a.id && i.type === a.type); + if (same.length === 0) return null; + const b = same[rng.int(same.length)]; + const ca = rectCenter(a); + const cb = rectCenter(b); + const a2 = { ...a, x: cb.x - a.width / 2, y: cb.y - a.height / 2 }; + const b2 = { ...b, x: ca.x - b.width / 2, y: ca.y - b.height / 2 }; + return placementOk(cons, items, [a2, b2]) ? replace(items, [a2, b2]) : null; +} + +/** Move a table together with the movable chairs pulled up to it. */ +function groupShift( + items: FurnitureItem[], + mov: FurnitureItem[], + configs: Record, + cons: Constraints, + rng: Rng, + s: number, +): FurnitureItem[] | null { + const tables = mov.filter((i) => configs[i.type]?.isTable); + if (tables.length === 0) return null; + const t = tables[rng.int(tables.length)]; + const chairs = mov.filter((i) => { + const c = configs[i.type]; + return c && c.shape === "chair" && !c.isTable && (c.seatsPerUnit ?? 1) <= 1 && distToShape(t, rectCenter(i)) <= CHAIR_ATTACH_M; + }); + const sigma = 1.5 * Math.pow(0.05 / 1.5, s); + let dx = rng.gauss() * sigma; + let dy = rng.gauss() * sigma; + // keep the whole group inside the walls by shifting the delta, not each piece separately + const group = [t, ...chairs]; + for (const g of group) { + const b = boundsOf(shifted(g, dx, dy)); + if (b.x < 0) dx -= b.x; + if (b.y < 0) dy -= b.y; + if (b.x + b.width > cons.room.width) dx -= b.x + b.width - cons.room.width; + if (b.y + b.height > cons.room.height) dy -= b.y + b.height - cons.room.height; + } + const moved = group.map((g) => shifted(g, dx, dy)); + return placementOk(cons, items, moved) ? replace(items, moved) : null; +} diff --git a/src/optimizer/autofill.ts b/src/optimizer/autofill.ts new file mode 100644 index 0000000..0f26f82 --- /dev/null +++ b/src/optimizer/autofill.ts @@ -0,0 +1,103 @@ +// Auto-fill: place tables, then loose chairs, from remaining inventory until enough seats exist. +import { boundsOf, clampToRoom } from "../geometry"; +import { remaining } from "../inventory"; +import { distToShape, rectCenter } from "../scoring/geom"; +import { evaluateRules } from "../scoring/rules"; +import { getSeatPoints, TABLE_SEAT_RADIUS_M } from "../scoring/seats"; +import type { FurnitureItem, FurnitureTypeConfig, Room, Rule } from "../types"; +import { makeConstraints, placementOk } from "./constraints"; +import type { Rng } from "./rng"; + +export interface AutoFillResult { + items: FurnitureItem[]; // original items plus the new ones + addedIds: string[]; + tablesAdded: number; + chairsAdded: number; + seats: number; // seats after filling + shortfall: number; // seats still missing (inventory or space ran out) + message: string; +} + +const TRIES_PER_ITEM = 300; + +export function autoFill( + room: Room, + items: FurnitureItem[], + configs: Record, + typeOrder: string[], + rules: Rule[], + seatsNeeded: number, + rng: Rng, +): AutoFillResult { + const cons = makeConstraints(room); + const required = rules.filter((r) => r.priority === "required"); + const breaks = (list: FurnitureItem[]) => (required.length ? evaluateRules(room, list, configs, required).violations.length : 0); + + let all = items.slice(); + const addedIds: string[] = []; + let n = 0; + const seatCount = () => getSeatPoints(all, configs).length; + + /** Put one new item at a random valid spot (not breaking Required rules it didn't already break). */ + const place = (cfg: FurnitureTypeConfig, avoidTables: boolean): boolean => { + const baseBreaks = breaks(all); + for (let t = 0; t < TRIES_PER_ITEM; t++) { + const rotation = cfg.rotatable && rng.next() < 0.5 ? 90 : 0; + let id = `auto-${all.length}-${n}`; + while (all.some((o) => o.id === id)) id += "x"; + const probe: FurnitureItem = { + id, + type: cfg.id, + x: 0, + y: 0, + width: cfg.defaultWidth, + height: cfg.defaultHeight, + rotation, + locked: false, + }; + const b = boundsOf(probe); + if (b.width > room.width || b.height > room.height) return false; + const cx = rng.range(b.width / 2, room.width - b.width / 2); + const cy = rng.range(b.height / 2, room.height - b.height / 2); + const it = clampToRoom({ ...probe, x: cx - probe.width / 2, y: cy - probe.height / 2 }, room); + if (!placementOk(cons, all, [it])) continue; + // a loose chair must sit away from tables, or it would just count as a table seat + if (avoidTables && all.some((o) => configs[o.type]?.isTable && distToShape(o, rectCenter(it)) <= TABLE_SEAT_RADIUS_M + 0.05)) continue; + const next = [...all, it]; + if (breaks(next) > baseBreaks) continue; + all = next; + addedIds.push(it.id); + n++; + return true; + } + return false; + }; + + const left = (cfg: FurnitureTypeConfig) => remaining(all, cfg.id, cfg.available); + const tableTypes = typeOrder.map((t) => configs[t]).filter((c) => c.isTable).sort((a, b) => (b.seatsPerUnit ?? 0) - (a.seatsPerUnit ?? 0)); + const chairTypes = typeOrder + .map((t) => configs[t]) + .filter((c) => c.shape === "chair" && !c.isTable) + .sort((a, b) => (b.seatsPerUnit ?? 1) - (a.seatsPerUnit ?? 1)); + + let tablesAdded = 0; + let chairsAdded = 0; + for (const cfg of tableTypes) { + while (seatCount() < seatsNeeded && left(cfg) > 0 && (cfg.seatsPerUnit ?? 0) > 0) { + if (!place(cfg, false)) break; + tablesAdded++; + } + } + for (const cfg of chairTypes) { + while (seatCount() < seatsNeeded && left(cfg) > 0) { + if (!place(cfg, (cfg.seatsPerUnit ?? 1) <= 1)) break; + chairsAdded++; + } + } + + const seats = seatCount(); + const shortfall = Math.max(0, seatsNeeded - seats); + const parts = [`Added ${tablesAdded} table${tablesAdded === 1 ? "" : "s"} and ${chairsAdded} loose chair${chairsAdded === 1 ? "" : "s"}.`]; + if (shortfall > 0) parts.push(`Only ${seats} of ${seatsNeeded} seats are possible with the remaining inventory and space; used everything available.`); + return { items: all, addedIds, tablesAdded, chairsAdded, seats, shortfall, message: parts.join(" ") }; +} diff --git a/src/optimizer/constraints.ts b/src/optimizer/constraints.ts new file mode 100644 index 0000000..a4dd9e8 --- /dev/null +++ b/src/optimizer/constraints.ts @@ -0,0 +1,30 @@ +// Hard constraints for the search. Pure; the room is fixed during a run, so blocked rects are computed once. +import { boundsOf, getBlockedRects, isInsideRoom, overlaps } from "../geometry"; +import type { FurnitureItem, Rect, Room } from "../types"; + +export interface Constraints { + room: Room; + blocked: Rect[]; // door zones, screen strips, obstacles +} + +export const makeConstraints = (room: Room): Constraints => ({ room, blocked: getBlockedRects(room) }); + +/** + * True if `moved` items can take their new places: inside the room, off the blocked rects, + * and off every item that isn't moving (and off each other). + */ +export function placementOk(c: Constraints, items: readonly FurnitureItem[], moved: readonly FurnitureItem[]): boolean { + const ids = new Set(moved.map((m) => m.id)); + const mb = moved.map(boundsOf); + for (const b of mb) { + if (!isInsideRoom(b, c.room)) return false; + for (const z of c.blocked) if (overlaps(b, z)) return false; + } + for (const o of items) { + if (ids.has(o.id)) continue; + const ob = boundsOf(o); + for (const b of mb) if (overlaps(b, ob)) return false; + } + for (let i = 0; i < mb.length; i++) for (let j = i + 1; j < mb.length; j++) if (overlaps(mb[i], mb[j])) return false; + return true; +} diff --git a/src/optimizer/messages.ts b/src/optimizer/messages.ts new file mode 100644 index 0000000..0108312 --- /dev/null +++ b/src/optimizer/messages.ts @@ -0,0 +1,11 @@ +import type { AnnealInput, AnnealResult, Progress } from "./anneal"; + +export interface StartMessage { + type: "start"; + input: AnnealInput; + ms: number; +} + +export type WorkerMessage = + | ({ type: "progress" } & Progress) + | { type: "done"; result: AnnealResult }; diff --git a/src/optimizer/rng.ts b/src/optimizer/rng.ts new file mode 100644 index 0000000..cd009dd --- /dev/null +++ b/src/optimizer/rng.ts @@ -0,0 +1,29 @@ +/** Small seeded PRNG (mulberry32) so runs are reproducible. */ +export interface Rng { + next(): number; // [0, 1) + range(min: number, max: number): number; + int(maxExclusive: number): number; + gauss(): number; // standard normal +} + +export function createRng(seed: number): Rng { + let a = seed >>> 0; + const next = () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return { + next, + range: (min, max) => min + (max - min) * next(), + int: (n) => Math.floor(next() * n), + gauss: () => { + const u = Math.max(next(), 1e-12); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * next()); + }, + }; +} + +export const randomSeed = (): number => Math.floor(Math.random() * 2 ** 31); diff --git a/src/optimizer/useOptimizer.ts b/src/optimizer/useOptimizer.ts new file mode 100644 index 0000000..51308d6 --- /dev/null +++ b/src/optimizer/useOptimizer.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { overallScore, scoreLayout, type LayoutScore } from "../scoring"; +import type { FurnitureItem, FurnitureTypeConfig, Room, Rule, ScoreWeights } from "../types"; +import { autoFill, type AutoFillResult } from "./autofill"; +import type { AnnealResult } from "./anneal"; +import type { StartMessage, WorkerMessage } from "./messages"; +import { createRng, randomSeed } from "./rng"; + +export interface OptimizeParams { + room: Room; + items: FurnitureItem[]; + configs: Record; + typeOrder: string[]; + rules: Rule[]; + weights: ScoreWeights; + seed: number | null; // null = random + durationS: number; + seatsNeeded: number; // 0 = off + onlyNew: boolean; // only move the auto-placed items +} + +export interface OptimizeResult extends AnnealResult { + seed: number; + autofill: AutoFillResult | null; + before: LayoutScore & { overallWeighted: number | null }; + after: LayoutScore & { overallWeighted: number | null }; +} + +export interface OptimizerState { + status: "idle" | "running" | "done"; + fraction: number; + best: number | null; // current best overall (0-100) + iterations: number; + items: FurnitureItem[] | null; // layout to preview (best so far, or the result) + result: OptimizeResult | null; +} + +const IDLE: OptimizerState = { status: "idle", fraction: 0, best: null, iterations: 0, items: null, result: null }; + +export function useOptimizer() { + const [state, setState] = useState(IDLE); + const worker = useRef(null); + + const stopWorker = () => { + worker.current?.terminate(); + worker.current = null; + }; + useEffect(() => stopWorker, []); + + const cancel = useCallback(() => { + stopWorker(); + setState(IDLE); + }, []); + + const start = useCallback((p: OptimizeParams) => { + stopWorker(); + const seed = p.seed ?? randomSeed(); + const full = (items: FurnitureItem[]) => { + const r = scoreLayout(p.room, items, p.configs, p.rules, p.weights); // final numbers at 0.1 m + return { ...r, overallWeighted: overallScore(r.scores, p.weights) }; + }; + const before = full(p.items); + + const fill = p.seatsNeeded > 0 ? autoFill(p.room, p.items, p.configs, p.typeOrder, p.rules, p.seatsNeeded, createRng(seed ^ 0x9e3779b9)) : null; + const startItems = fill?.items ?? p.items; + const movableIds = p.onlyNew ? new Set(fill?.addedIds ?? []) : undefined; + + setState({ status: "running", fraction: 0, best: before.overallWeighted, iterations: 0, items: startItems, result: null }); + + const w = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }); + worker.current = w; + w.onmessage = (e: MessageEvent) => { + const m = e.data; + if (m.type === "progress") { + setState((s) => (s.status === "running" ? { ...s, fraction: m.fraction, best: m.score, iterations: m.iterations, items: m.items } : s)); + } else { + stopWorker(); + const result: OptimizeResult = { ...m.result, seed, autofill: fill, before, after: full(m.result.items) }; + setState({ status: "done", fraction: 1, best: result.after.overallWeighted, iterations: m.result.iterations, items: m.result.items, result }); + } + }; + const msg: StartMessage = { + type: "start", + input: { + room: p.room, + items: startItems, + configs: p.configs, + rules: p.rules, + weights: p.weights, + seed, + movableIds, + }, + ms: p.durationS * 1000, + }; + w.postMessage(msg); + }, []); + + return { state, start, cancel, clear: cancel }; +} diff --git a/src/optimizer/worker.ts b/src/optimizer/worker.ts new file mode 100644 index 0000000..b1d8982 --- /dev/null +++ b/src/optimizer/worker.ts @@ -0,0 +1,14 @@ +// Runs the annealer off the main thread. Posts the best layout every ~300 ms, then the final result. +import { anneal } from "./anneal"; +import type { StartMessage, WorkerMessage } from "./messages"; + +const ctx = self as unknown as { + postMessage(m: WorkerMessage): void; + onmessage: ((e: MessageEvent) => void) | null; +}; + +ctx.onmessage = (e) => { + if (e.data.type !== "start") return; + const result = anneal(e.data.input, { ms: e.data.ms }, { onProgress: (p) => ctx.postMessage({ type: "progress", ...p }) }); + ctx.postMessage({ type: "done", result }); +}; diff --git a/src/scoring/access.ts b/src/scoring/access.ts index 5d7a044..2a8cd8d 100644 --- a/src/scoring/access.ts +++ b/src/scoring/access.ts @@ -1,12 +1,12 @@ import { doorZones } from "../geometry"; import type { FurnitureItem, FurnitureTypeConfig, Room } from "../types"; import { perimeterPoint, type Pt } from "./geom"; -import { RES, cellIndex, getOccupancy, reachFrom } from "./occupancy"; +import { cellIndex, getOccupancy, reachFrom } from "./occupancy"; import { getSeatInfo } from "./seats"; import type { ClearanceSegment, ScoreOpts, ScoreResult } from "./types"; export const ACCESS_CLEARANCE_M = 0.6; // free band needed along a table edge -const SAMPLE_M = 0.1; +const SAMPLE_M = 0.1; // perimeter sample spacing; coarser grids sample coarser const SIDE_OK_FRACTION = 0.5; // an edge with less free perimeter than this is a "blocked side" /** @@ -23,7 +23,8 @@ export function accessScore( if (tables.length === 0) return { score: null, issues: [] }; const { tableSeatChairIds } = getSeatInfo(items, configs); - const occ = getOccupancy(room, items, { ignoreIds: tableSeatChairIds }); + const occ = getOccupancy(room, items, { ignoreIds: tableSeatChairIds, res: opts.res }); + const RES = occ.res; // people only need 0.6 m to squeeze through, and every door counts const reach = reachFrom(occ, room.doors.map((d) => doorZones(room, d)[0]), ACCESS_CLEARANCE_M / 2); @@ -39,7 +40,7 @@ export function accessScore( for (const t of tables) { const P = 2 * (t.width + t.height); - const n = Math.max(8, Math.round(P / SAMPLE_M)); + const n = Math.max(8, Math.round(P / Math.max(SAMPLE_M, RES))); const okByEdge = [0, 0, 0, 0]; const totByEdge = [0, 0, 0, 0]; let ok = 0; diff --git a/src/scoring/loading.ts b/src/scoring/loading.ts index 8df1ee5..dfe6cfe 100644 --- a/src/scoring/loading.ts +++ b/src/scoring/loading.ts @@ -1,7 +1,7 @@ import { boundsOf, doorZones } from "../geometry"; import type { FurnitureItem, FurnitureTypeConfig, Rect, Room } from "../types"; import type { Pt } from "./geom"; -import { RES, costToMeters, getOccupancy, reachFrom } from "./occupancy"; +import { costToMeters, getOccupancy, reachFrom } from "./occupancy"; import type { Polyline, ScoreOpts, ScoreResult } from "./types"; export const LOADING_CLEARANCE_M = 0.9; // clear width needed to carry furniture @@ -22,7 +22,8 @@ export function loadingScore( ): ScoreResult<{ paths: Polyline[] }> { if (items.length === 0) return { score: null, issues: [] }; - const occ = getOccupancy(room, items); // all furniture, door zones are walkable + const occ = getOccupancy(room, items, { res: opts.res }); // all furniture, door zones are walkable + const RES = occ.res; const reach = reachFrom(occ, loadingSeeds(room), LOADING_CLEARANCE_M / 2); let reachable = 0; @@ -53,7 +54,7 @@ export function loadingScore( continue; } reachable++; - totalM += costToMeters(bestCost); + totalM += costToMeters(bestCost, RES); if (opts.debug && paths.length < 60) { const pts: Pt[] = []; diff --git a/src/scoring/occupancy.ts b/src/scoring/occupancy.ts index afc7ecf..67a59fd 100644 --- a/src/scoring/occupancy.ts +++ b/src/scoring/occupancy.ts @@ -1,11 +1,12 @@ // Internal 0.1 m occupancy grid used for pathfinding and clearance. Never drawn, nothing snaps to it. import { getBlockedRects } from "../geometry"; import type { FurnitureItem, Rect, Room } from "../types"; -import { pointInShape } from "./geom"; -export const RES = 0.1; +export const DEFAULT_RES = 0.1; // meters per cell; the optimizer searches at 0.2 and rescores at 0.1 +export const RES = DEFAULT_RES; export interface Occupancy { + res: number; // meters per cell cols: number; rows: number; blocked: Uint8Array; // 1 = furniture / obstacle / screen strip @@ -13,20 +14,22 @@ export interface Occupancy { } export interface OccOpts { + res?: number; // meters per cell (default 0.1) ignoreIds?: ReadonlySet; // furniture left out (e.g. chairs pulled up to a table) includeDoorZones?: boolean; // door keep-clear zones as blocked (off for pathfinding) } export const cellIndex = (o: Occupancy, x: number, y: number): number => { - const cx = Math.floor(x / RES); - const cy = Math.floor(y / RES); + const cx = Math.floor(x / o.res); + const cy = Math.floor(y / o.res); return cx < 0 || cy < 0 || cx >= o.cols || cy >= o.rows ? -1 : cy * o.cols + cx; }; /** Distance (m) from a cell's edge to the nearest blocked cell or wall. */ -export const clearanceOf = (o: Occupancy, i: number): number => (o.dist[i] - 0.5) * RES; +export const clearanceOf = (o: Occupancy, i: number): number => (o.dist[i] - 0.5) * o.res; function markRect(o: Occupancy, r: Rect) { + const RES = o.res; const x0 = Math.max(0, Math.ceil(r.x / RES - 0.5)); const x1 = Math.min(o.cols - 1, Math.ceil((r.x + r.width) / RES - 0.5) - 1); const y0 = Math.max(0, Math.ceil(r.y / RES - 0.5)); @@ -34,19 +37,43 @@ function markRect(o: Occupancy, r: Rect) { for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) o.blocked[y * o.cols + x] = 1; } +// Room-only layer (door zones, screen strips, obstacles): static while the optimizer searches, so it's cached. +const baseCache = new Map(); + +function baseLayer(room: Room, res: number, includeDoorZones: boolean, cols: number, rows: number): Uint8Array { + const key = `${res}|${includeDoorZones ? 1 : 0}`; + const hit = baseCache.get(key); + if (hit && hit.room === room) return hit.blocked; + const o: Occupancy = { res, cols, rows, blocked: new Uint8Array(cols * rows), dist: new Float32Array(0) }; + getBlockedRects(room, includeDoorZones).forEach((r) => markRect(o, r)); + baseCache.set(key, { room, blocked: o.blocked }); + return o.blocked; +} + function build(room: Room, items: FurnitureItem[], opts: OccOpts): Occupancy { + const RES = opts.res ?? DEFAULT_RES; const cols = Math.ceil(room.width / RES - 1e-9); const rows = Math.ceil(room.height / RES - 1e-9); - const o: Occupancy = { cols, rows, blocked: new Uint8Array(cols * rows), dist: new Float32Array(cols * rows) }; - - getBlockedRects(room, opts.includeDoorZones ?? false).forEach((r) => markRect(o, r)); + const o: Occupancy = { res: RES, cols, rows, blocked: new Uint8Array(cols * rows), dist: new Float32Array(cols * rows) }; + o.blocked.set(baseLayer(room, RES, opts.includeDoorZones ?? false, cols, rows)); for (const it of items) { if (opts.ignoreIds?.has(it.id)) continue; - // rasterize the true rotated rect, scanning only its bounding cells + const quarter = Math.abs(it.rotation % 90) < 1e-9; + if (quarter) { + // right-angle rotations are plain rectangles + const swap = Math.abs(it.rotation % 180) > 45; + const w = swap ? it.height : it.width; + const h = swap ? it.width : it.height; + markRect(o, { x: it.x + it.width / 2 - w / 2, y: it.y + it.height / 2 - h / 2, width: w, height: h }); + continue; + } + // free angle: test each cell center in the item's frame const t = (it.rotation * Math.PI) / 180; - const hw = (Math.abs(Math.cos(t)) * it.width + Math.abs(Math.sin(t)) * it.height) / 2; - const hh = (Math.abs(Math.sin(t)) * it.width + Math.abs(Math.cos(t)) * it.height) / 2; + const cos = Math.cos(t); + const sin = Math.sin(t); + const hw = (Math.abs(cos) * it.width + Math.abs(sin) * it.height) / 2; + const hh = (Math.abs(sin) * it.width + Math.abs(cos) * it.height) / 2; const cx = it.x + it.width / 2; const cy = it.y + it.height / 2; const x0 = Math.max(0, Math.floor((cx - hw) / RES)); @@ -55,30 +82,39 @@ function build(room: Room, items: FurnitureItem[], opts: OccOpts): Occupancy { const y1 = Math.min(rows - 1, Math.ceil((cy + hh) / RES)); for (let y = y0; y <= y1; y++) { for (let x = x0; x <= x1; x++) { - if (pointInShape(it, { x: (x + 0.5) * RES, y: (y + 0.5) * RES })) o.blocked[y * cols + x] = 1; + const dx = (x + 0.5) * RES - cx; + const dy = (y + 0.5) * RES - cy; + const lx = dx * cos + dy * sin; + const ly = -dx * sin + dy * cos; + if (Math.abs(lx) <= it.width / 2 && Math.abs(ly) <= it.height / 2) o.blocked[y * cols + x] = 1; } } } - // chamfer distance transform; cells outside the grid count as blocked - const d = o.dist; - for (let i = 0; i < d.length; i++) d[i] = o.blocked[i] ? 0 : 1e9; - const get = (x: number, y: number) => (x < 0 || y < 0 || x >= cols || y >= rows ? 0 : d[y * cols + x]); - const D = 1.4142; + // chamfer distance transform in a zero-padded buffer (the padding stands for the walls) + const W = cols + 2; + const pd = new Float32Array(W * (rows + 2)); for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - const i = y * cols + x; - if (d[i] === 0) continue; - d[i] = Math.min(d[i], get(x - 1, y) + 1, get(x, y - 1) + 1, get(x - 1, y - 1) + D, get(x + 1, y - 1) + D); + for (let x = 0; x < cols; x++) pd[(y + 1) * W + x + 1] = o.blocked[y * cols + x] ? 0 : 1e9; + } + const D = 1.4142; + for (let y = 1; y <= rows; y++) { + for (let x = 1; x <= cols; x++) { + const i = y * W + x; + const v = pd[i]; + if (v === 0) continue; + pd[i] = Math.min(v, pd[i - 1] + 1, pd[i - W] + 1, pd[i - W - 1] + D, pd[i - W + 1] + D); } } - for (let y = rows - 1; y >= 0; y--) { - for (let x = cols - 1; x >= 0; x--) { - const i = y * cols + x; - if (d[i] === 0) continue; - d[i] = Math.min(d[i], get(x + 1, y) + 1, get(x, y + 1) + 1, get(x + 1, y + 1) + D, get(x - 1, y + 1) + D); + for (let y = rows; y >= 1; y--) { + for (let x = cols; x >= 1; x--) { + const i = y * W + x; + const v = pd[i]; + if (v === 0) continue; + pd[i] = Math.min(v, pd[i + 1] + 1, pd[i + W] + 1, pd[i + W + 1] + D, pd[i + W - 1] + D); } } + for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) o.dist[y * cols + x] = pd[(y + 1) * W + x + 1]; return o; } @@ -86,7 +122,7 @@ function build(room: Room, items: FurnitureItem[], opts: OccOpts): Occupancy { const cache = new Map(); export function getOccupancy(room: Room, items: FurnitureItem[], opts: OccOpts = {}): Occupancy { - const key = `${opts.includeDoorZones ? 1 : 0}|${opts.ignoreIds ? [...opts.ignoreIds].sort().join(",") : ""}`; + const key = `${opts.res ?? DEFAULT_RES}|${opts.includeDoorZones ? 1 : 0}|${opts.ignoreIds ? [...opts.ignoreIds].sort().join(",") : ""}`; const hit = cache.get(key); if (hit && hit.room === room && hit.items === items) return hit.occ; const occ = build(room, items, opts); @@ -105,59 +141,77 @@ export interface Reach { * the free cells inside `seeds` (door strips, which are always walkable). */ export function reachFrom(o: Occupancy, seeds: Rect[], minClear: number): Reach { + const RES = o.res; const n = o.cols * o.rows; const cost = new Int32Array(n).fill(-1); const prev = new Int32Array(n).fill(-1); - const seedMask = new Uint8Array(n); + + // walkable cells: free with enough clearance; seed cells (door strips) are always walkable + const pass = new Uint8Array(n); + const need = minClear / RES + 0.5 - 1e-9; // dist >= need <=> clearance >= minClear + for (let i = 0; i < n; i++) if (!o.blocked[i] && o.dist[i] >= need) pass[i] = 1; + const seed = new Uint8Array(n); for (const r of seeds) { const x0 = Math.max(0, Math.ceil(r.x / RES - 0.5)); const x1 = Math.min(o.cols - 1, Math.ceil((r.x + r.width) / RES - 0.5) - 1); const y0 = Math.max(0, Math.ceil(r.y / RES - 0.5)); const y1 = Math.min(o.rows - 1, Math.ceil((r.y + r.height) / RES - 0.5) - 1); - for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) seedMask[y * o.cols + x] = 1; + for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + const i = y * o.cols + x; + if (!o.blocked[i]) { + pass[i] = 1; + seed[i] = 1; + } + } + } } - const passable = (i: number) => !o.blocked[i] && (seedMask[i] === 1 || clearanceOf(o, i) >= minClear - 1e-9); // Dial's algorithm: buckets indexed by cost mod 15 (max edge cost is 14) const buckets: number[][] = Array.from({ length: 15 }, () => []); let pending = 0; for (let i = 0; i < n; i++) { - if (seedMask[i] && !o.blocked[i]) { + if (seed[i]) { cost[i] = 0; buckets[0].push(i); pending++; } } - const DX = [1, -1, 0, 0, 1, 1, -1, -1]; - const DY = [0, 0, 1, -1, 1, -1, 1, -1]; + const cols = o.cols; + const rows = o.rows; for (let cur = 0; pending > 0; cur++) { const b = buckets[cur % 15]; while (b.length) { const i = b.pop()!; pending--; if (cost[i] !== cur) continue; // stale - const x = i % o.cols; - const y = (i - x) / o.cols; - for (let k = 0; k < 8; k++) { - const nx = x + DX[k]; - const ny = y + DY[k]; - if (nx < 0 || ny < 0 || nx >= o.cols || ny >= o.rows) continue; - const j = ny * o.cols + nx; - if (!passable(j)) continue; - const diag = k >= 4; - // no cutting corners through blocked cells - if (diag && (!passable(y * o.cols + nx) || !passable(ny * o.cols + x))) continue; - const c = cur + (diag ? 14 : 10); + const x = i % cols; + const y = (i - x) / cols; + const l = x > 0, r = x < cols - 1, u = y > 0, d = y < rows - 1; + const relax = (j: number, c: number) => { if (cost[j] === -1 || c < cost[j]) { cost[j] = c; prev[j] = i; buckets[c % 15].push(j); pending++; } - } + }; + const c10 = cur + 10; + const c14 = cur + 14; + const pl = l && pass[i - 1] === 1, pr = r && pass[i + 1] === 1, pu = u && pass[i - cols] === 1, pd = d && pass[i + cols] === 1; + if (pl) relax(i - 1, c10); + if (pr) relax(i + 1, c10); + if (pu) relax(i - cols, c10); + if (pd) relax(i + cols, c10); + // diagonals only when both straight neighbors are walkable (no corner cutting) + if (pl && pu && pass[i - cols - 1] === 1) relax(i - cols - 1, c14); + if (pr && pu && pass[i - cols + 1] === 1) relax(i - cols + 1, c14); + if (pl && pd && pass[i + cols - 1] === 1) relax(i + cols - 1, c14); + if (pr && pd && pass[i + cols + 1] === 1) relax(i + cols + 1, c14); } } return { cost, prev }; } -export const costToMeters = (c: number): number => c * RES * 0.1; +/** Path cost (10 per straight cell, 14 per diagonal) to meters. */ +export const costToMeters = (c: number, res: number): number => c * res * 0.1; diff --git a/src/scoring/seats.ts b/src/scoring/seats.ts index bce062e..fc6c09e 100644 --- a/src/scoring/seats.ts +++ b/src/scoring/seats.ts @@ -18,7 +18,18 @@ export interface SeatInfo { tableSeatChairIds: Set; // chairs assumed to be table seats (not counted again) } +const seatCache = new WeakMap(); + +/** Cached per items array (several scores need it for the same layout). */ export function getSeatInfo(items: FurnitureItem[], configs: Configs): SeatInfo { + const hit = seatCache.get(items); + if (hit && hit.configs === configs) return hit.info; + const info = computeSeatInfo(items, configs); + seatCache.set(items, { configs, info }); + return info; +} + +function computeSeatInfo(items: FurnitureItem[], configs: Configs): SeatInfo { const points: SeatPoint[] = []; const tableSeatChairIds = new Set(); const tables = items.filter((i) => configs[i.type]?.isTable); diff --git a/src/scoring/types.ts b/src/scoring/types.ts index 74b92f3..e7f0d63 100644 --- a/src/scoring/types.ts +++ b/src/scoring/types.ts @@ -13,6 +13,7 @@ export interface ScoreResult { } export interface ScoreOpts { + res?: number; // occupancy resolution in meters (default 0.1; the optimizer searches at 0.2) debug?: boolean; // also build overlay data (slower) } diff --git a/src/store.ts b/src/store.ts index f74b0c0..d8614b6 100644 --- a/src/store.ts +++ b/src/store.ts @@ -9,6 +9,14 @@ export interface LayoutState { typeOrder: FurnitureType[]; // palette order rules: Rule[]; weights: ScoreWeights; + past: Snapshot[]; // undo history (oldest first) + future: Snapshot[]; // redo stack +} + +/** What undo/redo restores: the layout and the room around it. */ +export interface Snapshot { + items: FurnitureItem[]; + room: Room; } export type Action = @@ -23,7 +31,10 @@ export type Action = | { kind: "addRule"; rule: Rule } | { kind: "updateRule"; id: string; patch: Partial } | { kind: "removeRule"; id: string } - | { kind: "setWeight"; key: keyof ScoreWeights; value: number }; + | { kind: "setWeight"; key: keyof ScoreWeights; value: number } + | { kind: "applyLayout"; items: FurnitureItem[] } + | { kind: "undo" } + | { kind: "redo" }; const BUILT_IN: FurnitureTypeConfig[] = [ { id: "chair", name: "Chair", shape: "chair", defaultWidth: 0.5, defaultHeight: 0.5, isTable: false, rotatable: false, available: 20 }, @@ -38,9 +49,11 @@ export const initialState: LayoutState = { typeOrder: BUILT_IN.map((c) => c.id), rules: [], weights: { loading: 5, screen: 5, access: 5, comfort: 5, rules: 5 }, + past: [], + future: [], }; -export function reducer(state: LayoutState, action: Action): LayoutState { +function baseReducer(state: LayoutState, action: Action): LayoutState { switch (action.kind) { case "add": { const cfg = state.configs[action.type]; @@ -88,6 +101,11 @@ export function reducer(state: LayoutState, action: Action): LayoutState { return { ...state, rules: state.rules.filter((r) => r.id !== action.id) }; case "setWeight": return { ...state, weights: { ...state.weights, [action.key]: action.value } }; + case "applyLayout": + return { ...state, items: action.items }; + case "undo": + case "redo": + return state; // handled by the history wrapper case "addConfig": return { ...state, @@ -96,3 +114,34 @@ export function reducer(state: LayoutState, action: Action): LayoutState { }; } } + +// ---- Undo / redo: simple snapshots of items + room ---- +const HISTORY_LIMIT = 100; +const COALESCE_MS = 700; +// These change items or the room, so they can be undone. +const UNDOABLE = new Set(["add", "move", "update", "remove", "setRoom", "applyLayout"]); +let lastPush: { key: string; at: number } | null = null; + +export function reducer(state: LayoutState, action: Action): LayoutState { + if (action.kind === "undo" || action.kind === "redo") { + const from = action.kind === "undo" ? state.past : state.future; + if (from.length === 0) return state; + const target = from[from.length - 1]; + const here: Snapshot = { items: state.items, room: state.room }; + lastPush = null; + return action.kind === "undo" + ? { ...state, ...target, past: state.past.slice(0, -1), future: [...state.future, here] } + : { ...state, ...target, future: state.future.slice(0, -1), past: [...state.past, here] }; + } + + const next = baseReducer(state, action); + if (!UNDOABLE.has(action.kind) || (next.items === state.items && next.room === state.room)) return next; + + // Rapid repeats on the same thing (a wheel being spun, a dial being dragged) share one undo step. + const key = `${action.kind}:${"id" in action ? action.id : ""}`; + const now = Date.now(); + const merge = lastPush !== null && lastPush.key === key && now - lastPush.at < COALESCE_MS && state.past.length > 0; + lastPush = { key, at: now }; + const past = merge ? state.past : [...state.past, { items: state.items, room: state.room }].slice(-HISTORY_LIMIT); + return { ...next, past, future: [] }; +} diff --git a/vite.config.ts b/vite.config.ts index 9175e67..5edad1a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,7 @@ -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; -export default defineConfig({ plugins: [react()] }); +export default defineConfig({ + plugins: [react()], + test: { testTimeout: 30000 }, // optimizer tests run thousands of search steps +});