// Simulated annealing over item positions and rotations. Pure TS: no React, Konva or DOM. import { boundsOf, clampToRoom } from "../geometry"; import { distToShape, rectCenter } from "../scoring/geom"; import { overallScore, scoreLayout } from "../scoring"; import type { FurnitureItem, FurnitureTypeConfig, Room, Rule, ScoreWeights } from "../types"; import { makeConstraints, placementOk, type Constraints } from "./constraints"; import { createRng, type Rng } from "./rng"; export interface AnnealInput { room: Room; items: FurnitureItem[]; configs: Record; rules: Rule[]; weights: ScoreWeights; seed: number; movableIds?: ReadonlySet; // only these may move (default: every unlocked item) res?: number; // occupancy resolution while searching (default 0.2 m) } export interface Budget { iterations?: number; // deterministic budget (tests) ms?: number; // wall-clock budget (the worker) } export interface Progress { items: FurnitureItem[]; // best layout so far score: number; // best overall (0-100) fraction: number; // 0..1 of the budget used iterations: number; } export interface AnnealResult { items: FurnitureItem[]; startScore: number; bestScore: number; iterations: number; remainingViolations: number; // Required-rule violations left in the result skippedLocked: number; } export interface Hooks { onProgress?: (p: Progress) => void; progressEveryMs?: number; // default 300 now?: () => number; shouldStop?: () => boolean; } const SEARCH_RES = 0.2; const T_START = 4; // score points; accepts small drops early const T_END = 0.05; const RESTARTS = 3; const REPAIR_SHARE = 0.2; // max share of the budget for the repair phase const CHAIR_ATTACH_M = 0.8; // chairs this close to a table edge move with it interface Evaluation { score: number; required: number; // Required-rule violations } export function anneal(input: AnnealInput, budget: Budget, hooks: Hooks = {}): AnnealResult { const { room, configs, rules, weights } = input; const res = input.res ?? SEARCH_RES; const rng = createRng(input.seed); const cons = makeConstraints(room); const now = hooks.now ?? (() => Date.now()); const movable = (it: FurnitureItem) => !it.locked && (input.movableIds ? input.movableIds.has(it.id) : true); const skippedLocked = input.items.filter((i) => i.locked).length; const evaluate = (items: FurnitureItem[]): Evaluation => { const r = scoreLayout(room, items, configs, rules, weights, { res }); return { score: overallScore(r.scores, weights) ?? 0, required: r.violations.filter((v) => v.priority === "required").length }; }; let items = input.items; let cur = evaluate(items); const start = cur; let best = { items, ...cur }; const startedAt = now(); let iterations = 0; let lastProgress = startedAt; const fractionOf = () => { const byIter = budget.iterations ? iterations / budget.iterations : 0; const byTime = budget.ms ? (now() - startedAt) / budget.ms : 0; return Math.min(1, Math.max(byIter, byTime)); }; const report = (fraction: number) => hooks.onProgress?.({ items: best.items, score: best.score, fraction, iterations }); const anyMovable = items.some(movable); if (!anyMovable) { report(1); return { items, startScore: start.score, bestScore: start.score, iterations: 0, remainingViolations: start.required, skippedLocked }; } const hooksCheck = (fraction: number): boolean => { const t = now(); if (t - lastProgress >= (hooks.progressEveryMs ?? 300)) { lastProgress = t; report(fraction); } return fraction >= 1 || !!hooks.shouldStop?.(); }; /** One random move; returns the new items array, or null if the move wasn't possible or isn't valid. */ const propose = (s: number): FurnitureItem[] | null => { const mov = items.filter(movable); const roll = rng.next(); if (roll < 0.4) return nudge(items, mov, cons, rng, s); if (roll < 0.55) return relocate(items, mov, cons, rng); if (roll < 0.7) return rotate(items, mov, configs, cons, rng); if (roll < 0.8) return swap(items, mov, cons, rng); return groupShift(items, mov, configs, cons, rng, s); }; // ---- Phase 1: repair. If the start breaks Required rules, minimize how many it breaks. ---- let repairEnd = 0; if (cur.required > 0) { repairEnd = REPAIR_SHARE; for (;;) { const f = fractionOf(); if (cur.required === 0 || f >= repairEnd || hooks.shouldStop?.()) break; iterations++; const next = propose(0.5); if (next) { const ev = evaluate(next); if (ev.required <= cur.required) { items = next; cur = ev; if (ev.required < best.required || (ev.required === best.required && ev.score > best.score)) best = { items, ...ev }; } } if (iterations % 16 === 0) hooksCheck(f); } items = best.items; cur = { score: best.score, required: best.required }; } // ---- Phase 2: anneal, Required rules as hard constraints, restarting from the best state ---- const limitRequired = cur.required; // never get worse than where repair left us for (let k = 0; k < RESTARTS; k++) { items = best.items; cur = { score: best.score, required: best.required }; const t0 = T_START * Math.pow(0.5, k); const segStart = repairEnd + ((1 - repairEnd) * k) / RESTARTS; const segEnd = repairEnd + ((1 - repairEnd) * (k + 1)) / RESTARTS; for (;;) { const f = fractionOf(); if (f >= segEnd || hooks.shouldStop?.()) break; const s = Math.min(1, Math.max(0, (f - segStart) / (segEnd - segStart))); // progress inside this restart iterations++; const next = propose(s); if (next) { const ev = evaluate(next); if (ev.required <= limitRequired) { const T = t0 * Math.pow(T_END / t0, s); const d = ev.score - cur.score; if (d >= 0 || rng.next() < Math.exp(d / T)) { items = next; cur = ev; if (ev.score > best.score) best = { items, ...ev }; } } } if (iterations % 16 === 0 && hooksCheck(fractionOf())) break; } if (hooks.shouldStop?.()) break; } report(1); return { items: best.items, startScore: start.score, bestScore: best.score, iterations, remainingViolations: best.required, skippedLocked, }; } // ---------------- moves ---------------- const replace = (items: FurnitureItem[], changed: FurnitureItem[]): FurnitureItem[] => { const byId = new Map(changed.map((c) => [c.id, c])); return items.map((i) => byId.get(i.id) ?? i); }; /** Move an item's center by (dx, dy). */ const shifted = (it: FurnitureItem, dx: number, dy: number): FurnitureItem => ({ ...it, x: it.x + dx, y: it.y + dy }); function nudge(items: FurnitureItem[], mov: FurnitureItem[], cons: Constraints, rng: Rng, s: number): FurnitureItem[] | null { const it = mov[rng.int(mov.length)]; const sigma = 1.2 * Math.pow(0.05 / 1.2, s); // 1.2 m early, 5 cm late const moved = clampToRoom(shifted(it, rng.gauss() * sigma, rng.gauss() * sigma), cons.room); return placementOk(cons, items, [moved]) ? replace(items, [moved]) : null; } function relocate(items: FurnitureItem[], mov: FurnitureItem[], cons: Constraints, rng: Rng): FurnitureItem[] | null { const it = mov[rng.int(mov.length)]; const b = boundsOf(it); const hx = b.width / 2; const hy = b.height / 2; if (cons.room.width < b.width || cons.room.height < b.height) return null; for (let tries = 0; tries < 8; tries++) { const cx = rng.range(hx, cons.room.width - hx); const cy = rng.range(hy, cons.room.height - hy); const moved = { ...it, x: cx - it.width / 2, y: cy - it.height / 2 }; if (placementOk(cons, items, [moved])) return replace(items, [moved]); } return null; } function rotate(items: FurnitureItem[], mov: FurnitureItem[], configs: Record, cons: Constraints, rng: Rng): FurnitureItem[] | null { const can = mov.filter((i) => configs[i.type]?.rotatable); if (can.length === 0) return null; const it = can[rng.int(can.length)]; const moved = clampToRoom({ ...it, rotation: (it.rotation + 90) % 360 }, cons.room); return placementOk(cons, items, [moved]) ? replace(items, [moved]) : null; } function swap(items: FurnitureItem[], mov: FurnitureItem[], cons: Constraints, rng: Rng): FurnitureItem[] | null { const a = mov[rng.int(mov.length)]; const same = mov.filter((i) => i.id !== a.id && i.type === a.type); if (same.length === 0) return null; const b = same[rng.int(same.length)]; const ca = rectCenter(a); const cb = rectCenter(b); const a2 = { ...a, x: cb.x - a.width / 2, y: cb.y - a.height / 2 }; const b2 = { ...b, x: ca.x - b.width / 2, y: ca.y - b.height / 2 }; return placementOk(cons, items, [a2, b2]) ? replace(items, [a2, b2]) : null; } /** Move a table together with the movable chairs pulled up to it. */ function groupShift( items: FurnitureItem[], mov: FurnitureItem[], configs: Record, cons: Constraints, rng: Rng, s: number, ): FurnitureItem[] | null { const tables = mov.filter((i) => configs[i.type]?.isTable); if (tables.length === 0) return null; const t = tables[rng.int(tables.length)]; const chairs = mov.filter((i) => { const c = configs[i.type]; return c && c.shape === "chair" && !c.isTable && (c.seatsPerUnit ?? 1) <= 1 && distToShape(t, rectCenter(i)) <= CHAIR_ATTACH_M; }); const sigma = 1.5 * Math.pow(0.05 / 1.5, s); let dx = rng.gauss() * sigma; let dy = rng.gauss() * sigma; // keep the whole group inside the walls by shifting the delta, not each piece separately const group = [t, ...chairs]; for (const g of group) { const b = boundsOf(shifted(g, dx, dy)); if (b.x < 0) dx -= b.x; if (b.y < 0) dy -= b.y; if (b.x + b.width > cons.room.width) dx -= b.x + b.width - cons.room.width; if (b.y + b.height > cons.room.height) dy -= b.y + b.height - cons.room.height; } const moved = group.map((g) => shifted(g, dx, dy)); return placementOk(cons, items, moved) ? replace(items, moved) : null; }