From 5d7df0187a3cc80c52864a53aca8497e12abc6bf Mon Sep 17 00:00:00 2001 From: john Date: Sat, 17 Oct 2026 13:01:59 -0400 Subject: [PATCH] Phase 4 (part 1): pure scoring modules, rules evaluation, vitest tests Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 288 +++++++++++++++++++++++++++++++++++- package.json | 6 +- src/geometry.ts | 4 +- src/scoring/access.ts | 88 +++++++++++ src/scoring/comfort.ts | 63 ++++++++ src/scoring/geom.ts | 100 +++++++++++++ src/scoring/index.ts | 68 +++++++++ src/scoring/loading.ts | 83 +++++++++++ src/scoring/occupancy.ts | 163 ++++++++++++++++++++ src/scoring/rules.ts | 150 +++++++++++++++++++ src/scoring/scoring.test.ts | 141 ++++++++++++++++++ src/scoring/screen.ts | 64 ++++++++ src/scoring/seats.ts | 70 +++++++++ src/scoring/types.ts | 34 +++++ src/store.ts | 20 ++- src/types.ts | 24 +++ 16 files changed, 1359 insertions(+), 7 deletions(-) create mode 100644 src/scoring/access.ts create mode 100644 src/scoring/comfort.ts create mode 100644 src/scoring/geom.ts create mode 100644 src/scoring/index.ts create mode 100644 src/scoring/loading.ts create mode 100644 src/scoring/occupancy.ts create mode 100644 src/scoring/rules.ts create mode 100644 src/scoring/scoring.test.ts create mode 100644 src/scoring/screen.ts create mode 100644 src/scoring/seats.ts create mode 100644 src/scoring/types.ts diff --git a/package-lock.json b/package-lock.json index 1c609a6..1797270 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index 38161ed..2d956d2 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/geometry.ts b/src/geometry.ts index b70a6b6..4a1356c 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -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, ]; diff --git a/src/scoring/access.ts b/src/scoring/access.ts new file mode 100644 index 0000000..5d7a044 --- /dev/null +++ b/src/scoring/access.ts @@ -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, + 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 = {}; + + 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 }; +} diff --git a/src/scoring/comfort.ts b/src/scoring/comfort.ts new file mode 100644 index 0000000..d0fa959 --- /dev/null +++ b/src/scoring/comfort.ts @@ -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, +): 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(); + const doorItems = new Set(); + 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 }; +} diff --git a/src/scoring/geom.ts b/src/scoring/geom.ts new file mode 100644 index 0000000..043eb00 --- /dev/null +++ b/src/scoring/geom.ts @@ -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) + ); +} diff --git a/src/scoring/index.ts b/src/scoring/index.ts new file mode 100644 index 0000000..2163396 --- /dev/null +++ b/src/scoring/index.ts @@ -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; // 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, 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, + 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, + }; +} diff --git a/src/scoring/loading.ts b/src/scoring/loading.ts new file mode 100644 index 0000000..8df1ee5 --- /dev/null +++ b/src/scoring/loading.ts @@ -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, + 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, + }; +} + diff --git a/src/scoring/occupancy.ts b/src/scoring/occupancy.ts new file mode 100644 index 0000000..afc7ecf --- /dev/null +++ b/src/scoring/occupancy.ts @@ -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; // 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(); + +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; diff --git a/src/scoring/rules.ts b/src/scoring/rules.ts new file mode 100644 index 0000000..9c4e1b0 --- /dev/null +++ b/src/scoring/rules.ts @@ -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, 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 => { + 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, + rules: Rule[], +): RulesResult { + const bounds = new Map(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(); + 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 }; +} diff --git a/src/scoring/scoring.test.ts b/src/scoring/scoring.test.ts new file mode 100644 index 0000000..85b4325 --- /dev/null +++ b/src/scoring/scoring.test.ts @@ -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 => ({ 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(); + }); +}); diff --git a/src/scoring/screen.ts b/src/scoring/screen.ts new file mode 100644 index 0000000..6e76b79 --- /dev/null +++ b/src/scoring/screen.ts @@ -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 = { 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, + 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(); + const poorItems = new Set(); + 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 }; +} diff --git a/src/scoring/seats.ts b/src/scoring/seats.ts new file mode 100644 index 0000000..bce062e --- /dev/null +++ b/src/scoring/seats.ts @@ -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; + +export interface SeatInfo { + points: SeatPoint[]; + tableSeatChairIds: Set; // chairs assumed to be table seats (not counted again) +} + +export function getSeatInfo(items: FurnitureItem[], configs: Configs): SeatInfo { + const points: SeatPoint[] = []; + const tableSeatChairIds = new Set(); + 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; diff --git a/src/scoring/types.ts b/src/scoring/types.ts new file mode 100644 index 0000000..74b92f3 --- /dev/null +++ b/src/scoring/types.ts @@ -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 { + 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; +} diff --git a/src/store.ts b/src/store.ts index 2c7a910..f74b0c0 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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; 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 } + | { 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, diff --git a/src/types.ts b/src/types.ts index 03199b3..3a7c8b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +}