diff --git a/dist/index.html b/dist/index.html index c6b1040..e3022c7 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5,8 +5,8 @@ furniture-app - - + +
diff --git a/src/App.tsx b/src/App.tsx index be77a09..8d345f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ function App() { const [selectedId, setSelectedId] = useState(null); const [optimizing, setOptimizing] = useState(false); const [timeLeft, setTimeLeft] = useState(15); + const [lastScore, setLastScore] = useState(null); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -39,23 +40,26 @@ function App() { if (timeLeft <= 0) { const res = optimizeWithNN(state, { gridSize: 10 }); setState(res.bestState); + setLastScore(res.bestEval.totalScore); setOptimizing(false); setTimeLeft(15); return; } const t = setTimeout(() => setTimeLeft((v) => v - 1), 1000); - const { bestState } = runRandomSearch(state, { + const { bestState, bestEval } = runRandomSearch(state, { iterations: 5, roomWidth: state.room.width, roomHeight: state.room.height, gridSize: state.room.gridSize, }); setState(bestState); + setLastScore(bestEval.totalScore); return () => clearTimeout(t); }, [optimizing, timeLeft]); const handleOptimize = () => { setState((s) => prepareForOptimize(s)); + setLastScore(null); setOptimizing(true); setTimeLeft(15); }; @@ -72,6 +76,7 @@ function App() { onOptimize={handleOptimize} optimizing={optimizing} timeLeft={timeLeft} + lastScore={lastScore} />
diff --git a/src/ai/nn.ts b/src/ai/nn.ts index 4ba4220..98b6ef7 100644 --- a/src/ai/nn.ts +++ b/src/ai/nn.ts @@ -1,6 +1,6 @@ import type { LayoutEvaluation, LayoutState } from '../types'; import { evaluateLayout } from '../layout/evaluator'; -import { clampRectToRoom } from '../geometry/bounds'; +import { entranceRect, findFreeSpot, overlapsAny, resolveMove } from '../geometry/bounds'; export interface NNOptions { gridSize?: number; @@ -56,10 +56,15 @@ export function optimizeWithNN(state: LayoutState, options: NNOptions = {}): { b } if (targetX < 0) targetX = 0; if (targetY < 0) targetY = 0; - const b = clampRectToRoom( - { x: targetX, y: targetY, width: f.shape.width, height: f.shape.height }, - candidate.room, - ); + const room = candidate.room; + const others: any[] = candidate.furniture + .filter((o) => o.id !== f.id) + .map((o) => ({ x: o.shape.x, y: o.shape.y, width: o.shape.width, height: o.shape.height })); + const ent = entranceRect(candidate); + if (ent) others.push(ent); + const b = overlapsAny({ x: targetX, y: targetY, width: f.shape.width, height: f.shape.height }, others) + ? findFreeSpot(room, { width: f.shape.width, height: f.shape.height }, others) + : resolveMove({ x: targetX, y: targetY, width: f.shape.width, height: f.shape.height }, others, room); f.shape.x = b.x; f.shape.y = b.y; f.shape.width = b.width; diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index 601d412..3a51a3d 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -13,6 +13,7 @@ interface ControlPanelProps { onOptimize: () => void; optimizing: boolean; timeLeft: number; + lastScore: number | null; } const CONDITION_KINDS: ConditionKind[] = [ @@ -47,6 +48,7 @@ export function ControlPanel({ onOptimize, optimizing, timeLeft, + lastScore, }: ControlPanelProps) { const [openId, setOpenId] = useState(null); const objects = state.presets || []; @@ -369,6 +371,12 @@ export function ControlPanel({ +
+ Score + + {lastScore === null ? '—' : lastScore.toFixed(1)} + +
{optimizing && (
diff --git a/src/components/RoomCanvas.tsx b/src/components/RoomCanvas.tsx index dffbd02..4d43499 100644 --- a/src/components/RoomCanvas.tsx +++ b/src/components/RoomCanvas.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import type { LayoutState, Point } from '../types'; import { computePaths } from '../pathfinding/paths'; -import { clampRectToRoom } from '../geometry/bounds'; +import { resolveMove } from '../geometry/bounds'; import { makePlaced } from '../optimization/prepare'; interface RoomCanvasProps { @@ -202,16 +202,24 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s const pos = getMousePos(e); if (drag.id === '__entrance__' && state.entrance) { const en = state.entrance; + const others = state.furniture.map((o) => ({ + x: o.shape.x, + y: o.shape.y, + width: o.shape.width, + height: o.shape.height, + })); if (drag.resizing) { - const b = clampRectToRoom( + const b = resolveMove( { x: en.x, y: en.y, width: pos.x - en.x, height: pos.y - en.y }, + others, state.room, 10, ); onStateChange({ ...state, entrance: { ...en, ...b } }); } else { - const b = clampRectToRoom( + const b = resolveMove( { x: pos.x - drag.offsetX, y: pos.y - drag.offsetY, width: en.width ?? 20, height: en.height ?? 20 }, + others, state.room, 10, ); @@ -221,15 +229,22 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s } const updated = state.furniture.map((f) => { if (f.id !== drag.id) return f; - const b = clampRectToRoom( + const others: any[] = state.furniture + .filter((o) => o.id !== drag.id) + .map((o) => ({ x: o.shape.x, y: o.shape.y, width: o.shape.width, height: o.shape.height })); + if (state.entrance) { + const e = state.entrance; + others.push({ x: e.x, y: e.y, width: e.width ?? 20, height: e.height ?? 20 }); + } + const b = resolveMove( { x: pos.x - drag.offsetX, y: pos.y - drag.offsetY, width: f.shape.width, height: f.shape.height, }, + others, state.room, - 10, ); return { ...f, diff --git a/src/geometry/bounds.ts b/src/geometry/bounds.ts index 6a70aaa..11e4929 100644 --- a/src/geometry/bounds.ts +++ b/src/geometry/bounds.ts @@ -1,4 +1,4 @@ -import type { Entrance, FurnitureConfig, LayoutState, Rect, RoomConfig } from '../types'; +import type { Entrance, FurnitureConfig, LayoutState, Rect, RoomConfig, Size } from '../types'; export interface Bounds { x: number; @@ -39,3 +39,63 @@ export function clampStateToRoom(state: LayoutState): LayoutState { export function rectsOverlap(a: Rect, b: Rect): boolean { return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y); } + +export function overlapsAny(rect: Rect, others: Rect[]): boolean { + return others.some((o) => rectsOverlap(rect, o)); +} + +export function resolveMove(moving: Bounds, others: Rect[], room: RoomConfig, minSize = 10): Bounds { + let r = clampRectToRoom(moving, room, minSize); + for (let iter = 0; iter < 12; iter++) { + let pushed = false; + for (const o of others) { + if (!rectsOverlap(r, o)) continue; + const overlapLeft = r.x + r.width - o.x; + const overlapRight = o.x + o.width - r.x; + const overlapTop = r.y + r.height - o.y; + const overlapBottom = o.y + o.height - r.y; + const minX = Math.min(overlapLeft, overlapRight); + const minY = Math.min(overlapTop, overlapBottom); + if (minX < minY) { + r.x = overlapLeft < overlapRight ? o.x - r.width : o.x + o.width; + } else { + r.y = overlapTop < overlapBottom ? o.y - r.height : o.y + o.height; + } + r = clampRectToRoom(r, room, minSize); + pushed = true; + } + if (!pushed) break; + } + return r; +} + +export function findFreeSpot(room: RoomConfig, size: Size, others: Rect[], tries = 40): Bounds { + for (let i = 0; i < tries; i++) { + const candidate = clampRectToRoom( + { + x: Math.random() * Math.max(0, room.width - size.width), + y: Math.random() * Math.max(0, room.height - size.height), + width: size.width, + height: size.height, + }, + room, + ); + if (!overlapsAny(candidate, others)) return candidate; + const resolved = resolveMove(candidate, others, room); + if (!overlapsAny(resolved, others)) return resolved; + } + return clampRectToRoom( + { x: 0, y: 0, width: size.width, height: size.height }, + room, + ); +} + +export function entranceRect(state: LayoutState): Rect | null { + if (!state.entrance) return null; + return { + x: state.entrance.x, + y: state.entrance.y, + width: state.entrance.width ?? 20, + height: state.entrance.height ?? 20, + }; +} diff --git a/src/index.css b/src/index.css index 5f22d25..6d77342 100644 --- a/src/index.css +++ b/src/index.css @@ -503,6 +503,31 @@ label { transition: width 0.9s linear; } +.score-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; + padding: 8px 10px; + border-radius: 8px; + background: var(--bg-input); + border: 1px solid var(--border); +} + +.score-label { + font-size: 12px; + color: var(--text-mute); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.score-value { + font-size: 16px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text); +} + .empty { color: var(--text-mute); font-size: 12px; diff --git a/src/layout/evaluator.ts b/src/layout/evaluator.ts index fdec1a1..f2b93b6 100644 --- a/src/layout/evaluator.ts +++ b/src/layout/evaluator.ts @@ -1,6 +1,7 @@ import type { AccessibilityResult, FurnitureConfig, LayoutEvaluation, LayoutState, Point, Rect } from '../types'; import { buildGrid, gridToWorld, nearestWalkable } from '../pathfinding/grid'; import { aStar } from '../pathfinding/aStar'; +import { entranceRect, rectsOverlap } from '../geometry/bounds'; export function evaluateLayout(state: LayoutState, gridSize: number = 10): LayoutEvaluation { const obstacles: Rect[] = state.furniture @@ -18,6 +19,18 @@ export function evaluateLayout(state: LayoutState, gridSize: number = 10): Layou let totalConditionScore = 0; const conditionScores: Record = {}; + for (let i = 0; i < obstacles.length; i++) { + for (let j = i + 1; j < obstacles.length; j++) { + if (rectsOverlap(obstacles[i], obstacles[j])) penalty += 20; + } + } + const entrance = entranceRect(state); + if (entrance) { + for (const o of obstacles) { + if (rectsOverlap(o, entrance)) penalty += 20; + } + } + const entrancePoint = state.entrance ? { x: state.entrance.x, y: state.entrance.y } : { x: 0, y: 0 }; const entranceCell = nearestWalkable(grid, entrancePoint, gridSize); const entranceWorld = entranceCell ? gridToWorld(entranceCell.col, entranceCell.row, gridSize) : entrancePoint; diff --git a/src/optimization/prepare.ts b/src/optimization/prepare.ts index 9cccf42..865d779 100644 --- a/src/optimization/prepare.ts +++ b/src/optimization/prepare.ts @@ -1,16 +1,13 @@ -import type { FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types'; -import { clampRectToRoom, clampStateToRoom } from '../geometry/bounds'; +import type { FurnitureConfig, FurniturePreset, LayoutState, Rect, RoomConfig } from '../types'; +import { clampStateToRoom, entranceRect, findFreeSpot, resolveMove } from '../geometry/bounds'; -export function makeGenerated(preset: FurniturePreset, index: number, room: RoomConfig): FurnitureConfig { - const b = clampRectToRoom( - { - x: Math.random() * Math.max(0, room.width - preset.shape.width), - y: Math.random() * Math.max(0, room.height - preset.shape.height), - width: preset.shape.width, - height: preset.shape.height, - }, - room, - ); +export function makeGenerated( + preset: FurniturePreset, + index: number, + room: RoomConfig, + others: Rect[] = [], +): FurnitureConfig { + const b = findFreeSpot(room, { width: preset.shape.width, height: preset.shape.height }, others); return { id: `${preset.id}_gen_${Date.now()}_${index}`, name: preset.name, @@ -22,11 +19,14 @@ export function makeGenerated(preset: FurniturePreset, index: number, room: Room }; } -export function makePlaced(preset: FurniturePreset, x: number, y: number, room: RoomConfig): FurnitureConfig { - const b = clampRectToRoom( - { x, y, width: preset.shape.width, height: preset.shape.height }, - room, - ); +export function makePlaced( + preset: FurniturePreset, + x: number, + y: number, + room: RoomConfig, + others: Rect[] = [], +): FurnitureConfig { + const b = resolveMove({ x, y, width: preset.shape.width, height: preset.shape.height }, others, room); return { id: `${preset.id}_placed_${Date.now()}_${Math.round(Math.random() * 1e6)}`, name: preset.name, @@ -41,6 +41,7 @@ export function makePlaced(preset: FurniturePreset, x: number, y: number, room: export function prepareForOptimize(state: LayoutState): LayoutState { const presets = state.presets || []; + const entrance = entranceRect(state); let furniture = [...state.furniture]; for (const preset of presets) { @@ -54,7 +55,13 @@ export function prepareForOptimize(state: LayoutState): LayoutState { generated = generated.slice(0, need); } while (generated.length < need) { - generated.push(makeGenerated(preset, generated.length, state.room)); + const others: Rect[] = [ + ...placed, + ...generated, + ...furniture.filter((f) => f.type !== preset.id), + ...(entrance ? [entrance] : []), + ].map((f: any) => ({ x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height })); + generated.push(makeGenerated(preset, generated.length, state.room, others)); } const synced = [...placed, ...generated].map((f) => ({ diff --git a/src/optimization/randomSearch.ts b/src/optimization/randomSearch.ts index d8f3214..f7f6580 100644 --- a/src/optimization/randomSearch.ts +++ b/src/optimization/randomSearch.ts @@ -1,7 +1,7 @@ -import type { LayoutEvaluation, LayoutState } from '../types'; +import type { LayoutEvaluation, LayoutState, Rect } from '../types'; import { evaluateLayout } from '../layout/evaluator'; import { Furniture } from '../furniture/Furniture'; -import { clampRectToRoom } from '../geometry/bounds'; +import { entranceRect, findFreeSpot } from '../geometry/bounds'; export interface RandomSearchOptions { iterations: number; @@ -19,17 +19,15 @@ export function runRandomSearch( for (let i = 0; i < options.iterations; i++) { const candidate = cloneState(bestState); + const room = { width: options.roomWidth, height: options.roomHeight }; + const entrance = entranceRect(candidate); for (const f of candidate.furniture) { if (f.lockedForOptimization) continue; - const b = clampRectToRoom( - { - x: Math.random() * Math.max(0, options.roomWidth - f.shape.width), - y: Math.random() * Math.max(0, options.roomHeight - f.shape.height), - width: f.shape.width, - height: f.shape.height, - }, - { width: options.roomWidth, height: options.roomHeight }, - ); + const others: Rect[] = candidate.furniture + .filter((o) => o.id !== f.id) + .map((o) => ({ x: o.shape.x, y: o.shape.y, width: o.shape.width, height: o.shape.height })); + if (entrance) others.push(entrance); + const b = findFreeSpot(room, { width: f.shape.width, height: f.shape.height }, others); f.shape.x = b.x; f.shape.y = b.y; f.shape.width = b.width;