Phase 4 (part 1): pure scoring modules, rules evaluation, vitest tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 13:01:59 -04:00
parent 3a2df91a2a
commit 5d7df0187a
16 changed files with 1359 additions and 7 deletions
+287 -1
View File
@@ -18,7 +18,36 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"typescript": "^7.0.2",
"vite": "^8.3.4"
"vite": "^8.3.4",
"vitest": "^5.0.5"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@oxc-project/types": {
@@ -293,6 +322,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
@@ -691,6 +745,64 @@
}
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
"integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.5",
"estree-walker": "^3.0.3",
"magic-string": "^1.4.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9 || ^3.0.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/spy": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
"integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/chai": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -707,6 +819,33 @@
"node": ">=8"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1054,6 +1193,16 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
@@ -1073,6 +1222,20 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/obug": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
"integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1239,6 +1402,33 @@
"node": ">=0.10.0"
}
},
"node_modules/std-env": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
"integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1368,6 +1558,102 @@
"optional": true
}
}
},
"node_modules/vitest": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
"integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.5",
"chai": "^6.3.0",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.4.3",
"obug": "^3.0.0",
"picomatch": "^4.0.7",
"std-env": "^4.3.0",
"tinybench": "^6.2.2",
"tinyexec": "^1.3.1",
"tinyglobby": "^0.2.17",
"why-is-node-running": "3.2.1"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.5",
"@vitest/browser-preview": "5.0.5",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.5",
"@vitest/coverage-v8": "5.0.5",
"@vitest/ui": "5.0.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/why-is-node-running": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
"integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
"dev": true,
"license": "MIT",
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=20.11"
}
}
}
}
+4 -2
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"konva": "^10.9.0",
@@ -19,6 +20,7 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"typescript": "^7.0.2",
"vite": "^8.3.4"
"vite": "^8.3.4",
"vitest": "^5.0.5"
}
}
+2 -2
View File
@@ -57,9 +57,9 @@ export const screenStrip = (room: Room, s: RoomScreen): Rect =>
wallRect(room, s.wall, s.offset, s.width, SCREEN_DEPTH_M);
/** Every rectangle furniture can't overlap: door zones, screen strips, obstacles. */
export function getBlockedRects(room: Room): Rect[] {
export function getBlockedRects(room: Room, includeDoorZones = true): Rect[] {
return [
...room.doors.flatMap((d) => doorZones(room, d)),
...(includeDoorZones ? room.doors.flatMap((d) => doorZones(room, d)) : []),
...room.screens.map((s) => screenStrip(room, s)),
...room.obstacles,
];
+88
View File
@@ -0,0 +1,88 @@
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 { 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 SIDE_OK_FRACTION = 0.5; // an edge with less free perimeter than this is a "blocked side"
/**
* For each table: the fraction of its perimeter with 0.6 m of free clearance that a door can reach.
* Chairs pulled up to a table are its seats, so they don't count as blocking.
*/
export function accessScore(
room: Room,
items: FurnitureItem[],
configs: Record<string, FurnitureTypeConfig>,
opts: ScoreOpts = {},
): ScoreResult<{ segments: ClearanceSegment[] }> {
const tables = items.filter((i) => configs[i.type]?.isTable);
if (tables.length === 0) return { score: null, issues: [] };
const { tableSeatChairIds } = getSeatInfo(items, configs);
const occ = getOccupancy(room, items, { ignoreIds: tableSeatChairIds });
// 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);
const free = (p: Pt): boolean => {
const i = cellIndex(occ, p.x, p.y);
return i >= 0 && !occ.blocked[i];
};
let sum = 0;
const segments: ClearanceSegment[] = [];
const issues = [];
const counts: Record<string, number> = {};
for (const t of tables) {
const P = 2 * (t.width + t.height);
const n = Math.max(8, Math.round(P / SAMPLE_M));
const okByEdge = [0, 0, 0, 0];
const totByEdge = [0, 0, 0, 0];
let ok = 0;
let prev: { p: Pt; ok: boolean } | null = null;
for (let k = 0; k < n; k++) {
const { p, n: nrm, edge } = perimeterPoint(t, ((k + 0.5) * P) / n);
let clear = true;
for (let d = RES; d <= ACCESS_CLEARANCE_M + 1e-9 && clear; d += RES) clear = free({ x: p.x + nrm.x * d, y: p.y + nrm.y * d });
let reached = false;
if (clear) {
for (let d = 0.3; d <= ACCESS_CLEARANCE_M + 1e-9 && !reached; d += RES) {
const i = cellIndex(occ, p.x + nrm.x * d, p.y + nrm.y * d);
reached = i >= 0 && reach.cost[i] >= 0;
}
}
const good = clear && reached;
totByEdge[edge]++;
if (good) {
ok++;
okByEdge[edge]++;
}
if (opts.debug) {
if (prev && prev.ok === good) {
const last = segments[segments.length - 1];
last.to = p;
} else {
segments.push({ itemId: t.id, from: prev?.p ?? p, to: p, ok: good });
}
prev = { p, ok: good };
}
}
sum += ok / n;
const blockedSides = [0, 1, 2, 3].filter((e) => totByEdge[e] > 0 && okByEdge[e] / totByEdge[e] < SIDE_OK_FRACTION).length;
if (blockedSides > 0) {
const name = configs[t.type].name;
counts[name] = (counts[name] ?? 0) + 1;
issues.push({
text: `${name} ${counts[name]} has no clearance on ${blockedSides} side${blockedSides > 1 ? "s" : ""}`,
itemIds: [t.id],
});
}
}
return { score: sum / tables.length, issues, debug: opts.debug ? { segments } : undefined };
}
+63
View File
@@ -0,0 +1,63 @@
import { wallRect } from "../geometry";
import type { FurnitureItem, FurnitureTypeConfig, Room } from "../types";
import { rectDistance } from "./geom";
import { getSeatPoints } from "./seats";
import type { ScoreResult } from "./types";
const SEAT_GAP_M = 0.9; // to the next seat at a different table
const DOOR_GAP_M = 1.5;
const OBSTACLE_GAP_M = 0.3; // "flush against" an obstacle
const CROWDED_SEATS_PER_M2 = 0.9;
const ramp = (d: number, ideal: number) => Math.min(1, Math.max(0, d / ideal));
/** Per seat: room to the next table's seats, space from doors, not flush to obstacles; blended with crowding. */
export function comfortScore(
room: Room,
items: FurnitureItem[],
configs: Record<string, FurnitureTypeConfig>,
): ScoreResult {
const seats = getSeatPoints(items, configs);
if (seats.length === 0) return { score: null, issues: [] };
const doorRects = room.doors.map((d) => wallRect(room, d.wall, d.offset, d.width, 0));
let sum = 0;
const crowdedItems = new Set<string>();
const doorItems = new Set<string>();
let nearOther = 0;
let nearDoor = 0;
for (const s of seats) {
let nearest = Infinity;
for (const o of seats) {
if (o.itemId === s.itemId) continue;
const d = Math.hypot(o.x - s.x, o.y - s.y);
if (d < nearest) nearest = d;
}
const spacing = nearest === Infinity ? 1 : ramp(nearest, SEAT_GAP_M);
const pt = { x: s.x, y: s.y, width: 0, height: 0 };
const door = ramp(Math.min(...doorRects.map((r) => rectDistance(pt, r))), DOOR_GAP_M);
const obstacle = room.obstacles.length ? ramp(Math.min(...room.obstacles.map((o) => rectDistance(pt, o))), OBSTACLE_GAP_M) : 1;
if (spacing < 1) {
nearOther++;
crowdedItems.add(s.itemId);
}
if (door < 1) {
nearDoor++;
doorItems.add(s.itemId);
}
sum += (spacing + door + obstacle) / 3;
}
const area = room.width * room.height - room.obstacles.reduce((a, o) => a + o.width * o.height, 0);
const density = seats.length / Math.max(area, 1e-6);
const crowding = density <= CROWDED_SEATS_PER_M2 ? 1 : Math.max(0, 1 - (density - CROWDED_SEATS_PER_M2) / CROWDED_SEATS_PER_M2);
const issues = [];
if (nearOther) issues.push({ text: `${nearOther} seat${nearOther > 1 ? "s" : ""} too close to another table's seats`, itemIds: [...crowdedItems] });
if (nearDoor) issues.push({ text: `${nearDoor} seat${nearDoor > 1 ? "s" : ""} within 1.5 m of a door`, itemIds: [...doorItems] });
if (crowding < 1) issues.push({ text: "The room is crowded", itemIds: [] });
return { score: 0.7 * (sum / seats.length) + 0.3 * crowding, issues };
}
+100
View File
@@ -0,0 +1,100 @@
// Small pure geometry helpers for scoring. No React or Konva.
import type { Rect } from "../types";
export interface Pt {
x: number;
y: number;
}
/** A rect rotated around its own center (the shape furniture has in the room). */
export interface Shape extends Rect {
rotation: number; // degrees
}
export const rectCenter = (r: Rect): Pt => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 });
/** Edge-to-edge distance between two axis-aligned rects (0 if they touch or overlap). */
export function rectDistance(a: Rect, b: Rect): number {
const dx = Math.max(b.x - (a.x + a.width), a.x - (b.x + b.width), 0);
const dy = Math.max(b.y - (a.y + a.height), a.y - (b.y + b.height), 0);
return Math.hypot(dx, dy);
}
const rad = (deg: number) => (deg * Math.PI) / 180;
/** Point in the shape's own frame (origin at its center, unrotated). */
export function toLocal(s: Shape, p: Pt): Pt {
const c = rectCenter(s);
const t = rad(s.rotation);
const dx = p.x - c.x;
const dy = p.y - c.y;
return { x: dx * Math.cos(t) + dy * Math.sin(t), y: -dx * Math.sin(t) + dy * Math.cos(t) };
}
export function pointInShape(s: Shape, p: Pt): boolean {
const l = toLocal(s, p);
return Math.abs(l.x) <= s.width / 2 && Math.abs(l.y) <= s.height / 2;
}
/** Distance from a point to the shape's edge (0 when inside). */
export function distToShape(s: Shape, p: Pt): number {
const l = toLocal(s, p);
return Math.hypot(Math.max(Math.abs(l.x) - s.width / 2, 0), Math.max(Math.abs(l.y) - s.height / 2, 0));
}
/**
* Point `s` meters along the perimeter (clockwise from the top-left corner) with its outward normal and
* edge index (0 top, 1 right, 2 bottom, 3 left), all in room coordinates.
*/
export function perimeterPoint(sh: Shape, s: number): { p: Pt; n: Pt; edge: number } {
const w = sh.width;
const h = sh.height;
const P = 2 * (w + h);
let d = ((s % P) + P) % P;
let lx: number, ly: number, nx: number, ny: number, edge: number;
if (d < w) {
[lx, ly, nx, ny, edge] = [-w / 2 + d, -h / 2, 0, -1, 0];
} else if ((d -= w) < h) {
[lx, ly, nx, ny, edge] = [w / 2, -h / 2 + d, 1, 0, 1];
} else if ((d -= h) < w) {
[lx, ly, nx, ny, edge] = [w / 2 - d, h / 2, 0, 1, 2];
} else {
d -= w;
[lx, ly, nx, ny, edge] = [-w / 2, h / 2 - d, -1, 0, 3];
}
const c = rectCenter(sh);
const t = rad(sh.rotation);
const cos = Math.cos(t);
const sin = Math.sin(t);
return {
p: { x: c.x + lx * cos - ly * sin, y: c.y + lx * sin + ly * cos },
n: { x: nx * cos - ny * sin, y: nx * sin + ny * cos },
edge,
};
}
/** Does segment a-b cross the axis-aligned rect? (Liang-Barsky) */
export function segmentHitsRect(a: Pt, b: Pt, r: Rect): boolean {
let t0 = 0;
let t1 = 1;
const dx = b.x - a.x;
const dy = b.y - a.y;
const clip = (p: number, q: number) => {
if (p === 0) return q >= 0;
const t = q / p;
if (p < 0) {
if (t > t1) return false;
if (t > t0) t0 = t;
} else {
if (t < t0) return false;
if (t < t1) t1 = t;
}
return true;
};
return (
clip(-dx, a.x - r.x) &&
clip(dx, r.x + r.width - a.x) &&
clip(-dy, a.y - r.y) &&
clip(dy, r.y + r.height - a.y)
);
}
+68
View File
@@ -0,0 +1,68 @@
// Layout scoring. Pure, deterministic, no React/Konva: the optimizer will call this thousands of times.
import type { FurnitureItem, FurnitureTypeConfig, Room, Rule, ScoreWeights } from "../types";
import { accessScore } from "./access";
import { comfortScore } from "./comfort";
import { loadingScore } from "./loading";
import { evaluateRules, type Violation } from "./rules";
import { screenScore } from "./screen";
import type { ClearanceSegment, Issue, Polyline, ScoreOpts, SightLine } from "./types";
export { getSeatPoints } from "./seats";
export { accessScore, comfortScore, loadingScore, screenScore, evaluateRules };
export { describeRule, PRIORITY_WEIGHT } from "./rules";
export type { Violation } from "./rules";
export type { ClearanceSegment, Issue, Polyline, ScoreOpts, SightLine } from "./types";
export type ScoreKey = keyof ScoreWeights;
export interface LayoutScore {
scores: Record<ScoreKey, number | null>; // 0..1, null = doesn't apply
overall: number | null; // 0..100
issues: Issue[];
violations: Violation[]; // every rule violation (Required ones get flagged in the layout)
debug?: { paths: Polyline[]; sightLines: SightLine[]; clearance: ClearanceSegment[] };
}
/** Weighted average of the scores that apply, using the 0-10 priority weights. */
export function overallScore(scores: Record<ScoreKey, number | null>, weights: ScoreWeights): number | null {
let sum = 0;
let wSum = 0;
for (const k of Object.keys(scores) as ScoreKey[]) {
const s = scores[k];
if (s === null || weights[k] <= 0) continue;
sum += s * weights[k];
wSum += weights[k];
}
return wSum > 0 ? (100 * sum) / wSum : null;
}
export function scoreLayout(
room: Room,
items: FurnitureItem[],
configs: Record<string, FurnitureTypeConfig>,
rules: Rule[],
weights: ScoreWeights,
opts: ScoreOpts = {},
): LayoutScore {
const loading = loadingScore(room, items, configs, opts);
const screen = screenScore(room, items, configs, opts);
const access = accessScore(room, items, configs, opts);
const comfort = comfortScore(room, items, configs);
const rulesRes = evaluateRules(room, items, configs, rules);
const scores = {
loading: loading.score,
screen: screen.score,
access: access.score,
comfort: comfort.score,
rules: rulesRes.score,
};
return {
scores,
overall: overallScore(scores, weights),
issues: [...rulesRes.issues, ...loading.issues, ...screen.issues, ...access.issues, ...comfort.issues],
violations: rulesRes.violations,
debug: opts.debug
? { paths: loading.debug?.paths ?? [], sightLines: screen.debug?.lines ?? [], clearance: access.debug?.segments ?? [] }
: undefined,
};
}
+83
View File
@@ -0,0 +1,83 @@
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 type { Polyline, ScoreOpts, ScoreResult } from "./types";
export const LOADING_CLEARANCE_M = 0.9; // clear width needed to carry furniture
const REACH_MARGIN_M = 0.6; // an item counts as reachable if a walkable cell is this close
/** Loading doors' keep-clear strips: the marked loading doors, or every door if none is marked. */
export function loadingSeeds(room: Room): Rect[] {
const marked = room.doors.filter((d) => d.isLoading);
return (marked.length ? marked : room.doors).map((d) => doorZones(room, d)[0]);
}
/** Cells visited so far are cheapest-first, so the min cost near an item is its path cost. */
export function loadingScore(
room: Room,
items: FurnitureItem[],
_configs: Record<string, FurnitureTypeConfig>,
opts: ScoreOpts = {},
): ScoreResult<{ paths: Polyline[] }> {
if (items.length === 0) return { score: null, issues: [] };
const occ = getOccupancy(room, items); // all furniture, door zones are walkable
const reach = reachFrom(occ, loadingSeeds(room), LOADING_CLEARANCE_M / 2);
let reachable = 0;
let totalM = 0;
const unreachable: string[] = [];
const paths: Polyline[] = [];
for (const it of items) {
const b = boundsOf(it);
const x0 = Math.max(0, Math.floor((b.x - REACH_MARGIN_M) / RES));
const x1 = Math.min(occ.cols - 1, Math.ceil((b.x + b.width + REACH_MARGIN_M) / RES));
const y0 = Math.max(0, Math.floor((b.y - REACH_MARGIN_M) / RES));
const y1 = Math.min(occ.rows - 1, Math.ceil((b.y + b.height + REACH_MARGIN_M) / RES));
let best = -1;
let bestCost = Infinity;
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const i = y * occ.cols + x;
const c = reach.cost[i];
if (c >= 0 && c < bestCost && !occ.blocked[i]) {
bestCost = c;
best = i;
}
}
}
if (best < 0) {
unreachable.push(it.id);
continue;
}
reachable++;
totalM += costToMeters(bestCost);
if (opts.debug && paths.length < 60) {
const pts: Pt[] = [];
let step = 0;
for (let i = best; i >= 0; i = reach.prev[i]) {
if (step++ % 3 === 0 || reach.prev[i] < 0) {
const x = i % occ.cols;
pts.push({ x: (x + 0.5) * RES, y: (Math.floor(i / occ.cols) + 0.5) * RES });
}
}
paths.push({ itemId: it.id, points: pts });
}
}
const frac = reachable / items.length;
const avg = reachable ? totalM / reachable : room.width + room.height;
const norm = Math.min(1, avg / (room.width + room.height));
const score = 0.6 * frac + 0.4 * (1 - norm);
return {
score,
issues: unreachable.length
? [{ text: `${unreachable.length} item${unreachable.length > 1 ? "s" : ""} can't be reached from the loading door`, itemIds: unreachable }]
: [],
debug: opts.debug ? { paths } : undefined,
};
}
+163
View File
@@ -0,0 +1,163 @@
// 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 interface Occupancy {
cols: number;
rows: number;
blocked: Uint8Array; // 1 = furniture / obstacle / screen strip
dist: Float32Array; // cells to the nearest blocked cell or wall (chamfer)
}
export interface OccOpts {
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);
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;
function markRect(o: Occupancy, r: Rect) {
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++) o.blocked[y * o.cols + x] = 1;
}
function build(room: Room, items: FurnitureItem[], opts: OccOpts): Occupancy {
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));
for (const it of items) {
if (opts.ignoreIds?.has(it.id)) continue;
// rasterize the true rotated rect, scanning only its bounding cells
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 cx = it.x + it.width / 2;
const cy = it.y + it.height / 2;
const x0 = Math.max(0, Math.floor((cx - hw) / RES));
const x1 = Math.min(cols - 1, Math.ceil((cx + hw) / RES));
const y0 = Math.max(0, Math.floor((cy - hh) / RES));
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;
}
}
}
// 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;
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 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);
}
}
return o;
}
// One-entry cache per variant: rebuilt only when the room or items change.
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 hit = cache.get(key);
if (hit && hit.room === room && hit.items === items) return hit.occ;
const occ = build(room, items, opts);
if (cache.size > 8) cache.clear();
cache.set(key, { room, items, occ });
return occ;
}
export interface Reach {
cost: Int32Array; // path cost in 0.01 m units (10 per straight cell, 14 per diagonal), -1 = unreached
prev: Int32Array; // previous cell on the shortest path, -1 for seeds
}
/**
* Shortest paths over free cells with at least `minClear` meters of clear width on each side, starting from
* the free cells inside `seeds` (door strips, which are always walkable).
*/
export function reachFrom(o: Occupancy, seeds: Rect[], minClear: number): Reach {
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);
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;
}
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]) {
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];
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);
if (cost[j] === -1 || c < cost[j]) {
cost[j] = c;
prev[j] = i;
buckets[c % 15].push(j);
pending++;
}
}
}
}
return { cost, prev };
}
export const costToMeters = (c: number): number => c * RES * 0.1;
+150
View File
@@ -0,0 +1,150 @@
import { boundsOf, screenStrip, wallRect } from "../geometry";
import type { FurnitureItem, FurnitureTypeConfig, Priority, Rect, Room, Rule } from "../types";
import { rectDistance } from "./geom";
import type { Issue, ScoreResult } from "./types";
export const PRIORITY_WEIGHT: Record<Exclude<Priority, "required">, number> = { high: 3, medium: 2, low: 1 };
const WALL_TOUCH_M = 0.05;
export interface Violation {
ruleId: string;
priority: Priority;
itemIds: string[];
amount: number; // meters off
}
export interface RuleStat {
ruleId: string;
subjects: number; // items the rule was checked on
violated: number; // of those, how many broke it
}
export interface RulesResult extends ScoreResult {
violations: Violation[];
stats: RuleStat[];
}
export const describeRule = (r: Rule, configs: Record<string, FurnitureTypeConfig>): string => {
const a = configs[r.typeA]?.name ?? r.typeA;
const b = r.typeB ? (configs[r.typeB]?.name ?? r.typeB) : "";
const m = (v?: number) => `${(v ?? 0).toFixed(1)} m`;
switch (r.kind) {
case "keepApart":
return `${a} and ${b} at least ${m(r.min)} apart`;
case "keepNear":
return `${a} within ${m(r.max)} of ${b}`;
case "screenDistance":
return `${a} ${m(r.min)}-${m(r.max)} from a screen`;
case "doorDistance":
return `${a} at least ${m(r.min)} from doors`;
case "againstWall":
return `${a} against a wall`;
}
};
/** Evaluates every rule per item / item pair. Required violations are returned but never block anything. */
export function evaluateRules(
room: Room,
items: FurnitureItem[],
configs: Record<string, FurnitureTypeConfig>,
rules: Rule[],
): RulesResult {
const bounds = new Map<string, Rect>(items.map((i) => [i.id, boundsOf(i)]));
const ofType = (t?: string) => items.filter((i) => i.type === t);
const violations: Violation[] = [];
const stats: RuleStat[] = [];
for (const rule of rules) {
const A = ofType(rule.typeA);
const found: Violation[] = [];
let subjects = 0;
const add = (itemIds: string[], amount: number) => found.push({ ruleId: rule.id, priority: rule.priority, itemIds, amount });
switch (rule.kind) {
case "keepApart": {
const B = ofType(rule.typeB);
const min = rule.min ?? 0;
const seen = new Set<string>();
for (const a of A) {
for (const b of B) {
if (a.id === b.id) continue;
const key = [a.id, b.id].sort().join("|");
if (seen.has(key)) continue; // A-A rules would count each pair twice
seen.add(key);
subjects++;
const d = rectDistance(bounds.get(a.id)!, bounds.get(b.id)!);
if (d < min - 1e-9) add([a.id, b.id], min - d);
}
}
break;
}
case "keepNear": {
const B = ofType(rule.typeB);
const max = rule.max ?? 0;
for (const a of A) {
const others = B.filter((b) => b.id !== a.id);
if (others.length === 0) continue; // nothing to be near
subjects++;
const d = Math.min(...others.map((b) => rectDistance(bounds.get(a.id)!, bounds.get(b.id)!)));
if (d > max + 1e-9) add([a.id], d - max);
}
break;
}
case "screenDistance": {
if (room.screens.length === 0) break;
const strips = room.screens.map((s) => screenStrip(room, s));
const min = rule.min ?? 0;
const max = rule.max ?? Infinity;
for (const a of A) {
subjects++;
const d = Math.min(...strips.map((s) => rectDistance(bounds.get(a.id)!, s)));
if (d < min - 1e-9) add([a.id], min - d);
else if (d > max + 1e-9) add([a.id], d - max);
}
break;
}
case "doorDistance": {
const openings = room.doors.map((d) => wallRect(room, d.wall, d.offset, d.width, 0));
const min = rule.min ?? 0;
for (const a of A) {
subjects++;
const d = Math.min(...openings.map((o) => rectDistance(bounds.get(a.id)!, o)));
if (d < min - 1e-9) add([a.id], min - d);
}
break;
}
case "againstWall": {
for (const a of A) {
subjects++;
const b = bounds.get(a.id)!;
const d = Math.min(b.x, b.y, room.width - (b.x + b.width), room.height - (b.y + b.height));
if (d > WALL_TOUCH_M) add([a.id], d);
}
break;
}
}
violations.push(...found);
stats.push({ ruleId: rule.id, subjects, violated: new Set(found.flatMap((v) => v.itemIds)).size });
}
// score: 1 - weighted violated fraction, over non-required rules that could be checked
let wSum = 0;
let wBad = 0;
const issues: Issue[] = [];
for (const rule of rules) {
const st = stats.find((s) => s.ruleId === rule.id)!;
if (st.subjects === 0) continue;
const mine = violations.filter((v) => v.ruleId === rule.id);
if (mine.length) {
issues.push({
text: `${rule.priority === "required" ? "Required: " : ""}${describeRule(rule, configs)} (${mine.length} broken)`,
itemIds: [...new Set(mine.flatMap((v) => v.itemIds))],
});
}
if (rule.priority === "required") continue;
const w = PRIORITY_WEIGHT[rule.priority];
wSum += w;
wBad += w * Math.min(1, mine.length / st.subjects);
}
return { score: wSum > 0 ? 1 - wBad / wSum : null, issues, violations, stats };
}
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_ROOM } from "../geometry";
import { initialState } from "../store";
import type { FurnitureItem, Room, Rule } from "../types";
import { accessScore, comfortScore, evaluateRules, getSeatPoints, loadingScore, overallScore, scoreLayout, screenScore } from "./index";
const configs = initialState.configs;
const weights = initialState.weights;
const item = (id: string, type: string, x: number, y: number): FurnitureItem => {
const c = configs[type];
return { id, type, x, y, width: c.defaultWidth, height: c.defaultHeight, rotation: 0, locked: false };
};
const withScreen = (room: Room): Room => ({ ...room, screens: [{ id: "s1", wall: "top", offset: 3, width: 4 }] });
describe("seat points", () => {
it("puts N points around a table and skips chairs pulled up to it", () => {
const table = item("t", "table", 4, 3); // 8 seats by default
const nearChair = item("c1", "chair", 4.3, 3.9); // within 0.8 m of the table edge
const farChair = item("c2", "chair", 8, 1);
const pts = getSeatPoints([table, nearChair, farChair], configs);
expect(pts.filter((p) => p.itemId === "t")).toHaveLength(8);
expect(pts.some((p) => p.itemId === "c1")).toBe(false);
expect(pts.filter((p) => p.itemId === "c2")).toHaveLength(1);
});
it("uses seatsOverride before the type default", () => {
const table = { ...item("t", "table", 4, 3), seatsOverride: 3 };
expect(getSeatPoints([table], configs)).toHaveLength(3);
});
});
describe("empty room", () => {
it("has nothing to score", () => {
const r = scoreLayout(DEFAULT_ROOM, [], configs, [], weights);
expect(r.scores).toEqual({ loading: null, screen: null, access: null, comfort: null, rules: null });
expect(r.overall).toBeNull();
});
});
describe("table access and loading", () => {
const room = DEFAULT_ROOM; // door on the bottom wall
it("scores a reachable table high", () => {
const items = [item("t", "table", 4, 3)];
expect(accessScore(room, items, configs).score).toBeGreaterThan(0.95);
expect(loadingScore(room, items, configs).score).toBeGreaterThan(0.7);
});
it("scores a table walled off from the door at 0", () => {
const wall = { id: "o", x: 0, y: 5, width: 10, height: 0.3 }; // splits the room, door is below it
const blocked: Room = { ...room, obstacles: [wall] };
const items = [item("t", "table", 4, 2)]; // above the wall
const access = accessScore(blocked, items, configs);
expect(access.score).toBe(0);
expect(access.issues[0].text).toMatch(/no clearance/);
const loading = loadingScore(blocked, items, configs);
expect(loading.score).toBeLessThan(0.1);
expect(loading.issues[0].itemIds).toEqual(["t"]);
});
it("returns debug paths only when asked", () => {
const items = [item("t", "table", 4, 3)];
expect(loadingScore(room, items, configs).debug).toBeUndefined();
expect(loadingScore(room, items, configs, { debug: true }).debug!.paths.length).toBe(1);
});
});
describe("screen visibility", () => {
const room = withScreen(DEFAULT_ROOM);
const chair = item("c", "chair", 5, 4);
it("is null without a screen", () => {
expect(screenScore(DEFAULT_ROOM, [chair], configs).score).toBeNull();
});
it("is positive with a clear line of sight", () => {
expect(screenScore(room, [chair], configs).score).toBeGreaterThan(0.5);
});
it("is 0 for a seat whose line of sight is blocked by an obstacle", () => {
const blocked: Room = { ...room, obstacles: [{ id: "o", x: 4.5, y: 2, width: 1, height: 0.5 }] };
const r = screenScore(blocked, [chair], configs, { debug: true });
expect(r.score).toBe(0);
expect(r.issues[0].text).toMatch(/blocked/);
expect(r.debug!.lines[0].blocked).toBe(true);
});
it("is not blocked by furniture", () => {
const table = item("t", "table", 4.1, 2); // between the chair and the screen
expect(screenScore(room, [chair, table], configs).debug).toBeUndefined();
const r = screenScore(room, [chair, table], configs, { debug: true });
expect(r.debug!.lines.find((l) => l.itemId === "c")!.blocked).toBe(false);
});
});
describe("comfort", () => {
it("rewards spacing and penalizes crowding", () => {
const apart = [item("a", "chair", 1, 1), item("b", "chair", 7, 1)];
const close = [item("a", "chair", 1, 1), item("b", "chair", 1.6, 1)];
expect(comfortScore(DEFAULT_ROOM, apart, configs).score!).toBeGreaterThan(comfortScore(DEFAULT_ROOM, close, configs).score!);
});
});
describe("rules", () => {
const rule = (r: Partial<Rule>): Rule => ({ id: "r", kind: "againstWall", priority: "medium", typeA: "table", ...r });
it("flags a table that isn't against a wall", () => {
const res = evaluateRules(DEFAULT_ROOM, [item("t", "table", 4, 3)], configs, [rule({})]);
expect(res.violations).toHaveLength(1);
expect(res.score).toBe(0);
});
it("passes a table touching a wall", () => {
const res = evaluateRules(DEFAULT_ROOM, [item("t", "table", 0, 3)], configs, [rule({})]);
expect(res.violations).toHaveLength(0);
expect(res.score).toBe(1);
});
it("keeps Required violations out of the score but reports them", () => {
const res = evaluateRules(DEFAULT_ROOM, [item("t", "table", 4, 3)], configs, [rule({ priority: "required" })]);
expect(res.violations[0].priority).toBe("required");
expect(res.score).toBeNull();
});
it("checks keepApart edge to edge", () => {
const rules = [rule({ kind: "keepApart", typeA: "table", typeB: "chair", min: 2 })];
const items = [item("t", "table", 4, 3), item("c", "chair", 6.3, 3)]; // 0.5 m gap
const res = evaluateRules(DEFAULT_ROOM, items, configs, rules);
expect(res.violations[0].amount).toBeCloseTo(1.5);
});
});
describe("overall score", () => {
it("averages only the scores that apply, by weight", () => {
const scores = { loading: 1, screen: null, access: 0, comfort: null, rules: null };
expect(overallScore(scores, { ...weights, loading: 5, access: 5 })).toBeCloseTo(50);
expect(overallScore(scores, { ...weights, loading: 10, access: 0 })).toBeCloseTo(100);
expect(overallScore(scores, { loading: 0, screen: 0, access: 0, comfort: 0, rules: 0 })).toBeNull();
});
});
+64
View File
@@ -0,0 +1,64 @@
import { screenStrip } from "../geometry";
import type { FurnitureItem, FurnitureTypeConfig, Room, Wall } from "../types";
import { rectCenter, segmentHitsRect, type Pt } from "./geom";
import { getSeatPoints } from "./seats";
import type { ScoreOpts, ScoreResult, SightLine } from "./types";
const NORMAL: Record<Wall, Pt> = { top: { x: 0, y: 1 }, bottom: { x: 0, y: -1 }, left: { x: 1, y: 0 }, right: { x: -1, y: 0 } };
const IDEAL_ANGLE_DEG = 45;
const IDEAL_MIN_X_WIDTH = 1.5; // ideal distance: 1.5x ... 6x the screen width
const IDEAL_MAX_X_WIDTH = 6;
/** Per seat: viewing angle off the screen's normal, distance, and line of sight (only obstacles block it). */
export function screenScore(
room: Room,
items: FurnitureItem[],
configs: Record<string, FurnitureTypeConfig>,
opts: ScoreOpts = {},
): ScoreResult<{ lines: SightLine[] }> {
const seats = getSeatPoints(items, configs);
if (room.screens.length === 0 || seats.length === 0) return { score: null, issues: [] };
const screens = room.screens.map((s) => ({ s, c: rectCenter(screenStrip(room, s)), n: NORMAL[s.wall] }));
let sum = 0;
const blockedItems = new Set<string>();
const poorItems = new Set<string>();
let blocked = 0;
let poor = 0;
const lines: SightLine[] = [];
for (const seat of seats) {
let best = screens[0];
let bestD = Infinity;
for (const sc of screens) {
const d = Math.hypot(seat.x - sc.c.x, seat.y - sc.c.y);
if (d < bestD) {
bestD = d;
best = sc;
}
}
const d = Math.max(bestD, 1e-6);
const cos = ((seat.x - best.c.x) * best.n.x + (seat.y - best.c.y) * best.n.y) / d;
const angle = (Math.acos(Math.max(-1, Math.min(1, cos))) * 180) / Math.PI;
const angleScore = angle <= IDEAL_ANGLE_DEG ? 1 : Math.max(0, 1 - (angle - IDEAL_ANGLE_DEG) / (90 - IDEAL_ANGLE_DEG));
const r = d / best.s.width;
const distScore =
r < IDEAL_MIN_X_WIDTH ? r / IDEAL_MIN_X_WIDTH : r <= IDEAL_MAX_X_WIDTH ? 1 : Math.max(0, 1 - (r - IDEAL_MAX_X_WIDTH) / IDEAL_MAX_X_WIDTH);
const isBlocked = room.obstacles.some((o) => segmentHitsRect({ x: seat.x, y: seat.y }, best.c, o));
if (isBlocked) {
blocked++;
blockedItems.add(seat.itemId);
} else if (angleScore < 0.5) {
poor++;
poorItems.add(seat.itemId);
}
sum += isBlocked ? 0 : 0.5 * angleScore + 0.5 * distScore;
if (opts.debug) lines.push({ itemId: seat.itemId, from: { x: seat.x, y: seat.y }, to: best.c, blocked: isBlocked });
}
const issues = [];
if (blocked) issues.push({ text: `${blocked} seat${blocked > 1 ? "s" : ""} blocked from the screen`, itemIds: [...blockedItems] });
if (poor) issues.push({ text: `${poor} seat${poor > 1 ? "s" : ""} with a poor view angle`, itemIds: [...poorItems] });
return { score: sum / seats.length, issues, debug: opts.debug ? { lines } : undefined };
}
+70
View File
@@ -0,0 +1,70 @@
import type { FurnitureItem, FurnitureTypeConfig } from "../types";
import { effectiveSeats } from "../inventory";
import { distToShape, perimeterPoint, rectCenter } from "./geom";
export interface SeatPoint {
x: number;
y: number;
itemId: string;
}
/** A chair within this distance of a table edge is that table's seat, not a separate one. */
export const TABLE_SEAT_RADIUS_M = 0.8;
type Configs = Record<string, FurnitureTypeConfig>;
export interface SeatInfo {
points: SeatPoint[];
tableSeatChairIds: Set<string>; // chairs assumed to be table seats (not counted again)
}
export function getSeatInfo(items: FurnitureItem[], configs: Configs): SeatInfo {
const points: SeatPoint[] = [];
const tableSeatChairIds = new Set<string>();
const tables = items.filter((i) => configs[i.type]?.isTable);
for (const item of items) {
const cfg = configs[item.type];
if (!cfg) continue;
if (cfg.isTable) {
// N seats evenly around the perimeter
const n = effectiveSeats(item, cfg) ?? 0;
const P = 2 * (item.width + item.height);
for (let k = 0; k < n; k++) {
const { p } = perimeterPoint(item, ((k + 0.5) * P) / n);
points.push({ x: p.x, y: p.y, itemId: item.id });
}
} else if (cfg.shape === "chair") {
const n = cfg.seatsPerUnit ?? 1;
const c = rectCenter(item);
if (n <= 1) {
// single chair: skip if it's pulled up to a table
if (tables.some((t) => distToShape(t, c) <= TABLE_SEAT_RADIUS_M)) {
tableSeatChairIds.add(item.id);
continue;
}
points.push({ x: c.x, y: c.y, itemId: item.id });
} else {
// multi-seat (couch): spread along the long side
const len = Math.max(item.width, item.height);
const alongWidth = item.width >= item.height;
const t = (item.rotation * Math.PI) / 180;
for (let k = 0; k < n; k++) {
const off = ((k + 0.5) / n - 0.5) * len;
const lx = alongWidth ? off : 0;
const ly = alongWidth ? 0 : off;
points.push({
x: c.x + lx * Math.cos(t) - ly * Math.sin(t),
y: c.y + lx * Math.sin(t) + ly * Math.cos(t),
itemId: item.id,
});
}
}
}
}
return { points, tableSeatChairIds };
}
export const getSeatPoints = (items: FurnitureItem[], configs: Configs): SeatPoint[] =>
getSeatInfo(items, configs).points;
+34
View File
@@ -0,0 +1,34 @@
import type { Pt } from "./geom";
export interface Issue {
text: string;
itemIds: string[]; // items to highlight when the issue is clicked
}
/** A score (0..1, or null when it doesn't apply to this room) plus issues and optional overlay data. */
export interface ScoreResult<D = undefined> {
score: number | null;
issues: Issue[];
debug?: D;
}
export interface ScoreOpts {
debug?: boolean; // also build overlay data (slower)
}
export interface Polyline {
itemId: string;
points: Pt[];
}
export interface SightLine {
itemId: string;
from: Pt;
to: Pt;
blocked: boolean;
}
export interface ClearanceSegment {
itemId: string;
from: Pt;
to: Pt;
ok: boolean;
}
+18 -2
View File
@@ -1,4 +1,4 @@
import type { FurnitureItem, FurnitureType, FurnitureTypeConfig, Room } from "./types";
import type { FurnitureItem, FurnitureType, FurnitureTypeConfig, Room, Rule, ScoreWeights } from "./types";
import { DEFAULT_ROOM } from "./geometry";
// Single source of truth. An optimizer can read `state` and dispatch actions.
@@ -7,6 +7,8 @@ export interface LayoutState {
items: FurnitureItem[];
configs: Record<FurnitureType, FurnitureTypeConfig>;
typeOrder: FurnitureType[]; // palette order
rules: Rule[];
weights: ScoreWeights;
}
export type Action =
@@ -17,7 +19,11 @@ export type Action =
| { kind: "setRoom"; room: Room; clearItems?: boolean }
| { kind: "setAvailable"; type: FurnitureType; value: number }
| { kind: "setSeats"; type: FurnitureType; value: number }
| { kind: "addConfig"; config: FurnitureTypeConfig };
| { kind: "addConfig"; config: FurnitureTypeConfig }
| { kind: "addRule"; rule: Rule }
| { kind: "updateRule"; id: string; patch: Partial<Rule> }
| { kind: "removeRule"; id: string }
| { kind: "setWeight"; key: keyof ScoreWeights; value: number };
const BUILT_IN: FurnitureTypeConfig[] = [
{ id: "chair", name: "Chair", shape: "chair", defaultWidth: 0.5, defaultHeight: 0.5, isTable: false, rotatable: false, available: 20 },
@@ -30,6 +36,8 @@ export const initialState: LayoutState = {
items: [],
configs: Object.fromEntries(BUILT_IN.map((c) => [c.id, c])),
typeOrder: BUILT_IN.map((c) => c.id),
rules: [],
weights: { loading: 5, screen: 5, access: 5, comfort: 5, rules: 5 },
};
export function reducer(state: LayoutState, action: Action): LayoutState {
@@ -72,6 +80,14 @@ export function reducer(state: LayoutState, action: Action): LayoutState {
...state,
configs: { ...state.configs, [action.type]: { ...state.configs[action.type], seatsPerUnit: action.value } },
};
case "addRule":
return { ...state, rules: [...state.rules, action.rule] };
case "updateRule":
return { ...state, rules: state.rules.map((r) => (r.id === action.id ? { ...r, ...action.patch } : r)) };
case "removeRule":
return { ...state, rules: state.rules.filter((r) => r.id !== action.id) };
case "setWeight":
return { ...state, weights: { ...state.weights, [action.key]: action.value } };
case "addConfig":
return {
...state,
+24
View File
@@ -73,3 +73,27 @@ export interface FurnitureTypeConfig {
available: number; // inventory count
seatsPerUnit?: number; // type-level seats (tables, couches...)
}
// ---- Placement rules & scoring ----
export type Priority = "required" | "high" | "medium" | "low";
export type RuleKind = "keepApart" | "keepNear" | "screenDistance" | "doorDistance" | "againstWall";
export interface Rule {
id: string;
kind: RuleKind;
priority: Priority;
typeA: string; // furniture type id the rule is about
typeB?: string; // keepApart / keepNear
min?: number; // meters
max?: number;
}
/** 0-10 importance of each sub-score in the overall score. */
export interface ScoreWeights {
loading: number;
screen: number;
access: number;
comfort: number;
rules: number;
}