Clamp all objects/entrance to room bounds and draw entrance-to-object path flow in optimize mode

This commit is contained in:
2026-10-17 21:39:34 -04:00
parent 73bdecef2b
commit 75b391bad1
10 changed files with 217 additions and 35 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>furniture-app</title>
<script type="module" crossorigin src="/assets/index-CEvVbPGt.js"></script>
<script type="module" crossorigin src="/assets/index-Be3LQNz7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Gl7EQEpA.css">
</head>
<body>
+18 -7
View File
@@ -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'}
/>
</div>
</div>
+9 -4
View File
@@ -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);
+6 -3
View File
@@ -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<RoomConfig>) => onStateChange({ ...state, room: { ...state.room, ...room } });
const setRoom = (room: Partial<RoomConfig>) =>
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, ...room } }));
const setEntrance = (entrance: Partial<Entrance>) => {
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<FurniturePreset>) => {
const presets = objects.map((p) => (p.id === id ? { ...p, ...patch } : p));
+62 -11
View File
@@ -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<HTMLCanvasElement>(null);
const [drag, setDrag] = useState<DragState | null>(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,
},
};
});
+41
View File
@@ -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);
}
+6 -3
View File
@@ -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<string, number> = {};
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;
+14 -6
View File
@@ -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) {
+28
View File
@@ -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;
}
+32
View File
@@ -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;
}