Compare commits
10 Commits
5b2bc25ef6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba7b0e89f | |||
| fa42856ccd | |||
| 2324d1db52 | |||
| 12e01b9ac9 | |||
| f596a48899 | |||
| d1af53cf07 | |||
| 8f8211c9b7 | |||
| 75b391bad1 | |||
| 73bdecef2b | |||
| 1ecac34502 |
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<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-xKTFqHjg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DfR9GACM.css">
|
||||
<script type="module" crossorigin src="/assets/index-BkulQhND.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-67F-Ik0K.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+34
-67
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RoomCanvas } from './components/RoomCanvas';
|
||||
import { ControlPanel } from './components/ControlPanel';
|
||||
import type { FurnitureConfig, FurniturePreset, LayoutState } from './types';
|
||||
import type { LayoutState } from './types';
|
||||
import { runRandomSearch } from './optimization/randomSearch';
|
||||
import { optimizeWithNN } from './ai/nn';
|
||||
import { prepareForOptimize } from './optimization/prepare';
|
||||
import { DEFAULT_PRESETS } from './furniture/presets';
|
||||
|
||||
const initialState: LayoutState = {
|
||||
@@ -13,84 +13,50 @@ const initialState: LayoutState = {
|
||||
presets: DEFAULT_PRESETS,
|
||||
};
|
||||
|
||||
function syncFurniture(state: LayoutState): LayoutState {
|
||||
const presets = state.presets || [];
|
||||
let furniture = [...state.furniture];
|
||||
for (const preset of presets) {
|
||||
const desired = preset.amount ?? 0;
|
||||
const existing = furniture.filter((f) => f.type === preset.id);
|
||||
if (existing.length > desired) {
|
||||
const removeIds = new Set(
|
||||
[...existing]
|
||||
.sort((a, b) => Number(a.placed) - Number(b.placed))
|
||||
.slice(0, existing.length - desired)
|
||||
.map((f) => f.id),
|
||||
);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...state, furniture };
|
||||
}
|
||||
|
||||
function makeFurniture(preset: FurniturePreset, index: number): FurnitureConfig {
|
||||
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,
|
||||
},
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
color: preset.color,
|
||||
lockedForOptimization: false,
|
||||
placed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<LayoutState>(initialState);
|
||||
const [mode, setMode] = useState<'layout' | 'optimize'>('layout');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState(15);
|
||||
const [lastScore, setLastScore] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setState((s) => syncFurniture(s));
|
||||
}, [state.presets]);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
|
||||
if (!selectedId || selectedId === '__entrance__') return;
|
||||
setState((s) => ({ ...s, furniture: s.furniture.filter((f) => f.id !== selectedId) }));
|
||||
setSelectedId(null);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!optimizing) return;
|
||||
if (timeLeft <= 0) {
|
||||
const res = optimizeWithNN(state, { gridSize: 10 });
|
||||
setState(res.bestState);
|
||||
setOptimizing(false);
|
||||
setTimeLeft(15);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setTimeLeft((v) => v - 1), 1000);
|
||||
const { bestState } = runRandomSearch(state, {
|
||||
iterations: 5,
|
||||
roomWidth: state.room.width,
|
||||
roomHeight: state.room.height,
|
||||
gridSize: state.room.gridSize,
|
||||
});
|
||||
setState(bestState);
|
||||
const tick = () => {
|
||||
const { bestState, bestEval } = runRandomSearch(state, {
|
||||
iterations: 40,
|
||||
roomWidth: state.room.width,
|
||||
roomHeight: state.room.height,
|
||||
gridSize: state.room.gridSize,
|
||||
});
|
||||
setState(bestState);
|
||||
setLastScore(bestEval.totalScore);
|
||||
};
|
||||
const t = setTimeout(tick, 30);
|
||||
return () => clearTimeout(t);
|
||||
}, [optimizing, timeLeft]);
|
||||
}, [optimizing, state, state.room.gridSize, state.room.width, state.room.height]);
|
||||
|
||||
const handleOptimize = () => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
furniture: s.furniture.map((f) => ({ ...f, lockedForOptimization: f.placed ? true : false })),
|
||||
}));
|
||||
if (optimizing) {
|
||||
setOptimizing(false);
|
||||
return;
|
||||
}
|
||||
setState((s) => prepareForOptimize(s));
|
||||
setLastScore(null);
|
||||
setOptimizing(true);
|
||||
setTimeLeft(15);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -104,7 +70,7 @@ function App() {
|
||||
onSelect={setSelectedId}
|
||||
onOptimize={handleOptimize}
|
||||
optimizing={optimizing}
|
||||
timeLeft={timeLeft}
|
||||
lastScore={lastScore}
|
||||
/>
|
||||
<div className="stage">
|
||||
<div className="canvas-frame">
|
||||
@@ -114,6 +80,7 @@ function App() {
|
||||
mode={mode}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
showPaths={mode === 'optimize'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+31
-5
@@ -1,5 +1,7 @@
|
||||
import type { LayoutEvaluation, LayoutState } from '../types';
|
||||
import { evaluateLayout } from '../layout/evaluator';
|
||||
import { entranceCollider, findFreeSpot, furnitureCollider, overlapsAny, resolveMove } from '../geometry/bounds';
|
||||
import type { Collider } from '../geometry/collision';
|
||||
|
||||
export interface NNOptions {
|
||||
gridSize?: number;
|
||||
@@ -14,6 +16,11 @@ export function optimizeWithNN(state: LayoutState, options: NNOptions = {}): { b
|
||||
...f,
|
||||
shape: { ...f.shape, points: f.shape.points?.map((p) => ({ ...p })) },
|
||||
})),
|
||||
presets: state.presets?.map((p) => ({
|
||||
...p,
|
||||
shape: { ...p.shape },
|
||||
conditions: p.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
})),
|
||||
};
|
||||
|
||||
const entrance = candidate.entrance;
|
||||
@@ -32,7 +39,10 @@ export function optimizeWithNN(state: LayoutState, options: NNOptions = {}): { b
|
||||
targetY = entrance.y + (Math.random() - 0.5) * 40;
|
||||
} else if (needsNearObj) {
|
||||
const tid = (needsNearObj.params as any)?.targetId;
|
||||
const t = tid ? candidate.furniture.find((x) => x.id === tid) : undefined;
|
||||
const t = tid
|
||||
? candidate.furniture.find((x) => x.id === tid) ||
|
||||
candidate.furniture.find((x) => x.type === tid && x.id !== f.id)
|
||||
: undefined;
|
||||
if (t) {
|
||||
targetX = t.shape.x + t.shape.width / 2 - f.shape.width / 2 + (Math.random() - 0.5) * 30;
|
||||
targetY = t.shape.y + t.shape.height / 2 - f.shape.height / 2 + (Math.random() - 0.5) * 30;
|
||||
@@ -52,10 +62,26 @@ 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 room = candidate.room;
|
||||
const others: Collider[] = candidate.furniture
|
||||
.filter((o) => o.id !== f.id)
|
||||
.map(furnitureCollider);
|
||||
const ent = entranceCollider(candidate);
|
||||
if (ent) others.push(ent);
|
||||
const moving: Collider = {
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
width: f.shape.width,
|
||||
height: f.shape.height,
|
||||
rotation: f.shape.rotation ?? 0,
|
||||
};
|
||||
const b = overlapsAny(moving, others)
|
||||
? findFreeSpot(room, { width: f.shape.width, height: f.shape.height }, others)
|
||||
: resolveMove(moving, others, 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);
|
||||
|
||||
+409
-279
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import type { ConditionKind, Entrance, FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ConditionKind, Entrance, FurniturePreset, LayoutState, RoomConfig } from '../types';
|
||||
import { NumberField } from './NumberField';
|
||||
import { clampStateToRoom } from '../geometry/bounds';
|
||||
import { rectanglePoints } from '../geometry/polygon';
|
||||
|
||||
interface ControlPanelProps {
|
||||
state: LayoutState;
|
||||
@@ -11,7 +13,7 @@ interface ControlPanelProps {
|
||||
onSelect: (id: string | null) => void;
|
||||
onOptimize: () => void;
|
||||
optimizing: boolean;
|
||||
timeLeft: number;
|
||||
lastScore: number | null;
|
||||
}
|
||||
|
||||
const CONDITION_KINDS: ConditionKind[] = [
|
||||
@@ -22,6 +24,7 @@ const CONDITION_KINDS: ConditionKind[] = [
|
||||
'accessible',
|
||||
'not_blocking_path',
|
||||
'open_circulation',
|
||||
'line_of_sight',
|
||||
];
|
||||
|
||||
const CONDITION_LABELS: Record<ConditionKind, string> = {
|
||||
@@ -32,8 +35,11 @@ const CONDITION_LABELS: Record<ConditionKind, string> = {
|
||||
accessible: 'Accessible',
|
||||
not_blocking_path: 'Not blocking path',
|
||||
open_circulation: 'Open circulation',
|
||||
line_of_sight: 'Clear line of sight',
|
||||
};
|
||||
|
||||
const SWATCHES = ['#a1887f', '#d7ccc5', '#90caf9', '#a5d6a7', '#ef9a9a', '#ce93d8', '#ffcc80'];
|
||||
|
||||
export function ControlPanel({
|
||||
state,
|
||||
onStateChange,
|
||||
@@ -43,269 +49,344 @@ export function ControlPanel({
|
||||
onSelect,
|
||||
onOptimize,
|
||||
optimizing,
|
||||
timeLeft,
|
||||
lastScore,
|
||||
}: ControlPanelProps) {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const objects = state.presets || [];
|
||||
|
||||
const selectedTypeId = selectedId
|
||||
? state.furniture.find((f) => f.id === selectedId)?.type ?? (selectedId === '__entrance__' ? '__entrance__' : null)
|
||||
: null;
|
||||
|
||||
const selectedFurniture = selectedId ? state.furniture.find((f) => f.id === selectedId) ?? null : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTypeId) setOpenId(selectedTypeId);
|
||||
}, [selectedTypeId]);
|
||||
|
||||
const setRoom = (room: Partial<RoomConfig>) => {
|
||||
onStateChange({ ...state, room: { ...state.room, ...room } });
|
||||
const next: RoomConfig = { ...state.room, ...room };
|
||||
if (next.points && next.points.length >= 3 && state.room.points && state.room.points.length>=3 && (Math.abs(next.width-state.room.width)>1e-12||Math.abs(next.height-state.room.height)>1e-12)) {
|
||||
const sx = next.width / (state.room.width || 1);
|
||||
const sy = next.height / (state.room.height || 1);
|
||||
next.points = next.points.map((pt) => ({ x: pt.x * sx, y: pt.y * sy }));
|
||||
}
|
||||
onStateChange(clampStateToRoom({ ...state, room: next }));
|
||||
};
|
||||
|
||||
const setRoomShape = (shape: 'rectangle' | 'polygon') => {
|
||||
if (shape === 'polygon') {
|
||||
const points = state.room.points ?? rectanglePoints(state.room.width, state.room.height);
|
||||
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, points } }));
|
||||
} else {
|
||||
const { width, height, gridSize } = state.room;
|
||||
onStateChange(clampStateToRoom({ ...state, room: { width, height, gridSize } }));
|
||||
}
|
||||
};
|
||||
|
||||
const addRoomVertex = () => {
|
||||
const pts = state.room.points;
|
||||
if (!pts || pts.length < 3) return;
|
||||
let best = -1;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
const len = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
|
||||
if (len > best) {
|
||||
best = len;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
const a = pts[idx];
|
||||
const b = pts[(idx + 1) % pts.length];
|
||||
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
const next = [...pts.slice(0, idx + 1), mid, ...pts.slice(idx + 1)];
|
||||
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, points: next } }));
|
||||
};
|
||||
|
||||
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 commitPresets = (presets: FurniturePreset[]) => {
|
||||
const byId = new Map(presets.map((p) => [p.id, p]));
|
||||
const furniture = state.furniture.map((f) => {
|
||||
const p = f.type ? byId.get(f.type) : undefined;
|
||||
if (!p) return f;
|
||||
return {
|
||||
...f,
|
||||
name: p.name,
|
||||
color: p.color,
|
||||
shape: {
|
||||
...f.shape,
|
||||
type: p.shape.type,
|
||||
width: p.shape.width,
|
||||
height: p.shape.height,
|
||||
scale: p.shape.scale ?? 1,
|
||||
},
|
||||
conditions: p.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
};
|
||||
});
|
||||
onStateChange(clampStateToRoom({ ...state, presets, furniture }));
|
||||
};
|
||||
|
||||
const updateObject = (id: string, patch: Partial<FurniturePreset>) => {
|
||||
const presets = objects.map((p) => (p.id === id ? { ...p, ...patch } : p));
|
||||
commitPresets(presets);
|
||||
};
|
||||
|
||||
const setAmount = (id: string, amount: number) => updateObject(id, { amount: Math.max(0, amount) });
|
||||
|
||||
const setFurnitureRotation = (id: string, rotation: number) => {
|
||||
const furniture = state.furniture.map((f) =>
|
||||
f.id === id ? { ...f, shape: { ...f.shape, rotation } } : f,
|
||||
);
|
||||
onStateChange(clampStateToRoom({ ...state, furniture }));
|
||||
};
|
||||
|
||||
const unlockAll = () => {
|
||||
onStateChange({
|
||||
...state,
|
||||
furniture: state.furniture.map((f) => ({ ...f, lockedForOptimization: false })),
|
||||
});
|
||||
};
|
||||
|
||||
const removeAllFurniture = () => {
|
||||
onStateChange({ ...state, furniture: [] });
|
||||
};
|
||||
|
||||
const removeObject = (id: string) => {
|
||||
onStateChange({
|
||||
...state,
|
||||
presets: objects.filter((p) => p.id !== id),
|
||||
furniture: state.furniture.filter((f) => f.type !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const createObject = () => {
|
||||
const id = 'obj_' + Date.now();
|
||||
const newItem: FurnitureConfig = {
|
||||
const newObject: FurniturePreset = {
|
||||
id,
|
||||
name: 'Object ' + (state.furniture.length + 1),
|
||||
type: 'custom',
|
||||
shape: { type: 'rectangle', x: 100, y: 100, width: 50, height: 50 },
|
||||
name: 'Object ' + (objects.filter((o) => !o.isPreset).length + 1),
|
||||
color: SWATCHES[objects.length % SWATCHES.length],
|
||||
amount: 1,
|
||||
isPreset: false,
|
||||
shape: { type: 'rectangle', width: 50, height: 50 },
|
||||
conditions: [
|
||||
{ kind: 'walk_to_and_sit', weight: 1 },
|
||||
{ kind: 'accessible', weight: 1 },
|
||||
],
|
||||
color: '#90caf9',
|
||||
lockedForOptimization: false,
|
||||
placed: true,
|
||||
};
|
||||
onStateChange({ ...state, furniture: [...state.furniture, newItem] });
|
||||
onStateChange({ ...state, presets: [...objects, newObject] });
|
||||
onSelect(id);
|
||||
setOpenId(id);
|
||||
};
|
||||
|
||||
const updatePreset = (id: string, patch: Partial<FurniturePreset>) => {
|
||||
const presets = (state.presets || []).map((p) => (p.id === id ? { ...p, ...patch } : p));
|
||||
onStateChange({ ...state, presets });
|
||||
};
|
||||
|
||||
const setPresetAmount = (id: string, amount: number) => {
|
||||
const presets = (state.presets || []).map((p) => (p.id === id ? { ...p, amount: Math.max(0, amount) } : p));
|
||||
onStateChange({ ...state, presets });
|
||||
};
|
||||
|
||||
const toggleLock = (id: string) => {
|
||||
const updated = state.furniture.map((f) =>
|
||||
f.id === id ? { ...f, lockedForOptimization: !f.lockedForOptimization } : f,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const setNearObjectTarget = (id: string, targetId: string) => {
|
||||
const updated = state.furniture.map((f) => {
|
||||
if (f.id !== id) return f;
|
||||
return {
|
||||
...f,
|
||||
conditions: f.conditions.map((c) =>
|
||||
c.kind === 'near_object' ? { ...c, params: { ...c.params, targetId } } : c,
|
||||
),
|
||||
};
|
||||
});
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const toggleFurnitureCondition = (id: string, k: ConditionKind, enabled: boolean) => {
|
||||
const updated = state.furniture.map((x) => {
|
||||
if (x.id !== id) return x;
|
||||
const has = x.conditions.some((c) => c.kind === k);
|
||||
if (enabled && !has) {
|
||||
const params = k === 'near_object' ? { targetId: '' } : {};
|
||||
return { ...x, conditions: [...x.conditions, { kind: k, weight: 1, params }] };
|
||||
}
|
||||
if (!enabled && has) {
|
||||
return { ...x, conditions: x.conditions.filter((c) => c.kind !== k) };
|
||||
}
|
||||
return x;
|
||||
});
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const togglePresetCondition = (id: string, k: ConditionKind, enabled: boolean) => {
|
||||
const presets = (state.presets || []).map((p) => {
|
||||
const toggleObjectCondition = (id: string, k: ConditionKind, enabled: boolean) => {
|
||||
const presets = objects.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
const has = p.conditions.some((c) => c.kind === k);
|
||||
if (enabled && !has) {
|
||||
return { ...p, conditions: [...p.conditions, { kind: k, weight: 1, params: {} }] };
|
||||
const params = k === 'near_object' ? { targetId: '' } : {};
|
||||
return { ...p, conditions: [...p.conditions, { kind: k, weight: 1, leniency: 0.5, params }] };
|
||||
}
|
||||
if (!enabled && has) {
|
||||
return { ...p, conditions: p.conditions.filter((c) => c.kind !== k) };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
onStateChange({ ...state, presets });
|
||||
commitPresets(presets);
|
||||
};
|
||||
|
||||
const setNearObjectTarget = (id: string, targetId: string) => {
|
||||
const presets = objects.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
return {
|
||||
...p,
|
||||
conditions: p.conditions.map((c) =>
|
||||
c.kind === 'near_object' ? { ...c, params: { ...c.params, targetId } } : c,
|
||||
),
|
||||
};
|
||||
});
|
||||
commitPresets(presets);
|
||||
};
|
||||
|
||||
const setConditionLeniency = (id: string, k: ConditionKind, leniency: number) => {
|
||||
const presets = objects.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
return {
|
||||
...p,
|
||||
conditions: p.conditions.map((c) => (c.kind === k ? { ...c, leniency } : c)),
|
||||
};
|
||||
});
|
||||
commitPresets(presets);
|
||||
};
|
||||
|
||||
const toggleOpen = (id: string) => {
|
||||
onSelect(id);
|
||||
onSelect(id === '__entrance__' ? '__entrance__' : id);
|
||||
setOpenId((cur) => (cur === id ? null : id));
|
||||
};
|
||||
|
||||
const conditionsEditor = (
|
||||
conditions: FurnitureConfig['conditions'],
|
||||
onToggle: (k: ConditionKind, enabled: boolean) => void,
|
||||
nearSelect?: { value: string; onChange: (v: string) => void; options: { id: string; name: string }[] },
|
||||
) => (
|
||||
<div className="cond-list">
|
||||
{CONDITION_KINDS.map((k) => {
|
||||
const active = conditions.some((c) => c.kind === k);
|
||||
return (
|
||||
<div className="cond" key={k}>
|
||||
<label>
|
||||
<input type="checkbox" checked={active} onChange={(e) => onToggle(k, e.target.checked)} />
|
||||
{CONDITION_LABELS[k]}
|
||||
</label>
|
||||
{k === 'near_object' && active && nearSelect && (
|
||||
<select
|
||||
value={nearSelect.value}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => nearSelect.onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select object…</option>
|
||||
{nearSelect.options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPresetCard = (p: FurniturePreset) => {
|
||||
const open = openId === 'preset_' + p.id;
|
||||
const renderObjectCard = (p: FurniturePreset) => {
|
||||
const open = openId === p.id;
|
||||
const placedCount = state.furniture.filter((f) => f.type === p.id && f.placed).length;
|
||||
return (
|
||||
<div className={`obj-card ${open ? 'open' : ''}`} key={p.id}>
|
||||
<div className="obj-head" onClick={() => toggleOpen('preset_' + p.id)}>
|
||||
<div key={p.id} className={`obj-card ${open ? 'open' : ''}`}>
|
||||
<div
|
||||
className="obj-head"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-preset-id', p.id);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
onClick={() => toggleOpen(p.id)}
|
||||
>
|
||||
<span className={`obj-caret ${open ? 'open' : ''}`}>▶</span>
|
||||
<span className="obj-swatch" style={{ background: p.color }} />
|
||||
<span className="obj-name">{p.name}</span>
|
||||
{placedCount > 0 && <span className="obj-badge">{placedCount} placed</span>}
|
||||
<span className="obj-count" onClick={(e) => e.stopPropagation()}>
|
||||
<NumberField
|
||||
value={p.amount ?? 0}
|
||||
min={0}
|
||||
width={44}
|
||||
onChange={(v) => setPresetAmount(p.id, v)}
|
||||
/>
|
||||
<NumberField value={p.amount ?? 0} min={0} width={44} onChange={(v) => setAmount(p.id, v)} />
|
||||
</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="obj-body">
|
||||
{!p.isPreset && (
|
||||
<label className="field" style={{ marginBottom: 10 }}>
|
||||
<span>Name</span>
|
||||
<input value={p.name} onChange={(e) => updateObject(p.id, { name: e.target.value })} />
|
||||
</label>
|
||||
)}
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<span>Width (cm)</span>
|
||||
<NumberField
|
||||
value={p.shape.width}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => updatePreset(p.id, { shape: { ...p.shape, width: v } })}
|
||||
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, width: v } })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<span>Height (cm)</span>
|
||||
<NumberField
|
||||
value={p.shape.height}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => updatePreset(p.id, { shape: { ...p.shape, height: v } })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="section-title" style={{ marginTop: 12 }}>
|
||||
Default Conditions
|
||||
</div>
|
||||
{conditionsEditor(p.conditions, (k, enabled) => togglePresetCondition(p.id, k, enabled))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderObjectCard = (f: FurnitureConfig) => {
|
||||
const open = openId === f.id;
|
||||
const selected = selectedId === f.id;
|
||||
return (
|
||||
<div key={f.id} className={`obj-card ${selected ? 'selected' : ''}`}>
|
||||
<div className="obj-head" onClick={() => toggleOpen(f.id)}>
|
||||
<span className={`obj-caret ${open ? 'open' : ''}`}>▶</span>
|
||||
<span className="obj-swatch" style={{ background: f.color || '#9aa0a6' }} />
|
||||
<span className="obj-name">{f.name}</span>
|
||||
{f.lockedForOptimization && <span className="obj-badge locked">locked</span>}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="obj-body">
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input
|
||||
value={f.name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
const updated = state.furniture.map((x) => (x.id === f.id ? { ...x, name: e.target.value } : x));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid-2" style={{ marginTop: 8 }}>
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<NumberField
|
||||
value={f.shape.width}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => {
|
||||
const updated = state.furniture.map((x) =>
|
||||
x.id === f.id ? { ...x, shape: { ...x.shape, width: v } } : x,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, height: v } })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<span>Shape</span>
|
||||
<select
|
||||
value={p.shape.type === 'circle' ? 'oval' : p.shape.type}
|
||||
onChange={(e) =>
|
||||
updateObject(p.id, { shape: { ...p.shape, type: e.target.value as 'rectangle' | 'oval' } })
|
||||
}
|
||||
>
|
||||
<option value="rectangle">Rectangle</option>
|
||||
<option value="oval">Oval</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Scale</span>
|
||||
<NumberField
|
||||
value={f.shape.height}
|
||||
min={10}
|
||||
value={p.shape.scale ?? 1}
|
||||
min={0.1}
|
||||
step={0.05}
|
||||
width="100%"
|
||||
onChange={(v) => {
|
||||
const updated = state.furniture.map((x) =>
|
||||
x.id === f.id ? { ...x, shape: { ...x.shape, height: v } } : x,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, scale: v } })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.lockedForOptimization}
|
||||
onChange={() => toggleLock(f.id)}
|
||||
/>
|
||||
Lock position
|
||||
</label>
|
||||
<span className="hint" style={{ margin: 0 }}>
|
||||
Color
|
||||
</span>
|
||||
<div className="swatch-row">
|
||||
{SWATCHES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`swatch-btn ${p.color === c ? 'active' : ''}`}
|
||||
style={{ background: c }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateObject(p.id, { color: c });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-title" style={{ marginTop: 12 }}>
|
||||
Conditions
|
||||
</div>
|
||||
{conditionsEditor(
|
||||
f.conditions,
|
||||
(k, enabled) => toggleFurnitureCondition(f.id, k, enabled),
|
||||
{
|
||||
value:
|
||||
(f.conditions.find((c) => c.kind === 'near_object')?.params as { targetId?: string } | undefined)
|
||||
?.targetId || '',
|
||||
onChange: (v) => setNearObjectTarget(f.id, v),
|
||||
options: state.furniture.filter((o) => o.id !== f.id).map((o) => ({ id: o.id, name: o.name })),
|
||||
},
|
||||
<div className="cond-list">
|
||||
{CONDITION_KINDS.map((k) => {
|
||||
const active = p.conditions.some((c) => c.kind === k);
|
||||
const cond = p.conditions.find((c) => c.kind === k);
|
||||
const targetId = (cond?.params as { targetId?: string } | undefined)?.targetId;
|
||||
const leniency = cond?.leniency ?? 0;
|
||||
return (
|
||||
<div className="cond" key={k}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={(e) => toggleObjectCondition(p.id, k, e.target.checked)}
|
||||
/>
|
||||
{CONDITION_LABELS[k]}
|
||||
</label>
|
||||
{active && (
|
||||
<div className="leniency">
|
||||
<span className="leniency-label">
|
||||
Leniency
|
||||
<span className="leniency-value">{Math.round(leniency * 100)}%</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round(leniency * 100)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setConditionLeniency(p.id, k, Number(e.target.value) / 100)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{k === 'near_object' && active && (
|
||||
<select
|
||||
value={targetId || ''}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setNearObjectTarget(p.id, e.target.value)}
|
||||
>
|
||||
<option value="">Select object…</option>
|
||||
{objects
|
||||
.filter((o) => o.id !== p.id)
|
||||
.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!p.isPreset && (
|
||||
<button
|
||||
style={{ marginTop: 12, width: '100%' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeObject(p.id);
|
||||
}}
|
||||
>
|
||||
Delete Object
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -316,9 +397,19 @@ export function ControlPanel({
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-title">
|
||||
<span className="dot" />
|
||||
Venue Planner
|
||||
<div className="sidebar-top">
|
||||
<div className="sidebar-title">
|
||||
<span className="dot" />
|
||||
Venue Planner
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="mini" onClick={unlockAll} title="Unlock every object so the optimizer can move it">
|
||||
Unlock All
|
||||
</button>
|
||||
<button className="mini danger" onClick={removeAllFurniture} title="Remove every object from the room">
|
||||
Clear Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="segmented" style={{ marginTop: 12 }}>
|
||||
<button className={mode === 'layout' ? 'active' : ''} onClick={() => onModeChange('layout')}>
|
||||
@@ -338,101 +429,134 @@ export function ControlPanel({
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<NumberField
|
||||
value={state.room.width}
|
||||
min={100}
|
||||
width="100%"
|
||||
onChange={(v) => setRoom({ width: v })}
|
||||
/>
|
||||
<NumberField value={state.room.width} min={100} width="100%" onChange={(v) => setRoom({ width: v })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<NumberField
|
||||
value={state.room.height}
|
||||
min={100}
|
||||
width="100%"
|
||||
onChange={(v) => setRoom({ height: v })}
|
||||
/>
|
||||
<NumberField value={state.room.height} min={100} width="100%" onChange={(v) => setRoom({ height: v })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: 10 }}>
|
||||
<span>Shape</span>
|
||||
<select
|
||||
value={state.room.points ? 'polygon' : 'rectangle'}
|
||||
onChange={(e) => setRoomShape(e.target.value as 'rectangle' | 'polygon')}
|
||||
>
|
||||
<option value="rectangle">Rectangle</option>
|
||||
<option value="polygon">Polygon</option>
|
||||
</select>
|
||||
</div>
|
||||
{state.room.points && (
|
||||
<>
|
||||
<div className="hint" style={{ margin: '8px 0' }}>
|
||||
Drag the blue corners on the canvas to reshape the room.
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<button className="mini" onClick={addRoomVertex}>
|
||||
Add Vertex
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Scene</div>
|
||||
<div className="row">
|
||||
<button onClick={placeEntrance} style={{ flex: 1 }}>
|
||||
Place Entrance
|
||||
</button>
|
||||
<button onClick={createObject} style={{ flex: 1 }}>
|
||||
Create Object
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={createObject} style={{ width: '100%' }}>
|
||||
Create Object
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedFurniture && (
|
||||
<div className="section">
|
||||
<div className="section-title">Selected Object</div>
|
||||
<div className="hint" style={{ marginBottom: 6 }}>
|
||||
{selectedFurniture.name}
|
||||
{selectedFurniture.lockedForOptimization ? ' · locked' : ''}
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Rotation</span>
|
||||
<NumberField
|
||||
value={Math.round((selectedFurniture.shape.rotation ?? 0) + 360) % 360}
|
||||
width={64}
|
||||
onChange={(v) => setFurnitureRotation(selectedFurniture.id, v)}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={Math.round((selectedFurniture.shape.rotation ?? 0) + 360) % 360}
|
||||
onChange={(e) => setFurnitureRotation(selectedFurniture.id, Number(e.target.value))}
|
||||
style={{ width: '100%', marginTop: 8, accentColor: '#4f8cff' }}
|
||||
/>
|
||||
<div className="row" style={{ marginTop: 8, gap: 6 }}>
|
||||
<button className="mini" onClick={() => setFurnitureRotation(selectedFurniture.id, ((selectedFurniture.shape.rotation ?? 0) - 15 + 360) % 360)}>
|
||||
-15°
|
||||
</button>
|
||||
<button className="mini" onClick={() => setFurnitureRotation(selectedFurniture.id, ((selectedFurniture.shape.rotation ?? 0) + 15) % 360)}>
|
||||
+15°
|
||||
</button>
|
||||
<button className="mini" onClick={() => setFurnitureRotation(selectedFurniture.id, 0)}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Presets</div>
|
||||
<div className="obj-list">{(state.presets || []).map(renderPresetCard)}</div>
|
||||
<div className="section-title">Entrance</div>
|
||||
{state.entrance ? (
|
||||
<>
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>X (cm)</span>
|
||||
<NumberField value={state.entrance.x} width="100%" onChange={(v) => setEntrance({ x: v })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Y (cm)</span>
|
||||
<NumberField value={state.entrance.y} width="100%" onChange={(v) => setEntrance({ y: v })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<NumberField
|
||||
value={state.entrance.width ?? 20}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ width: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<NumberField
|
||||
value={state.entrance.height ?? 20}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ height: v })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
style={{ marginTop: 8, width: '100%' }}
|
||||
onClick={() => onStateChange({ ...state, entrance: null })}
|
||||
>
|
||||
Remove Entrance
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={placeEntrance} style={{ width: '100%' }}>
|
||||
Place Entrance
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Objects</div>
|
||||
{state.entrance && (
|
||||
<div
|
||||
className={`obj-card ${selectedId === '__entrance__' ? 'selected' : ''}`}
|
||||
style={{ marginBottom: 6 }}
|
||||
>
|
||||
<div className="obj-head" onClick={() => onSelect('__entrance__')}>
|
||||
<span className="obj-swatch" style={{ background: '#3fbf7f' }} />
|
||||
<span className="obj-name">Entrance</span>
|
||||
<span className="obj-badge">fixed</span>
|
||||
</div>
|
||||
{selectedId === '__entrance__' && (
|
||||
<div className="obj-body">
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>X</span>
|
||||
<NumberField
|
||||
value={state.entrance.x}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ x: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Y</span>
|
||||
<NumberField
|
||||
value={state.entrance.y}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ y: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<NumberField
|
||||
value={state.entrance.width ?? 20}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ width: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<NumberField
|
||||
value={state.entrance.height ?? 20}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ height: v })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.furniture.length === 0 ? (
|
||||
<div className="empty">No objects yet — add a preset or create one.</div>
|
||||
) : (
|
||||
<div className="obj-list">{state.furniture.map(renderObjectCard)}</div>
|
||||
)}
|
||||
<div className="hint">
|
||||
Count is the maximum number for each object. Drag a card onto the room to pin one in place (locked);
|
||||
the optimizer arranges the rest.
|
||||
</div>
|
||||
<div className="obj-list">{objects.map(renderObjectCard)}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -441,15 +565,21 @@ export function ControlPanel({
|
||||
<div className="section" style={{ marginTop: 0 }}>
|
||||
<div className="section-title">Optimization</div>
|
||||
<p className="hint">
|
||||
Placed objects are locked in position. Unplaced objects are randomized across the room for 15
|
||||
seconds; the best-scoring layout is kept.
|
||||
The count is the maximum for each object. Optimizing adds only what's missing and randomizes those for
|
||||
15 seconds; everything already on the room stays locked in place.
|
||||
</p>
|
||||
<button className="primary" onClick={onOptimize} disabled={optimizing} style={{ width: '100%' }}>
|
||||
{optimizing ? `Optimizing… ${timeLeft}s` : 'Optimize Layout'}
|
||||
<button className="primary" onClick={onOptimize} style={{ width: '100%' }}>
|
||||
{optimizing ? 'Stop Optimization' : 'Optimize Layout'}
|
||||
</button>
|
||||
<div className="score-row">
|
||||
<span className="score-label">Score</span>
|
||||
<span className="score-value">
|
||||
{lastScore === null ? '—' : lastScore.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
{optimizing && (
|
||||
<div className="progress">
|
||||
<div style={{ width: `${((15 - timeLeft) / 15) * 100}%` }} />
|
||||
<div style={{ width: `$0%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+271
-24
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { LayoutState, Point } from '../types';
|
||||
import { computePaths } from '../pathfinding/paths';
|
||||
import { clampStateToRoom, furnitureCollider, resolveMove } from '../geometry/bounds';
|
||||
import { makePlaced } from '../optimization/prepare';
|
||||
import { Shape } from '../geometry/shapes';
|
||||
|
||||
interface RoomCanvasProps {
|
||||
state: LayoutState;
|
||||
@@ -7,6 +11,7 @@ interface RoomCanvasProps {
|
||||
mode: 'layout' | 'optimize';
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
showPaths?: boolean;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
@@ -14,16 +19,25 @@ interface DragState {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
resizing?: boolean;
|
||||
vertexIndex?: number;
|
||||
rotating?: 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;
|
||||
@@ -35,7 +49,19 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
ctx.fillStyle = '#12151b';
|
||||
ctx.fillRect(0, 0, state.room.width, state.room.height);
|
||||
const poly = state.room.points && state.room.points.length >= 3 ? state.room.points : null;
|
||||
const roomPath = new Path2D();
|
||||
if (poly) {
|
||||
roomPath.moveTo(poly[0].x, poly[0].y);
|
||||
for (let i = 1; i < poly.length; i++) roomPath.lineTo(poly[i].x, poly[i].y);
|
||||
roomPath.closePath();
|
||||
} else {
|
||||
roomPath.rect(0, 0, state.room.width, state.room.height);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.fill(roomPath);
|
||||
ctx.clip(roomPath);
|
||||
|
||||
// subtle grid
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.035)';
|
||||
@@ -53,10 +79,49 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
ctx.lineTo(state.room.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.strokeStyle = '#2c3342';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(0, 0, state.room.width, state.room.height);
|
||||
ctx.stroke(roomPath);
|
||||
|
||||
if (poly && mode === 'layout') {
|
||||
ctx.fillStyle = '#4f8cff';
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
for (const p of poly) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -78,14 +143,30 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
}
|
||||
|
||||
for (const f of state.furniture) {
|
||||
const b = { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height };
|
||||
const r = Math.min(8, b.w / 4, b.h / 4);
|
||||
const selected = selectedId === f.id;
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
const rot = ((f.shape.rotation ?? 0) * Math.PI) / 180;
|
||||
const isOval = f.shape.type === 'oval' || f.shape.type === 'circle';
|
||||
|
||||
ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.55 : 1;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(rot);
|
||||
ctx.beginPath();
|
||||
roundRect(ctx, b.x, b.y, b.w, b.h, r);
|
||||
if (isOval) {
|
||||
ctx.ellipse(0, 0, f.shape.width / 2, f.shape.height / 2, 0, 0, Math.PI * 2);
|
||||
} else {
|
||||
roundRect(
|
||||
ctx,
|
||||
-f.shape.width / 2,
|
||||
-f.shape.height / 2,
|
||||
f.shape.width,
|
||||
f.shape.height,
|
||||
Math.min(6, f.shape.width / 2, f.shape.height / 2),
|
||||
);
|
||||
}
|
||||
ctx.fillStyle = f.color || '#9aa0a6';
|
||||
ctx.fill();
|
||||
|
||||
@@ -101,26 +182,52 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)';
|
||||
ctx.font = '600 11px Inter, sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
const label = b.w > 44 ? f.name.substring(0, 14) : '';
|
||||
if (label) ctx.fillText(label, b.x + 7, b.y + b.h / 2);
|
||||
const label = f.shape.width > 44 ? f.name.substring(0, 14) : '';
|
||||
if (label) ctx.fillText(label, f.shape.x + 7, cy);
|
||||
|
||||
if (f.lockedForOptimization) {
|
||||
ctx.font = '10px Inter, sans-serif';
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
||||
ctx.fillText('🔒', b.x + b.w - 15, b.y + 9);
|
||||
ctx.fillText('🔒', f.shape.x + f.shape.width - 15, f.shape.y + 9);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
if (selectedId && mode === 'layout') {
|
||||
const sf = state.furniture.find((f) => f.id === selectedId);
|
||||
if (sf) {
|
||||
const cx = sf.shape.x + sf.shape.width / 2;
|
||||
const cy = sf.shape.y + sf.shape.height / 2;
|
||||
const rot = ((sf.shape.rotation ?? 0) * Math.PI) / 180;
|
||||
const arm = sf.shape.height / 2 + 22;
|
||||
const hx = cx + Math.sin(rot) * arm;
|
||||
const hy = cy - Math.cos(rot) * arm;
|
||||
ctx.strokeStyle = 'rgba(79, 140, 255, 0.85)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.sin(rot) * (sf.shape.height / 2), cy - Math.cos(rot) * (sf.shape.height / 2));
|
||||
ctx.lineTo(hx, hy);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#4f8cff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(hx, hy, 7, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>): Point => {
|
||||
const getMousePos = (e: { clientX: number; clientY: number }): Point => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -131,6 +238,16 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const pos = getMousePos(e);
|
||||
if (state.room.points && state.room.points.length >= 3 && mode === 'layout') {
|
||||
for (let i = 0; i < state.room.points.length; i++) {
|
||||
const v = state.room.points[i];
|
||||
if (Math.abs(pos.x - v.x) <= 8 && Math.abs(pos.y - v.y) <= 8) {
|
||||
onSelect('__room__');
|
||||
setDrag({ id: '__vertex__', offsetX: 0, offsetY: 0, vertexIndex: i });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.entrance) {
|
||||
const en = state.entrance;
|
||||
const w = en.width ?? 20;
|
||||
@@ -150,7 +267,22 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
}
|
||||
for (let i = state.furniture.length - 1; i >= 0; i--) {
|
||||
const f = state.furniture[i];
|
||||
if (pointInRect(pos, { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height })) {
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
const rot = ((f.shape.rotation ?? 0) * Math.PI) / 180;
|
||||
const arm = f.shape.height / 2 + 22;
|
||||
const hx = cx + Math.sin(rot) * arm;
|
||||
const hy = cy - Math.cos(rot) * arm;
|
||||
if (Math.abs(pos.x - hx) <= 9 && Math.abs(pos.y - hy) <= 9) {
|
||||
onSelect(f.id);
|
||||
setDrag({ id: f.id, offsetX: 0, offsetY: 0, rotating: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let i = state.furniture.length - 1; i >= 0; i--) {
|
||||
const f = state.furniture[i];
|
||||
const shape = new Shape(f.shape);
|
||||
if (shape.containsPoint(pos)) {
|
||||
onSelect(f.id);
|
||||
setDrag({ id: f.id, offsetX: pos.x - f.shape.x, offsetY: pos.y - f.shape.y });
|
||||
return;
|
||||
@@ -163,28 +295,75 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!drag) return;
|
||||
const pos = getMousePos(e);
|
||||
if (drag.id === '__vertex__' && state.room.points && drag.vertexIndex !== undefined) {
|
||||
const idx = drag.vertexIndex;
|
||||
const vx = Math.min(Math.max(0, pos.x), state.room.width);
|
||||
const vy = Math.min(Math.max(0, pos.y), state.room.height);
|
||||
const points = state.room.points.map((p, i) => (i === idx ? { x: vx, y: vy } : p));
|
||||
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, points } }));
|
||||
return;
|
||||
}
|
||||
if (drag.rotating) {
|
||||
const target = state.furniture.find((f) => f.id === drag.id);
|
||||
if (!target) return;
|
||||
const cx = target.shape.x + target.shape.width / 2;
|
||||
const cy = target.shape.y + target.shape.height / 2;
|
||||
const handleAngle = Math.atan2(pos.x - cx, -(pos.y - cy));
|
||||
let deg = (handleAngle * 180) / Math.PI;
|
||||
deg = ((deg % 360) + 360) % 360;
|
||||
if (e.shiftKey) deg = Math.round(deg / 15) * 15;
|
||||
const updated = state.furniture.map((f) =>
|
||||
f.id === drag.id ? { ...f, shape: { ...f.shape, rotation: deg } } : f,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
return;
|
||||
}
|
||||
if (drag.id === '__entrance__' && state.entrance) {
|
||||
const en = state.entrance;
|
||||
const others = state.furniture.map(furnitureCollider);
|
||||
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 = 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 {
|
||||
onStateChange({
|
||||
...state,
|
||||
entrance: { ...en, x: pos.x - drag.offsetX, y: pos.y - drag.offsetY },
|
||||
});
|
||||
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,
|
||||
);
|
||||
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 others = state.furniture.filter((o) => o.id !== drag.id).map(furnitureCollider);
|
||||
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,
|
||||
rotation: f.shape.rotation ?? 0,
|
||||
},
|
||||
others,
|
||||
state.room,
|
||||
);
|
||||
return {
|
||||
...f,
|
||||
shape: {
|
||||
...f.shape,
|
||||
x: pos.x - drag.offsetX,
|
||||
y: pos.y - drag.offsetY,
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -192,22 +371,80 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (drag && drag.id !== '__entrance__') {
|
||||
if (drag && drag.id !== '__entrance__' && drag.id !== '__vertex__') {
|
||||
const updated = state.furniture.map((f) => (f.id === drag.id ? { ...f, placed: true } : f));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!state.room.points || state.room.points.length < 3) return;
|
||||
const pos = getMousePos(e);
|
||||
let bestIdx = -1;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < state.room.points.length; i++) {
|
||||
const a = state.room.points[i];
|
||||
const b = state.room.points[(i + 1) % state.room.points.length];
|
||||
const q = closestOnSegment(a, b, pos);
|
||||
const d = (q.x - pos.x) ** 2 + (q.y - pos.y) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
if (bestIdx >= 0 && bestD < 1600) {
|
||||
e.preventDefault();
|
||||
const a = state.room.points[bestIdx];
|
||||
const b = state.room.points[(bestIdx + 1) % state.room.points.length];
|
||||
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
const next = [...state.room.points.slice(0, bestIdx + 1), mid, ...state.room.points.slice(bestIdx + 1)];
|
||||
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, points: next } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const presetId = e.dataTransfer.getData('application/x-preset-id');
|
||||
if (!presetId) return;
|
||||
const preset = (state.presets || []).find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
const placedCount = state.furniture.filter((f) => f.type === presetId).length;
|
||||
if (placedCount >= (preset.amount ?? 0)) return;
|
||||
const pos = getMousePos(e);
|
||||
const others: import('../geometry/collision').Collider[] = state.furniture.map(furnitureCollider);
|
||||
if (state.entrance) {
|
||||
const en = state.entrance;
|
||||
others.push({ x: en.x, y: en.y, width: en.width ?? 20, height: en.height ?? 20 });
|
||||
}
|
||||
const f = makePlaced(
|
||||
preset,
|
||||
pos.x - preset.shape.width / 2,
|
||||
pos.y - preset.shape.height / 2,
|
||||
state.room,
|
||||
others,
|
||||
);
|
||||
onStateChange({ ...state, furniture: [...state.furniture, f] });
|
||||
onSelect(f.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={Math.max(state.room.width, 400)}
|
||||
height={Math.max(state.room.height, 300)}
|
||||
width={Math.max(state.room.width, Math.max(...((state.room.points && state.room.points.length)?state.room.points.map(v=>v.x):[0]),400))}
|
||||
height={Math.max(state.room.height, Math.max(...((state.room.points && state.room.points.length)?state.room.points.map(v=>v.y):[0]),300))}
|
||||
style={{ maxWidth: '100%', height: 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -231,3 +468,13 @@ function roundRect(
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function closestOnSegment(a: Point, b: Point, p: Point): Point {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 === 0) return { ...a };
|
||||
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return { x: a.x + t * dx, y: a.y + t * dy };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Shape } from '../geometry/shapes';
|
||||
export class Furniture {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
shape: Shape;
|
||||
conditions: ConditionSpec[];
|
||||
color: string;
|
||||
@@ -13,6 +14,7 @@ export class Furniture {
|
||||
constructor(config: FurnitureConfig) {
|
||||
this.id = config.id;
|
||||
this.name = config.name;
|
||||
this.type = config.type;
|
||||
this.shape = new Shape(config.shape);
|
||||
this.conditions = config.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } }));
|
||||
this.color = config.color ?? '#9aa0a6';
|
||||
@@ -51,6 +53,7 @@ export class Furniture {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
shape: {
|
||||
type: this.shape.type,
|
||||
x: this.shape.x,
|
||||
|
||||
@@ -6,6 +6,7 @@ export const DEFAULT_PRESETS: FurniturePreset[] = [
|
||||
name: 'Table',
|
||||
color: '#a1887f',
|
||||
amount: 0,
|
||||
isPreset: true,
|
||||
shape: { type: 'rectangle', width: 80, height: 60 },
|
||||
conditions: [
|
||||
{ kind: 'walk_to_and_sit', weight: 1 },
|
||||
@@ -17,6 +18,7 @@ export const DEFAULT_PRESETS: FurniturePreset[] = [
|
||||
name: 'Chair',
|
||||
color: '#d7ccc5',
|
||||
amount: 0,
|
||||
isPreset: true,
|
||||
shape: { type: 'rectangle', width: 40, height: 40 },
|
||||
conditions: [
|
||||
{ kind: 'walk_to_and_sit', weight: 1 },
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { LayoutState, RoomConfig } from '../types';
|
||||
import { colliderCenter, satMTV, type Collider } from './collision';
|
||||
import { overlapsAny } from './collision';
|
||||
import { nearestPointInPolygon, pointInPolygon } from './polygon';
|
||||
|
||||
export { overlapsAny };
|
||||
|
||||
export interface Bounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function clampRectToRoom(rect: Collider, room: RoomConfig, minSize = 10): Collider {
|
||||
const width = Math.min(Math.max(minSize, rect.width), room.width);
|
||||
const height = Math.min(Math.max(minSize, rect.height), room.height);
|
||||
let x = rect.x;
|
||||
let y = rect.y;
|
||||
const poly = room.points;
|
||||
if (poly && poly.length >= 3) {
|
||||
for (let iter = 0; iter < 20; iter++) {
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
const test: Collider = { ...rect, x, y, width, height };
|
||||
if (shapeFullyInside(test, poly)) return test;
|
||||
const inside = nearestPointInPolygon({ x: cx, y: cy }, poly);
|
||||
x += (inside.x - cx) * 0.25;
|
||||
y += (inside.y - cy) * 0.25;
|
||||
x = Math.min(Math.max(0, x), Math.max(0, room.width - width));
|
||||
y = Math.min(Math.max(0, y), Math.max(0, room.height - height));
|
||||
}
|
||||
x = rect.x;
|
||||
y = rect.y;
|
||||
}
|
||||
x = Math.min(Math.max(0, x), Math.max(0, room.width - width));
|
||||
y = Math.min(Math.max(0, y), Math.max(0, room.height - height));
|
||||
return { ...rect, x, y, width, height };
|
||||
}
|
||||
|
||||
export function clampStateToRoom(state: LayoutState): LayoutState {
|
||||
const room = state.room;
|
||||
const furniture = 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, x: b.x, y: b.y, width: b.width, height: b.height } };
|
||||
});
|
||||
let entrance = 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 resolveMove(moving: Collider, others: Collider[], room: RoomConfig, minSize = 10): Collider {
|
||||
let r = clampRectToRoom(moving, room, minSize);
|
||||
for (let iter = 0; iter < 12; iter++) {
|
||||
let pushed = false;
|
||||
for (const o of others) {
|
||||
const mtv = satMTV(r, o);
|
||||
if (!mtv) continue;
|
||||
const cM = colliderCenter(r);
|
||||
const cO = colliderCenter(o);
|
||||
const dir = (cM.x - cO.x) * mtv.axis.x + (cM.y - cO.y) * mtv.axis.y;
|
||||
const sign = dir >= 0 ? 1 : -1;
|
||||
r = clampRectToRoom(
|
||||
{ ...r, x: r.x + sign * mtv.axis.x * mtv.depth, y: r.y + sign * mtv.axis.y * mtv.depth },
|
||||
room,
|
||||
minSize,
|
||||
);
|
||||
pushed = true;
|
||||
}
|
||||
if (!pushed) break;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export function findFreeSpot(room: RoomConfig, size: { width: number; height: number }, others: Collider[], tries = 40): Collider {
|
||||
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 entranceCollider(state: LayoutState): Collider | 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function furnitureCollider(f: { shape: { x: number; y: number; width: number; height: number; rotation?: number } }): Collider {
|
||||
return { x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height, rotation: f.shape.rotation };
|
||||
}
|
||||
|
||||
function shapeFullyInside(c: Collider, poly: any[]): boolean {
|
||||
const cx = c.x + (c.width || 0) / 2;
|
||||
const cy = c.y + (c.height || 0) / 2;
|
||||
const w = c.width || 0;
|
||||
const h = c.height || 0;
|
||||
const rot = ((c.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(rot);
|
||||
const sin = Math.sin(rot);
|
||||
const samples = 20;
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const a = (Math.PI * 2 * i) / samples;
|
||||
const r = Math.sqrt((w / 2) ** 2 + (h / 2) ** 2);
|
||||
const lx = r * Math.cos(a);
|
||||
const ly = r * Math.sin(a);
|
||||
const wx = cx + lx * cos - ly * sin;
|
||||
const wy = cy + lx * sin + ly * cos;
|
||||
if (!pointInPolygon({ x: wx, y: wy }, poly)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Point } from '../types';
|
||||
|
||||
export interface Collider {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
export function colliderCenter(c: Collider): Point {
|
||||
return { x: c.x + c.width / 2, y: c.y + c.height / 2 };
|
||||
}
|
||||
|
||||
export function colliderCorners(c: Collider): Point[] {
|
||||
const cx = c.x + c.width / 2;
|
||||
const cy = c.y + c.height / 2;
|
||||
const rad = ((c.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const hw = c.width / 2;
|
||||
const hh = c.height / 2;
|
||||
const local: Point[] = [
|
||||
{ x: -hw, y: -hh },
|
||||
{ x: hw, y: -hh },
|
||||
{ x: hw, y: hh },
|
||||
{ x: -hw, y: hh },
|
||||
];
|
||||
return local.map((p) => ({
|
||||
x: cx + p.x * cos - p.y * sin,
|
||||
y: cy + p.x * sin + p.y * cos,
|
||||
}));
|
||||
}
|
||||
|
||||
function project(points: Point[], axis: Point): [number, number] {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const p of points) {
|
||||
const d = p.x * axis.x + p.y * axis.y;
|
||||
if (d < min) min = d;
|
||||
if (d > max) max = d;
|
||||
}
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
function axesOf(corners: Point[]): Point[] {
|
||||
const edges: Point[] = [
|
||||
{ x: corners[1].x - corners[0].x, y: corners[1].y - corners[0].y },
|
||||
{ x: corners[3].x - corners[0].x, y: corners[3].y - corners[0].y },
|
||||
];
|
||||
return edges.map((e) => {
|
||||
const len = Math.hypot(e.x, e.y) || 1;
|
||||
return { x: -e.y / len, y: e.x / len };
|
||||
});
|
||||
}
|
||||
|
||||
export function satMTV(a: Collider, b: Collider): { axis: Point; depth: number } | null {
|
||||
const ca = colliderCorners(a);
|
||||
const cb = colliderCorners(b);
|
||||
const axes = [...axesOf(ca), ...axesOf(cb)];
|
||||
let minOverlap = Infinity;
|
||||
let axis: Point = { x: 0, y: 0 };
|
||||
for (const ax of axes) {
|
||||
const [amin, amax] = project(ca, ax);
|
||||
const [bmin, bmax] = project(cb, ax);
|
||||
const overlap = Math.min(amax, bmax) - Math.max(amin, bmin);
|
||||
if (overlap <= 0) return null;
|
||||
if (overlap < minOverlap) {
|
||||
minOverlap = overlap;
|
||||
axis = ax;
|
||||
}
|
||||
}
|
||||
return { axis, depth: minOverlap };
|
||||
}
|
||||
|
||||
export function collidersOverlap(a: Collider, b: Collider): boolean {
|
||||
return satMTV(a, b) !== null;
|
||||
}
|
||||
|
||||
export function overlapsAny(c: Collider, others: Collider[]): boolean {
|
||||
return others.some((o) => collidersOverlap(c, o));
|
||||
}
|
||||
|
||||
export function containsWorldPoint(c: Collider, p: Point): boolean {
|
||||
const center = colliderCenter(c);
|
||||
const rad = (-(c.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const dx = p.x - center.x;
|
||||
const dy = p.y - center.y;
|
||||
const lx = dx * cos - dy * sin;
|
||||
const ly = dx * sin + dy * cos;
|
||||
return Math.abs(lx) <= c.width / 2 && Math.abs(ly) <= c.height / 2;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Point } from '../types';
|
||||
|
||||
export function pointInPolygon(p: Point, poly: Point[]): boolean {
|
||||
if (poly.length < 3) return true;
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[j];
|
||||
if (onSegment(a, b, p)) return true;
|
||||
const intersect = a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function onSegment(a: Point, b: Point, p: Point): boolean {
|
||||
const cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||||
if (Math.abs(cross) > 1e-9) return false;
|
||||
const dot = (p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y);
|
||||
if (dot < 0) return false;
|
||||
const len2 = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
|
||||
return dot <= len2;
|
||||
}
|
||||
|
||||
function closestOnSegment(a: Point, b: Point, p: Point): Point {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 === 0) return { ...a };
|
||||
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return { x: a.x + t * dx, y: a.y + t * dy };
|
||||
}
|
||||
|
||||
export function nearestPointInPolygon(p: Point, poly: Point[]): Point {
|
||||
if (poly.length < 3) return { ...p };
|
||||
if (pointInPolygon(p, poly)) return { ...p };
|
||||
let best = poly[0];
|
||||
let bestD = Infinity;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const q = closestOnSegment(poly[j], poly[i], p);
|
||||
const d = (q.x - p.x) ** 2 + (q.y - p.y) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = q;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function rectanglePoints(width: number, height: number): Point[] {
|
||||
return [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: width, y: 0 },
|
||||
{ x: width, y: height },
|
||||
{ x: 0, y: height },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Collider } from './collision';
|
||||
import type { Point } from '../types';
|
||||
import { pointInPolygon } from './polygon';
|
||||
|
||||
export function shapeCompletelyInsidePolygon(c: Collider, poly: Point[], samples: number = 16): boolean {
|
||||
if (!poly || poly.length < 3) return true;
|
||||
const cx = c.x + (c.width || 0) / 2;
|
||||
const cy = c.y + (c.height || 0) / 2;
|
||||
const w = c.width || 0;
|
||||
const h = c.height || 0;
|
||||
const rot = ((c.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(rot);
|
||||
const sin = Math.sin(rot);
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const a = (Math.PI * 2 * i) / samples;
|
||||
const r = Math.sqrt((w / 2) ** 2 + (h / 2) ** 2);
|
||||
const lx = r * Math.cos(a);
|
||||
const ly = r * Math.sin(a);
|
||||
const wx = cx + lx * cos - ly * sin;
|
||||
const wy = cy + lx * sin + ly * cos;
|
||||
if (!pointInPolygon({ x: wx, y: wy }, poly)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clampRectInsidePolygon(c: Collider, poly: Point[], roomWidth: number, roomHeight: number, minSize = 10): Collider {
|
||||
if (!poly || poly.length < 3) {
|
||||
const width = Math.min(Math.max(minSize, c.width), roomWidth);
|
||||
const height = Math.min(Math.max(minSize, c.height), roomHeight);
|
||||
const x = Math.min(Math.max(0, c.x), Math.max(0, roomWidth - width));
|
||||
const y = Math.min(Math.max(0, c.y), Math.max(0, roomHeight - height));
|
||||
return { ...c, x, y, width, height };
|
||||
}
|
||||
const width = Math.min(Math.max(minSize, c.width), roomWidth);
|
||||
const height = Math.min(Math.max(minSize, c.height), roomHeight);
|
||||
let x = c.x;
|
||||
let y = c.y;
|
||||
for (let iter = 0; iter < 20; iter++) {
|
||||
const test = { ...c, x, y, width, height };
|
||||
if (shapeCompletelyInsidePolygon(test, poly, 20)) {
|
||||
return { ...test, x, y, width, height };
|
||||
}
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
const inside = nearestPointInPoly(cx, cy, poly);
|
||||
x += (inside.x - cx) * 0.25;
|
||||
y += (inside.y - cy) * 0.25;
|
||||
x = Math.min(Math.max(0, x), Math.max(0, roomWidth - width));
|
||||
y = Math.min(Math.max(0, y), Math.max(0, roomHeight - height));
|
||||
}
|
||||
return { ...c, x, y, width, height };
|
||||
}
|
||||
|
||||
function nearestPointInPoly(px: number, py: number, poly: Point[]): Point {
|
||||
let best: Point = poly[0];
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
const q = closestOnSeg(a, b, px, py);
|
||||
const d = (q.x - px) ** 2 + (q.y - py) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = q;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function closestOnSeg(a: Point, b: Point, px: number, py: number): Point {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 === 0) return { ...a };
|
||||
let t = ((px - a.x) * dx + (py - a.y) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return { x: a.x + t * dx, y: a.y + t * dy };
|
||||
}
|
||||
+39
-28
@@ -1,4 +1,5 @@
|
||||
import type { Point, Rect, ShapeData } from '../types';
|
||||
import { colliderCorners, containsWorldPoint } from './collision';
|
||||
|
||||
export class Shape {
|
||||
type: ShapeData['type'];
|
||||
@@ -10,7 +11,7 @@ export class Shape {
|
||||
points: Point[];
|
||||
|
||||
constructor(data: ShapeData) {
|
||||
this.type = data.type;
|
||||
this.type = data.type === 'circle' ? 'oval' : data.type;
|
||||
this.x = data.x;
|
||||
this.y = data.y;
|
||||
this.width = Math.max(1, data.width);
|
||||
@@ -31,15 +32,12 @@ export class Shape {
|
||||
});
|
||||
}
|
||||
|
||||
getCenter(): Point {
|
||||
return { x: this.x + this.width / 2, y: this.y + this.height / 2 };
|
||||
}
|
||||
|
||||
getBounds(): Rect {
|
||||
if (this.type === 'rectangle') {
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
||||
}
|
||||
if (this.type === 'circle') {
|
||||
const r = Math.max(this.width, this.height) / 2;
|
||||
return { x: this.x - r, y: this.y - r, width: r * 2, height: r * 2 };
|
||||
}
|
||||
if (this.points.length > 0) {
|
||||
if (this.type === 'polygon' && this.points.length > 0) {
|
||||
const xs = this.points.map((p) => p.x);
|
||||
const ys = this.points.map((p) => p.y);
|
||||
const minX = Math.min(...xs);
|
||||
@@ -48,22 +46,26 @@ export class Shape {
|
||||
const maxY = Math.max(...ys);
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||
}
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
||||
if (this.rotation % 360 === 0) {
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
||||
}
|
||||
const corners = colliderCorners({
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
rotation: this.rotation,
|
||||
});
|
||||
const xs = corners.map((p) => p.x);
|
||||
const ys = corners.map((p) => p.y);
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const maxY = Math.max(...ys);
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||
}
|
||||
|
||||
containsPoint(p: Point): boolean {
|
||||
const b = this.getBounds();
|
||||
if (p.x < b.x || p.x > b.x + b.width || p.y < b.y || p.y > b.y + b.height) {
|
||||
if (this.type !== 'polygon') return false;
|
||||
}
|
||||
if (this.type === 'circle') {
|
||||
const cx = b.x + b.width / 2;
|
||||
const cy = b.y + b.height / 2;
|
||||
const r = b.width / 2;
|
||||
const dx = p.x - cx;
|
||||
const dy = p.y - cy;
|
||||
return dx * dx + dy * dy <= r * r + 1e-8;
|
||||
}
|
||||
if (this.type === 'polygon' && this.points.length > 0) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = this.points.length - 1; i < this.points.length; j = i++) {
|
||||
@@ -76,11 +78,20 @@ export class Shape {
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
return p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height;
|
||||
}
|
||||
|
||||
intersectsRect(r: Rect): boolean {
|
||||
const b = this.getBounds();
|
||||
return !(r.x + r.width < b.x || r.x > b.x + b.width || r.y + r.height < b.y || r.y > b.y + b.height);
|
||||
const c = { x: this.x, y: this.y, width: this.width, height: this.height, rotation: this.rotation };
|
||||
if (this.type === 'oval') {
|
||||
const center = this.getCenter();
|
||||
const rad = (-this.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const dx = p.x - center.x;
|
||||
const dy = p.y - center.y;
|
||||
const lx = dx * cos - dy * sin;
|
||||
const ly = dx * sin + dy * cos;
|
||||
const rx = this.width / 2;
|
||||
const ry = this.height / 2;
|
||||
return (lx * lx) / (rx * rx) + (ly * ly) / (ry * ry) <= 1 + 1e-8;
|
||||
}
|
||||
return containsWorldPoint(c, p);
|
||||
}
|
||||
}
|
||||
|
||||
+109
-2
@@ -153,6 +153,44 @@ label {
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button.mini {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
line-height: 1.2;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
button.mini:hover {
|
||||
background: var(--bg-elevated, var(--bg-input));
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
button.mini.danger:hover {
|
||||
border-color: #e57373;
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -387,11 +425,29 @@ label {
|
||||
text-align: center;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swatch-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.swatch-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.swatch-btn.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.obj-count input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
@@ -436,6 +492,32 @@ label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.leniency {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 2px 0 6px;
|
||||
}
|
||||
|
||||
.leniency-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.leniency-value {
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.leniency input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: #4f8cff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-mute);
|
||||
font-size: 12px;
|
||||
@@ -485,6 +567,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;
|
||||
|
||||
+131
-15
@@ -1,6 +1,16 @@
|
||||
import type { AccessibilityResult, FurnitureConfig, LayoutEvaluation, LayoutState, Point, Rect } from '../types';
|
||||
import { buildGrid } from '../pathfinding/grid';
|
||||
import type {
|
||||
AccessibilityResult,
|
||||
FurnitureConfig,
|
||||
GridCell,
|
||||
LayoutEvaluation,
|
||||
LayoutState,
|
||||
Point,
|
||||
Rect,
|
||||
} from '../types';
|
||||
import { buildGrid, gridToWorld, nearestWalkable } from '../pathfinding/grid';
|
||||
import { aStar } from '../pathfinding/aStar';
|
||||
import { entranceCollider, furnitureCollider } from '../geometry/bounds';
|
||||
import { collidersOverlap } from '../geometry/collision';
|
||||
|
||||
export function evaluateLayout(state: LayoutState, gridSize: number = 10): LayoutEvaluation {
|
||||
const obstacles: Rect[] = state.furniture
|
||||
@@ -18,13 +28,29 @@ export function evaluateLayout(state: LayoutState, gridSize: number = 10): Layou
|
||||
let totalConditionScore = 0;
|
||||
const conditionScores: Record<string, number> = {};
|
||||
|
||||
const colliders = state.furniture.map(furnitureCollider);
|
||||
for (let i = 0; i < colliders.length; i++) {
|
||||
for (let j = i + 1; j < colliders.length; j++) {
|
||||
if (collidersOverlap(colliders[i], colliders[j])) penalty += 20;
|
||||
}
|
||||
}
|
||||
const entrance = entranceCollider(state);
|
||||
if (entrance) {
|
||||
for (const c of colliders) {
|
||||
if (collidersOverlap(c, 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;
|
||||
|
||||
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;
|
||||
@@ -40,24 +66,90 @@ export function evaluateLayout(state: LayoutState, gridSize: number = 10): Layou
|
||||
|
||||
let score = 0;
|
||||
for (const c of f.conditions) {
|
||||
const s = scoreCondition(c.kind, c, f, state, accessibility.find((a) => a.furnitureId === f.id) || null, path);
|
||||
const raw = scoreCondition(
|
||||
c.kind,
|
||||
c,
|
||||
f,
|
||||
state,
|
||||
accessibility.find((a) => a.furnitureId === f.id) || null,
|
||||
grid,
|
||||
gridSize,
|
||||
);
|
||||
const s = applyLeniency(raw, c.leniency ?? 0);
|
||||
score += (s * (c.weight || 1)) / (f.conditions.length || 1);
|
||||
}
|
||||
conditionScores[f.id] = score;
|
||||
totalConditionScore += score;
|
||||
}
|
||||
|
||||
const totalScore = totalConditionScore - penalty;
|
||||
let alignmentBonus = 0;
|
||||
let densityBonus = 0;
|
||||
const byType: Record<string, any[]> = {};
|
||||
for (const f of state.furniture) {
|
||||
const tkey = f.type || f.name;
|
||||
if (!byType[tkey]) byType[tkey] = [];
|
||||
byType[tkey].push(f);
|
||||
}
|
||||
for (const tkey of Object.keys(byType)) {
|
||||
const list = byType[tkey];
|
||||
if (list.length < 2) continue;
|
||||
const base = 2.0;
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
alignmentBonus += base / (i + 1);
|
||||
}
|
||||
}
|
||||
const totalFurnitureArea = state.furniture.reduce((s,f)=>s + f.shape.width*f.shape.height,0);
|
||||
const roomArea = state.room.width*state.room.height;
|
||||
if (roomArea > 0) densityBonus = Math.min(5, (totalFurnitureArea/roomArea)*10);
|
||||
let flowBonus = 0;
|
||||
// reward good path flow (straight, short to entrance)
|
||||
if (state.entrance) {
|
||||
const ex=state.entrance.x+(state.entrance.width??20)/2, ey=state.entrance.y+(state.entrance.height??20)/2;
|
||||
for (const f of state.furniture) {
|
||||
const tx=f.shape.x+f.shape.width/2, ty=f.shape.y+f.shape.height/2;
|
||||
const d=Math.hypot(tx-ex,ty-ey);
|
||||
flowBonus += Math.max(0, 200-d)/40;
|
||||
}
|
||||
}
|
||||
const totalScore = totalConditionScore - penalty + alignmentBonus + densityBonus + flowBonus;
|
||||
return { totalScore, conditionScores, accessibility, penalty };
|
||||
}
|
||||
|
||||
function applyLeniency(score: number, leniency: number): number {
|
||||
const l = Math.max(0, Math.min(1, leniency));
|
||||
return 0.5 + (score - 0.5) * (1 - l);
|
||||
}
|
||||
|
||||
function localOpenness(
|
||||
grid: GridCell[][],
|
||||
cell: { col: number; row: number } | null,
|
||||
radiusCells: number,
|
||||
): number {
|
||||
if (!cell) return 0;
|
||||
const rows = grid.length;
|
||||
const cols = grid[0]?.length ?? 0;
|
||||
let open = 0;
|
||||
let total = 0;
|
||||
for (let dr = -radiusCells; dr <= radiusCells; dr++) {
|
||||
for (let dc = -radiusCells; dc <= radiusCells; dc++) {
|
||||
const r = cell.row + dr;
|
||||
const c = cell.col + dc;
|
||||
if (r < 0 || c < 0 || r >= rows || c >= cols) continue;
|
||||
total++;
|
||||
if (grid[r][c].walkable) open++;
|
||||
}
|
||||
}
|
||||
return total > 0 ? open / total : 0;
|
||||
}
|
||||
|
||||
function scoreCondition(
|
||||
kind: string,
|
||||
c: any,
|
||||
f: FurnitureConfig,
|
||||
state: LayoutState,
|
||||
acc: AccessibilityResult | null,
|
||||
path: Point[] | null,
|
||||
grid: GridCell[][],
|
||||
gridSize: number,
|
||||
): number {
|
||||
switch (kind) {
|
||||
case 'walk_to_and_sit': {
|
||||
@@ -88,20 +180,44 @@ function scoreCondition(
|
||||
}
|
||||
case 'near_object': {
|
||||
const tid = (c.params as any)?.targetId;
|
||||
if (!tid) return 0.3;
|
||||
const t = state.furniture.find((x) => x.id === tid);
|
||||
if (!t) return 0.3;
|
||||
if (!tid) return 0.2;
|
||||
const targets = state.furniture.filter((x) => (x.id === tid || x.type === tid) && x.id !== f.id);
|
||||
if (targets.length === 0) return 0.2;
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
const tx = t.shape.x + t.shape.width / 2;
|
||||
const ty = t.shape.y + t.shape.height / 2;
|
||||
const d = Math.sqrt((cx - tx) ** 2 + (cy - ty) ** 2);
|
||||
return Math.max(0, 1 - d / 80);
|
||||
let best = 0;
|
||||
for (const t of targets) {
|
||||
const tx = t.shape.x + t.shape.width / 2;
|
||||
const ty = t.shape.y + t.shape.height / 2;
|
||||
const d = Math.sqrt((cx - tx) ** 2 + (cy - ty) ** 2);
|
||||
const s = Math.max(0, 1 - d / 50);
|
||||
if (s > best) best = s;
|
||||
}
|
||||
return best * 2.0;
|
||||
}
|
||||
case 'open_circulation': {
|
||||
return path && path.length > 2 ? 0.7 : 0.5;
|
||||
if (!acc?.reachable) return 0;
|
||||
const radiusCells = Math.max(1, Math.round(60 / gridSize));
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
const cell = nearestWalkable(grid, { x: cx, y: cy }, gridSize);
|
||||
return localOpenness(grid, cell, radiusCells);
|
||||
}
|
||||
case 'line_of_sight': {
|
||||
return computeLineOfSightScore(state, f, gridSize);
|
||||
}
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
function computeLineOfSightScore(state: LayoutState, f: FurnitureConfig, _gridSize: number): number {
|
||||
if (!state.entrance) return 0.5;
|
||||
const from = { x: state.entrance.x + (state.entrance.width ?? 20) / 2, y: state.entrance.y + (state.entrance.height ?? 20) / 2 };
|
||||
const to = { x: f.shape.x + f.shape.width / 2, y: f.shape.y + f.shape.height / 2 };
|
||||
const obstacles = state.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, rotation: o.shape.rotation }));
|
||||
if ((() => { try { const v: any = (0, eval)("require")("./visibility"); return v.lineOfSightClear(from, to, obstacles, state.room.points); } catch { return false; } })()) return 1.0;
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Collider } from '../geometry/collision';
|
||||
import type { Point } from '../types';
|
||||
|
||||
|
||||
function intersectsSegment(p1: Point, p2: Point, q1: Point, q2: Point): boolean {
|
||||
const o1 = orientation(p1, p2, q1);
|
||||
const o2 = orientation(p1, p2, q2);
|
||||
const o3 = orientation(q1, q2, p1);
|
||||
const o4 = orientation(q1, q2, p2);
|
||||
if (o1 !== o2 && o3 !== o4) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function orientation(a: Point, b: Point, c: Point): number {
|
||||
const val = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
|
||||
if (Math.abs(val) < 1e-9) return 0;
|
||||
return val > 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
export function lineOfSightClear(from: Point, to: Point, obstacles: Collider[], roomPoints?: Point[]): boolean {
|
||||
if (roomPoints && roomPoints.length >= 3) {
|
||||
for (let i = 0; i < roomPoints.length; i++) {
|
||||
const a = roomPoints[i];
|
||||
const b = roomPoints[(i + 1) % roomPoints.length];
|
||||
if (intersectsSegment(from, to, a, b) && !colinearTouch(from, to, a, b)) return false;
|
||||
}
|
||||
}
|
||||
for (const o of obstacles) {
|
||||
if (segmentIntersectsRect(from, to, o)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function colinearTouch(a: Point, b: Point, c: Point, d: Point): boolean {
|
||||
return orientation(a, b, c) === 0 && orientation(a, b, d) === 0;
|
||||
}
|
||||
|
||||
function segmentIntersectsRect(p1: Point, p2: Point, r: Collider): boolean {
|
||||
const rot = ((r.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(-rot);
|
||||
const sin = Math.sin(-rot);
|
||||
const cx = r.x + (r.width || 0) / 2;
|
||||
const cy = r.y + (r.height || 0) / 2;
|
||||
const local1 = {
|
||||
x: (p1.x - cx) * cos - (p1.y - cy) * sin,
|
||||
y: (p1.x - cx) * sin + (p1.y - cy) * cos,
|
||||
};
|
||||
const local2 = {
|
||||
x: (p2.x - cx) * cos - (p2.y - cy) * sin,
|
||||
y: (p2.x - cx) * sin + (p2.y - cy) * cos,
|
||||
};
|
||||
const w = (r.width || 0) / 2;
|
||||
const h = (r.height || 0) / 2;
|
||||
const left = -w;
|
||||
const right = w;
|
||||
const top = -h;
|
||||
const bottom = h;
|
||||
if (pointInRect(local1, left, right, top, bottom) || pointInRect(local2, left, right, top, bottom)) return true;
|
||||
const rect = [
|
||||
{ x: left, y: top },
|
||||
{ x: right, y: top },
|
||||
{ x: right, y: bottom },
|
||||
{ x: left, y: bottom },
|
||||
];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const a = rect[i];
|
||||
const b = rect[(i + 1) % 4];
|
||||
const g1 = { x: local1.x + cx, y: local1.y + cy };
|
||||
const g2 = { x: local2.x + cx, y: local2.y + cy };
|
||||
if (intersectsSegment({ x: a.x + cx, y: a.y + cy }, { x: b.x + cx, y: b.y + cy }, g1, g2)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pointInRect(p: Point, l: number, r: number, t: number, b: number): boolean {
|
||||
return p.x >= l && p.x <= r && p.y >= t && p.y <= b;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types';
|
||||
import { clampStateToRoom, entranceCollider, findFreeSpot, furnitureCollider, resolveMove } from '../geometry/bounds';
|
||||
import type { Collider } from '../geometry/collision';
|
||||
|
||||
export function makeGenerated(
|
||||
preset: FurniturePreset,
|
||||
index: number,
|
||||
room: RoomConfig,
|
||||
others: Collider[] = [],
|
||||
): 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,
|
||||
type: preset.id,
|
||||
shape: { ...preset.shape, x: b.x, y: b.y, width: b.width, height: b.height },
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
color: preset.color,
|
||||
placed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function makePlaced(
|
||||
preset: FurniturePreset,
|
||||
x: number,
|
||||
y: number,
|
||||
room: RoomConfig,
|
||||
others: Collider[] = [],
|
||||
): FurnitureConfig {
|
||||
const b = resolveMove(
|
||||
{ x, y, width: preset.shape.width, height: preset.shape.height, rotation: preset.shape.rotation },
|
||||
others,
|
||||
room,
|
||||
);
|
||||
return {
|
||||
id: `${preset.id}_placed_${Date.now()}_${Math.round(Math.random() * 1e6)}`,
|
||||
name: preset.name,
|
||||
type: preset.id,
|
||||
shape: { ...preset.shape, x: b.x, y: b.y, width: b.width, height: b.height },
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
color: preset.color,
|
||||
placed: true,
|
||||
lockedForOptimization: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareForOptimize(state: LayoutState): LayoutState {
|
||||
const presets = state.presets || [];
|
||||
const entrance = entranceCollider(state);
|
||||
let furniture = [...state.furniture];
|
||||
|
||||
for (const preset of presets) {
|
||||
const target = preset.amount ?? 0;
|
||||
const existing = furniture.filter((f) => f.type === preset.id);
|
||||
|
||||
if (existing.length > target) {
|
||||
const removeIds = new Set(
|
||||
existing
|
||||
.filter((f) => !f.placed)
|
||||
.slice(0, existing.length - target)
|
||||
.map((f) => f.id),
|
||||
);
|
||||
furniture = furniture.filter((f) => !removeIds.has(f.id));
|
||||
}
|
||||
|
||||
furniture = furniture.map((f) =>
|
||||
f.type === preset.id
|
||||
? {
|
||||
...f,
|
||||
name: preset.name,
|
||||
color: preset.color,
|
||||
shape: {
|
||||
...f.shape,
|
||||
type: preset.shape.type,
|
||||
width: preset.shape.width,
|
||||
height: preset.shape.height,
|
||||
},
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
placed: true,
|
||||
lockedForOptimization: true,
|
||||
}
|
||||
: f,
|
||||
);
|
||||
|
||||
const current = furniture.filter((f) => f.type === preset.id).length;
|
||||
const need = Math.max(0, target - current);
|
||||
const added: FurnitureConfig[] = [];
|
||||
for (let i = 0; i < need; i++) {
|
||||
const others: Collider[] = [...furniture, ...added].map(furnitureCollider);
|
||||
if (entrance) others.push(entrance);
|
||||
added.push(makeGenerated(preset, i, state.room, others));
|
||||
}
|
||||
furniture = [...furniture, ...added];
|
||||
}
|
||||
|
||||
return clampStateToRoom({ ...state, furniture });
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { LayoutEvaluation, LayoutState } from '../types';
|
||||
import { evaluateLayout } from '../layout/evaluator';
|
||||
import { Furniture } from '../furniture/Furniture';
|
||||
import { clampRectToRoom, overlapsAny, resolveMove } from '../geometry/bounds';
|
||||
import { entranceCollider, findFreeSpot, furnitureCollider } from '../geometry/bounds';
|
||||
import type { Collider } from '../geometry/collision';
|
||||
|
||||
export interface RandomSearchOptions {
|
||||
iterations: number;
|
||||
@@ -18,14 +21,44 @@ 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 = entranceCollider(candidate);
|
||||
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 others: Collider[] = candidate.furniture
|
||||
.filter((o) => o.id !== f.id)
|
||||
.map(furnitureCollider);
|
||||
if (entrance) others.push(entrance);
|
||||
const hasNear = f.conditions.some((cc) => cc.kind === 'near_object' && (cc.params as any)?.targetId);
|
||||
let b;
|
||||
if (hasNear && Math.random() < 0.6) {
|
||||
const tgtId = f.conditions.find((cc) => cc.kind === 'near_object' && (cc.params as any)?.targetId)?.params as any;
|
||||
const target = candidate.furniture.find((x) => x.id === tgtId?.targetId || (x.type === tgtId?.targetId && x.id !== f.id));
|
||||
if (target) {
|
||||
const tcx = target.shape.x + target.shape.width / 2;
|
||||
const tcy = target.shape.y + target.shape.height / 2;
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
const rad = 45 + Math.random() * 40;
|
||||
b = clampRectToRoom({
|
||||
x: tcx + Math.cos(ang) * rad - f.shape.width / 2,
|
||||
y: tcy + Math.sin(ang) * rad - f.shape.height / 2,
|
||||
width: f.shape.width,
|
||||
height: f.shape.height,
|
||||
rotation: f.shape.rotation,
|
||||
}, room);
|
||||
const resolved = resolveMove(b, others, room);
|
||||
if (!overlapsAny(resolved, others)) b = resolved;
|
||||
}
|
||||
}
|
||||
if (!b) 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;
|
||||
f.shape.height = b.height;
|
||||
|
||||
if (Math.random() < 0.5) {
|
||||
f.shape.rotation = Math.floor(Math.random() * 24) * 15;
|
||||
}
|
||||
}
|
||||
const evalRes = evaluateLayout(candidate, options.gridSize || 10);
|
||||
if (evalRes.totalScore > bestEval.totalScore + 1e-9) {
|
||||
@@ -41,5 +74,6 @@ function cloneState(state: LayoutState): LayoutState {
|
||||
room: { ...state.room },
|
||||
entrance: state.entrance ? { ...state.entrance } : null,
|
||||
furniture: state.furniture.map((f) => new Furniture(f).toConfig()),
|
||||
presets: state.presets?.map((p) => ({ ...p, shape: { ...p.shape }, conditions: p.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })) })),
|
||||
};
|
||||
}
|
||||
|
||||
+38
-4
@@ -1,8 +1,10 @@
|
||||
import type { GridCell, Point, Rect, RoomConfig } from '../types';
|
||||
import { pointInPolygon } from '../geometry/polygon';
|
||||
|
||||
export function buildGrid(room: RoomConfig, obstacles: Rect[], gridSize: number = 10): GridCell[][] {
|
||||
const cols = Math.ceil(room.width / gridSize);
|
||||
const rows = Math.ceil(room.height / gridSize);
|
||||
const poly = room.points && room.points.length >= 3 ? room.points : null;
|
||||
const grid: GridCell[][] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
grid[r] = [];
|
||||
@@ -10,10 +12,14 @@ export function buildGrid(room: RoomConfig, obstacles: Rect[], gridSize: number
|
||||
const x = c * gridSize + gridSize / 2;
|
||||
const y = r * gridSize + gridSize / 2;
|
||||
let walkable = true;
|
||||
for (const obs of obstacles) {
|
||||
if (pointInRect(x, y, obs)) {
|
||||
walkable = false;
|
||||
break;
|
||||
if (poly && !pointInPolygon({ x, y }, poly)) {
|
||||
walkable = false;
|
||||
} else {
|
||||
for (const obs of obstacles) {
|
||||
if (pointInRect(x, y, obs)) {
|
||||
walkable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
grid[r][c] = { x, y, walkable };
|
||||
@@ -36,3 +42,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;
|
||||
}
|
||||
+7
-2
@@ -1,4 +1,4 @@
|
||||
export type ShapeType = 'rectangle' | 'circle' | 'polygon';
|
||||
export type ShapeType = 'rectangle' | 'oval' | 'circle' | 'polygon';
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
@@ -19,6 +19,7 @@ export interface ShapeData {
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
scale?: number;
|
||||
points?: Point[];
|
||||
}
|
||||
|
||||
@@ -29,11 +30,13 @@ export type ConditionKind =
|
||||
| 'near_object'
|
||||
| 'not_blocking_path'
|
||||
| 'accessible'
|
||||
| 'open_circulation';
|
||||
| 'open_circulation'
|
||||
| 'line_of_sight';
|
||||
|
||||
export interface ConditionSpec {
|
||||
kind: ConditionKind;
|
||||
weight: number;
|
||||
leniency?: number;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -60,6 +63,7 @@ export interface RoomConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
gridSize?: number;
|
||||
points?: Point[];
|
||||
}
|
||||
|
||||
export interface GridCell {
|
||||
@@ -96,6 +100,7 @@ export interface FurniturePreset {
|
||||
conditions: ConditionSpec[];
|
||||
color?: string;
|
||||
amount?: number;
|
||||
isPreset?: boolean;
|
||||
}
|
||||
|
||||
export interface LayoutState {
|
||||
|
||||
Reference in New Issue
Block a user