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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(" ") };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user