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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user