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;
+}