Phase 5: simulated-annealing optimizer in a Web Worker, auto-fill, undo/redo

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 13:18:55 -04:00
parent 53de44c3db
commit 70c2199d87
20 changed files with 1091 additions and 69 deletions
+13 -5
View File
@@ -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.
+63 -3
View File
@@ -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<OptimizeFormValues>({ 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 = (
<OptimizePanel
values={optForm}
onChange={(p) => 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 = (
<RulesPanel
@@ -498,7 +558,7 @@ export default function App() {
onMoveObstacle={(id, x, y) => patchObstacle(id, { x, y })}
pxPerM={pxPerM}
room={state.room}
items={state.items}
items={shownItems}
configs={state.configs}
preview={preview}
floorRef={floorRef}
+4 -2
View File
@@ -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 (
<div style={{ gridColumn: "1 / -1", display: "flex", gap: 2, padding: 3, background: "#d9d9d9", borderRadius: 9 }}>
<div style={{ gridColumn: "1 / -1", display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(54px, 1fr))", gap: 2, padding: 3, background: "#d9d9d9", borderRadius: 9 }}>
<button style={seg("layout")} onClick={() => onChange("layout")}>
Layout
</button>
@@ -23,6 +22,9 @@ export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode)
<button style={seg("rules")} onClick={() => onChange("rules")}>
Rules
</button>
<button style={seg("optimize")} onClick={() => onChange("optimize")}>
Optimize
</button>
</div>
);
}
+160
View File
@@ -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<OptimizeFormValues>) => 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 (
<div style={panelRoot}>
<div style={title}>Optimize</div>
<div style={twoCol}>
<div>
<div style={label}>Duration (s)</div>
<Wheel value={values.durationS} min={5} max={60} rows={3} disabled={running} onChange={(v) => onChange({ durationS: v })} />
</div>
<div>
<div style={label}>Seats needed</div>
<Wheel value={values.seatsNeeded} min={0} max={999} rows={3} disabled={running} format={(v) => (v === 0 ? "off" : String(v))} onChange={(v) => onChange({ seatsNeeded: v })} />
</div>
</div>
<div>
<div style={label}>Seed</div>
<input
value={values.seed}
disabled={running}
placeholder="random"
inputMode="numeric"
onChange={(e) => 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" }}
/>
</div>
<label style={{ ...hint, marginTop: 0, display: "flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
<input type="checkbox" checked={values.onlyNew} disabled={running} onChange={(e) => onChange({ onlyNew: e.target.checked })} />
Only place remaining inventory (existing items stay put)
</label>
{locked && <div style={{ ...hint, marginTop: 0 }}>{locked}</div>}
{running ? (
<>
<div>
<div style={{ height: 10, background: "#ddd", borderRadius: 5, overflow: "hidden" }}>
<div style={{ width: `${Math.round(state.fraction * 100)}%`, height: "100%", background: "#1c7ed6", transition: "width 200ms linear" }} />
</div>
<div style={{ ...hint, display: "flex", justifyContent: "space-between" }}>
<span>Best score: {state.best === null ? "n/a" : state.best.toFixed(1)}</span>
<span>{state.iterations.toLocaleString()} steps</span>
</div>
</div>
<button style={{ ...btn, color: "#e03131", borderColor: "#e8a8a8" }} onClick={onCancel}>
Cancel
</button>
</>
) : (
<button style={{ ...btn, background: "#2b2b2b", color: "#fff" }} onClick={onStart}>
{done ? "Optimize again" : "Optimize"}
</button>
)}
{done && (
<div style={{ background: "#fff", border: "1px solid #d4d4d4", borderRadius: 10, padding: 10, display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr auto auto auto", columnGap: 8, rowGap: 3, alignItems: "baseline", font: "12px system-ui, sans-serif", color: "#444" }}>
<span />
<span style={{ color: "#888" }}>before</span>
<span style={{ color: "#888" }}>after</span>
<span />
<b>Overall</b>
<span>{num(done.before.overallWeighted)}</span>
<b>{num(done.after.overallWeighted)}</b>
<span style={{ color: delta(done.before.overallWeighted, done.after.overallWeighted, 1).color }}>{delta(done.before.overallWeighted, done.after.overallWeighted, 1).text}</span>
{ROWS.map(([k, name]) => (
<Row key={k} name={name} before={done.before.scores[k]} after={done.after.scores[k]} d={delta(done.before.scores[k], done.after.scores[k], 100)} />
))}
</div>
{done.autofill && <div style={{ ...hint, marginTop: 4 }}>{done.autofill.message}</div>}
{done.remainingViolations > 0 && (
<div style={{ ...hint, color: "#c05621", marginTop: 0 }}>
{done.remainingViolations} Required rule violation{done.remainingViolations > 1 ? "s" : ""} could not be fixed.
</div>
)}
<div style={{ ...hint, marginTop: 0 }}>
{done.skippedLocked} locked item{done.skippedLocked === 1 ? "" : "s"} skipped · seed {done.seed} · {done.iterations.toLocaleString()} steps
</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={{ ...btn, flex: 1, background: "#2b2b2b", color: "#fff" }} onClick={onApply}>
Apply
</button>
<button style={{ ...btn, flex: 1 }} onClick={onRevert}>
Revert
</button>
</div>
</div>
)}
<div style={{ display: "flex", gap: 8 }}>
<button style={{ ...btn, flex: 1, opacity: canUndo ? 1 : 0.45 }} disabled={!canUndo || running} onClick={onUndo}>
↶ Undo
</button>
<button style={{ ...btn, flex: 1, opacity: canRedo ? 1 : 0.45 }} disabled={!canRedo || running} onClick={onRedo}>
↷ Redo
</button>
</div>
</div>
);
}
function Row({ name, before, after, d }: { name: string; before: number | null; after: number | null; d: { text: string; color: string } }) {
return (
<>
<span>{name}</span>
<span>{pct(before)}</span>
<span>{pct(after)}</span>
<span style={{ color: d.color }}>{d.text}</span>
</>
);
}
+1 -1
View File
@@ -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 {
+123
View File
@@ -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> = {}): 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<AnnealInput> = {}, 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);
});
});
+263
View File
@@ -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<string, FurnitureTypeConfig>;
rules: Rule[];
weights: ScoreWeights;
seed: number;
movableIds?: ReadonlySet<string>; // 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<string, FurnitureTypeConfig>, 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<string, FurnitureTypeConfig>,
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;
}
+103
View File
@@ -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<string, FurnitureTypeConfig>,
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(" ") };
}
+30
View File
@@ -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;
}
+11
View File
@@ -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 };
+29
View File
@@ -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);
+99
View File
@@ -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<string, FurnitureTypeConfig>;
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<OptimizerState>(IDLE);
const worker = useRef<Worker | null>(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<WorkerMessage>) => {
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 };
}
+14
View File
@@ -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<StartMessage>) => 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 });
};
+5 -4
View File
@@ -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;
+4 -3
View File
@@ -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[] = [];
+101 -47
View File
@@ -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<string>; // 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<string, { room: Room; blocked: Uint8Array }>();
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<string, { room: Room; items: FurnitureItem[]; occ: Occupancy }>();
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;
+11
View File
@@ -18,7 +18,18 @@ export interface SeatInfo {
tableSeatChairIds: Set<string>; // chairs assumed to be table seats (not counted again)
}
const seatCache = new WeakMap<FurnitureItem[], { configs: Configs; info: SeatInfo }>();
/** 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<string>();
const tables = items.filter((i) => configs[i.type]?.isTable);
+1
View File
@@ -13,6 +13,7 @@ export interface ScoreResult<D = undefined> {
}
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)
}
+51 -2
View File
@@ -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<Rule> }
| { 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<Action["kind"]>(["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: [] };
}
+5 -2
View File
@@ -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
});