Clamp all objects/entrance to room bounds and draw entrance-to-object path flow in optimize mode
This commit is contained in:
Vendored
+1
-1
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user