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;