From 75b391bad14aba49cf2431722241f9ccba8bc515 Mon Sep 17 00:00:00 2001 From: plus_one Date: Sat, 17 Oct 2026 21:39:34 -0400 Subject: [PATCH] Clamp all objects/entrance to room bounds and draw entrance-to-object path flow in optimize mode --- dist/index.html | 2 +- src/App.tsx | 25 ++++++++--- src/ai/nn.ts | 13 ++++-- src/components/ControlPanel.tsx | 9 ++-- src/components/RoomCanvas.tsx | 73 +++++++++++++++++++++++++++----- src/geometry/bounds.ts | 41 ++++++++++++++++++ src/layout/evaluator.ts | 9 ++-- src/optimization/randomSearch.ts | 20 ++++++--- src/pathfinding/grid.ts | 28 ++++++++++++ src/pathfinding/paths.ts | 32 ++++++++++++++ 10 files changed, 217 insertions(+), 35 deletions(-) create mode 100644 src/geometry/bounds.ts create mode 100644 src/pathfinding/paths.ts diff --git a/dist/index.html b/dist/index.html index 0cca11c..f8101ee 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5,7 +5,7 @@ furniture-app - + diff --git a/src/App.tsx b/src/App.tsx index 2dcc5dd..e8f20f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import type { FurnitureConfig, FurniturePreset, LayoutState } from './types'; import { runRandomSearch } from './optimization/randomSearch'; import { optimizeWithNN } from './ai/nn'; import { DEFAULT_PRESETS } from './furniture/presets'; +import { clampRectToRoom, clampStateToRoom } from './geometry/bounds'; const initialState: LayoutState = { room: { width: 600, height: 400, gridSize: 10 }, @@ -13,20 +14,29 @@ const initialState: LayoutState = { presets: DEFAULT_PRESETS, }; -function makeFurniture(preset: FurniturePreset, index: number): FurnitureConfig { +function makeFurniture(preset: FurniturePreset, index: number, room: LayoutState['room']): FurnitureConfig { + const spawn = clampRectToRoom( + { + x: 80 + (index % 5) * 60, + y: 80 + Math.floor(index / 5) * 60, + width: preset.shape.width, + height: preset.shape.height, + }, + room, + ); return { id: `${preset.id}_${Date.now()}_${index}`, name: `${preset.name} ${index + 1}`, type: preset.id, shape: { ...preset.shape, - x: 80 + (index % 5) * 60, - y: 80 + Math.floor(index / 5) * 60, + x: spawn.x, + y: spawn.y, + width: spawn.width, + height: spawn.height, }, conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })), color: preset.color, - lockedForOptimization: false, - placed: false, }; } @@ -49,7 +59,7 @@ function syncFurniture(state: LayoutState): LayoutState { furniture = furniture.filter((f) => !removeIds.has(f.id)); } else if (existing.length < desired) { for (let i = existing.length; i < desired; i++) { - furniture.push(makeFurniture(preset, i)); + furniture.push(makeFurniture(preset, i, state.room)); } } @@ -76,7 +86,7 @@ function App() { const [timeLeft, setTimeLeft] = useState(15); useEffect(() => { - setState((s) => syncFurniture(s)); + setState((s) => clampStateToRoom(syncFurniture(s))); }, [state.presets]); useEffect(() => { @@ -129,6 +139,7 @@ function App() { mode={mode} selectedId={selectedId} onSelect={setSelectedId} + showPaths={mode === 'optimize'} /> diff --git a/src/ai/nn.ts b/src/ai/nn.ts index c76bc0c..4ba4220 100644 --- a/src/ai/nn.ts +++ b/src/ai/nn.ts @@ -1,5 +1,6 @@ import type { LayoutEvaluation, LayoutState } from '../types'; import { evaluateLayout } from '../layout/evaluator'; +import { clampRectToRoom } from '../geometry/bounds'; export interface NNOptions { gridSize?: number; @@ -55,10 +56,14 @@ export function optimizeWithNN(state: LayoutState, options: NNOptions = {}): { b } if (targetX < 0) targetX = 0; if (targetY < 0) targetY = 0; - if (targetX + f.shape.width > candidate.room.width) targetX = candidate.room.width - f.shape.width; - if (targetY + f.shape.height > candidate.room.height) targetY = candidate.room.height - f.shape.height; - f.shape.x = targetX; - f.shape.y = targetY; + const b = clampRectToRoom( + { x: targetX, y: targetY, width: f.shape.width, height: f.shape.height }, + candidate.room, + ); + f.shape.x = b.x; + f.shape.y = b.y; + f.shape.width = b.width; + f.shape.height = b.height; } const bestEval = evaluateLayout(candidate, options.gridSize || 10); diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index 7d0d7a7..06cfbae 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import type { ConditionKind, Entrance, FurniturePreset, LayoutState, RoomConfig } from '../types'; import { NumberField } from './NumberField'; +import { clampStateToRoom } from '../geometry/bounds'; interface ControlPanelProps { state: LayoutState; @@ -58,14 +59,16 @@ export function ControlPanel({ if (selectedTypeId) setOpenId(selectedTypeId); }, [selectedTypeId]); - const setRoom = (room: Partial) => onStateChange({ ...state, room: { ...state.room, ...room } }); + const setRoom = (room: Partial) => + onStateChange(clampStateToRoom({ ...state, room: { ...state.room, ...room } })); const setEntrance = (entrance: Partial) => { if (!state.entrance) return; - onStateChange({ ...state, entrance: { ...state.entrance, ...entrance } }); + onStateChange(clampStateToRoom({ ...state, entrance: { ...state.entrance, ...entrance } })); }; - const placeEntrance = () => onStateChange({ ...state, entrance: { x: 20, y: 20, width: 20, height: 20 } }); + const placeEntrance = () => + onStateChange(clampStateToRoom({ ...state, entrance: { x: 20, y: 20, width: 20, height: 20 } })); const updateObject = (id: string, patch: Partial) => { const presets = objects.map((p) => (p.id === id ? { ...p, ...patch } : p)); diff --git a/src/components/RoomCanvas.tsx b/src/components/RoomCanvas.tsx index e02338f..fac571a 100644 --- a/src/components/RoomCanvas.tsx +++ b/src/components/RoomCanvas.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import type { LayoutState, Point } from '../types'; +import { computePaths } from '../pathfinding/paths'; +import { clampRectToRoom } from '../geometry/bounds'; interface RoomCanvasProps { state: LayoutState; @@ -7,6 +9,7 @@ interface RoomCanvasProps { mode: 'layout' | 'optimize'; selectedId: string | null; onSelect: (id: string | null) => void; + showPaths?: boolean; } interface DragState { @@ -16,14 +19,21 @@ interface DragState { resizing?: boolean; } -export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }: RoomCanvasProps) { +export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, showPaths }: RoomCanvasProps) { const canvasRef = useRef(null); const [drag, setDrag] = useState(null); const [zoom] = useState(1); + const [phase, setPhase] = useState(0); + + useEffect(() => { + if (!showPaths) return; + const t = setInterval(() => setPhase((p) => (p + 1) % 1000), 60); + return () => clearInterval(t); + }, [showPaths]); useEffect(() => { draw(); - }, [state, selectedId, mode, zoom]); + }, [state, selectedId, mode, zoom, showPaths, phase]); const draw = () => { const canvas = canvasRef.current; @@ -58,6 +68,32 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }: ctx.lineWidth = 2; ctx.strokeRect(0, 0, state.room.width, state.room.height); + if (showPaths && state.entrance) { + const paths = computePaths(state, state.room.gridSize || 10); + ctx.save(); + ctx.lineWidth = 2; + ctx.setLineDash([6, 6]); + ctx.lineDashOffset = -phase * 1.5; + for (const { path } of paths) { + if (path.length < 2) continue; + ctx.strokeStyle = 'rgba(79, 140, 255, 0.6)'; + ctx.beginPath(); + ctx.moveTo(path[0].x, path[0].y); + for (let i = 1; i < path.length; i++) { + ctx.lineTo(path[i].x, path[i].y); + } + ctx.stroke(); + const end = path[path.length - 1]; + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(79, 140, 255, 0.9)'; + ctx.beginPath(); + ctx.arc(end.x, end.y, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.setLineDash([6, 6]); + } + ctx.restore(); + } + if (state.entrance) { const e = state.entrance; const w = e.width ?? 20; @@ -166,25 +202,40 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }: if (drag.id === '__entrance__' && state.entrance) { const en = state.entrance; if (drag.resizing) { - const newW = Math.max(10, pos.x - en.x); - const newH = Math.max(10, pos.y - en.y); - onStateChange({ ...state, entrance: { ...en, width: newW, height: newH } }); + const b = clampRectToRoom( + { x: en.x, y: en.y, width: pos.x - en.x, height: pos.y - en.y }, + state.room, + 10, + ); + onStateChange({ ...state, entrance: { ...en, ...b } }); } else { - onStateChange({ - ...state, - entrance: { ...en, x: pos.x - drag.offsetX, y: pos.y - drag.offsetY }, - }); + const b = clampRectToRoom( + { x: pos.x - drag.offsetX, y: pos.y - drag.offsetY, width: en.width ?? 20, height: en.height ?? 20 }, + state.room, + 10, + ); + onStateChange({ ...state, entrance: { ...en, x: b.x, y: b.y } }); } return; } const updated = state.furniture.map((f) => { if (f.id !== drag.id) return f; + const b = clampRectToRoom( + { + x: pos.x - drag.offsetX, + y: pos.y - drag.offsetY, + width: f.shape.width, + height: f.shape.height, + }, + state.room, + 10, + ); return { ...f, shape: { ...f.shape, - x: pos.x - drag.offsetX, - y: pos.y - drag.offsetY, + x: b.x, + y: b.y, }, }; }); diff --git a/src/geometry/bounds.ts b/src/geometry/bounds.ts new file mode 100644 index 0000000..6a70aaa --- /dev/null +++ b/src/geometry/bounds.ts @@ -0,0 +1,41 @@ +import type { Entrance, FurnitureConfig, LayoutState, Rect, RoomConfig } from '../types'; + +export interface Bounds { + x: number; + y: number; + width: number; + height: number; +} + +export function clampRectToRoom(rect: Bounds, room: RoomConfig, minSize = 10): Bounds { + const width = Math.min(Math.max(minSize, rect.width), room.width); + const height = Math.min(Math.max(minSize, rect.height), room.height); + const x = Math.min(Math.max(0, rect.x), Math.max(0, room.width - width)); + const y = Math.min(Math.max(0, rect.y), Math.max(0, room.height - height)); + return { x, y, width, height }; +} + +export function clampStateToRoom(state: LayoutState): LayoutState { + const room = state.room; + const furniture: FurnitureConfig[] = state.furniture.map((f) => { + const b = clampRectToRoom( + { x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height }, + room, + ); + return { ...f, shape: { ...f.shape, ...b } }; + }); + let entrance: Entrance | null = state.entrance; + if (entrance) { + const ev = clampRectToRoom( + { x: entrance.x, y: entrance.y, width: entrance.width ?? 20, height: entrance.height ?? 20 }, + room, + 10, + ); + entrance = { ...entrance, x: ev.x, y: ev.y, width: ev.width, height: ev.height }; + } + return { ...state, furniture, entrance }; +} + +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); +} diff --git a/src/layout/evaluator.ts b/src/layout/evaluator.ts index e1d25b6..fdec1a1 100644 --- a/src/layout/evaluator.ts +++ b/src/layout/evaluator.ts @@ -1,5 +1,5 @@ import type { AccessibilityResult, FurnitureConfig, LayoutEvaluation, LayoutState, Point, Rect } from '../types'; -import { buildGrid } from '../pathfinding/grid'; +import { buildGrid, gridToWorld, nearestWalkable } from '../pathfinding/grid'; import { aStar } from '../pathfinding/aStar'; export function evaluateLayout(state: LayoutState, gridSize: number = 10): LayoutEvaluation { @@ -19,12 +19,15 @@ export function evaluateLayout(state: LayoutState, gridSize: number = 10): Layou const conditionScores: Record = {}; 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; for (const f of state.furniture) { const cx = f.shape.x + f.shape.width / 2; const cy = f.shape.y + f.shape.height / 2; - const target: Point = { x: cx, y: cy }; - const path = aStar(entrancePoint, target, grid, gridSize); + const targetCell = nearestWalkable(grid, { x: cx, y: cy }, gridSize); + const target: Point = targetCell ? gridToWorld(targetCell.col, targetCell.row, gridSize) : { x: cx, y: cy }; + const path = aStar(entranceWorld, target, grid, gridSize); if (path === null) { accessibility.push({ furnitureId: f.id, distanceFromEntrance: null, reachable: false, pathLength: null }); penalty += 100; diff --git a/src/optimization/randomSearch.ts b/src/optimization/randomSearch.ts index b3d4f47..d8f3214 100644 --- a/src/optimization/randomSearch.ts +++ b/src/optimization/randomSearch.ts @@ -1,6 +1,7 @@ import type { LayoutEvaluation, LayoutState } from '../types'; import { evaluateLayout } from '../layout/evaluator'; import { Furniture } from '../furniture/Furniture'; +import { clampRectToRoom } from '../geometry/bounds'; export interface RandomSearchOptions { iterations: number; @@ -20,12 +21,19 @@ export function runRandomSearch( const candidate = cloneState(bestState); for (const f of candidate.furniture) { if (f.lockedForOptimization) continue; - f.shape.x = Math.random() * (options.roomWidth - f.shape.width); - f.shape.y = Math.random() * (options.roomHeight - f.shape.height); - if (f.shape.x < 0) f.shape.x = 0; - if (f.shape.y < 0) f.shape.y = 0; - if (f.shape.x + f.shape.width > options.roomWidth) f.shape.x = options.roomWidth - f.shape.width; - if (f.shape.y + f.shape.height > options.roomHeight) f.shape.y = options.roomHeight - f.shape.height; + 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 }, + ); + f.shape.x = b.x; + f.shape.y = b.y; + f.shape.width = b.width; + f.shape.height = b.height; } const evalRes = evaluateLayout(candidate, options.gridSize || 10); if (evalRes.totalScore > bestEval.totalScore + 1e-9) { diff --git a/src/pathfinding/grid.ts b/src/pathfinding/grid.ts index 5111e6b..3721d2e 100644 --- a/src/pathfinding/grid.ts +++ b/src/pathfinding/grid.ts @@ -36,3 +36,31 @@ export function worldToGrid(p: Point, gridSize: number): { col: number; row: num export function gridToWorld(col: number, row: number, gridSize: number): Point { return { x: col * gridSize + gridSize / 2, y: row * gridSize + gridSize / 2 }; } + +export function nearestWalkable( + grid: GridCell[][], + point: Point, + gridSize: number, +): { col: number; row: number } | null { + const cols = grid[0]?.length ?? 0; + const rows = grid.length ?? 0; + const start = worldToGrid(point, gridSize); + const sc = Math.min(Math.max(start.col, 0), cols - 1); + const sr = Math.min(Math.max(start.row, 0), rows - 1); + if (grid[sr]?.[sc]?.walkable) return { col: sc, row: sr }; + + const maxRadius = Math.max(cols, rows); + for (let radius = 1; radius <= maxRadius; radius++) { + for (let dr = -radius; dr <= radius; dr++) { + for (let dc = -radius; dc <= radius; dc++) { + if (Math.abs(dr) !== radius && Math.abs(dc) !== radius) continue; + const c = sc + dc; + const r = sr + dr; + if (r < 0 || c < 0 || r >= rows || c >= cols) continue; + if (grid[r][c].walkable) return { col: c, row: r }; + } + } + } + return null; +} + diff --git a/src/pathfinding/paths.ts b/src/pathfinding/paths.ts new file mode 100644 index 0000000..585c18f --- /dev/null +++ b/src/pathfinding/paths.ts @@ -0,0 +1,32 @@ +import type { LayoutState, Point, Rect } from '../types'; +import { buildGrid, gridToWorld, nearestWalkable } from './grid'; +import { aStar } from './aStar'; + +export interface ObjectPath { + furnitureId: string; + path: Point[]; +} + +export function computePaths(state: LayoutState, gridSize: number = 10): ObjectPath[] { + if (!state.entrance) return []; + const obstacles: Rect[] = state.furniture + .map((f) => ({ x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height })) + .filter((r) => r.width > 0 && r.height > 0); + + const grid = buildGrid(state.room, obstacles, gridSize); + const entranceCell = nearestWalkable(grid, { x: state.entrance.x, y: state.entrance.y }, gridSize); + if (!entranceCell) return []; + const entranceWorld = gridToWorld(entranceCell.col, entranceCell.row, gridSize); + + const paths: ObjectPath[] = []; + for (const f of state.furniture) { + const cx = f.shape.x + f.shape.width / 2; + const cy = f.shape.y + f.shape.height / 2; + const targetCell = nearestWalkable(grid, { x: cx, y: cy }, gridSize); + if (!targetCell) continue; + const target = gridToWorld(targetCell.col, targetCell.row, gridSize); + const path = aStar(entranceWorld, target, grid, gridSize); + if (path) paths.push({ furnitureId: f.id, path }); + } + return paths; +}