Compare commits

..

10 Commits

21 changed files with 1688 additions and 434 deletions
View File
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
+3
View File
@@ -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,
+2
View File
@@ -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 },
+136
View File
@@ -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;
}
+94
View File
@@ -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;
}
+58
View File
@@ -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 },
];
}
+78
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+77
View File
@@ -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;
}
+97
View File
@@ -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 });
}
+40 -6
View File
@@ -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
View File
@@ -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;
}
+32
View File
@@ -0,0 +1,32 @@
import type { LayoutState, Point, Rect } from '../types';
import { buildGrid, gridToWorld, nearestWalkable } from './grid';
import { aStar } from './aStar';
export interface ObjectPath {
furnitureId: string;
path: Point[];
}
export function computePaths(state: LayoutState, gridSize: number = 10): ObjectPath[] {
if (!state.entrance) return [];
const obstacles: Rect[] = state.furniture
.map((f) => ({ x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height }))
.filter((r) => r.width > 0 && r.height > 0);
const grid = buildGrid(state.room, obstacles, gridSize);
const entranceCell = nearestWalkable(grid, { x: state.entrance.x, y: state.entrance.y }, gridSize);
if (!entranceCell) return [];
const entranceWorld = gridToWorld(entranceCell.col, entranceCell.row, gridSize);
const paths: ObjectPath[] = [];
for (const f of state.furniture) {
const cx = f.shape.x + f.shape.width / 2;
const cy = f.shape.y + f.shape.height / 2;
const targetCell = nearestWalkable(grid, { x: cx, y: cy }, gridSize);
if (!targetCell) continue;
const target = gridToWorld(targetCell.col, targetCell.row, gridSize);
const path = aStar(entranceWorld, target, grid, gridSize);
if (path) paths.push({ furnitureId: f.id, path });
}
return paths;
}
+7 -2
View File
@@ -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 {