70c2199d87
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
// 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;
|
|
}
|